| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465 |
- #!/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);
- }
|