// Drives a real Chrome at the running server and checks two things that no // assertion in cljs.test can: that the frozen take is a MOVING MOUTH on a canvas, // and that a document which has been through the server comes back as the same // picture. // // This exists because port-plan step 5 is the first step whose done-criterion is // a picture, and a picture cannot be asserted from cljs.test. A take that // resolves to the right numbers and draws nothing would pass every assertion in // arthur.flow.freeze-test: a blank canvas under a perfectly correct transport is // the bug class unit tests miss, and it has already happened once here. // // No dependencies, deliberately. Playwright is not installed and CDP needs // nothing: `node --experimental-websocket` has a global WebSocket and // `--headless=new --remote-debugging-port=N` is the whole of the other side. // // Since step 9 the page is Django's, so the suite needs the backend up rather than // shadow-cljs's `:dev-http`, which is gone. ARTHUR_URL is unchanged because the // port is unchanged — 8778 was never 8777, which is still the old JS tool's. // // mise exec -- python manage.py runserver 8778 # from the REPO ROOT // cd frontend && mise exec -- npx shadow-cljs watch app // cd frontend && mise exec -- node --experimental-websocket test/browser/take.mjs // // Writes a PNG per sampled frame into test/browser/out/ so that "it drew // something" can be checked by eye as well as by pixel count. import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const OUT = join(HERE, 'out'); const URL_ = process.env.ARTHUR_URL ?? 'http://localhost:8778/index.html'; const PORT = 9333; const CHROME = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; // The palette's background, from domain/palette. A pixel of this colour is // nothing drawn, and every check below is a count of pixels that are not it. const BG = [0x12, 0x14, 0x1c]; // :mouth-dark, from domain/palette, packed the way PROBE packs a tone. It is the // mouth INTERIOR's colour and nothing else on the stage carries it, so its // presence is the whole of "the mouth is open" without needing to know where the // mouth is. A whole-canvas tone COUNT cannot answer that any more: step 7 put // eyes, brows, an iris, a pupil and teeth on the same stage, so every frame has // several tones whatever the mouth is doing. const MOUTH_DARK = (0x24 << 16) | (0x16 << 8) | 0x1a; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let failures = 0; function check(ok, label, detail = '') { console.log(`${ok ? ' ok ' : ' FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); if (!ok) failures++; } // --------------------------------------------------------------------------- // the CDP connection async function connect() { const profile = mkdtempSync(join(tmpdir(), 'arthur-chrome-')); const chrome = spawn(CHROME, [ '--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, // The take plays against audio.wav, and the clock IS the audio element, so // without these the frame never advances and "plays back" cannot be checked // at all — the failure would look like a broken rAF loop. '--autoplay-policy=no-user-gesture-required', '--mute-audio', '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--window-size=1200,900', URL_, ], { stdio: ['ignore', 'ignore', 'pipe'] }); chrome.stderr.on('data', () => {}); let wsUrl = null; for (let i = 0; i < 100 && !wsUrl; i++) { await sleep(100); try { const targets = await fetch(`http://127.0.0.1:${PORT}/json/list`).then((r) => r.json()); wsUrl = targets.find((t) => t.type === 'page' && t.url.includes('index.html')) ?.webSocketDebuggerUrl; } catch { /* not up yet */ } } if (!wsUrl) throw new Error(`Chrome never offered a page target on ${PORT}`); const ws = new WebSocket(wsUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); let id = 0; const pending = new Map(); const logs = []; ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result); } else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { logs.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); } else if (msg.method === 'Runtime.exceptionThrown') { logs.push(msg.params.exceptionDetails.text + ' ' + (msg.params.exceptionDetails.exception?.description ?? '')); } }; const send = (method, params = {}) => new Promise((res, rej) => { const n = ++id; pending.set(n, { res, rej }); ws.send(JSON.stringify({ id: n, method, params })); }); await send('Runtime.enable'); await send('Page.enable'); return { send, logs, async eval(expr) { const r = await this.send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true, }); if (r.exceptionDetails) { throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); } return r.result.value; }, async shot(name) { const r = await this.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64')); }, // Teardown must not be able to fail the run. `chrome.kill()` returns as soon // as the signal is sent, so Chrome is often still writing its profile when // rmSync walks it, and macOS then throws ENOTEMPTY on a directory that // reappeared mid-delete. That throw used to propagate out of main's `finally` // and past the summary line, so the suite exited 2 and printed no verdict // whether it had passed or failed — which is how a stale assertion sat here // unnoticed. A leaked temp profile is worth less than a truthful exit code. close() { ws.close(); chrome.kill(); try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } catch (e) { console.log(` note could not remove the temp profile — ${e.code ?? e.message}`); } }, }; } // --------------------------------------------------------------------------- // what we ask the page // // Every probe reads the canvas's own pixels rather than a screenshot: the CSS // scales the stage up by 2 with image-rendering:pixelated, so a screenshot is // four pixels per raster pixel and is for looking at, not for counting. const PROBE = `(() => { const c = document.querySelector('canvas.stage'); if (!c) return null; const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; const bg = [${BG.join(',')}]; let drawn = 0; const tones = new Set(); let cx = 0, cy = 0; for (let i = 0; i < d.length; i += 4) { if (d[i] === bg[0] && d[i+1] === bg[1] && d[i+2] === bg[2]) continue; drawn++; tones.add((d[i] << 16) | (d[i+1] << 8) | d[i+2]); const p = i / 4; cx += p % c.width; cy += Math.floor(p / c.width); } // A cheap 32-bit hash of the whole buffer: two frames with the same drawn // count can still be different pictures, and "the mouth moved" is a question // about the picture. let h = 2166136261; for (let i = 0; i < d.length; i += 4) { h ^= d[i] + d[i+1] * 31 + d[i+2] * 131; h = Math.imul(h, 16777619); } return { w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones], cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null, hash: h >>> 0, frame: document.querySelector('.readout span')?.textContent ?? '', selectedClip: [...document.querySelectorAll('.transport .row button')] .filter((b) => b.classList.contains('on')).map((b) => b.textContent), }; })()`; const SEEK = (f) => `(() => { const el = document.querySelector('input.scrub'); // React installs its own value setter on the element, so assigning .value // directly updates the DOM and not React's idea of it, and onChange never // fires. The prototype-level setter plus a bubbling 'input' event is what // React's synthetic onChange actually listens for. const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(el, '${f}'); el.dispatchEvent(new Event('input', { bubbles: true })); return el.value; })()`; // Everything the page has to say about loading, saving and opening. Read off the // page rather than out of app-db, for the same reason the playhead is: what the // page SHOWS is what a person would check. const STATUS = `[...document.querySelectorAll('.load-status')].map((d) => d.textContent).join(' | ')`; const CLICK = (label) => `(() => { const b = [...document.querySelectorAll('.transport button')] .find((b) => b.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true; })()`; // --------------------------------------------------------------------------- async function main() { rmSync(OUT, { recursive: true, force: true }); mkdirSync(OUT, { recursive: true }); const page = await connect(); try { // Mounted, and painting. Polled rather than waited on a fixed delay: the // canvas :ref fires after the loop starts, so there genuinely is a window in // which the page is up and the canvas is blank. let probe = null; for (let i = 0; i < 100; i++) { probe = await page.eval(PROBE); if (probe && probe.drawn > 0) break; await sleep(100); } if (!probe) throw new Error('no canvas.stage on the page — ' + (page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?')); console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`); check(probe.selectedClip.includes('take'), 'the take is the clip that opens'); check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size', `${probe.w}x${probe.h}`); check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`); // --- it is a mouth: two tones, one inside the other --- // // The three-layer structure is what makes a flat shape read as an opening // rather than a blob, so the interior being its OWN tone is the check that // this is a mouth and not one polygon. Asked of :mouth-dark specifically // rather than of the tone count: the count is now dominated by the eyes and // brows, which are on the stage whatever the mouth is doing. await page.eval(SEEK(10)); // beat 1: wide open await sleep(120); const open = await page.eval(PROBE); await page.shot('take-open'); check(open.toneSet.includes(MOUTH_DARK), 'an open mouth draws an outline and an interior', `${open.tones} tones, interior ${open.toneSet.includes(MOUTH_DARK) ? 'present' : 'MISSING'}`); await page.eval(SEEK(28)); // beat 3: shut await sleep(120); const shut = await page.eval(PROBE); await page.shot('take-shut'); check(!shut.toneSet.includes(MOUTH_DARK), 'a shut mouth draws the outline alone', `${shut.tones} tones, interior ${shut.toneSet.includes(MOUTH_DARK) ? 'STILL DRAWN' : 'gone'}`); check(open.hash !== shut.hash, 'the open and the shut mouth are different pictures'); check(open.drawn > shut.drawn, 'the open mouth covers more of the stage', `${open.drawn} vs ${shut.drawn} px`); // --- it moves under the head, and the head is a channel --- const filmed = []; for (const f of [0, 40, 80, 120, 160, 200]) { await page.eval(SEEK(f)); await sleep(120); filmed.push(await page.eval(PROBE)); } check(new Set(filmed.map((p) => p.hash)).size === filmed.length, 'every sampled frame is a different picture'); const xs = filmed.map((p) => p.cx); check(Math.max(...xs) - Math.min(...xs) > 8, 'as filmed, the head carries the mouth across the stage', `centroid x spans ${(Math.max(...xs) - Math.min(...xs)).toFixed(1)} px`); check(await page.eval(CLICK('locked')), 'the locked take is selectable'); await sleep(200); const locked = []; for (const f of [0, 40, 80, 120, 160, 200]) { await page.eval(SEEK(f)); await sleep(120); locked.push(await page.eval(PROBE)); } await page.shot('take-locked'); const lxs = locked.map((p) => p.cx); check(locked.every((p) => p.drawn > 200), 'the locked take draws too'); // The same blocks with one node's channels written differently: the mouth // still articulates, and the head no longer wanders. That is the claim // "stabilisation is a channel, not a mode", by eye. check(Math.max(...lxs) - Math.min(...lxs) < (Math.max(...xs) - Math.min(...xs)) / 2, 'locked, the head holds still while the mouth still articulates', `centroid x spans ${(Math.max(...lxs) - Math.min(...lxs)).toFixed(1)} px`); check(new Set(locked.map((p) => p.hash)).size > 3, 'and it is still a performance, not a still frame'); // --- it PLAYS, against the audio clock --- check(await page.eval(CLICK('take')), 'back to the take'); await sleep(150); await page.eval(SEEK(0)); await sleep(150); check(await page.eval(CLICK('play')), 'play is clickable'); const during = []; for (let i = 0; i < 8; i++) { await sleep(180); during.push(await page.eval(PROBE)); } await page.eval(CLICK('pause')); // The readout is "frame 12 / 229", so the first run of digits is the // playhead. Parsed rather than reached for in app-db on purpose: what the // page SHOWS is what a person would check, and the readout agreeing with the // picture is half of what the transport is for. const nums = during.map((p) => parseInt((p.frame.match(/\d+/) ?? [NaN])[0], 10)); check(nums[nums.length - 1] > nums[0] + 5, 'the playhead advances under the clock', `frame ${nums[0]} -> ${nums[nums.length - 1]}`); check(new Set(during.map((p) => p.hash)).size > 4, 'and the picture changes while it runs', `${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`); await page.shot('take-playing'); // --- it round-trips through the server --- // // THE DONE CRITERION of port-plan step 9, end to end: tier 1 over HTTP, tier 2 // as content-addressed blocks, and the same frames on the far side. The ops are // compared frame for frame in arthur.domain.project-test, which is the strict // version of this; what only a browser can check is that the whole path — the // CSRF header, the block upload, the leaf write, the reload, the typed arrays // rebuilt out of base64 — draws the same pixels at the end of it. async function statusMatching(pattern, tries = 120) { for (let i = 0; i < tries; i++) { const text = await page.eval(STATUS); if (pattern.test(text)) return text; await sleep(250); } return null; } async function sample(frames) { const out = []; for (const f of frames) { await page.eval(SEEK(f)); await sleep(120); out.push(await page.eval(PROBE)); } return out; } const FRAMES = [0, 10, 28, 80, 160]; check(await page.eval(CLICK('take')), 'back to the take, for the round trip'); await sleep(150); const sent = await sample(FRAMES); check(await page.eval(CLICK('save')), 'save is clickable'); const saved = await statusMatching(/saved r\d+/); check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS))); // Upload count may be zero when the content-addressed blocks already exist // on this server. The saved clip must still reference them. const savedBlocks = await page.eval(`(async () => { const listed = await fetch('/api/projects').then(r => r.json()); const project = await fetch('/api/projects/' + listed.projects[0].id).then(r => r.json()); return project.clips[0].blocks.length; })()`); check(savedBlocks > 0, 'the saved clip references its tier 2 blocks', `${savedBlocks} blocks`); // Again, unchanged. Content addressing means the second save uploads nothing // and rewrites nothing: this is the assertion that the keys are stable across // two independent freezes of the same take, and that an unchanged leaf keeps // its version rather than being rewritten. check(await page.eval(CLICK('save')), 'save is clickable again'); const resaved = await statusMatching(/saved r\d+ · 0 leaves · 0 blocks/); check(resaved !== null, 'saving an unchanged document writes nothing', resaved ?? (await page.eval(STATUS))); check(await page.eval(CLICK('open')), 'open is clickable'); const opened = await statusMatching(/opened /); check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS))); const back = await sample(FRAMES); await page.shot('take-round-trip'); check(back.every((p) => p.drawn > 200), 'the reopened take draws', back.map((p) => p.drawn).join(',')); check(FRAMES.every((f, i) => sent[i].hash === back[i].hash), 'every sampled frame is the same picture after the round trip', FRAMES.filter((f, i) => sent[i].hash !== back[i].hash).join(',') || 'all identical'); check(back[1].toneSet.includes(MOUTH_DARK), 'and the open mouth still has an interior on the far side'); // The reopened clip is not one of the built-ins: this is the document that came // back from the server, not the one that was in the page all along. check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document', JSON.stringify(back[0].selectedClip)); // The local 8625 study is optional in a fresh test database. When present, // tune its shared symbol while its stage is playing. const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f') .then(r => r.ok)`); if (stageSource) { check(await page.eval(CLICK('stage 8625')), 'the 8625 stage loads'); const stageLoaded = await statusMatching(/loaded 8625 stage study/, 160); check(stageLoaded !== null, 'the 8625 stage is ready', stageLoaded ?? (await page.eval(STATUS))); const eyeSelected = await page.eval(`(() => { const select = document.querySelector('.controls select'); // The instance prefix is the placement's NAME ("8625 left"), not its id: // a placement is keyed by a uuid now, and a uuid is not something to show // anyone. So this matches the feature and requires SOME instance prefix, // rather than pinning the label a rename is free to change. const option = [...select.options] .find(o => o.textContent.trim().endsWith('/ feature · eye-r') && o.textContent.includes(' / ')); if (!option) return false; select.value = option.value; select.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`); check(eyeSelected, 'a stage instance exposes its tracked right eye'); await sleep(100); const irisSlider = await page.eval(`(() => { const row = [...document.querySelectorAll('.control-row')] .find(row => row.querySelector('span')?.textContent === 'iris-size'); if (!row) return null; // Scrolled into view FIRST, because the click below is dispatched at // viewport coordinates: a control panel that has grown past the fold // otherwise reports a y outside the window, the click lands on nothing, // and the failure reads as "regeneration is broken" rather than "the // slider was off-screen". Which is exactly what it read as once. row.scrollIntoView({ block: 'center' }); const r = row.querySelector('input').getBoundingClientRect(); return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 }; })()`); check(irisSlider !== null, 'the stage eye has an iris-size slider'); if (irisSlider) { check(await page.eval(CLICK('play')), 'the stage starts playing'); const startFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`); await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1, }); await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1, }); const preview = await statusMatching(/preview · unsaved/, 160); const debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`); check(preview !== null, 'the slider updates the stage preview', preview ?? debug); check(debug.includes(':eye-r') && debug.includes('tier 1 only'), 'the panel reports the affected feature and tier', debug); const endFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`); check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`); await page.eval(CLICK('pause')); } const subjectSelected = await page.eval(`(() => { const select = document.querySelector('.controls select'); const option = [...select.options] .find(o => o.textContent.includes('subject · face-1')); if (!option) return false; select.value = option.value; select.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`); check(subjectSelected, 'the stage exposes its tracked subject'); if (subjectSelected) { await sleep(100); const anchorSlider = await page.eval(`(() => { const row = [...document.querySelectorAll('.control-row')] .find(row => row.querySelector('span')?.textContent === 'anchor-avg'); if (!row) return null; // Scrolled into view FIRST, because the click below is dispatched at // viewport coordinates: a control panel that has grown past the fold // otherwise reports a y outside the window, the click lands on nothing, // and the failure reads as "regeneration is broken" rather than "the // slider was off-screen". Which is exactly what it read as once. row.scrollIntoView({ block: 'center' }); const r = row.querySelector('input').getBoundingClientRect(); return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 }; })()`); check(anchorSlider !== null, 'the subject has an anchor slider'); if (anchorSlider) { const anchorStart = Date.now(); await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: anchorSlider.x, y: anchorSlider.y, button: 'left', clickCount: 1, }); await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: anchorSlider.x, y: anchorSlider.y, button: 'left', clickCount: 1, }); let debug = ''; for (let i = 0; i < 160; i++) { debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`); if (debug.includes('head-pos')) break; await sleep(250); } check(debug.includes('head-pos') && debug.includes(':teeth'), 'anchor invalidates the head and teeth', debug); const preview = await statusMatching(/preview · unsaved/, 160); check(preview !== null, 'the anchor edit finishes previewing', preview ? `${Date.now() - anchorStart}ms` : (await page.eval(STATUS))); } } const teethSelected = await page.eval(`(() => { const select = document.querySelector('.controls select'); const option = [...select.options] .find(o => o.textContent.trim().endsWith('/ feature · teeth') && o.textContent.includes(' / ')); if (!option) return false; select.value = option.value; select.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`); check(teethSelected, 'the stage exposes its tracked teeth'); if (teethSelected) { await sleep(100); const slider = await page.eval(`(() => { const row = [...document.querySelectorAll('.control-row')] .find(row => row.querySelector('span')?.textContent === 'cavity-erode'); if (!row) return null; // Scrolled into view FIRST, because the click below is dispatched at // viewport coordinates: a control panel that has grown past the fold // otherwise reports a y outside the window, the click lands on nothing, // and the failure reads as "regeneration is broken" rather than "the // slider was off-screen". Which is exactly what it read as once. row.scrollIntoView({ block: 'center' }); const r = row.querySelector('input').getBoundingClientRect(); return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 }; })()`); check(slider !== null, 'the teeth have a pixel-setting slider'); if (slider) { await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: slider.x, y: slider.y, button: 'left', clickCount: 1, }); await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: slider.x, y: slider.y, button: 'left', clickCount: 1, }); let debug = ''; for (let i = 0; i < 160; i++) { debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`); if (debug.includes('dirty features: [:teeth]')) break; await sleep(250); } check(debug.includes('dirty features: [:teeth]'), 'the pixel setting invalidates only teeth', debug); const preview = await statusMatching(/preview · unsaved/, 160); check(preview !== null, 'the teeth edit finishes previewing', preview ?? (await page.eval(STATUS))); } } } // Exercise the actual file input and FormData path, then wait for the // extraction job's footage to appear in the server list. const uploadDir = mkdtempSync(join(tmpdir(), 'arthur-upload-')); try { const video = join(uploadDir, 'browser-upload.mp4'); const encoded = spawnSync('ffmpeg', [ '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'color=c=blue:s=64x48:r=4:d=1', '-c:v', 'mpeg4', video, ]); if (encoded.status !== 0) throw new Error(`ffmpeg failed: ${encoded.stderr}`); const { root } = await page.send('DOM.getDocument'); const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'input[type=file]', }); await page.send('DOM.setFileInputFiles', { files: [video], nodeId }); const extracted = await statusMatching(/video extracted/, 160); check(extracted !== null, 'the page uploads and extracts a video', extracted ?? (await page.eval(STATUS))); const uploadedFootage = await page.eval(`(async () => { const listed = await fetch('/api/footage').then(r => r.json()); return listed.footage.some(f => f.label === 'browser-upload.mp4' && f.frames === 4); })()`); check(uploadedFootage, 'the uploaded footage is selectable'); } finally { rmSync(uploadDir, { recursive: true, force: true }); } check(page.logs.length === 0, 'no errors on the console', page.logs.slice(0, 3).join(' | ')); } finally { page.close(); } console.log(`\n${failures ? `${failures} FAILED` : 'all checks passed'}` + ` — screenshots in test/browser/out/\n`); process.exit(failures ? 1 : 0); } main().catch((e) => { console.error(e); process.exit(2); });