#!/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 * driver.mjs visit [--as role] [--out f.png] [--w] [--h] [--wait] [--full] * driver.mjs api [--as role] [--body '{...}'] * driver.mjs authz [--body '{...}'] * driver.mjs ux [--as role] * driver.mjs perf [--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 here means "staff-only endpoint", not "wrong password". if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006')) { 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, }; await S('Runtime.evaluate', { expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))}); localStorage.setItem('pwa-dismissed','1');`, }); // 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 + ' 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