1
0

render-check.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. #!/usr/bin/env node
  2. /**
  3. * Renders the dashboard in a real browser against the fixture and checks that
  4. * every panel drew, and drew the numbers the API returned.
  5. *
  6. * smoke-api.sh proves the SQL; this proves the other half — that each panel is
  7. * wired to the right endpoint and plots it without mangling it. It reads the
  8. * Chart.js instance off each canvas and compares its dataset arrays against the
  9. * same endpoint fetched straight from Node, so a panel pointed at the wrong dim
  10. * fails here even though both halves are individually fine.
  11. *
  12. * node scripts/render-check.mjs (or: npm run smoke:render)
  13. *
  14. * Zero new dependencies: it drives whatever Chromium is already on the machine
  15. * over the DevTools protocol (Node 22 has WebSocket built in). With no browser
  16. * installed it SKIPS rather than fails — the shell smoke suites stay the
  17. * portable floor, and this is the deeper check where a browser exists.
  18. */
  19. import { spawn } from 'node:child_process';
  20. import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
  21. import { tmpdir } from 'node:os';
  22. import { dirname, join } from 'node:path';
  23. import { fileURLToPath, pathToFileURL } from 'node:url';
  24. const root = dirname(dirname(fileURLToPath(import.meta.url)));
  25. const PORT = Number(process.env.DASH_PORT ?? 8790);
  26. const BASE = `http://127.0.0.1:${PORT}`;
  27. /** The fixture's own window — see scripts/fixture.sql. */
  28. const FROM = '2026-07-01';
  29. const TO = '2026-07-10';
  30. let pass = 0;
  31. let fail = 0;
  32. const ok = (what) => {
  33. console.log(` ok ${what}`);
  34. pass++;
  35. };
  36. const bad = (what, detail) => {
  37. console.log(` FAIL ${what}${detail ? ` (${detail})` : ''}`);
  38. fail++;
  39. };
  40. const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail));
  41. const same = (what, expected, actual) =>
  42. check(
  43. what,
  44. JSON.stringify(expected) === JSON.stringify(actual),
  45. `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
  46. );
  47. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  48. // ---------------------------------------------------------------------------
  49. // Finding a browser
  50. // ---------------------------------------------------------------------------
  51. /** Expands one `*` in a path segment, newest match first. */
  52. function glob(pattern) {
  53. const [head, ...rest] = pattern.split('*');
  54. const base = dirname(head);
  55. const prefix = head.slice(base.length + 1);
  56. if (!existsSync(base)) return [];
  57. return readdirSync(base)
  58. .filter((name) => name.startsWith(prefix))
  59. .sort()
  60. .reverse()
  61. .map((name) => join(base, name) + rest.join('*'));
  62. }
  63. function findBrowser() {
  64. const home = process.env.HOME ?? '';
  65. const candidates = [
  66. process.env.CHROME_BIN,
  67. ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`),
  68. ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`),
  69. ...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`),
  70. ...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`),
  71. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  72. '/Applications/Chromium.app/Contents/MacOS/Chromium',
  73. '/usr/bin/chromium',
  74. '/usr/bin/chromium-browser',
  75. '/usr/bin/google-chrome',
  76. ];
  77. return candidates.find((path) => path && existsSync(path)) ?? null;
  78. }
  79. // ---------------------------------------------------------------------------
  80. // A minimal DevTools-protocol client
  81. // ---------------------------------------------------------------------------
  82. class CDP {
  83. constructor(socket) {
  84. this.socket = socket;
  85. this.nextId = 1;
  86. this.pending = new Map();
  87. this.events = [];
  88. socket.addEventListener('message', (event) => {
  89. const message = JSON.parse(event.data);
  90. if (message.id !== undefined) {
  91. const waiter = this.pending.get(message.id);
  92. if (!waiter) return;
  93. this.pending.delete(message.id);
  94. if (message.error) waiter.reject(new Error(message.error.message));
  95. else waiter.resolve(message.result);
  96. } else {
  97. this.events.push(message);
  98. }
  99. });
  100. }
  101. static async connect(url) {
  102. const socket = new WebSocket(url);
  103. await new Promise((resolve, reject) => {
  104. socket.addEventListener('open', resolve, { once: true });
  105. socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true });
  106. });
  107. return new CDP(socket);
  108. }
  109. send(method, params = {}, sessionId) {
  110. const id = this.nextId++;
  111. return new Promise((resolve, reject) => {
  112. this.pending.set(id, { resolve, reject });
  113. this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
  114. });
  115. }
  116. /** Runs an expression in the page and returns its value, awaiting promises. */
  117. async evaluate(sessionId, expression) {
  118. const result = await this.send(
  119. 'Runtime.evaluate',
  120. { expression, returnByValue: true, awaitPromise: true },
  121. sessionId,
  122. );
  123. if (result.exceptionDetails) {
  124. throw new Error(result.exceptionDetails.exception?.description ?? 'page threw');
  125. }
  126. return result.result.value;
  127. }
  128. }
  129. // ---------------------------------------------------------------------------
  130. // The page probe
  131. // ---------------------------------------------------------------------------
  132. /**
  133. * Runs inside the page. Reads what each panel actually rendered — including the
  134. * live Chart.js instance behind each canvas — rather than trusting that a
  135. * fetch resolved.
  136. */
  137. const PROBE = `(() => {
  138. const panels = [...document.querySelectorAll('[data-panel]')].map((section) => {
  139. const canvas = section.querySelector('canvas');
  140. const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null;
  141. return {
  142. id: section.dataset.panel,
  143. state: section.dataset.state,
  144. stale: section.dataset.stale,
  145. title: section.querySelector('h2').textContent,
  146. figure: section.querySelector('[data-role="figure"]').textContent,
  147. note: section.querySelector('.panel-note')?.textContent ?? '',
  148. message: section.querySelector('[data-role="state"]').textContent,
  149. stat: section.querySelector('.stat-value')?.textContent ?? null,
  150. funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent),
  151. funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width),
  152. chart: chart && {
  153. type: chart.config.type,
  154. labels: chart.data.labels,
  155. datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })),
  156. legend: chart.options.plugins?.legend?.display !== false,
  157. },
  158. tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length,
  159. tableCols: section.querySelectorAll('[data-role="table"] thead th').length,
  160. tableHidden: section.querySelector('[data-role="table"]').hidden,
  161. };
  162. });
  163. return {
  164. ready: document.body.dataset.ready === 'true',
  165. range: document.getElementById('range-summary').textContent,
  166. dataThrough: document.getElementById('data-through').textContent,
  167. refreshed: document.getElementById('refreshed-at').textContent,
  168. selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null,
  169. panels,
  170. };
  171. })()`;
  172. // ---------------------------------------------------------------------------
  173. // Run
  174. // ---------------------------------------------------------------------------
  175. const children = [];
  176. let profileDir = null;
  177. function cleanup() {
  178. for (const child of children) {
  179. try {
  180. child.kill('SIGTERM');
  181. } catch {
  182. /* already gone */
  183. }
  184. }
  185. if (profileDir) rmSync(profileDir, { recursive: true, force: true });
  186. }
  187. process.on('exit', cleanup);
  188. process.on('SIGINT', () => process.exit(130));
  189. function run(command, args, options = {}) {
  190. const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options });
  191. children.push(child);
  192. return child;
  193. }
  194. async function waitFor(what, probe, attempts = 90) {
  195. for (let i = 0; i < attempts; i++) {
  196. try {
  197. if (await probe()) return true;
  198. } catch {
  199. /* not up yet */
  200. }
  201. await sleep(1000);
  202. }
  203. throw new Error(`timed out waiting for ${what}`);
  204. }
  205. async function main() {
  206. const browserPath = findBrowser();
  207. if (!browserPath) {
  208. console.log('render-check: no Chromium found — skipping.');
  209. console.log(' Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.');
  210. return 0;
  211. }
  212. console.log(`Browser: ${browserPath}`);
  213. console.log('Seeding the local D1 fixture…');
  214. const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' });
  215. const seeded = await new Promise((resolve) => seed.on('exit', resolve));
  216. if (seeded !== 0) throw new Error('seeding failed');
  217. console.log(`Starting wrangler dev on :${PORT}…`);
  218. run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']);
  219. await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok);
  220. const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1];
  221. if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars');
  222. const login = await fetch(`${BASE}/login`, {
  223. method: 'POST',
  224. body: new URLSearchParams({ password }),
  225. redirect: 'manual',
  226. });
  227. const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session='));
  228. if (!cookie) throw new Error('login did not set a session cookie');
  229. const [name, value] = cookie.split(';')[0].split('=');
  230. profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-'));
  231. // chrome-headless-shell is headless by construction and rejects the flag;
  232. // a full Chrome needs it.
  233. const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new'];
  234. run(browserPath, [
  235. ...headlessFlag,
  236. '--disable-gpu',
  237. '--no-first-run',
  238. '--no-default-browser-check',
  239. '--remote-debugging-port=0',
  240. `--user-data-dir=${profileDir}`,
  241. 'about:blank',
  242. ]);
  243. let devtoolsPort = null;
  244. await waitFor('the browser', () => {
  245. const portFile = join(profileDir, 'DevToolsActivePort');
  246. if (!existsSync(portFile)) return false;
  247. devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
  248. return Number.isFinite(devtoolsPort) && devtoolsPort > 0;
  249. }, 30);
  250. const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json();
  251. const cdp = await CDP.connect(version.webSocketDebuggerUrl);
  252. const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
  253. const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
  254. await cdp.send('Page.enable', {}, sessionId);
  255. await cdp.send('Runtime.enable', {}, sessionId);
  256. await cdp.send('Log.enable', {}, sessionId);
  257. await cdp.send('Network.enable', {}, sessionId);
  258. await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId);
  259. await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId);
  260. await waitFor('the dashboard to finish rendering', async () => {
  261. const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"');
  262. return view === true;
  263. }, 60);
  264. let view = await cdp.evaluate(sessionId, PROBE);
  265. // -- what loaded ---------------------------------------------------------
  266. console.log('\nThe page renders');
  267. // The very same registry the page just rendered from, imported here so the
  268. // expectations cannot drift from the panels under test.
  269. const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href);
  270. check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`);
  271. const broken = view.panels.filter((p) => p.state !== 'ready');
  272. check(
  273. 'every panel reached its ready state',
  274. broken.length === 0,
  275. broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '),
  276. );
  277. check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset);
  278. check('the range is stated in the filter row', /Jun|Jul/.test(view.range), view.range);
  279. check('the data horizon is stated', view.dataThrough.includes('Jul 10'), view.dataThrough);
  280. check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed);
  281. // A CSP violation surfaces here as a `security` log entry, which is the point
  282. // of the check: the page must work under `script-src 'self'` with no inline
  283. // styles at all. The favicon 404 is expected — there isn't one — and is the
  284. // only network noise allowed through.
  285. const errors = cdp.events.filter(
  286. (e) =>
  287. (e.method === 'Log.entryAdded' &&
  288. e.params.entry.level === 'error' &&
  289. !/favicon/.test(e.params.entry.url ?? '')) ||
  290. e.method === 'Runtime.exceptionThrown',
  291. );
  292. check(
  293. 'no console errors — the strict CSP allows everything the page needs',
  294. errors.length === 0,
  295. errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '),
  296. );
  297. // -- the range picker really re-queries ----------------------------------
  298. console.log('\nChanging the range re-queries every panel');
  299. await cdp.evaluate(
  300. sessionId,
  301. `document.body.dataset.ready = "";
  302. [...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`,
  303. );
  304. await waitFor('the 7-day render', async () =>
  305. (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
  306. );
  307. view = await cdp.evaluate(sessionId, PROBE);
  308. const weekly = view.panels.find((p) => p.id === 'daily-production-users');
  309. check('a daily line now holds 7 points', weekly.chart?.labels.length === 7, `${weekly.chart?.labels.length}`);
  310. check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset);
  311. check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready'));
  312. console.log('\nA custom range works the same way');
  313. await cdp.evaluate(
  314. sessionId,
  315. `document.body.dataset.ready = "";
  316. document.querySelector('[data-role="custom-from"]').value = "${FROM}";
  317. document.querySelector('[data-role="custom-to"]').value = "${TO}";
  318. document.querySelector('[data-role="custom-apply"]').click();`,
  319. );
  320. await waitFor('the custom-range render', async () =>
  321. (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
  322. );
  323. view = await cdp.evaluate(sessionId, PROBE);
  324. check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10);
  325. check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset);
  326. // -- every panel plots what the API returned ------------------------------
  327. console.log('\nEvery panel plots the API’s own numbers');
  328. const query = `from=${FROM}&to=${TO}`;
  329. const fetched = new Map();
  330. const apiGet = async (path) => {
  331. if (!fetched.has(path)) {
  332. fetched.set(
  333. path,
  334. fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()),
  335. );
  336. }
  337. return fetched.get(path);
  338. };
  339. for (const panel of PANELS) {
  340. const rendered = view.panels.find((p) => p.id === panel.id);
  341. const data = await apiGet(panel.source(query));
  342. if (panel.kind === 'chart') {
  343. const plotted = rendered.chart?.datasets.map((d) => d.data);
  344. same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted);
  345. // A legend is owed wherever colour carries identity: any multi-series
  346. // chart, and every pie (whose slices are identities inside one dataset).
  347. // A single line needs none — the panel title already names it.
  348. const owed = rendered.chart.type === 'pie' || data.datasets.length > 1;
  349. check(
  350. `${panel.id}: a legend exactly where colour carries identity`,
  351. rendered.chart.legend === owed,
  352. `legend ${rendered.chart.legend}, expected ${owed}`,
  353. );
  354. } else if (panel.kind === 'stat') {
  355. same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat);
  356. } else if (panel.kind === 'funnel') {
  357. same(
  358. `${panel.id}: shows both funnel stages`,
  359. panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')),
  360. rendered.funnelValues,
  361. );
  362. }
  363. const table = panel.table(data);
  364. check(
  365. `${panel.id}: the table twin carries every row`,
  366. rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length,
  367. `${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`,
  368. );
  369. }
  370. // -- a few numbers checked against the fixture by hand --------------------
  371. console.log('\nSpot checks against the fixture, worked out by hand');
  372. const byId = Object.fromEntries(view.panels.map((p) => [p.id, p]));
  373. same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat);
  374. same('installs is 12', '12', byId['installs'].stat);
  375. same('uninstalls is 2', '2', byId['uninstalls'].stat);
  376. same('indexing runs is 13', '13', byId['indexing-runs'].stat);
  377. same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues);
  378. const widths = byId['activation-funnel'].funnelWidths;
  379. check(
  380. '…and draws the drop as a shorter bar',
  381. widths[0] === '100%' && widths[1].startsWith('83.3'),
  382. widths.join(' / '),
  383. );
  384. same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels);
  385. same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data));
  386. check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure);
  387. same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels);
  388. same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]);
  389. check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100);
  390. // Colour, spacing and label collisions are not things an assertion catches.
  391. // RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it.
  392. if (process.env.RENDER_SHOT) {
  393. await cdp.send(
  394. 'Emulation.setDeviceMetricsOverride',
  395. { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false },
  396. sessionId,
  397. );
  398. await sleep(500);
  399. const shot = await cdp.send(
  400. 'Page.captureScreenshot',
  401. { format: 'png', captureBeyondViewport: true },
  402. sessionId,
  403. );
  404. writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64'));
  405. console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`);
  406. }
  407. console.log('\nPanel copy follows the house rules');
  408. const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title));
  409. check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', '));
  410. check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20));
  411. check('tables start closed', view.panels.every((p) => p.tableHidden));
  412. return fail;
  413. }
  414. try {
  415. const failures = await main();
  416. console.log(`\n${pass} passed, ${fail} failed`);
  417. process.exit(failures === 0 ? 0 : 1);
  418. } catch (err) {
  419. console.error(`\nrender-check: ${err.message}`);
  420. process.exit(1);
  421. }