Files
clinicpro/.claude/skills/redesign-page/driver.mjs
T
hamedandClaude Opus 5 1c4f2a2451 chore(skill): make redesign-page a real UI/UX review harness
The skill's driver could not log in any more: its default credentials were
a user the scenario seeder wiped, so every command died on ERR_AUTH_005
before taking a single screenshot. Defaults now point at a user the seeder
actually creates, and the failure message says how to rebuild the users.

A page was also being judged on one screenshot. Dark mode and compact
density are real settings in this panel and mobile is where an RTL,
table-heavy admin breaks, so `variants` now captures all four and the theme
is written to the ui store rather than only stamped on the element — the
attribute alone is overwritten at hydrate. Narrow shots enable device
metrics, without which pointer:coarse media queries never fire and the
44px touch targets stay invisible.

Every shot now probes the live DOM for the things no grep can see:
horizontal overflow, nameless icon buttons, unlabelled fields, controls
under 32px. The static audit gained Gregorian dates, native date inputs,
icon buttons with no aria-label, and .seg without an on/active class.

`ds` prints the tokens and the shared components with their props, so a
redesign starts from what exists instead of inventing a second Modal.

Also corrected a stale claim: the suite has no pre-broken tests — it is
100 files / 660 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:27:23 +03:30

431 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* ClinicPro admin page driver — screenshots and audits a page of the React admin
* SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
* CDP directly, so there is no playwright/puppeteer install to babysit).
*
* driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
* [--theme dark] [--density compact] [--context clinic]
* driver.mjs variants <url> [--dir /tmp/review] ← the four shots a review needs
* driver.mjs inspect <url>
* driver.mjs audit <file.tsx>
* driver.mjs ds [components|tokens]
*
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
* navigates and captures. Needed because the admin is a client-side
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
* `variants` runs `shot` four times — light desktop, dark, compact, 390px mobile.
* A redesign judged on one screenshot ships a page that breaks in the
* other three; dark mode and compact density are real user settings
* here, not hypotheticals.
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
* the design-system components it already imports.
* `audit` greps one source file for the anti-patterns this project keeps
* regrowing (native <select>, hardcoded hex, dead tokens, …) plus the
* accessibility misses that never fail a build.
* `ds` prints the design system — tokens from styles.css and the shared
* components — so a redesign starts from what exists.
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
// دو کاربرِ سیدر که رمز دارند: 0912000101 پزشک مستقل، 0912000201 پزشکِ مالک کلینیک.
// دومی هر دو محیط را دارد، پس بیشترین صفحه با آن باز می‌شود.
const USER = process.env.CLINICPRO_USER ?? '0912000201';
const PASS = process.env.CLINICPRO_PASS ?? 'QaTest@1234';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9333);
// ddev serves a locally-signed cert that Node's fetch refuses. Only relax TLS for
// that local host — never for a real origin someone might point this at.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
// ── CDP plumbing ───────────────────────────────────────────────────────────
/** Chrome needs a moment before /json/version answers; poll instead of sleeping. */
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`);
}
/** Minimal CDP client: send(method, params) → Promise<result>. */
function cdp(ws) {
let id = 0;
const pending = new Map();
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve: res, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result);
}
});
return (method, params = {}, sessionId) =>
new Promise((res, reject) => {
const msgId = ++id;
pending.set(msgId, { resolve: res, reject });
ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
});
}
async function login() {
const r = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: USER, password: PASS }),
});
const j = await r.json();
if (!j.access_token) {
throw new Error(
`login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n`
+ ' → seeded users are in TEST_USERS.md; rebuild them with\n'
+ ' ddev exec php bin/console app:seed-scenarios --reset -n',
);
}
return j;
}
/**
* محیط کاری را عوض می‌کند. کاربری که هم مطب شخصی دارد هم کلینیک، پیش‌فرض روی مطب
* می‌نشیند و صفحه‌های کلینیک خالی می‌آیند — که شبیه باگ است ولی نیست.
*/
async function switchContext(token, kind) {
const r = await fetch(`${BASE}/api/v1/auth/contexts`, {
headers: { Authorization: `Bearer ${token}` },
}).catch(() => null);
// اندپوینت فهرست محیط‌ها عمومی نیست؛ uuid را از /api/v1/user/me می‌گیریم.
const me = await (await fetch(`${BASE}/api/v1/user/me`, {
headers: { Authorization: `Bearer ${token}` },
})).json().catch(() => ({}));
const contexts = me?.data?.available_contexts ?? me?.available_contexts ?? [];
const want = contexts.find((c) => (c.type ?? c.scope) === kind);
if (!want) {
console.log(`⚠ no "${kind}" context for this user — staying where we are`);
return;
}
await fetch(`${BASE}/api/v1/auth/switch-context`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ db_uuid: want.db_uuid }),
});
console.log(`context → ${kind} (${want.name ?? want.db_uuid})`);
}
async function shot(url, opts) {
const { access_token, refresh_token } = await login();
if (opts.context) await switchContext(access_token, opts.context);
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
'--ignore-certificate-errors', // ddev serves a local CA cert
`--remote-debugging-port=${PORT}`,
`--user-data-dir=/tmp/clinicpro-shot-${process.pid}`,
`--window-size=${opts.w},${opts.h}`,
'about:blank',
], { stdio: 'ignore' });
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');
// موبایل بدون این فلگ فقط «پنجرهٔ باریک» است: مدیا کوئری‌های pointer: coarse
// خاموش می‌مانند و ارتفاع لمسی ۴۴px که برای موبایل نوشته شده دیده نمی‌شود.
if (opts.w <= 480) {
await S('Emulation.setDeviceMetricsOverride', {
width: opts.w, height: opts.h, deviceScaleFactor: 2, mobile: true,
});
await S('Emulation.setTouchEmulationEnabled', { enabled: true });
}
// localStorage is origin-scoped, so the origin must be loaded before seeding.
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,
};
// تم و تراکم را uiStore در همان localStorage نگه می‌دارد و روی <html> می‌نشاند؛
// ست‌کردن مستقیم صفت، بعد از hydrate پس گرفته می‌شود، پس استور هم نوشته می‌شود.
const ui = { state: { darkMode: opts.theme === 'dark', density: opts.density }, version: 0 };
await S('Runtime.evaluate', {
expression: `
localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('clinicpro-ui', ${JSON.stringify(JSON.stringify(ui))});
localStorage.setItem('pwa-dismissed', '1');
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
`,
});
await S('Page.navigate', { url });
await new Promise((r) => setTimeout(r, opts.wait));
// تم بعد از hydrate ممکن است از استور دوباره خوانده شود؛ آخرین کلام با ما.
await S('Runtime.evaluate', {
expression: `
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
`,
});
await new Promise((r) => setTimeout(r, 400));
const { data } = await S('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: opts.full,
});
mkdirSync(dirname(resolve(opts.out)), { recursive: true });
writeFileSync(opts.out, Buffer.from(data, 'base64'));
console.log(`✓ ${opts.out}`);
// The SPA redirects silently: RoleRoute bounces a user whose role lacks access
// straight to /dashboard, so you get a valid-looking screenshot of the WRONG
// page. Compare the landed path against the requested one and say so loudly.
const { result } = await S('Runtime.evaluate', {
expression: 'location.pathname + "|" + (document.body.innerText||"").trim().length',
returnByValue: true,
});
const [landed, len] = String(result.value).split('|');
const wanted = new URL(url).pathname;
if (landed.includes('/login')) {
console.log('⚠ redirected to /login — token rejected or expired');
} else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
console.log(`⚠ WRONG PAGE: asked for ${wanted}, landed on ${landed}`);
console.log(' → the test user\'s role probably lacks access (see RoleRoute in App.tsx).');
console.log(' → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
}
if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
if (opts.probe) await probeRuntime(S);
ws.close();
} finally {
chrome.kill();
}
}
/**
* چیزهایی که فقط در DOMِ رندرشده دیده می‌شوند و هیچ گرپی رویشان نمی‌افتد:
* سرریز افقی، دکمهٔ بی‌نام، و فیلد بدون لیبل.
*/
async function probeRuntime(S) {
const { result } = await S('Runtime.evaluate', {
returnByValue: true,
expression: `(() => {
const out = [];
if (document.documentElement.scrollWidth > window.innerWidth + 2) {
out.push('horizontal scroll: page is ' + document.documentElement.scrollWidth
+ 'px wide in a ' + window.innerWidth + 'px viewport');
}
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
.filter(b => !(b.innerText || '').trim()
&& !b.getAttribute('aria-label') && !b.getAttribute('title')).length;
if (nameless) out.push(nameless + ' icon-only control(s) with no accessible name');
const unlabelled = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
.filter(i => !i.getAttribute('aria-label') && !i.getAttribute('aria-labelledby')
&& !(i.id && document.querySelector('label[for="' + i.id + '"]'))
&& !i.closest('label')).length;
if (unlabelled) out.push(unlabelled + ' form field(s) with no label');
const tiny = [...document.querySelectorAll('button, a')]
.filter(b => { const r = b.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.height < 32; }).length;
if (tiny) out.push(tiny + ' control(s) under 32px tall (44px is the touch target)');
return out;
})()`,
});
const findings = result.value ?? [];
console.log(findings.length ? 'RUNTIME\n' + findings.map((f) => ' ⚠ ' + f).join('\n')
: 'RUNTIME clean');
}
/** چهار نمای اجباریِ هر بازطراحی: روشن، تیره، فشرده، موبایل. */
async function variants(url, dir) {
const slug = new URL(url).pathname.replace(/^\/admin\/?/, '').replace(/\W+/g, '-') || 'page';
const runs = [
{ name: 'light', w: 1440, h: 900, theme: 'light', density: 'comfortable' },
{ name: 'dark', w: 1440, h: 900, theme: 'dark', density: 'comfortable' },
{ name: 'compact', w: 1440, h: 900, theme: 'light', density: 'compact' },
{ name: 'mobile', w: 390, h: 844, theme: 'light', density: 'comfortable' },
];
for (const r of runs) {
console.log(`\n── ${r.name} ${r.w}×${r.h} ${r.theme}/${r.density}`);
await shot(url, {
out: `${dir}/${slug}-${r.name}.png`,
w: r.w, h: r.h, wait: 5000, full: true, probe: true,
theme: r.theme, density: r.density, context: null,
});
}
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
}
// ── Static inspection ──────────────────────────────────────────────────────
/** URL path → the <Route> line in App.tsx → the page component file. */
function inspect(url) {
const path = url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/admin\/?/, '').split('?')[0];
const app = readFileSync(`${REPO}/assets/admin/App.tsx`, 'utf8');
const segs = path.split('/').filter(Boolean);
const routes = [...app.matchAll(/<Route\s+path="([^"]+)"[\s\S]*?element=\{([\s\S]*?)\}\s*\/>/g)]
.map(([, p, el]) => ({ p, comp: (el.match(/<(\w+)\s*\/>/g) ?? []).pop() ?? el.trim() }));
const score = (rp) => {
const rs = rp.split('/').filter(Boolean);
if (rs.length !== segs.length) return -1;
return rs.every((s, i) => s.startsWith(':') || s === segs[i]) ? rs.length : -1;
};
const hit = routes.map((r) => ({ ...r, s: score(r.p) })).filter((r) => r.s >= 0)
.sort((a, b) => b.s - a.s)[0];
if (!hit) {
console.log(`no route matched "${path}". Routes:\n` + routes.map((r) => ' ' + r.p).join('\n'));
return;
}
const comp = hit.comp.replace(/[<>/\s]/g, '');
const imp = app.match(new RegExp(`import\\s+${comp}\\s+from\\s+'([^']+)'`));
const file = imp ? `assets/admin/${imp[1].replace(/^\.\//, '')}.tsx` : '(inline element)';
console.log(`route ${hit.p}`);
console.log(`component ${comp}`);
console.log(`file ${file}`);
const abs = `${REPO}/${file}`;
if (existsSync(abs)) {
const src = readFileSync(abs, 'utf8');
const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
console.log(`lines ${src.split('\n').length}`);
const test = file.replace(/\.tsx$/, '.test.tsx');
console.log(`test ${existsSync(`${REPO}/${test}`) ? test : '— none, write one'}`);
auditSource(file, src);
}
}
/** The anti-patterns this codebase keeps regrowing. Each one is a real past bug. */
function auditSource(label, src) {
const tokens = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
const findings = [];
const push = (re, msg) => {
src.split('\n').forEach((line, i) => { if (re.test(line)) findings.push(`${label}:${i + 1} ${msg}`); });
};
push(/<select\b/, 'native <select> — use SearchableSelect');
push(/className="btn"(?!\s*\+)/, '.btn with no variant — renders borderless/invisible');
push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
push(/className="overlay"/, 'hand-rolled overlay — use the shared <Modal>');
push(/className="field"[\s\S]*?<label/, '<label> inside .field — .field is an inline box; use .field-block');
push(/new Date\([^)]*\)\.toLocaleDateString\((?!'fa)/, 'Gregorian date — use formatDate() (Jalali)');
push(/type="date"/, 'native date input — use PersianDateInput');
// آیکون تنها داخل دکمه، بدون aria-label: در تست سبز است و برای screen reader بی‌نام.
src.split('\n').forEach((line, i) => {
if (/<button(?![^>]*aria-label)/.test(line) && /Icon\b/.test(line) && !/>\s*[^\s<]/.test(line)) {
findings.push(`${label}:${i + 1} icon-only <button> with no aria-label`);
}
});
// var(--x) references that styles.css never defines (e.g. the dead --error).
for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
if (!tokens.includes(`${m[1]}:`)) findings.push(`${label} undefined token ${m[1]}`);
}
// .seg فقط کلاس on/active را می‌شناسد؛ هر چیز دیگری یعنی تب فعال بی‌نشانه.
if (/className="seg"/.test(src) && !/'(on|active)'/.test(src)) {
findings.push(`${label} .seg without an on/active class — the selected tab has no highlight`);
}
console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
: '\nAUDIT clean');
}
/** دیزاین‌سیستم موجود — قدم اول هر بازطراحی، پیش از نوشتن یک خط JSX. */
function designSystem(what) {
const css = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
if (what !== 'components') {
const root = css.match(/^:root\s*\{([\s\S]*?)^\}/m)?.[1] ?? '';
const vars = [...root.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((m) => ` ${m[1]}: ${m[2].trim()}`);
console.log(`TOKENS (${vars.length}) — assets/admin/styles.css`);
console.log(vars.join('\n'));
const classes = [...new Set([...css.matchAll(/^\.([\w-]+)[\s,{:]/gm)].map((m) => m[1]))];
console.log(`\nCLASSES (${classes.length})\n ${classes.join(' · ')}`);
}
if (what !== 'tokens') {
const dir = `${REPO}/assets/admin/components/ui`;
const ui = readdirSync(dir).filter((f) => f.endsWith('.tsx') && !f.endsWith('.test.tsx'));
console.log(`\nSHARED COMPONENTS (${ui.length}) — assets/admin/components/ui/`);
for (const f of ui) {
const src = readFileSync(`${dir}/${f}`, 'utf8');
const props = src.match(/interface Props\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
const names = [...props.matchAll(/^\s*\/?\*?\s*(\w+)\??:/gm)].map((m) => m[1]);
console.log(` ${f.replace('.tsx', '').padEnd(26)} ${names.slice(0, 8).join(', ')}`);
}
}
}
// ── CLI ────────────────────────────────────────────────────────────────────
const [cmd, arg, ...rest] = process.argv.slice(2);
const flag = (n, d) => { const i = rest.indexOf(`--${n}`); return i >= 0 ? rest[i + 1] : d; };
if (cmd === 'shot' && arg) {
await shot(arg, {
out: flag('out', 'page.png'),
w: Number(flag('w', 1440)),
h: Number(flag('h', 900)),
wait: Number(flag('wait', 4000)),
full: rest.includes('--full'),
probe: !rest.includes('--no-probe'),
theme: flag('theme', 'light'),
density: flag('density', 'comfortable'),
context: flag('context', null),
});
} else if (cmd === 'variants' && arg) {
await variants(arg, flag('dir', '/tmp/clinicpro-review'));
} else if (cmd === 'inspect' && arg) {
inspect(arg);
} else if (cmd === 'audit' && arg) {
auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
} else if (cmd === 'ds') {
designSystem(arg);
} else {
console.log(`usage:
driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
[--theme light|dark] [--density comfortable|compact]
[--context clinic|personal] [--no-probe]
driver.mjs variants <url> [--dir /tmp/clinicpro-review]
driver.mjs inspect <url>
driver.mjs audit <path/to/File.tsx>
driver.mjs ds [tokens|components]`);
process.exit(1);
}