Files
clinicpro/.claude/skills/qa-clinicpro/driver.mjs
T
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
Screenshotting the pages under dark mode and compact density (rather than
trusting that design tokens were enough) turned up two mistakes repeated across
every page this feature set added:

- `.card` carries only the surface, border and radius — padding comes from the
  separate `.card-pad`. Fifteen cards were rendering with their content flush
  against the edges.
- `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a
  control in it produced a joined addon rather than a label above its field.
  `.field-block` is the label-above layout, and thirty-seven wrappers now use it.

Both were invisible to type-checking and to the tests, which is exactly why the
visual pass was worth running. Numbers in the new UI now go through
formatNumber so they render as Persian digits, and the utilization page's
header no longer repeats the sentence that appears under its filters verbatim.

The QA driver gained a `--ui` flag: theme and density live in
localStorage['clinicpro-ui'], so without seeding them dark mode and compact
density cannot be screenshotted at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:42:11 +03:30

496 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* ClinicPro QA driver — drives the running app the way a real user would, and
* reports what broke. No npm dependencies: Node 22's global WebSocket speaks CDP
* to a headless Chrome directly, so there is no playwright/puppeteer to install.
*
* driver.mjs login <role>
* driver.mjs visit <url> [--as role] [--out f.png] [--w] [--h] [--wait] [--full]
* driver.mjs api <METHOD> <path> [--as role] [--body '{...}']
* driver.mjs authz <METHOD> <path> [--body '{...}']
* driver.mjs ux <url> [--as role]
* driver.mjs perf <url> [--as role]
* driver.mjs roles
*
* `visit` is the workhorse: it logs in over the API, seeds the SPA's auth store
* into localStorage, navigates, then reports console errors, failed network
* requests, the path it actually landed on, and a screenshot.
*/
import { spawn, execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9444);
/**
* Local QA personas — one per distinct authorization identity in the product,
* not merely one per ROLE_* constant: an independent doctor and a clinic-member
* doctor carry the same role but see different data, so each gets its own row.
*
* The first five predate this list and are known to exist; the rest are
* provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
* until they are. TEST_USERS.md is stale — its accounts do not exist.
*/
const ROLES = {
admin: ['09120671756', 'QaTest@1234'],
clinic: ['09127000000', 'QaTest@1234'],
secretary: ['09123456778', 'QaTest@1234'],
doctor: ['09390039833', 'QaTest@1234'],
representation: ['09124000001', 'QaTest@1234'],
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
doctor_member: ['09129000002', 'QaTest@1234'], // member of a clinic
clinic_doctor: ['09129000003', 'QaTest@1234'], // ROLE_CLINIC + ROLE_DOCTOR
secretary_clinic: ['09129000004', 'QaTest@1234'], // secretary of a clinic
unclaimed_doctor: ['09129000005', 'QaTest@1234'], // imported, unclaimed profile
patient: ['09129000006', 'QaTest@1234'], // ROLE_USER only
importer: ['09129000007', 'QaTest@1234'],
};
// ddev serves a locally-signed cert Node's fetch refuses. Relax TLS only for it.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// …which Node then warns about on every run, drowning the actual QA output.
process.removeAllListeners('warning');
process.on('warning', () => {});
}
// ── auth ───────────────────────────────────────────────────────────────────
function creds(role) {
if (ROLES[role]) return ROLES[role];
if (role.includes(':')) return role.split(':'); // "0912...:password"
throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`);
}
/**
* Non-staff accounts (ROLE_USER, ROLE_UNCLAIMED_DOCTOR) are rejected by
* PasswordAuthenticator with ERR_AUTH_006 by design — they are OTP-only.
* In dev the OTP is the fixed '12345' (OtpService::sendCode), so the whole
* send-code → verify-code → otp-login chain is scriptable.
*/
async function otpLogin(mobile) {
const post = async (path, body) => {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const sent = await post('/api/v1/user/send-code', { mobile });
if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 200)}`);
const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
const grant = ver?.data?.grant;
if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 200)}`);
return post('/api/v1/user/otp-login', { grant });
}
/** Same signing key and claims as a real login — only skips the rate limiter. */
function mintToken(mobile) {
const out = execSync(
`ddev exec 'php bin/console lexik:jwt:generate-token ${mobile} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd() },
);
const tok = out.trim().split('\n').pop().trim();
if (!tok.startsWith('ey')) throw new Error(`could not mint token for ${mobile}`);
return tok;
}
async function login(role) {
const [mobile_number, password] = creds(role);
const r = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number, password }),
});
const j = await r.json();
if (j.access_token) return j;
// ERR_AUTH_006 means "staff-only endpoint", not "wrong password".
// ERR_AUTH_005 IS "wrong password" — expected whenever the local DB was
// replaced by a production dump, which brings its own hashes and drops the
// QA accounts' passwords. OTP still authenticates those users, so fall
// through to it rather than failing the whole run.
if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006' || e.code === 'ERR_AUTH_005')) {
const o = await otpLogin(mobile_number).catch((e) => ({ _err: e.message }));
if (o.access_token) return o;
// send-code is capped at 5/hour/IP; once burned, mint the JWT with the app's
// own command rather than loosening a real product limit for a test.
return { access_token: mintToken(mobile_number) };
}
throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
}
/** JWT is unsigned-read here purely to report which roles a token carries. */
function claims(token) {
const s = token.split('.')[1];
return JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
}
// ── CDP plumbing ───────────────────────────────────────────────────────────
async function waitForCdp(timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return (await r.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
}
/** CDP client with both request/response and event subscription. */
function cdp(ws) {
let id = 0;
const pending = new Map();
const listeners = [];
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
} else if (msg.method) {
listeners.forEach((fn) => fn(msg.method, msg.params));
}
});
const send = (method, params = {}, sessionId) =>
new Promise((res, rej) => {
const msgId = ++id;
pending.set(msgId, { resolve: res, reject: rej });
ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
});
send.on = (fn) => listeners.push(fn);
return send;
}
/**
* Boot Chrome, authenticate the SPA, navigate, and hand the page to `fn`.
* Collects console errors and failed requests for the whole session.
*/
async function withPage(url, opts, fn) {
const { access_token, refresh_token } = await login(opts.as);
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
'--ignore-certificate-errors', // ddev's local CA
`--remote-debugging-port=${PORT}`,
`--user-data-dir=/tmp/clinicpro-qa-${process.pid}`,
`--window-size=${opts.w},${opts.h}`,
'about:blank',
], { stdio: 'ignore' });
const errors = [];
const netFails = [];
try {
const ws = new WebSocket(await waitForCdp());
await new Promise((res) => ws.addEventListener('open', res, { once: true }));
const send = cdp(ws);
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => send(m, p, sessionId);
await S('Page.enable');
await S('Runtime.enable');
await S('Log.enable');
await S('Network.enable');
send.on((method, p) => {
if (method === 'Runtime.exceptionThrown') {
errors.push(`uncaught: ${p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text}`);
} else if (method === 'Runtime.consoleAPICalled' && p.type === 'error') {
errors.push('console.error: ' + p.args.map((a) => a.value ?? a.description ?? a.type).join(' '));
} else if (method === 'Log.entryAdded' && p.entry.level === 'error') {
errors.push(`log(${p.entry.source}): ${p.entry.text}`);
} else if (method === 'Network.loadingFailed') {
netFails.push(`request failed: ${p.errorText}`);
} else if (method === 'Network.responseReceived' && p.response.status >= 400) {
netFails.push(`HTTP ${p.response.status} ${p.response.url.replace(BASE, '')}`);
}
});
// localStorage is origin-scoped: load the origin before seeding it.
await S('Page.navigate', { url: `${BASE}/admin/login` });
await new Promise((r) => setTimeout(r, 1500));
const auth = {
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
version: 0,
};
// `--ui '{"darkMode":true,"density":"compact"}'` تم را پیش از اولین رندر می‌کارد.
const ui = opts.ui
? `localStorage.setItem('clinicpro-ui', ${JSON.stringify(
JSON.stringify({ state: JSON.parse(opts.ui), version: 0 }),
)});`
: '';
await S('Runtime.evaluate', {
expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('pwa-dismissed','1');${ui}`,
});
// Errors before this point belong to the login page, not the page under test.
errors.length = 0; netFails.length = 0;
await S('Page.navigate', { url });
await new Promise((r) => setTimeout(r, opts.wait));
const evalJs = async (expression) => {
const { result, exceptionDetails } = await S('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise: true,
});
if (exceptionDetails) throw new Error(exceptionDetails.text);
return result.value;
};
await fn({ S, evalJs, errors, netFails, url, opts });
ws.close();
} finally {
chrome.kill();
}
}
/** Report the two failure modes a screenshot alone hides: wrong page, blank page. */
async function landingCheck(evalJs, url) {
const v = await evalJs('JSON.stringify({p:location.pathname,t:(document.body.innerText||"").trim().length})');
const { p: landed, t: len } = JSON.parse(v);
const wanted = new URL(url).pathname;
const out = [];
if (landed.includes('/login')) out.push('⚠ redirected to /login — token rejected, expired, or route requires auth');
else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
out.push(`⚠ WRONG PAGE: asked ${wanted}, landed ${landed} — role likely lacks access (RoleRoute in App.tsx)`);
}
if (len < 40) out.push(`⚠ page text only ${len} chars — likely blank / crashed render`);
return out;
}
function report(title, lines) {
console.log(`\n${title}`);
console.log(lines.length ? lines.map((l) => ' ' + l).join('\n') : ' (none)');
}
// ── commands ───────────────────────────────────────────────────────────────
async function cmdVisit(url, opts) {
await withPage(url, opts, async ({ S, evalJs, errors, netFails }) => {
const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.full });
writeFileSync(opts.out, Buffer.from(data, 'base64'));
console.log(`✓ screenshot ${opts.out} (${opts.w}x${opts.h}, as ${opts.as})`);
report('LANDING', await landingCheck(evalJs, url));
report('CONSOLE ERRORS', [...new Set(errors)]);
report('NETWORK FAILURES', [...new Set(netFails)]);
});
}
/**
* DOM heuristics for the recurring UX defects of an RTL Persian admin: layout
* that overflows sideways, Latin digits leaking into Persian copy, tap targets
* too small for the mobile viewport, tables with no empty state.
*/
const UX_PROBE = `(() => {
const out = [];
const de = document.documentElement;
if (de.dir !== 'rtl' && getComputedStyle(de).direction !== 'rtl') out.push('root is not RTL');
if (de.lang !== 'fa') out.push('html lang is "' + de.lang + '", expected "fa"');
if (de.scrollWidth > de.clientWidth + 2)
out.push('horizontal overflow: content ' + de.scrollWidth + 'px > viewport ' + de.clientWidth + 'px');
// Latin digits inside Persian text read as untranslated to a Persian user.
const fa = /[\\u0600-\\u06FF]/, latin = /[0-9]/;
let mixed = 0;
document.querySelectorAll('h1,h2,h3,label,th,button,a').forEach(el => {
const t = (el.textContent||'').trim();
if (t && fa.test(t) && latin.test(t)) mixed++;
});
if (mixed) out.push(mixed + ' element(s) mix Persian text with Latin digits (use Persian numerals)');
// 44px is the usual minimum comfortable touch target.
if (innerWidth < 600) {
let small = 0;
document.querySelectorAll('button,a,[role=button]').forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && (r.height < 36 || r.width < 36)) small++;
});
if (small) out.push(small + ' tap target(s) under 36px on a mobile viewport');
}
document.querySelectorAll('img:not([alt])').forEach(() => {});
const noAlt = document.querySelectorAll('img:not([alt])').length;
if (noAlt) out.push(noAlt + ' <img> without alt');
const noLabel = [...document.querySelectorAll('input,select,textarea')]
.filter(el => !el.labels?.length && !el.getAttribute('aria-label') && !el.placeholder).length;
if (noLabel) out.push(noLabel + ' form field(s) with no label, aria-label, or placeholder');
const ids = {}; let dup = 0;
document.querySelectorAll('[id]').forEach(el => { dup += (ids[el.id] = (ids[el.id]||0) + 1) > 1 ? 1 : 0; });
if (dup) out.push(dup + ' duplicate DOM id(s)');
// A table rendered with zero rows and no empty-state message is a dead end.
document.querySelectorAll('table').forEach((t, i) => {
const rows = t.querySelectorAll('tbody tr').length;
if (rows === 0 && !/(هیچ|یافت نشد|خالی|موردی)/.test(t.parentElement?.textContent||''))
out.push('table #' + (i+1) + ' has 0 rows and no empty-state message');
});
if (document.querySelector('select')) out.push('native <select> present — project standard is SearchableSelect');
return JSON.stringify(out);
})()`;
async function cmdUx(url, opts) {
await withPage(url, opts, async ({ evalJs, errors, netFails }) => {
report('LANDING', await landingCheck(evalJs, url));
report(`UX FINDINGS (${opts.w}x${opts.h}, as ${opts.as})`, JSON.parse(await evalJs(UX_PROBE)));
report('CONSOLE ERRORS', [...new Set(errors)]);
report('NETWORK FAILURES', [...new Set(netFails)]);
});
}
async function cmdPerf(url, opts) {
await withPage(url, opts, async ({ evalJs }) => {
const t = JSON.parse(await evalJs(`JSON.stringify({
nav: performance.getEntriesByType('navigation')[0],
paint: performance.getEntriesByType('paint'),
api: performance.getEntriesByType('resource')
.filter(r => r.name.includes('/api/'))
.map(r => ({ u: r.name.split('/api/')[1], ms: Math.round(r.duration), kb: Math.round(r.transferSize/1024) }))
.sort((a,b) => b.ms - a.ms).slice(0, 12),
res: performance.getEntriesByType('resource').length,
dom: document.querySelectorAll('*').length,
})`));
console.log(`\nPERF ${url} (as ${opts.as})`);
if (t.nav) {
console.log(` ${'ttfb'.padEnd(24)}${Math.round(t.nav.responseStart)}ms`);
console.log(` ${'domContentLoaded'.padEnd(24)}${Math.round(t.nav.domContentLoadedEventEnd)}ms`);
console.log(` ${'load'.padEnd(24)}${Math.round(t.nav.loadEventEnd)}ms`);
}
t.paint.forEach((p) => console.log(` ${p.name.padEnd(24)}${Math.round(p.startTime)}ms`));
console.log(` resources ${t.res} · DOM nodes ${t.dom}`);
report('SLOWEST API CALLS', t.api.map((a) => `${String(a.ms).padStart(5)}ms ${a.kb}kb ${a.u}`));
});
}
async function apiCall(method, path, role, body) {
const { access_token } = await login(role);
const t0 = Date.now();
const r = await fetch(`${BASE}${path.startsWith('/') ? path : '/' + path}`, {
method,
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: body ?? undefined,
});
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch { /* not json */ }
return { status: r.status, ms: Date.now() - t0, json, text };
}
async function cmdApi(method, path, opts) {
const { status, ms, json, text } = await apiCall(method, path, opts.as, opts.body);
console.log(`${method} ${path}${status} ${ms}ms (as ${opts.as})`);
// BaseController's envelope is the contract every client depends on.
const problems = [];
if (!json) problems.push('response is not JSON');
else {
if (!('success' in json)) problems.push('envelope missing "success"');
if (status >= 400 && !json.errors) problems.push('error response has no "errors" array');
if (json?.data?.data?.data) problems.push('triple-nested data — BaseController double-nesting pitfall');
else if (json?.data?.data && !Array.isArray(json.data)) problems.push('double-nested data (client must read data.data.data)');
}
report('ENVELOPE', problems);
console.log('\nBODY\n' + (json ? JSON.stringify(json, null, 2) : text).slice(0, 2000));
}
/** Same request as every role plus anonymous — the access-control matrix. */
async function cmdAuthz(method, path, opts) {
console.log(`AUTHZ ${method} ${path}\n`);
const rows = [];
const anon = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: opts.body ?? undefined });
rows.push(['anonymous', anon.status]);
for (const role of Object.keys(ROLES)) {
try {
const { status } = await apiCall(method, path, role, opts.body);
rows.push([role, status]);
} catch (e) {
rows.push([role, `login failed (${String(e.message).slice(0, 40)})`]);
}
}
rows.forEach(([r, s]) => console.log(` ${r.padEnd(16)} ${s}`));
const leaks = rows.filter(([r, s]) => r === 'anonymous' && s === 200);
if (leaks.length) console.log('\n ⚠ anonymous got 200 — endpoint is public. Intended?');
const allowed = rows.filter(([, s]) => s === 200).map(([r]) => r);
console.log(`\n 200 for: ${allowed.join(', ') || '(nobody)'}`);
}
async function cmdRoles() {
for (const role of Object.keys(ROLES)) {
try {
const j = await login(role);
const c = claims(j.access_token);
const mins = Math.round((c.exp - c.iat) / 60);
console.log(`${role.padEnd(16)} ${creds(role)[0]} ${c.roles.join(',')} token ${mins}min`);
} catch (e) {
console.log(`${role.padEnd(16)}${e.message.slice(0, 90)}`);
}
}
}
// ── CLI ────────────────────────────────────────────────────────────────────
const [cmd, ...argv] = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : d; };
const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--full'));
const opts = {
as: flag('as', 'admin'),
out: flag('out', '/tmp/clinicpro-qa.png'),
w: Number(flag('w', 1440)),
h: Number(flag('h', 900)),
wait: Number(flag('wait', 4000)),
full: argv.includes('--full'),
body: flag('body', null),
// تم و چگالی در `localStorage['clinicpro-ui']` می‌نشینند و بدون seed کردنشان
// دارک‌مود و حالت فشرده اصلاً قابل اسکرین‌شات نیستند.
ui: flag('ui', null),
};
try {
if (cmd === 'visit' && positional[0]) await cmdVisit(positional[0], opts);
else if (cmd === 'ux' && positional[0]) await cmdUx(positional[0], opts);
else if (cmd === 'perf' && positional[0]) await cmdPerf(positional[0], opts);
else if (cmd === 'api' && positional[1]) await cmdApi(positional[0].toUpperCase(), positional[1], opts);
else if (cmd === 'authz' && positional[1]) await cmdAuthz(positional[0].toUpperCase(), positional[1], opts);
else if (cmd === 'login' && positional[0]) console.log(JSON.stringify(claims((await login(positional[0])).access_token), null, 2));
else if (cmd === 'roles') await cmdRoles();
else {
console.log(`usage (roles: ${Object.keys(ROLES).join(', ')}, or "mobile:password")
driver.mjs roles
driver.mjs login <role>
driver.mjs visit <url> [--as admin] [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
driver.mjs ux <url> [--as admin] [--w] [--h]
driver.mjs perf <url> [--as admin]
driver.mjs api <METHOD> <path> [--as admin] [--body '{"k":1}']
driver.mjs authz <METHOD> <path> [--body '{"k":1}']`);
process.exit(1);
}
} catch (e) {
console.error('✗ ' + e.message);
process.exit(1);
}