feat: add admin page driver for screenshots and audits of React SPA
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user