Files
stack/agents/dewey/work/wui/checks/mockups-verify.mjs
T
jason.woltjeandClaude Opus 5.5 1f4882d2b4 docs(wui): Dewey's design work: brand board, five mockups, slice 1 S5 views and checks
Brand board revisions, the five dashboard mockups with their verify
script and evidence, the slice 1 S5 design note (SLICE1-VIEWS.md, rev 3
plus the S2c section 10 update), the Console mockup of inbox, tasks,
agents and trail (mockups/slice1/) and checks/slice1-verify.mjs (16
checks; check 16 pinned to bus at d27042fa). Also the CHAT-03 progress
log and the 2026-09-26 dirty-tree manifest. Design and evidence only;
no package code. Row 40 (#1522), committed on its own at Sage's
direction, 2026-10-09.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:54:50 -05:00

245 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Verification for the five WUI mockups. Run: node checks/mockups-verify.mjs
// Writes screenshots and a JSON report under evidence/mockups/. Uses the installed Chromium over CDP; no npm.
import { browser } from './browser.mjs';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const base = 'file://' + join(root, 'mockups') + '/';
const out = join(root, 'evidence', 'mockups');
await mkdir(out, { recursive: true });
const designs = ['d1-rail', 'd2-deck', 'd3-console', 'd4-board', 'd5-stream'];
const routes = ['#/', '#/sessions', '#/sessions/s-01a07cfa', '#/sessions/s-01a08111', '#/projects', '#/projects/mosaic-stack', '#/agents', '#/work', '#/runs', '#/messages', '#/library', '#/settings', '#/launch', '#/does-not-exist'];
const widths = [320, 390, 768, 960, 1440, 1920, 2560, 3440];
const modes = ['light', 'dim', 'dark'];
const report = { generated: new Date().toISOString(), checks: [] };
let failures = 0;
function record(name, status, detail = '') { report.checks.push({ name, status, detail }); if (status === 'fail') failures++; const mark = { pass: 'PASS', fail: 'FAIL', 'not verified': 'N/V ' }[status]; console.log(`${mark} ${name}${detail ? ' : ' + detail : ''}`); }
const b = await browser();
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.__errs=[];window.addEventListener("error",e=>window.__errs.push(e.message+" @"+(e.filename||"").split("/").pop()+":"+e.lineno));window.addEventListener("unhandledrejection",e=>window.__errs.push("rejection: "+e.reason));' });
const settle = ms => b.evaluate(`new Promise(r=>setTimeout(r,${ms}))`);
const go = async hash => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await settle(60); };
const page = async (d, query = '') => { await b.navigate(`${base}${d}.html${query}`); await settle(60); };
const probe = () => b.evaluate(`JSON.stringify({errs:window.__errs, sw:document.documentElement.scrollWidth, cw:document.documentElement.clientWidth, main:(document.getElementById('main')||{}).innerHTML?.length||0, title:document.title, h1:document.querySelector('#main h1')?.textContent||''})`).then(JSON.parse);
// 1. Every route in every design at every width: renders, no script errors, no horizontal overflow.
for (const d of designs) {
const problems = [];
for (const w of widths) {
await b.viewport(w, w < 700 ? 844 : 1000);
await page(d);
for (const r of routes) {
await go(r);
const p = await probe();
if (p.errs.length) problems.push(`${w} ${r} errors: ${p.errs.join('; ')}`);
if (p.sw > p.cw) problems.push(`${w} ${r} overflow ${p.sw}>${p.cw}`);
if (p.main < 200) problems.push(`${w} ${r} empty main`);
if (r === '#/does-not-exist' && !/not found/i.test(p.h1)) problems.push(`${w} not-found page missing`);
if (r === '#/' && (w === 390 || w === 1440 || w === 3440)) await b.screenshot(join(out, `${d}-home-${w}.png`));
if (w === 1440 && ['#/sessions/s-01a07cfa', '#/work', '#/launch', '#/settings'].includes(r)) await b.screenshot(join(out, `${d}-${r.slice(2).replace(/[^a-z0-9]+/gi, '-')}-1440.png`));
if (w === 390 && r === '#/sessions/s-01a07cfa') await b.screenshot(join(out, `${d}-session-390.png`));
}
}
record(`${d}: ${routes.length} routes × ${widths.length} widths render without script errors or horizontal overflow`, problems.length ? 'fail' : 'pass', problems.slice(0, 6).join(' | '));
}
// Index page
{
const problems = [];
for (const w of widths) { await b.viewport(w, 1000); await page('index'); const p = await probe(); if (p.errs.length || p.sw > p.cw) problems.push(`${w}: ${p.errs.join(';')} ${p.sw > p.cw ? 'overflow' : ''}`); }
await b.viewport(1440, 1000); await page('index'); await b.screenshot(join(out, 'index-1440.png'));
await b.viewport(390, 844); await b.screenshot(join(out, 'index-390.png'));
const links = await b.evaluate(`[...document.querySelectorAll('#designs a.primary')].map(a=>a.getAttribute('href').split('?')[0])`);
record('index: renders at all widths and links all five designs', problems.length || designs.some(d => !links.includes(d + '.html')) ? 'fail' : 'pass', problems.join(' | ') || links.join(', '));
}
// 2. Palettes and modes: every palette in every mode applies tokens, and measured text contrast on the rendered board meets 4.5:1 (3:1 for large text and UI parts).
await b.viewport(1440, 1000);
{
const palettes = await (async () => { await page('d1-rail'); return b.evaluate('window.Brand.palettes.map(p=>p.id)'); })();
const fails = [];
const contrastScript = `(() => {
const parse = c => { if (c.startsWith('color(')) { const m = c.match(/[\\d.]+/g).map(Number); return [m[0] * 255, m[1] * 255, m[2] * 255, m.length > 3 ? m[3] : 1]; } const m = c.match(/[\\d.]+/g).map(Number); return [m[0], m[1], m[2], m.length > 3 ? m[3] : 1]; };
const lum = c => { const m = parse(c); const f = v => { v /= 255; return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; }; return .2126 * f(m[0]) + .7152 * f(m[1]) + .0722 * f(m[2]); };
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
const bgOf = el => { let e = el; while (e) { const bg = getComputedStyle(e).backgroundColor; if (bg && !bg.startsWith('rgba(0, 0, 0, 0)') && bg !== 'transparent') return bg; e = e.parentElement; } return getComputedStyle(document.documentElement).backgroundColor; };
const blend = (fg, bg) => { const f = parse(fg), g = parse(bg); const a = f[3]; if (a >= 1) return fg; return 'rgb(' + [0,1,2].map(i => Math.round(f[i] * a + g[i] * (1 - a))).join(', ') + ')'; };
const bgFlat = el => { let e = el, acc = null; const layers = []; while (e) { const bg = getComputedStyle(e).backgroundColor; if (bg && bg !== 'transparent' && !bg.startsWith('rgba(0, 0, 0, 0)')) layers.push(bg); e = e.parentElement; } layers.push(getComputedStyle(document.documentElement).backgroundColor); let result = layers.pop(); while (layers.length) result = blend(layers.pop(), result); return result; };
const sel = ['#main h1', '.wait-detail', '.wait-since', '.goal-report', '.muted', '.badge-ok', '.badge-attn', '.badge-danger', '.badge-muted', '.btn.primary', '.btn:not(.primary)', '.nav-link[aria-current=page]', '.feat-implemented', '.feat-planned', '.feat-proposed', '.feat-mockup', '.cfg-changed', '.count', 'th', '.tab[aria-current=page]', '.bar-link[aria-current=page]', '.cmd-link[aria-current=page]', 'a.rail-count', '.rail-count', '.tab-dot', '.chip[aria-current=true]', '.gen', '.event time', 'code', '.stream a', '.crumbs a', '.legend-link'];
const out = [];
for (const s of sel) { const el = document.querySelector(s); if (!el) continue; const cs = getComputedStyle(el); const fg = cs.color; const bg = bgFlat(el); const size = parseFloat(cs.fontSize); const large = size >= 24 || (size >= 18.66 && parseInt(cs.fontWeight) >= 700); const r = ratio(fg, bg); out.push({ s, r: +r.toFixed(2), need: large ? 3 : 4.5, ok: r >= (large ? 3 : 4.5) }); }
return out;
})()`;
const worst = [];
for (const p of palettes) for (const m of modes) {
await page('d1-rail', `?palette=${p}&mode=${m}`);
const applied = await b.evaluate(`document.documentElement.dataset.palette+'/'+document.documentElement.dataset.mode`);
if (applied !== `${p}/${m}`) fails.push(`${p}/${m} not applied (${applied})`);
const rows = await b.evaluate(contrastScript);
for (const r of rows) if (!r.ok) fails.push(`${p}/${m} ${r.s} ${r.r}:1 < ${r.need}`);
worst.push(...rows.map(r => ({ p, m, ...r })));
if (p === 'harbor' || (p === 'graphite' && m === 'dim') || (p === 'grove' && m === 'dark')) await b.screenshot(join(out, `palette-${p}-${m}.png`));
}
worst.sort((a, b) => a.r - b.r);
report.contrastLowest = worst.slice(0, 12);
record(`palettes × modes: ${palettes.length} palettes × 3 modes apply, and ${worst.length} sampled text/UI pairs meet WCAG contrast`, fails.length ? 'fail' : 'pass', fails.slice(0, 8).join(' | ') || `lowest ${worst[0].r}:1 (${worst[0].p}/${worst[0].m} ${worst[0].s})`);
}
// 3. Demo states on every design's board: loading, empty, error render with recovery, and the error state offers retry.
for (const d of designs) {
const problems = [];
for (const s of ['loading', 'empty', 'error']) {
await page(d, `?state=${s}`);
const t = await b.evaluate(`document.getElementById('main').innerText`);
if (s === 'loading' && !/Loading/.test(t)) problems.push('loading text missing');
if (s === 'empty' && !/No .* yet/.test(t)) problems.push('empty text missing');
if (s === 'error' && !(/Could not load/.test(t) && /unchanged/.test(t) && await b.evaluate(`!!document.querySelector('[data-action=retry]')`))) problems.push('error state lacks explanation or retry');
if (d === 'd1-rail' || d === 'd4-board') await b.screenshot(join(out, `${d}-state-${s}-1440.png`));
}
// Retry recovers
await b.evaluate(`document.querySelector('[data-action=retry]').click()`); await settle(60);
if (await b.evaluate(`window.MosaicApp.ctx.demoState`) !== 'normal') problems.push('retry did not recover');
record(`${d}: loading, empty, and error states render and error recovers via retry`, problems.length ? 'fail' : 'pass', problems.join(' | '));
}
// 4. Primary flows (simulated): launch conflict, retired-workspace refusal, successful simulated launch, empty-message guard, disabled controls on a session you already control, config-changed notice non-blocking.
for (const d of designs) {
const problems = [];
await page(d); await go('#/launch');
const submit = async (set) => b.evaluate(`(() => { const f = document.querySelector('form[data-form=launch]'); ${Object.entries(set).map(([k, v]) => `f.elements['${k}'].value = ${JSON.stringify(v)}; f.elements['${k}'].dispatchEvent(new Event('change', {bubbles:true}));`).join('')} f.requestSubmit(); return f.querySelector('.form-result').innerText; })()`);
const conflict = await submit({ agent: 'dewey', project: 'mosaic-stack', workspace: 'wui' });
if (!/Already running/.test(conflict) || !/Nothing was launched twice/.test(conflict)) problems.push('conflict path: ' + conflict.slice(0, 80));
await b.evaluate(`(() => { const f = document.querySelector('form[data-form=launch]'); f.elements.project.value = 'mosaic-stack'; f.elements.project.dispatchEvent(new Event('change', {bubbles:true})); })()`);
const refusal = await submit({ agent: 'rocko', workspace: 'archive-2025' });
if (!/Refused/.test(refusal) || !/Nothing was launched/.test(refusal)) problems.push('refusal path: ' + refusal.slice(0, 80));
const ok = await submit({ agent: 'sage', project: 'mosaic-stack', workspace: 'skills' });
if (!/Simulated launch/.test(ok)) problems.push('success path: ' + ok.slice(0, 80));
if (d === 'd2-deck') await b.screenshot(join(out, 'd2-deck-launch-refusal-1440.png'));
await go('#/sessions/s-01a08111');
const guard = await b.evaluate(`(() => { const f = document.querySelector('form[data-form=composer]'); f.requestSubmit(); return f.querySelector('.form-result').innerText; })()`);
if (!/Write a message first/.test(guard)) problems.push('empty message guard: ' + guard.slice(0, 60));
const disabled = await b.evaluate(`[...document.querySelectorAll('.head-actions button')].map(b => b.textContent.trim() + ':' + b.disabled).join(',')`);
if (!/Observe:true/.test(disabled) || !/You control:true/.test(disabled)) problems.push('controller buttons: ' + disabled);
await go('#/sessions/s-01a07cfa');
const cfg = await b.evaluate(`(() => { const p = document.querySelector('.cfg-panel'); return (p.classList.contains('cfg-changed') ? 'changed ' : '') + (document.querySelector('form[data-form=composer] textarea').disabled ? 'composer-disabled' : 'composer-enabled'); })()`);
if (cfg !== 'changed composer-enabled') problems.push('config notice: ' + cfg);
const toast = await b.evaluate(`(() => { document.querySelector('[data-action^="config-check"]').click(); return document.querySelector('#toasts .toast')?.innerText || ''; })()`);
if (!/Simulated/.test(toast) || !/No live effect/.test(toast)) problems.push('simulated action not labelled: ' + toast.slice(0, 60));
// Settings: palette + mode persist through reload
await go('#/settings');
await b.evaluate(`(() => { const s = document.getElementById('palette-select'); s.value = 'grove'; s.dispatchEvent(new Event('change', {bubbles:true})); const r = document.querySelector('[data-theme=mode][value=dim]'); r.checked = true; r.dispatchEvent(new Event('change', {bubbles:true})); })()`);
await page(d);
const persisted = await b.evaluate(`document.documentElement.dataset.palette + '/' + document.documentElement.dataset.mode`);
if (persisted !== 'grove/dim') problems.push('persistence: ' + persisted);
await b.evaluate(`localStorage.removeItem('mosaic-wui-mockup-v1')`);
record(`${d}: launch conflict, retired refusal, simulated launch, empty-message guard, controller-disabled buttons, non-blocking config notice, labelled simulation, appearance persistence`, problems.length ? 'fail' : 'pass', problems.join(' | '));
}
// 5. Keyboard path: skip link first, Tab reaches navigation and the first waiting action, Escape closes overlays, design-specific keys.
for (const d of designs) {
const problems = [];
await b.viewport(1440, 1000); await page(d);
await b.key('Tab');
const first = await b.evaluate(`document.activeElement.className + '|' + document.activeElement.textContent.trim()`);
if (!/skip/.test(first)) problems.push('first tab is not the skip link: ' + first);
await b.key('Enter'); await settle(30);
const afterSkip = await b.evaluate(`document.activeElement.id || document.activeElement.tagName`);
if (afterSkip !== 'main') problems.push('skip link did not focus main: ' + afterSkip);
await b.key('Tab');
const inMain = await b.evaluate(`document.getElementById('main').contains(document.activeElement) && (document.activeElement.tagName + ' ' + document.activeElement.textContent.trim().slice(0, 30))`);
if (!inMain) problems.push('tab after skip did not enter main content');
// Every interactive element in the board has a visible focus style (outline) when focused
const focusOk = await b.evaluate(`(() => { const els = [...document.querySelectorAll('#main a, #main button, #main select, #main textarea')].slice(0, 40); let bad = 0; for (const el of els) { el.focus(); const cs = getComputedStyle(el); if (!document.activeElement.isSameNode(el)) continue; if (!cs.outlineStyle || cs.outlineStyle === 'none' || parseFloat(cs.outlineWidth) < 2) { if (!el.closest('.segmented')) bad++; } } return bad; })()`);
// Chromium computed outline on :focus-visible needs real keyboard focus; if programmatic focus reports none, verify via matches
if (focusOk > 0) { const visible = await b.evaluate(`(() => { const el = document.querySelector('#main a'); el.focus(); return el.matches(':focus-visible') ? getComputedStyle(el).outlineWidth : 'no-focus-visible'; })()`); if (visible === 'no-focus-visible') problems.push(`focus ring: ${focusOk} elements without outline (programmatic focus)`); }
if (d === 'd3-console') {
await b.key('k', 'KeyK', 2); await settle(50);
const open = await b.evaluate(`document.getElementById('palette').open && document.activeElement.id`);
if (open !== 'cmd-input') problems.push('Ctrl+K did not open the command palette');
await b.evaluate(`(() => { const i = document.getElementById('cmd-input'); i.value = 'sess'; i.dispatchEvent(new Event('input', {bubbles:true})); })()`);
await b.key('ArrowDown'); await b.key('Enter'); await settle(80);
const dest = await b.evaluate(`location.hash`);
if (!/sessions/.test(dest)) problems.push('palette Enter did not navigate: ' + dest);
await go('#/');
await b.evaluate(`document.querySelector('table.sessions tbody tr').focus()`);
await b.key('ArrowDown'); await b.key('Enter'); await settle(50);
const insp = await b.evaluate(`!document.getElementById('inspector').hidden && document.querySelector('table.sessions tbody tr[aria-selected=true]')?.dataset.session`);
if (insp !== 's-01a07cfa') problems.push('arrow + enter did not inspect second row: ' + insp);
await b.key('Escape'); await settle(30);
await b.screenshot(join(out, 'd3-console-inspector-1440.png'));
}
if (d === 'd1-rail') {
await b.viewport(390, 844); await page(d);
await b.evaluate(`document.getElementById('menu-btn').click()`); await settle(250);
const opened = await b.evaluate(`document.body.classList.contains('rail-open') && document.getElementById('rail').contains(document.activeElement)`);
if (!opened) problems.push('phone menu did not open or focus the rail');
await b.screenshot(join(out, 'd1-rail-drawer-390.png'));
await b.key('Escape'); await settle(250);
if (await b.evaluate(`document.body.classList.contains('rail-open')`)) problems.push('Escape did not close the drawer');
await b.viewport(1440, 1000); await page(d);
await b.evaluate(`document.getElementById('rail-toggle').click()`); await settle(30);
const collapsed = await b.evaluate(`document.body.classList.contains('rail-collapsed') && document.querySelector('.rail .nav-link').textContent.trim()`);
if (!collapsed) problems.push('rail collapse lost accessible names');
await b.screenshot(join(out, 'd1-rail-collapsed-1440.png'));
await b.evaluate(`document.getElementById('rail-toggle').click(); localStorage.removeItem('mosaic-wui-mockup-v1')`);
}
if (d === 'd2-deck') {
await b.evaluate(`document.querySelector('details.menu').open = true`); await settle(30);
await b.key('Escape'); await settle(30);
if (await b.evaluate(`document.querySelector('details.menu').open`)) problems.push('Escape did not close the appearance menu');
}
if (d === 'd5-stream') {
await b.viewport(390, 844); await page(d);
const tabs = await b.evaluate(`[...document.querySelectorAll('.tabbar > ul > li > a')].length + '/' + getComputedStyle(document.querySelector('.tabbar')).display`);
if (!/^4\/block/.test(tabs)) problems.push('phone tab bar: ' + tabs);
await b.evaluate(`document.querySelector('details.more').open = true`); await settle(30);
const moreLinks = await b.evaluate(`[...document.querySelectorAll('.more-list a')].map(a => a.getAttribute('href')).join(',')`);
for (const need of ['#/projects', '#/agents', '#/runs', '#/library', '#/settings', '#/launch']) if (!moreLinks.includes(need)) problems.push('More menu missing ' + need);
await b.screenshot(join(out, 'd5-stream-more-390.png'));
await b.key('Escape'); await settle(30);
await b.viewport(1440, 1000);
}
record(`${d}: keyboard path (skip link, main focus, focus rings, Escape, design-specific keys)`, problems.length ? 'fail' : 'pass', problems.join(' | '));
}
// 6. Long content and text scaling: a 400-character goal and 200% zoom at 320 px do not overflow.
for (const d of designs) {
const problems = [];
await b.viewport(320, 700); await page(d);
await b.evaluate(`(() => { const D = window.MosaicData; D.sessions[1].goal.text = 'Reconcile the queue steering plan with MOSAIC-STACK-D-001 and produce a written comparison of every affected route, including the sessions list, the session view, and the launch form, then summarise what changes for observers versus controllers'.repeat(2); D.sessions[1].last = 'https://example.invalid/a/very/long/unbroken/path/segment/that/should/wrap/rather/than/overflow/the/column/0123456789'; window.MosaicApp.render(); })()`);
for (const r of ['#/', '#/sessions', '#/sessions/s-01a07cfa', '#/work']) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow at 320 with long text`); }
await b.evaluate(`document.documentElement.style.fontSize = '32px'`); await settle(30);
for (const r of ['#/', '#/sessions/s-01a07cfa', '#/settings']) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow at 320 with 200% text`); }
await go('#/'); await b.screenshot(join(out, `${d}-text-200-320.png`));
record(`${d}: long unbroken content and 200% text at 320 px stay within the viewport`, problems.length ? 'fail' : 'pass', problems.join(' | '));
}
// 7. Secrets and simulation labelling: no credential-shaped values anywhere in the rendered accounts table; every data-action button produces a labelled simulated toast.
{
const problems = [];
await b.viewport(1440, 1000); await page('d1-rail'); await go('#/settings');
const text = await b.evaluate(`document.getElementById('main').innerText`);
if (/sk-[a-z0-9]{8,}|Bearer |-----BEGIN/i.test(text)) problems.push('credential-shaped text on settings page');
if (!/not shown/.test(text)) problems.push('accounts table does not state credentials are not shown');
const count = await b.evaluate(`(() => { let n = 0, bad = []; for (const r of ['#/', '#/sessions/s-01a07cfa', '#/projects/mosaic-stack', '#/agents', '#/work', '#/runs', '#/library', '#/settings']) { location.hash = r; window.MosaicApp.render(); for (const btn of document.querySelectorAll('#main [data-action]:not(:disabled)')) { if (btn.dataset.action.startsWith('observe') || btn.dataset.action.startsWith('cancel') || btn.dataset.action === 'retry') continue; document.querySelector('#toasts').replaceChildren(); btn.click(); const t = document.querySelector('#toasts .toast')?.innerText || ''; n++; if (!/Simulated/.test(t)) bad.push(r + ' ' + btn.dataset.action); } } return JSON.stringify({ n, bad }); })()`).then(JSON.parse);
if (count.bad.length) problems.push('unlabelled actions: ' + count.bad.slice(0, 5).join(', '));
record(`secrets never rendered; ${count.n} action buttons across routes produce a labelled simulated notice`, problems.length ? 'fail' : 'pass', problems.join(' | '));
}
// 8. Reduced motion and forced colors media queries exist in the shared stylesheet (static check; visual verification of forced colors not available headless).
{
const css = await readFile(join(root, 'mockups', 'shared', 'app.css'), 'utf8');
const hasRM = /prefers-reduced-motion/.test(css), hasFC = /forced-colors/.test(css);
record('shared stylesheet declares reduced-motion and forced-colors rules', hasRM && hasFC ? 'pass' : 'fail', `reduced-motion ${hasRM}, forced-colors ${hasFC}`);
record('forced-colors rendering', 'not verified', 'headless Chromium does not emulate Windows high contrast here; rules exist but were not visually checked');
record('screen reader announcement behaviour', 'not verified', 'aria-live regions are present and updated on route change; no screen reader was run');
}
await b.close();
report.summary = { total: report.checks.length, failed: failures, notVerified: report.checks.filter(c => c.status === 'not verified').length };
await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(`\n${report.summary.total} checks, ${failures} failed, ${report.summary.notVerified} not verified. Report: evidence/mockups/report.json`);
process.exit(failures ? 1 : 0);