- 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.
517 lines
25 KiB
JavaScript
517 lines
25 KiB
JavaScript
#!/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);
|
||
}
|