#!/usr/bin/env node // Browser checks for the design preview (docs/design/mosaic-stack-design.html). // Usage: node docs/design/tools/check-design.mjs [page.html] // Needs a local Chromium: $CHROMIUM, else /usr/bin/chromium. No dependencies. // Exit 0 when every check passes, 1 when any fails, 2 when the browser cannot start. // // Checks: // 1. no script errors while visiting every tab, console view, preview state, // inspector, terminal screen, palette and mode; // 2. no sideways page scroll at 400 px on any tab or console view; // 3. terminal screens are exactly 120 columns (80 for the compact screen) and // no taller than 36 rows (24 for compact); shell screens stay within 120; // 4. the preview's palette colours equal tokens.json for every palette and mode. // The CDP client follows packages/webui/tests/browser.mjs. import { spawn } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const page = resolve(process.argv[2] || join(here, '..', 'mosaic-stack-design.html')); const tokens = JSON.parse(readFileSync(join(here, '..', 'tokens.json'), 'utf8')); async function browser() { const profile = await mkdtemp(join(tmpdir(), 'mosaic-design-check-')); const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [ '--headless', '--no-first-run', '--no-default-browser-check', '--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }); const started = new Promise((ok, fail) => { child.once('error', fail); child.once('spawn', ok); }); await started; let id = 0, buffer = ''; const pending = new Map(), errors = []; child.stdio[4].on('data', (chunk) => { buffer += chunk.toString(); let end; while ((end = buffer.indexOf('\0')) !== -1) { const m = JSON.parse(buffer.slice(0, end)); buffer = buffer.slice(end + 1); if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text); if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') errors.push(m.params.args.map((a) => a.value ?? a.description).join(' ')); if (pending.has(m.id)) { const { ok, fail, timer } = pending.get(m.id); clearTimeout(timer); pending.delete(m.id); m.error ? fail(new Error(JSON.stringify(m.error))) : ok(m.result); } } }); const send = (method, params = {}, sessionId) => new Promise((ok, fail) => { const key = ++id; const timer = setTimeout(() => { pending.delete(key); fail(new Error(`CDP timeout: ${method}`)); }, 25000); pending.set(key, { ok, fail, timer }); child.stdio[3].write(JSON.stringify({ id: key, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0'); }); const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); const call = (method, params) => send(method, params, sessionId); await call('Page.enable'); await call('Runtime.enable'); const evaluate = async (expression) => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description); return r.result.value; }; return { errors, call, evaluate, viewport: (width, height = 900) => call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }), scheme: (value) => call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value }] }), async navigate(url) { const r = await call('Page.navigate', { url }); if (r.errorText) throw new Error(r.errorText); for (let n = 0; n < 50 && !(await evaluate('document.readyState === "complete"')); n++) await new Promise((ok) => setTimeout(ok, 100)); }, async close() { await send('Browser.close').catch(() => {}); if (child.exitCode === null) await new Promise((ok) => child.once('exit', ok)); await rm(profile, { recursive: true, force: true }); }, }; } const results = []; const check = (name, failures) => { results.push({ name, failures }); console.log(`${failures.length ? 'FAIL' : 'pass'} ${name}`); for (const f of failures.slice(0, 12)) console.log(` ${f}`); if (failures.length > 12) console.log(` ... ${failures.length - 12} more`); }; let b; try { b = await browser(); } catch (err) { console.error(`cannot start Chromium (${process.env.CHROMIUM || '/usr/bin/chromium'}): ${err.message}`); process.exit(2); } try { await b.viewport(1280); await b.scheme('light'); await b.navigate(pathToFileURL(page).href); await b.evaluate('localStorage.clear(), true').catch(() => {}); const tabs = await b.evaluate('[...document.querySelectorAll("[id^=tab-]")].map(e => e.id.slice(4))'); const views = await b.evaluate('(document.querySelector("#tab-console").click(), [...document.querySelectorAll("#side [data-go]")].map(e => e.dataset.go))'); const states = await b.evaluate('[...document.querySelectorAll("#stateSel option")].map(o => o.value)'); const screens = await b.evaluate('(document.querySelector("#tab-terminal").click(), [...document.querySelectorAll("#screenList [data-scr]")].map(e => e.dataset.scr))'); const nav = (v) => `document.querySelector('#side [data-go="${v}"]').click()`; const setState = (s) => `(()=>{const e=document.querySelector("#stateSel");e.value=${JSON.stringify(s)};e.dispatchEvent(new Event("change",{bubbles:true}))})()`; // 1. Script errors across every view, state, inspector and screen. const before = b.errors.length; for (const t of tabs) await b.evaluate(`document.querySelector("#tab-${t}").click(), true`); await b.evaluate('document.querySelector("#tab-console").click(), true'); for (const s of states) { await b.evaluate(`${setState(s)}, true`); for (const v of views) { await b.evaluate(`${nav(v)}, true`); await b.evaluate('(()=>{const r=document.querySelector("#view tbody tr");if(r)r.click();document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))})(), true'); } } await b.evaluate(`${setState('normal')}, true`); await b.evaluate('document.querySelector("#tab-terminal").click(), true'); for (const s of screens) await b.evaluate(`document.querySelector('#screenList [data-scr="${s}"]').click(), true`); check(`no script errors (${tabs.length} tabs, ${views.length} views x ${states.length} states, ${screens.length} terminal screens)`, b.errors.slice(before)); // 3. Terminal widths and heights. const termFails = []; for (const s of screens) { const lines = await b.evaluate(`(document.querySelector('#screenList [data-scr="${s}"]').click(), document.querySelector("#term").innerText.split("\\n"))`); const shell = await b.evaluate('document.querySelector("#termTitle").textContent.endsWith("shell")'); const compact = await b.evaluate('document.querySelector("#termTitle").textContent.endsWith("80×24")'); const [w, h] = compact ? [80, 24] : [120, 36]; lines.forEach((l, i) => { const n = [...l].length; if (shell ? n > w : n !== w) termFails.push(`${s} line ${i + 1}: ${n} columns, expected ${shell ? 'at most ' : ''}${w}`); }); if (!shell && lines.length > h) termFails.push(`${s}: ${lines.length} rows, expected at most ${h}`); } check(`terminal screens fit 120x36 and 80x24 (${screens.length} screens)`, termFails); // 2. No sideways scroll at 400 px. await b.viewport(400, 860); const wide = []; const overflow = async (label) => { const r = await b.evaluate('({sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth})'); if (r.sw > r.cw) wide.push(`${label}: page is ${r.sw}px wide in a ${r.cw}px viewport`); }; for (const t of tabs) { await b.evaluate(`document.querySelector("#tab-${t}").click(), true`); await overflow(`tab ${t}`); } await b.evaluate('document.querySelector("#tab-console").click(), true'); for (const v of views) { await b.evaluate(`${nav(v)}, true`); await overflow(`view ${v}`); await b.evaluate('(()=>{const r=document.querySelector("#view tbody tr");if(r)r.click()})(), true'); await overflow(`view ${v} with inspector`); await b.evaluate('document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true})), true'); } check(`no sideways scroll at 400 px (${tabs.length} tabs, ${views.length} views)`, wide); await b.viewport(1280); // 4. Palettes and modes match tokens.json. const colourFails = []; const before4 = b.errors.length; const modes = await b.evaluate('[...document.querySelectorAll("#modeSeg button")].map(e => e.dataset.mode)'); for (const p of tokens.palettes) { await b.evaluate(`(()=>{const e=document.querySelector("#palSel");e.value=${JSON.stringify(p.id)};e.dispatchEvent(new Event("change",{bubbles:true}))})(), true`); for (const m of modes) { for (const scheme of m === 'system' ? ['light', 'dark'] : ['light']) { await b.scheme(scheme); await b.evaluate(`document.querySelector('#modeSeg [data-mode="${m}"]').click(), true`); const want = p.modes[m === 'system' ? scheme : m]; const got = await b.evaluate('(()=>{const s=getComputedStyle(document.documentElement);return {canvas:s.getPropertyValue("--canvas").trim(),action:s.getPropertyValue("--action").trim(),text:s.getPropertyValue("--text").trim(),danger:s.getPropertyValue("--danger").trim()}})()'); for (const k of Object.keys(got)) { if (got[k].toLowerCase() !== String(want[k]).toLowerCase()) colourFails.push(`${p.id} ${m}${m === 'system' ? `/${scheme}` : ''} --${k}: page ${got[k]}, tokens.json ${want[k]}`); } } } } await b.scheme('light'); check(`palette colours match tokens.json (${tokens.palettes.length} palettes x ${modes.length} modes)`, colourFails.concat(b.errors.slice(before4))); } finally { await b.close(); } const failed = results.filter((r) => r.failures.length).length; console.log(failed ? `${failed} of ${results.length} checks failed` : `all ${results.length} checks passed`); process.exit(failed ? 1 : 0);