Files
hamed 60ccd5cc1d feat: implement OTP login flow and enhance role-based access control
- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment.
- Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions.
- Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience.
- Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking.
- Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements.
- Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
2026-08-09 12:41:11 +03:30

517 lines
25 KiB
JavaScript
Raw Permalink 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, execSync } 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 }));
});
}
/**
* ورود با OTP — در dev کد ثابتِ `12345` است (OtpService::sendCode)، پس کل زنجیرهٔ
* send-code → verify-code → otp-login اسکریپت‌پذیر است.
*
* راهِ نجاتِ دیتابیسی که seed نشده: کاربر واقعیِ چنین دیتابیسی ممکن است اصلاً
* `password_hash` نداشته باشد یا رمزش را ندانیم، ولی شمارهٔ موبایلش کافی است.
*/
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, 160)}`);
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, 160)}`);
return post('/api/v1/user/otp-login', { grant });
}
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) return j;
// رمز نخورد؛ با OTP امتحان کن. دیتابیس‌های واقعی (نه seed) رمزِ مستندشده ندارند و
// بدون این، تنها راه یا reset کردن دیتابیس کاربر بود یا نوشتنِ رمز روی حسابش.
const otp = await otpLogin(USER).catch((e) => ({ _err: e.message }));
if (otp.access_token) return otp;
// `send-code` سقف ۵ بار در ساعت دارد. وقتی سوخت، به‌جای شل‌کردن یک محدودیتِ واقعیِ
// محصول برای تست، توکن را با کامندِ خودِ اپ می‌سازیم — همان کلید و همان claimها.
try {
const out = execSync(
`ddev exec 'php bin/console lexik:jwt:generate-token ${USER} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const minted = out.trim().split('\n').pop().trim();
if (minted.startsWith('ey')) return { access_token: minted, refresh_token: null };
} catch { /* کامند در دسترس نیست؛ می‌افتیم روی خطای زیر */ }
throw new Error(
`login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n`
+ ` → OTP fallback also failed: ${otp._err ?? JSON.stringify(otp).slice(0, 120)}\n`
+ ' → CLINICPRO_USER را روی شمارهٔ یک کاربر واقعیِ همین دیتابیس بگذار،\n'
+ ' یا حساب‌های تست را بساز: ddev exec php bin/console app:seed-scenarios --reset -n',
);
}
/**
* محیط کاری را عوض می‌کند. کاربری که هم مطب شخصی دارد هم کلینیک، پیش‌فرض روی مطب
* می‌نشیند و صفحه‌های کلینیک خالی می‌آیند — که شبیه باگ است ولی نیست.
*/
async function switchContext(token, kind) {
// فهرست محیط‌ها فقط در `/oauth/userinfo` است. مسیر قبلی (`/api/v1/user/me`) اصلاً
// وجود ندارد و ۴۰۴ می‌داد، پس `--context clinic` همیشه بی‌صدا نادیده گرفته می‌شد و
// اسکرین‌شاتِ محیطِ اشتباه گرفته می‌شد.
const me = await (await fetch(`${BASE}/oauth/userinfo`, {
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));
// مودال‌ها فقط با تعامل باز می‌شوند و بدون این، نقدشان ممکن نیست: --click یک
// متنِ دیدنی یا سلکتور می‌گیرد، اولین تطابق را می‌زند و منتظر رندر می‌ماند.
if (opts.click) {
const clicked = await S('Runtime.evaluate', {
returnByValue: true,
expression: `(() => {
const q = ${JSON.stringify(opts.click)};
let el = null;
try { el = document.querySelector(q); } catch {}
if (!el) {
// دکمه بر لینک مقدم است: نامِ یکسان معمولاً هم در سایدبار (a) هست هم
// روی خودِ صفحه (button)، و منظورِ نقد همیشه دومی است.
const hits = [...document.querySelectorAll('button,[role=button],a,td,.slot,.tl-slot')]
.filter((n) => (n.innerText || '').trim().includes(q) && n.offsetParent !== null);
el = hits.find((n) => n.closest('nav,.sidebar') === null) ?? hits[0];
}
if (!el) return 'not found: ' + q;
el.scrollIntoView({ block: 'center' });
el.click();
return 'clicked: ' + (el.innerText || el.className || el.tagName).slice(0, 60);
})()`,
});
console.log(' CLICK', clicked?.result?.value ?? '—');
await new Promise((r) => setTimeout(r, opts.clickWait ?? 1800));
}
// تم بعد از 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 where = (el) => {
const tag = el.tagName.toLowerCase();
const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
const txt = (el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 24);
const near = el.closest('[class]');
return tag + (el.id ? '#' + el.id : '') + (cls.length ? '.' + cls.join('.') : '')
+ (txt ? ' «' + txt + '»' : '')
+ (near && near !== el && typeof near.className === 'string'
? ' ← in .' + near.className.trim().split(/\s+/)[0] : '');
};
const list = (arr) => arr.map(where).join(' · ');
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
.filter(b => !(b.innerText || '').trim()
&& !b.getAttribute('aria-label') && !b.getAttribute('title'));
if (nameless.length) out.push(nameless.length + ' icon-only control(s) with no accessible name\n ' + list(nameless));
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'));
if (unlabelled.length) out.push(unlabelled.length + ' form field(s) with no label\n ' + list(unlabelled));
const tiny = [...document.querySelectorAll('button, a')]
.filter(b => { const r = b.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.height < 32; });
if (tiny.length) out.push(tiny.length + ' control(s) under 32px tall (44px is the touch target)\n '
+ tiny.map(b => where(b) + ' [' + Math.round(b.getBoundingClientRect().height) + 'px]').join(' · '));
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, extra = {}) {
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, ...extra,
});
}
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),
click: flag('click', null),
clickWait: Number(flag('click-wait', 1800)),
});
} else if (cmd === 'variants' && arg) {
await variants(arg, flag('dir', '/tmp/clinicpro-review'), {
click: flag('click', null),
clickWait: Number(flag('click-wait', 1800)),
});
} 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);
}