feat: add admin page driver for screenshots and audits of React SPA

This commit is contained in:
hamed
2026-07-19 11:30:44 +03:30
parent e7de7aa88b
commit f1d147f9ef
+245
View File
@@ -0,0 +1,245 @@
#!/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).
*
* node .claude/skills/redesign-page/driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
* node .claude/skills/redesign-page/driver.mjs inspect <url>
* node .claude/skills/redesign-page/driver.mjs audit <file.tsx>
*
* `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.
* `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, …).
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } 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';
const USER = process.env.CLINICPRO_USER ?? '09390039833';
const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
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: ${JSON.stringify(j).slice(0, 200)}`);
return j;
}
async function shot(url, opts) {
const { access_token, refresh_token } = await login();
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');
// 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,
};
await S('Runtime.evaluate', {
expression: `
localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('pwa-dismissed', '1');
`,
});
await S('Page.navigate', { url });
await new Promise((r) => setTimeout(r, opts.wait));
const { data } = await S('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: opts.full,
});
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`);
ws.close();
} finally {
chrome.kill();
}
}
// ── 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}`);
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');
// 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]}`);
}
console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
: '\nAUDIT clean');
}
// ── 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'),
});
} else if (cmd === 'inspect' && arg) {
inspect(arg);
} else if (cmd === 'audit' && arg) {
auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
} else {
console.log(`usage:
driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
driver.mjs inspect <url>
driver.mjs audit <path/to/File.tsx>`);
process.exit(1);
}