Skip to main content

max / makenotwork

11.4 KB · 301 lines History Blame Raw
1 #!/usr/bin/env node
2 //
3 // Load real pages in a real browser and fail if the JavaScript did not run.
4 //
5 // node scripts/page-smoke.mjs # against testnot.work
6 // BASE=http://127.0.0.1:8080 node scripts/page-smoke.mjs
7 //
8 // Exit 0 if every page loaded clean and every expectation held; 1 otherwise,
9 // with the failures listed.
10 //
11 // WHY THIS EXISTS
12 //
13 // On 2026-08-14 testnot served a landing page with no working JavaScript for
14 // several hours behind nine green Sando gates. `core/index.js` failed to link
15 // against a stale `dispatch.js` the CDN was still holding, and because
16 // `core/index.ts` side-effect-imports every common island, one bad link killed
17 // the carousel, toasts, tabs, keyboard shortcuts and the htmx glue together.
18 //
19 // Nothing caught it, and nothing could have. Every artifact was individually
20 // correct: the HTML carried the right markup, both stylesheets carried the
21 // right rules, and every JS file answered 200 with the current bytes. It was
22 // the *composition* that was broken, and composition is only observable in a
23 // browser.
24 //
25 // The gates cannot see this class by construction. `boot_smoke` runs on the
26 // build host and, in sando.toml's own words, "proves nothing about testnot-1".
27 // `node_health` probes the deployed node over its executor, which reaches the
28 // origin directly and never crosses the CDN. Both were green. So this script
29 // exists to check the site the way a visitor receives it, over the public
30 // hostname, and it is worth nothing if it is ever pointed at an origin.
31 //
32 // WHY PROGRESSIVE ENHANCEMENT MAKES THIS NECESSARY RATHER THAN NICE
33 //
34 // Every island here enhances server-rendered markup that stands on its own. So
35 // a dead bundle does not throw a visible error or leave a blank region: the
36 // page renders its unenhanced form, which is a state the design deliberately
37 // supports. "Broken" and "working as designed, unenhanced" are the same
38 // picture. The carousel is the sharpest case — with no script it is an ordered
39 // stack of screenshots, which is exactly what the widget tier says an
40 // unrecognising renderer should draw.
41 //
42 // That is why the checks below are not "does it look right". They are:
43 //
44 // 1. Nothing threw while loading. A module graph that fails to link, a CSP
45 // refusal, a null deref in an island — all of it lands here.
46 // 2. Every island element carries `data-island`, which `MnwElement` sets
47 // after `init()` returns. This is the direct evidence that behaviour ran,
48 // and it is what the failure above had no way of showing.
49 // 3. Per-page expectations, for enhancement a marker alone cannot prove.
50 //
51 // NO DEPENDENCIES. Node 22 ships a global WebSocket, so the DevTools protocol
52 // is reachable with nothing installed. Same reason and same shape as
53 // `capture-landing-carousel.mjs`, which is the other CDP script here.
54
55 import { spawn } from 'node:child_process';
56 import { mkdtempSync, rmSync } from 'node:fs';
57 import { tmpdir } from 'node:os';
58 import { join } from 'node:path';
59
60 const CHROME = process.env.CHROME ?? `${process.env.HOME}/.local/bin/chrome-for-testing`;
61 const BASE = (process.env.BASE ?? 'https://testnot.work').replace(/\/$/, '');
62 const SETTLE_MS = Number(process.env.SETTLE_MS ?? 5000);
63
64 /**
65 * The pages to check, and what has to be true on each.
66 *
67 * `expect` runs in the page and returns an array of failure strings — empty
68 * means the page is good. Keep them behavioural: assert what the JavaScript
69 * did, not what the server rendered, because the server's half is already
70 * covered by the Rust tests and is not what goes quietly wrong.
71 *
72 * Paths that depend on seeded content are resolved at runtime from the landing
73 * page rather than hardcoded, so a reseed does not turn this red.
74 */
75 const PAGES = [
76 {
77 path: '/',
78 expect: () => {
79 const out = [];
80 const car = document.querySelector('[data-widget="carousel"]');
81 if (!car) {
82 out.push('no carousel on the landing page');
83 return out;
84 }
85 // The whole point. Without this, "the island is dead" and "the island
86 // deliberately left a one-frame gallery alone" look the same.
87 if (!car.hasAttribute('data-ready')) {
88 out.push('carousel present but never enhanced (no data-ready)');
89 }
90 const frames = Array.from(car.children).filter((c) => c.matches('.picture, .picture-img'));
91 if (frames.length < 2) {
92 out.push(`carousel has ${frames.length} frame(s); expected the seeded three`);
93 }
94 const shown = frames.filter((f) => getComputedStyle(f).display !== 'none');
95 if (car.hasAttribute('data-ready') && shown.length !== 1) {
96 out.push(`enhanced carousel shows ${shown.length} frames at once; expected exactly 1`);
97 }
98 // The frame needs a visible edge. This is the other half of the same
99 // day's damage: makeover drew the frame with an inset bevel, which on a
100 // light screenshot is painted over the picture and disappears.
101 const img = car.querySelector('.picture-img');
102 if (img) {
103 const s = getComputedStyle(img);
104 const framed = s.borderTopWidth !== '0px' || s.boxShadow !== 'none';
105 if (!framed) out.push('carousel frame has neither a border nor a shadow');
106 }
107 return out;
108 },
109 },
110 ];
111
112 /** Every island element on the page ran its behaviour. */
113 const ISLANDS_RAN = () => {
114 const out = [];
115 // An island is a custom element this codebase defines; they all carry the
116 // `mnw-` prefix. Anything matching that and lacking `data-island` either
117 // never upgraded (the bundle is dead) or threw inside init().
118 for (const el of document.querySelectorAll('*')) {
119 if (!el.tagName.toLowerCase().startsWith('mnw-')) continue;
120 if (!el.hasAttribute('data-island')) {
121 out.push(`<${el.tagName.toLowerCase()}> never ran its island behaviour`);
122 }
123 }
124 return out;
125 };
126
127 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
128
129 function launch(dir) {
130 const child = spawn(
131 CHROME,
132 [
133 '--headless',
134 '--disable-gpu',
135 '--hide-scrollbars',
136 '--no-sandbox',
137 '--no-first-run',
138 '--remote-debugging-port=0',
139 `--user-data-dir=${dir}`,
140 'about:blank',
141 ],
142 { stdio: ['ignore', 'ignore', 'pipe'] },
143 );
144 return new Promise((resolve, reject) => {
145 let buf = '';
146 const timer = setTimeout(() => reject(new Error(`Chrome never listened.\n${buf}`)), 20_000);
147 child.stderr.on('data', (c) => {
148 buf += c;
149 const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
150 if (m) {
151 clearTimeout(timer);
152 resolve({ child, wsUrl: m[1] });
153 }
154 });
155 child.on('exit', (code) => {
156 clearTimeout(timer);
157 reject(new Error(`Chrome exited (${code}) before listening.\n${buf}`));
158 });
159 });
160 }
161
162 /** Minimal CDP client: id-tagged requests over one socket, flat sessions. */
163 function connect(wsUrl) {
164 const ws = new WebSocket(wsUrl);
165 const pending = new Map();
166 const listeners = [];
167 ws.addEventListener('message', (e) => {
168 const m = JSON.parse(e.data);
169 if (m.id && pending.has(m.id)) {
170 pending.get(m.id)(m);
171 pending.delete(m.id);
172 } else if (m.method) {
173 for (const fn of listeners) fn(m);
174 }
175 });
176 let id = 0;
177 const send = (method, params = {}, sessionId) =>
178 new Promise((resolve) => {
179 const i = ++id;
180 pending.set(i, resolve);
181 ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) }));
182 });
183 const ready = new Promise((r) => ws.addEventListener('open', r));
184 return { send, on: (fn) => listeners.push(fn), ready };
185 }
186
187 /** Load one page and return everything that went wrong on it. */
188 async function checkPage(cdp, url, expect) {
189 const problems = [];
190 const { result: { targetId } } = await cdp.send('Target.createTarget', { url: 'about:blank' });
191 const { result: { sessionId } } = await cdp.send('Target.attachToTarget', {
192 targetId,
193 flatten: true,
194 });
195
196 cdp.on((m) => {
197 if (m.sessionId !== sessionId) return;
198 if (m.method === 'Runtime.exceptionThrown') {
199 const d = m.params.exceptionDetails;
200 problems.push(`uncaught: ${d?.exception?.description ?? d?.text ?? 'unknown'}`);
201 }
202 if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
203 const text = (m.params.args ?? []).map((a) => a.value ?? a.description ?? '').join(' ');
204 problems.push(`console.error: ${text}`);
205 }
206 if (m.method === 'Log.entryAdded') {
207 const e = m.params.entry;
208 // A failed subresource is how a missing module shows up, and it is an
209 // error rather than a warning. CSP report-only violations are noise here:
210 // the policy is deliberately report-only and blocks nothing.
211 if (e.level === 'error' && !/report-only|report only/i.test(e.text)) {
212 problems.push(`log: ${e.text} ${e.url ?? ''}`.trim());
213 }
214 }
215 });
216
217 await cdp.send('Runtime.enable', {}, sessionId);
218 await cdp.send('Log.enable', {}, sessionId);
219 await cdp.send('Page.enable', {}, sessionId);
220 await cdp.send('Page.navigate', { url }, sessionId);
221 await sleep(SETTLE_MS);
222
223 for (const [label, fn] of [['islands', ISLANDS_RAN], ['page', expect]]) {
224 if (!fn) continue;
225 const r = await cdp.send(
226 'Runtime.evaluate',
227 { expression: `(${fn.toString()})()`, returnByValue: true },
228 sessionId,
229 );
230 const thrown = r.result?.exceptionDetails;
231 if (thrown) {
232 problems.push(`${label} check threw: ${thrown.exception?.description ?? thrown.text}`);
233 continue;
234 }
235 for (const p of r.result?.result?.value ?? []) problems.push(p);
236 }
237
238 await cdp.send('Target.closeTarget', { targetId });
239 return problems;
240 }
241
242 /** Ask the landing page for a real project and item path, so the check follows the seed. */
243 async function discoverPaths(cdp) {
244 const { result: { targetId } } = await cdp.send('Target.createTarget', { url: `${BASE}/discover` });
245 const { result: { sessionId } } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
246 await cdp.send('Runtime.enable', {}, sessionId);
247 await sleep(SETTLE_MS);
248 const r = await cdp.send(
249 'Runtime.evaluate',
250 {
251 expression: `JSON.stringify(['/p/','/i/'].map(p =>
252 (Array.from(document.querySelectorAll('a[href]'))
253 .map(a => new URL(a.href, location.origin).pathname)
254 .find(h => h.startsWith(p)) || null)))`,
255 returnByValue: true,
256 },
257 sessionId,
258 );
259 await cdp.send('Target.closeTarget', { targetId });
260 try {
261 return JSON.parse(r.result?.result?.value ?? '[]').filter(Boolean);
262 } catch {
263 return [];
264 }
265 }
266
267 const dir = mkdtempSync(join(tmpdir(), 'page-smoke-'));
268 const { child, wsUrl } = await launch(dir);
269 const cdp = connect(wsUrl);
270 await cdp.ready;
271
272 let failed = 0;
273 const pages = [...PAGES];
274 for (const path of await discoverPaths(cdp)) {
275 // Content pages carry the same carousel; check they loaded clean and that
276 // every island on them ran, without asserting the seed's shape.
277 pages.push({ path, expect: null });
278 }
279
280 for (const { path, expect } of pages) {
281 const url = `${BASE}${path}`;
282 const problems = await checkPage(cdp, url, expect);
283 if (problems.length === 0) {
284 console.log(`ok ${url}`);
285 } else {
286 failed += 1;
287 console.log(`FAIL ${url}`);
288 for (const p of problems) console.log(` ${p}`);
289 }
290 }
291
292 child.kill();
293 rmSync(dir, { recursive: true, force: true });
294
295 if (failed > 0) {
296 console.log(`\npage-smoke: ${failed} page(s) failed against ${BASE}`);
297 process.exit(1);
298 }
299 console.log(`\npage-smoke: clean against ${BASE}`);
300 process.exit(0);
301