mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-09-19 07:34:57 +08:00
00f4621830
Replaces the scaffold page with the dashboard proper: 19 panels covering every
view of the PostHog dashboard this retires, driven by one filter row.
src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries,
breakdown,activation,retention}, all range-scoped, all parameterized against a
closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend
does no arithmetic. Rollups answer everything except the activation funnel,
which needs raw events and says where they start.
The frontend splits into a DOM-free panel registry (public/panels.js) and the
page that mounts it (public/app.js), so the render check can drive the same
registry the browser rendered from. Panels fail alone, refetch dims rather than
flashing, and every chart carries a table twin.
Two numbers are labelled rather than rounded off: range-wide "users" per
dimension is machine-days (the rollups cannot give distinct machines, and
per-day counts are taken as the largest single-event count so one machine's
install + index + usage is not counted three times), and recent activation and
retention cohorts are marked as still-converting instead of drawn as a cliff.
Both colour scales were run through the data-viz validator against the panel
surface, not picked by eye; the results are recorded in public/theme.js.
Verification, all against the committed fixture (12 machines over 10 days, every
expected number worked out by hand from the events, not recorded from a run):
scripts/smoke-api.sh 98 assertions
scripts/render-check.mjs 79 assertions — real Chromium over CDP, no new deps
scripts/smoke-auth.sh 54 assertions (unchanged, still green)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
466 lines
19 KiB
JavaScript
466 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Renders the dashboard in a real browser against the fixture and checks that
|
||
* every panel drew, and drew the numbers the API returned.
|
||
*
|
||
* smoke-api.sh proves the SQL; this proves the other half — that each panel is
|
||
* wired to the right endpoint and plots it without mangling it. It reads the
|
||
* Chart.js instance off each canvas and compares its dataset arrays against the
|
||
* same endpoint fetched straight from Node, so a panel pointed at the wrong dim
|
||
* fails here even though both halves are individually fine.
|
||
*
|
||
* node scripts/render-check.mjs (or: npm run smoke:render)
|
||
*
|
||
* Zero new dependencies: it drives whatever Chromium is already on the machine
|
||
* over the DevTools protocol (Node 22 has WebSocket built in). With no browser
|
||
* installed it SKIPS rather than fails — the shell smoke suites stay the
|
||
* portable floor, and this is the deeper check where a browser exists.
|
||
*/
|
||
|
||
import { spawn } from 'node:child_process';
|
||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||
const PORT = Number(process.env.DASH_PORT ?? 8790);
|
||
const BASE = `http://127.0.0.1:${PORT}`;
|
||
|
||
/** The fixture's own window — see scripts/fixture.sql. */
|
||
const FROM = '2026-07-01';
|
||
const TO = '2026-07-10';
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
|
||
const ok = (what) => {
|
||
console.log(` ok ${what}`);
|
||
pass++;
|
||
};
|
||
const bad = (what, detail) => {
|
||
console.log(` FAIL ${what}${detail ? ` (${detail})` : ''}`);
|
||
fail++;
|
||
};
|
||
const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail));
|
||
const same = (what, expected, actual) =>
|
||
check(
|
||
what,
|
||
JSON.stringify(expected) === JSON.stringify(actual),
|
||
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||
);
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Finding a browser
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Expands one `*` in a path segment, newest match first. */
|
||
function glob(pattern) {
|
||
const [head, ...rest] = pattern.split('*');
|
||
const base = dirname(head);
|
||
const prefix = head.slice(base.length + 1);
|
||
if (!existsSync(base)) return [];
|
||
return readdirSync(base)
|
||
.filter((name) => name.startsWith(prefix))
|
||
.sort()
|
||
.reverse()
|
||
.map((name) => join(base, name) + rest.join('*'));
|
||
}
|
||
|
||
function findBrowser() {
|
||
const home = process.env.HOME ?? '';
|
||
const candidates = [
|
||
process.env.CHROME_BIN,
|
||
...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`),
|
||
...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`),
|
||
...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`),
|
||
...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`),
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||
'/usr/bin/chromium',
|
||
'/usr/bin/chromium-browser',
|
||
'/usr/bin/google-chrome',
|
||
];
|
||
return candidates.find((path) => path && existsSync(path)) ?? null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// A minimal DevTools-protocol client
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class CDP {
|
||
constructor(socket) {
|
||
this.socket = socket;
|
||
this.nextId = 1;
|
||
this.pending = new Map();
|
||
this.events = [];
|
||
socket.addEventListener('message', (event) => {
|
||
const message = JSON.parse(event.data);
|
||
if (message.id !== undefined) {
|
||
const waiter = this.pending.get(message.id);
|
||
if (!waiter) return;
|
||
this.pending.delete(message.id);
|
||
if (message.error) waiter.reject(new Error(message.error.message));
|
||
else waiter.resolve(message.result);
|
||
} else {
|
||
this.events.push(message);
|
||
}
|
||
});
|
||
}
|
||
|
||
static async connect(url) {
|
||
const socket = new WebSocket(url);
|
||
await new Promise((resolve, reject) => {
|
||
socket.addEventListener('open', resolve, { once: true });
|
||
socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true });
|
||
});
|
||
return new CDP(socket);
|
||
}
|
||
|
||
send(method, params = {}, sessionId) {
|
||
const id = this.nextId++;
|
||
return new Promise((resolve, reject) => {
|
||
this.pending.set(id, { resolve, reject });
|
||
this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
|
||
});
|
||
}
|
||
|
||
/** Runs an expression in the page and returns its value, awaiting promises. */
|
||
async evaluate(sessionId, expression) {
|
||
const result = await this.send(
|
||
'Runtime.evaluate',
|
||
{ expression, returnByValue: true, awaitPromise: true },
|
||
sessionId,
|
||
);
|
||
if (result.exceptionDetails) {
|
||
throw new Error(result.exceptionDetails.exception?.description ?? 'page threw');
|
||
}
|
||
return result.result.value;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The page probe
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Runs inside the page. Reads what each panel actually rendered — including the
|
||
* live Chart.js instance behind each canvas — rather than trusting that a
|
||
* fetch resolved.
|
||
*/
|
||
const PROBE = `(() => {
|
||
const panels = [...document.querySelectorAll('[data-panel]')].map((section) => {
|
||
const canvas = section.querySelector('canvas');
|
||
const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null;
|
||
return {
|
||
id: section.dataset.panel,
|
||
state: section.dataset.state,
|
||
stale: section.dataset.stale,
|
||
title: section.querySelector('h2').textContent,
|
||
figure: section.querySelector('[data-role="figure"]').textContent,
|
||
note: section.querySelector('.panel-note')?.textContent ?? '',
|
||
message: section.querySelector('[data-role="state"]').textContent,
|
||
stat: section.querySelector('.stat-value')?.textContent ?? null,
|
||
funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent),
|
||
funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width),
|
||
chart: chart && {
|
||
type: chart.config.type,
|
||
labels: chart.data.labels,
|
||
datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })),
|
||
legend: chart.options.plugins?.legend?.display !== false,
|
||
},
|
||
tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length,
|
||
tableCols: section.querySelectorAll('[data-role="table"] thead th').length,
|
||
tableHidden: section.querySelector('[data-role="table"]').hidden,
|
||
};
|
||
});
|
||
return {
|
||
ready: document.body.dataset.ready === 'true',
|
||
range: document.getElementById('range-summary').textContent,
|
||
dataThrough: document.getElementById('data-through').textContent,
|
||
refreshed: document.getElementById('refreshed-at').textContent,
|
||
selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null,
|
||
panels,
|
||
};
|
||
})()`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Run
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const children = [];
|
||
let profileDir = null;
|
||
|
||
function cleanup() {
|
||
for (const child of children) {
|
||
try {
|
||
child.kill('SIGTERM');
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}
|
||
if (profileDir) rmSync(profileDir, { recursive: true, force: true });
|
||
}
|
||
process.on('exit', cleanup);
|
||
process.on('SIGINT', () => process.exit(130));
|
||
|
||
function run(command, args, options = {}) {
|
||
const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options });
|
||
children.push(child);
|
||
return child;
|
||
}
|
||
|
||
async function waitFor(what, probe, attempts = 90) {
|
||
for (let i = 0; i < attempts; i++) {
|
||
try {
|
||
if (await probe()) return true;
|
||
} catch {
|
||
/* not up yet */
|
||
}
|
||
await sleep(1000);
|
||
}
|
||
throw new Error(`timed out waiting for ${what}`);
|
||
}
|
||
|
||
async function main() {
|
||
const browserPath = findBrowser();
|
||
if (!browserPath) {
|
||
console.log('render-check: no Chromium found — skipping.');
|
||
console.log(' Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.');
|
||
return 0;
|
||
}
|
||
console.log(`Browser: ${browserPath}`);
|
||
|
||
console.log('Seeding the local D1 fixture…');
|
||
const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' });
|
||
const seeded = await new Promise((resolve) => seed.on('exit', resolve));
|
||
if (seeded !== 0) throw new Error('seeding failed');
|
||
|
||
console.log(`Starting wrangler dev on :${PORT}…`);
|
||
run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']);
|
||
await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok);
|
||
|
||
const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1];
|
||
if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars');
|
||
const login = await fetch(`${BASE}/login`, {
|
||
method: 'POST',
|
||
body: new URLSearchParams({ password }),
|
||
redirect: 'manual',
|
||
});
|
||
const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session='));
|
||
if (!cookie) throw new Error('login did not set a session cookie');
|
||
const [name, value] = cookie.split(';')[0].split('=');
|
||
|
||
profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-'));
|
||
// chrome-headless-shell is headless by construction and rejects the flag;
|
||
// a full Chrome needs it.
|
||
const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new'];
|
||
run(browserPath, [
|
||
...headlessFlag,
|
||
'--disable-gpu',
|
||
'--no-first-run',
|
||
'--no-default-browser-check',
|
||
'--remote-debugging-port=0',
|
||
`--user-data-dir=${profileDir}`,
|
||
'about:blank',
|
||
]);
|
||
|
||
let devtoolsPort = null;
|
||
await waitFor('the browser', () => {
|
||
const portFile = join(profileDir, 'DevToolsActivePort');
|
||
if (!existsSync(portFile)) return false;
|
||
devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
|
||
return Number.isFinite(devtoolsPort) && devtoolsPort > 0;
|
||
}, 30);
|
||
|
||
const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json();
|
||
const cdp = await CDP.connect(version.webSocketDebuggerUrl);
|
||
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
||
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
||
|
||
await cdp.send('Page.enable', {}, sessionId);
|
||
await cdp.send('Runtime.enable', {}, sessionId);
|
||
await cdp.send('Log.enable', {}, sessionId);
|
||
await cdp.send('Network.enable', {}, sessionId);
|
||
await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId);
|
||
|
||
await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId);
|
||
await waitFor('the dashboard to finish rendering', async () => {
|
||
const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"');
|
||
return view === true;
|
||
}, 60);
|
||
|
||
let view = await cdp.evaluate(sessionId, PROBE);
|
||
|
||
// -- what loaded ---------------------------------------------------------
|
||
console.log('\nThe page renders');
|
||
// The very same registry the page just rendered from, imported here so the
|
||
// expectations cannot drift from the panels under test.
|
||
const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href);
|
||
check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`);
|
||
const broken = view.panels.filter((p) => p.state !== 'ready');
|
||
check(
|
||
'every panel reached its ready state',
|
||
broken.length === 0,
|
||
broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '),
|
||
);
|
||
check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset);
|
||
check('the range is stated in the filter row', /Jun|Jul/.test(view.range), view.range);
|
||
check('the data horizon is stated', view.dataThrough.includes('Jul 10'), view.dataThrough);
|
||
check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed);
|
||
|
||
// A CSP violation surfaces here as a `security` log entry, which is the point
|
||
// of the check: the page must work under `script-src 'self'` with no inline
|
||
// styles at all. The favicon 404 is expected — there isn't one — and is the
|
||
// only network noise allowed through.
|
||
const errors = cdp.events.filter(
|
||
(e) =>
|
||
(e.method === 'Log.entryAdded' &&
|
||
e.params.entry.level === 'error' &&
|
||
!/favicon/.test(e.params.entry.url ?? '')) ||
|
||
e.method === 'Runtime.exceptionThrown',
|
||
);
|
||
check(
|
||
'no console errors — the strict CSP allows everything the page needs',
|
||
errors.length === 0,
|
||
errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '),
|
||
);
|
||
|
||
// -- the range picker really re-queries ----------------------------------
|
||
console.log('\nChanging the range re-queries every panel');
|
||
await cdp.evaluate(
|
||
sessionId,
|
||
`document.body.dataset.ready = "";
|
||
[...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`,
|
||
);
|
||
await waitFor('the 7-day render', async () =>
|
||
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
|
||
);
|
||
view = await cdp.evaluate(sessionId, PROBE);
|
||
const weekly = view.panels.find((p) => p.id === 'daily-production-users');
|
||
check('a daily line now holds 7 points', weekly.chart?.labels.length === 7, `${weekly.chart?.labels.length}`);
|
||
check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset);
|
||
check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready'));
|
||
|
||
console.log('\nA custom range works the same way');
|
||
await cdp.evaluate(
|
||
sessionId,
|
||
`document.body.dataset.ready = "";
|
||
document.querySelector('[data-role="custom-from"]').value = "${FROM}";
|
||
document.querySelector('[data-role="custom-to"]').value = "${TO}";
|
||
document.querySelector('[data-role="custom-apply"]').click();`,
|
||
);
|
||
await waitFor('the custom-range render', async () =>
|
||
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
|
||
);
|
||
view = await cdp.evaluate(sessionId, PROBE);
|
||
check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10);
|
||
check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset);
|
||
|
||
// -- every panel plots what the API returned ------------------------------
|
||
console.log('\nEvery panel plots the API’s own numbers');
|
||
const query = `from=${FROM}&to=${TO}`;
|
||
const fetched = new Map();
|
||
const apiGet = async (path) => {
|
||
if (!fetched.has(path)) {
|
||
fetched.set(
|
||
path,
|
||
fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()),
|
||
);
|
||
}
|
||
return fetched.get(path);
|
||
};
|
||
|
||
for (const panel of PANELS) {
|
||
const rendered = view.panels.find((p) => p.id === panel.id);
|
||
const data = await apiGet(panel.source(query));
|
||
|
||
if (panel.kind === 'chart') {
|
||
const plotted = rendered.chart?.datasets.map((d) => d.data);
|
||
same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted);
|
||
// A legend is owed wherever colour carries identity: any multi-series
|
||
// chart, and every pie (whose slices are identities inside one dataset).
|
||
// A single line needs none — the panel title already names it.
|
||
const owed = rendered.chart.type === 'pie' || data.datasets.length > 1;
|
||
check(
|
||
`${panel.id}: a legend exactly where colour carries identity`,
|
||
rendered.chart.legend === owed,
|
||
`legend ${rendered.chart.legend}, expected ${owed}`,
|
||
);
|
||
} else if (panel.kind === 'stat') {
|
||
same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat);
|
||
} else if (panel.kind === 'funnel') {
|
||
same(
|
||
`${panel.id}: shows both funnel stages`,
|
||
panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')),
|
||
rendered.funnelValues,
|
||
);
|
||
}
|
||
|
||
const table = panel.table(data);
|
||
check(
|
||
`${panel.id}: the table twin carries every row`,
|
||
rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length,
|
||
`${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`,
|
||
);
|
||
}
|
||
|
||
// -- a few numbers checked against the fixture by hand --------------------
|
||
console.log('\nSpot checks against the fixture, worked out by hand');
|
||
const byId = Object.fromEntries(view.panels.map((p) => [p.id, p]));
|
||
same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat);
|
||
same('installs is 12', '12', byId['installs'].stat);
|
||
same('uninstalls is 2', '2', byId['uninstalls'].stat);
|
||
same('indexing runs is 13', '13', byId['indexing-runs'].stat);
|
||
same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues);
|
||
const widths = byId['activation-funnel'].funnelWidths;
|
||
check(
|
||
'…and draws the drop as a shorter bar',
|
||
widths[0] === '100%' && widths[1].startsWith('83.3'),
|
||
widths.join(' / '),
|
||
);
|
||
same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels);
|
||
same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data));
|
||
check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure);
|
||
same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels);
|
||
same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]);
|
||
check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100);
|
||
|
||
// Colour, spacing and label collisions are not things an assertion catches.
|
||
// RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it.
|
||
if (process.env.RENDER_SHOT) {
|
||
await cdp.send(
|
||
'Emulation.setDeviceMetricsOverride',
|
||
{ width: 1440, height: 900, deviceScaleFactor: 2, mobile: false },
|
||
sessionId,
|
||
);
|
||
await sleep(500);
|
||
const shot = await cdp.send(
|
||
'Page.captureScreenshot',
|
||
{ format: 'png', captureBeyondViewport: true },
|
||
sessionId,
|
||
);
|
||
writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64'));
|
||
console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`);
|
||
}
|
||
|
||
console.log('\nPanel copy follows the house rules');
|
||
const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title));
|
||
check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', '));
|
||
check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20));
|
||
check('tables start closed', view.panels.every((p) => p.tableHidden));
|
||
|
||
return fail;
|
||
}
|
||
|
||
try {
|
||
const failures = await main();
|
||
console.log(`\n${pass} passed, ${fail} failed`);
|
||
process.exit(failures === 0 ? 0 : 1);
|
||
} catch (err) {
|
||
console.error(`\nrender-check: ${err.message}`);
|
||
process.exit(1);
|
||
}
|