Files
arah2/web/scripts/capture.mjs
T
araharah-executor c9b1128a0f test(05-05): add five route capture legs racing ARAHARAH-ROUTE (RED)
- SIGNALS + seen-map + diagnostics gain ARAHARAH-ROUTE; waitMode 'route'
  races the state token, asserts expectState + expectText DOM text, and
  applies the PX-02 theme-slot honesty check on themed legs
- captureLeg gains an interact(page) pre-shot hook (the alt leg's chip tap)
- five legs data-driven from the committed corpus (demoPair picked at
  runtime — never a hardcoded pair literal): fixture / alt / pending /
  dark / route-gallery-compare (composer refactored into composeCompare)
- expected list grows 8 → 13
- RED by construction: the ?fixpair knob does not exist yet, so the
  default fix sits outside the demo pair's 200m tolerance → the drawn
  legs must fail with state=pending
2026-10-24 05:47:40 +08:00

588 lines
28 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.
#!/usr/bin/env node
/*
* capture.mjs — Phase-3 capture harness (D-11, DSGN-02, GEO-03 runtime probe).
* Extends theme/scripts/screenshot.mjs (Phase 1 precedent) rather than
* replacing it: same MIN_BYTES blank bar, same docroot-guarded static
* server, same READY-signal console race, same FAILED/MISSING/UNDERSIZED
* report + exit(1).
*
* Docroot is web/dist-HARNESS (the --mode harness build), NOT web/dist.
* Reason: web/index.html's entire Mock-Telegram profile block — the mock
* installer and the /api/session + /api/location + /api/location-fallback
* fetch stubs — sits inside `if (__ARAHARAH_HARNESS__)`. `vite build` replaces
* that identifier with `false`, so the branch is dead-code-eliminated and
* web/dist/index.html carries none of it (BA5 scans the dist HTML too).
* Serving web/dist would yield no window.Telegram, a real POST /api/session
* with no Worker behind it, the denied card, and a permanent
* ARAHARAH-LIVE timeout.
*
* Wait signals: RAMS-BASEMAP-READY (earlier gate), then ARAHARAH-LIVE.
* DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first
*
* Outputs (web/screenshots/ — regenerated, gitignored):
* shell-boot-1.png boot stage 1 (validating session card over live map)
* shell-boot-2.png boot stage 2 (acquiring location card)
* shell-live-light.png LIVE state, Telegram light theme
* shell-live-dark.png LIVE state, Telegram dark theme (PX-02)
* shell-denied.png denied card + ASK VIA CHAT (GEO-02 entry point)
* shell-chat-pin.png LIVE via the chat-pin relay (?pin=1, stubbed /api/location)
* shell-dest-preload.png the Mini App booted WITH a preloaded destination (?dest=<id>)
* shell-route-fixture.png route drawn from the demo pair's pinned origin (?fixpair=, 05-05)
* shell-route-alt.png alternative selected via chip tap (ROUTE {n}/{N} re-render, 05-05)
* shell-route-pending.png the honest ROUTE // ROUTER pending card (D-03, 05-05)
* shell-route-dark.png route drawn, Telegram dark theme (PX-02 route half, 05-05)
* gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png
* route-gallery-compare.png DSGN-02 route fidelity artifact (05-05, same composer)
*
* Also runs the GEO-03 runtime probe in-page after boot-to-live and fails
* the run on any non-empty storage surface.
*/
import { createServer } from 'node:http';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
const WEB = resolve(SCRIPTS, '..');
const REPOROOT = resolve(WEB, '..');
const DIST = resolve(SCRIPTS, "..", "dist-harness"); // the HARNESS build — never web/dist (see header)
const SHOTS = join(WEB, 'screenshots');
const MOCKUP_COMPARE = resolve(REPOROOT, 'stitch_minimalist_route_navigator', 'navigate_map_overview', 'screen.png');
/* Minimum plausible non-blank PNG (the Phase-1 blank bar, carried over). */
const MIN_BYTES = 10240;
/* DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first */
const DENIED_LEG_TIMEOUT_MS = 90000;
const SIGNAL_TIMEOUT_MS = 30000;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.png': 'image/png',
'.woff2': 'font/woff2',
'.svg': 'image/svg+xml',
};
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
/* ————— build first, and PROVE it is the harness bundle ————— */
function buildBundles() {
console.log('[capture] building theme → web (production) → web harness…');
execSync('npm run build -w theme && npm run build -w web && npm run build:harness -w web', {
cwd: REPOROOT,
stdio: ['ignore', 'inherit', 'inherit'],
});
if (!existsSync(join(DIST, 'index.html'))) {
console.error('[capture] FAILED web/dist-harness/index.html missing — harness build did not run');
process.exit(1);
}
/* Forward assertion: the harness bundle (web/dist-harness/assets) actually
* contains the installer — the race must be reachably wired, never assumed. */
const assetsDir = join(DIST, 'assets');
let harnessHasInstaller = false;
if (existsSync(assetsDir)) {
for (const f of walk(assetsDir)) {
if (f.endsWith('.js') && readFileSync(f, 'utf8').includes('installMockWebApp')) harnessHasInstaller = true;
}
}
if (!harnessHasInstaller) {
console.error('[capture] FAILED harness build did not include the Mock-Telegram installer — the capture would race forever');
process.exit(1);
}
/* Inverse assertion: the production web/dist built moments ago carries NONE. */
let prodHasInstaller = false;
const prodDir = join(WEB, 'dist');
if (existsSync(prodDir)) {
for (const f of walk(prodDir)) {
if ((f.endsWith('.js') || f.endsWith('.html')) && readFileSync(f, 'utf8').includes('installMockWebApp')) prodHasInstaller = true;
}
}
if (prodHasInstaller) {
console.error('[capture] FAILED installer found under web/dist — production/harness builds leaked into each other (T-03-21)');
process.exit(1);
}
console.log('[capture] harness bundle verified: installer present in dist-harness, absent from dist (both directions)');
}
/* ————— tiny static server: dist-harness as docroot (WR-05 guard kept) ————— */
function serveHarness() {
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://127.0.0.1');
let pathname = decodeURIComponent(url.pathname);
/* The document root is the entry — Vite's output is a flat index.html,
* and every capture leg loads `/?mock=…`, so a bare "/" (and any
* directory-shaped path) must resolve to the dist-harness entry file. */
if (pathname === "/" || pathname === "") pathname = "/index.html";
const target = join(DIST, pathname);
/* docroot guard: the prefix must end with a separator — a bare
* startsWith(DIST) lets sibling dist* paths escape the docroot (WR-05). */
if (target !== DIST && !target.startsWith(DIST + sep)) {
res.writeHead(403);
res.end('forbidden');
return;
}
const body = await readFile(target);
res.writeHead(200, { 'content-type': MIME[extname(target)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404);
res.end('not found');
}
});
return new Promise((resolveReady) => {
server.listen(0, '127.0.0.1', () => resolveReady({ server, port: server.address().port }));
});
}
function sizeOf(file) {
return statSync(file).size;
}
const log = [];
const failures = [];
/* ————— build + serve ————— */
buildBundles();
mkdirSync(SHOTS, { recursive: true });
const { server, port } = await serveHarness();
const base = `http://127.0.0.1:${port}`;
log.push(`serving web/dist-harness/ → ${base}/`);
const browser = await chromium.launch();
const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE', 'ARAHARAH-DEST', 'ARAHARAH-ROUTE'];
/* The five route legs (05-05) are DATA-DRIVEN from the committed corpus:
* the demo pair is picked AT RUNTIME — never a hardcoded pair literal.
* RT10 guarantees >= 4 multi-route pairs, so the find succeeds; the
* fallback keeps the legs runnable in the degenerate single-route case. */
const routePairs = JSON.parse(
readFileSync(resolve(REPOROOT, 'routing/src/data/route-fixtures.json'), 'utf8'),
).pairs;
const demoPair = routePairs.find((p) => p.routes.length >= 2) ?? routePairs[0];
const routeGazetteer = JSON.parse(
readFileSync(resolve(REPOROOT, 'search/src/data/kl-gazetteer.json'), 'utf8'),
);
const routeDestEntry = routeGazetteer.entries.find((e) => e.id === demoPair.destinationId);
if (routeDestEntry === undefined) {
console.error(`[capture] FAILED demo pair destination "${demoPair.destinationId}" is not a gazetteer id`);
process.exit(1);
}
/*
* One capture leg = one context + one screenshot. The Phase-1 READY-race
* shape: a console watcher records per-signal truth, the leg waits on its
* signal, and a timeout is a HARD FAILURE with a diagnostic naming which
* signals fired — never a silent skip, never a blank-but-passing shot.
* waitMode: 'immediate' → shot right after goto (boot card 1)
* 'settle400' → shot after 400ms (boot card 2)
* 'live' → ARAHARAH-LIVE race + 2000ms tile settle
* 'denied' → .lamp[data-state="denied"] race (LINK 3's 60s poll first)
* 'dest' → ARAHARAH-DEST race + state=resolved + card-text poll
* 'route' → ARAHARAH-ROUTE race + expectState + expectText DOM poll
* (05-05; expectState = 'drawn'|'pending', expectText =
* body-text substrings polled up to 15s; a themed leg
* gets the same PX-02 honesty check as 'live';
* `interact(page)` runs after the waits, pre-shot —
* the alt leg's chip tap)
*/
async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, interact }) {
const target = join(SHOTS, name);
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
colorScheme,
deviceScaleFactor: 2,
});
if (grantGeo) {
await context.grantPermissions(['geolocation'], { origin: base });
await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });
}
const page = await context.newPage();
const seen = { 'RAMS-BASEMAP-READY': false, 'ARAHARAH-LIVE': false, 'ARAHARAH-DEST': false, 'ARAHARAH-ROUTE': false, live: 'no' };
const consoleLines = [];
page.on('console', (m) => {
for (const s of SIGNALS) if (m.text().startsWith(s)) seen[s] = true;
consoleLines.push(m.text());
if (consoleLines.length > 20) consoleLines.shift();
});
const signalDiagnostics = () =>
`${seen['RAMS-BASEMAP-READY'] ? 'RAMS-BASEMAP-READY yes' : 'RAMS-BASEMAP-READY no'}; ${seen['ARAHARAH-LIVE'] ? 'ARAHARAH-LIVE yes' : 'ARAHARAH-LIVE no'}; ${seen['ARAHARAH-DEST'] ? 'ARAHARAH-DEST yes' : 'ARAHARAH-DEST no'}; ${seen['ARAHARAH-ROUTE'] ? 'ARAHARAH-ROUTE yes' : 'ARAHARAH-ROUTE no'}; last console: ${JSON.stringify(consoleLines.slice(-5))}`;
try {
await page.goto(`${base}/?${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 });
if (waitMode === 'live') {
const signal = await Promise.race([
page.waitForEvent('console', {
predicate: (m) => m.text().startsWith('ARAHARAH-LIVE'),
timeout: SIGNAL_TIMEOUT_MS,
}).then(() => 'signal'),
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
]);
if (signal === 'timeout') {
throw new Error(`ARAHARAH-LIVE race timed out after ${SIGNAL_TIMEOUT_MS}ms — ${signalDiagnostics()}`);
}
/* PX-02 honesty check (anti-fake, T-03-30): assert the theme slot ACTUALLY
* flipped to the requested scheme before shooting the themed capture. */
const themed = await page.evaluate(() => document.documentElement.dataset.tgTheme ?? null);
if (themed !== colorScheme) {
throw new Error(`theme slot mismatch: expected ${colorScheme}, documentElement[data-tg-theme]=${String(themed)}`);
}
await page.waitForTimeout(2000); // tile render settle (plan: first render + 2s)
} else if (waitMode === 'dest') {
const signal = await Promise.race([
page.waitForEvent('console', {
predicate: (m) => m.text().startsWith('ARAHARAH-DEST'),
timeout: SIGNAL_TIMEOUT_MS,
}).then(() => 'signal'),
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
]);
if (signal === 'timeout') {
throw new Error(`ARAHARAH-DEST race timed out after ${SIGNAL_TIMEOUT_MS}ms — ${signalDiagnostics()}`);
}
/* The destination must actually be the RESOLVED one — a signal that fired
* with state=absent means the payload never reached the resolver (T-04-42).
* The signal is a STATE TOKEN (no id — D-15 hygiene), so the leg then
* proves WHICH destination resolved via the rendered card's text. The card
* mounts when the chain reaches live (the boot stages replace the stack
* transiently), so the DOM check waits rather than sampling once. */
const destLine = consoleLines.filter((l) => l.startsWith('ARAHARAH-DEST')).pop() ?? '';
if (!destLine.includes('state=resolved')) {
throw new Error(`destination did not resolve: "${destLine}" — expected state=resolved`);
}
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('Suria KLCC'),
undefined,
{ timeout: 15000, polling: 250 },
);
await page.waitForTimeout(500);
} else if (waitMode === 'route') {
/* 05-05: race the ARAHARAH-ROUTE console event — a STATE TOKEN only
* (state=drawn|pending, never coordinates; D-15/T-05-13: RS8 pins the
* source, this race pins the runtime). The leg then asserts its
* expected state and DOM text, and themed legs get the same PX-02
* honesty check as the live branch. */
const signal = await Promise.race([
page.waitForEvent('console', {
predicate: (m) => m.text().startsWith('ARAHARAH-ROUTE'),
timeout: SIGNAL_TIMEOUT_MS,
}).then(() => 'signal'),
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
]);
if (signal === 'timeout') {
throw new Error(`ARAHARAH-ROUTE race timed out after ${SIGNAL_TIMEOUT_MS}ms — ${signalDiagnostics()}`);
}
const routeLine = consoleLines.filter((l) => l.startsWith('ARAHARAH-ROUTE')).pop() ?? '';
if (!routeLine.includes(`state=${expectState}`)) {
throw new Error(`route state mismatch: "${routeLine}" — expected state=${expectState}`);
}
for (const text of expectText ?? []) {
await page.waitForFunction(
(t) => (document.body.textContent ?? '').includes(t),
text,
{ timeout: 15000, polling: 250 },
);
}
/* PX-02 honesty check (anti-fake, T-03-30): the theme slot ACTUALLY
* flipped to the requested scheme before shooting. */
const themed = await page.evaluate(() => document.documentElement.dataset.tgTheme ?? null);
if (themed !== colorScheme) {
throw new Error(`theme slot mismatch: expected ${colorScheme}, documentElement[data-tg-theme]=${String(themed)}`);
}
await page.waitForTimeout(2000); // tile render settle (route + camera refit at 400ms)
} else if (waitMode === 'denied') {
// Wait on the denied strip lamp specifically — the awaiting-pin variant of
// the card also carries a CTA during LINK 3's 60s poll, so the CARD alone
// would resolve too early; the lamp flips only when the chain returns denied.
const signal = await Promise.race([
page.waitForSelector('.lamp[data-state="denied"]', { timeout: DENIED_LEG_TIMEOUT_MS }).then(() => 'denied'),
new Promise((r) => setTimeout(() => r('timeout'), DENIED_LEG_TIMEOUT_MS)),
]);
if (signal === 'timeout') {
throw new Error(
`denied card never rendered within ${DENIED_LEG_TIMEOUT_MS}ms (LINK 3's 60s pre-denial chat-pin poll must elapse first) — ${signalDiagnostics()}`,
);
}
await page.waitForSelector('.card .btn-primary', { timeout: 5000 }); // the ASK VIA CHAT CTA is rendered
} else if (waitMode === 'settle400') {
await page.waitForTimeout(400);
} // 'immediate' → no wait beyond goto
if (interact) await interact(page);
await page.screenshot({ path: target });
log.push(`captured ${name} (${sizeOf(target)}B, ?${query || '—'} | wait=${waitMode})`);
} catch (err) {
failures.push(`${name}: ${err.message}`);
} finally {
await context.close();
}
}
try {
/* 1 — boot stage 1: the VALIDATING card held on screen by ?sessiondelay
* (localhost resolves the session in ms — the stage needs a hold to be
* photographed); shot after the 400ms module-settle. */
await captureLeg({ name: 'shell-boot-1.png', query: 'mock=android&sessiondelay=1000', colorScheme: 'light', grantGeo: true, waitMode: 'settle400' });
/* 2 — boot stage 2: the ACQUIRING card held by ?fixdelay (the LocationManager
* delivery waits), while the session completes instantly. */
await captureLeg({ name: 'shell-boot-2.png', query: 'mock=android&fixdelay=1200', colorScheme: 'light', grantGeo: true, waitMode: 'settle400' });
/* 3 — LIVE, light theme. */
await captureLeg({ name: 'shell-live-light.png', query: 'mock=android&scheme=light', colorScheme: 'light', grantGeo: true, waitMode: 'live' });
/* 4 — LIVE, dark theme (PX-02 cold-boot half). */
await captureLeg({ name: 'shell-live-dark.png', query: 'mock=android&scheme=dark', colorScheme: 'dark', grantGeo: true, waitMode: 'live' });
/* 5 — Desktop Telegram: no geolocation grant, no setGeolocation; the mock's
* grantLocation:false forces the chain through LINK 3's 60s chat-pin poll to
* the denied card with the ASK VIA CHAT CTA (GEO-02 exit point, issue #77). */
await captureLeg({ name: 'shell-denied.png', query: 'mock=tdesktop', colorScheme: 'light', grantGeo: false, waitMode: 'denied' });
/* 6 — chat-pin relay: ?pin=1 makes the runner's GET /api/location stub return
* a pin; the chain reaches LIVE offline — the automated Desktop-leg counterpart. */
await captureLeg({ name: 'shell-chat-pin.png', query: 'mock=tdesktop&pin=1', colorScheme: 'light', grantGeo: false, waitMode: 'live' });
/* 7 — ONBD-02: the destination-preload boot. ?dest=<id> carries the plain
* gazetteer id in the URL's own query string (the inline-button carrier —
* RESEARCH Pitfall 1). The ARAHARAH-DEST signal resolves the race. */
await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' });
/* GEO-03 runtime probe (its own live session) — GEO-03's "spot-checkable at
* phase exit" criterion made mechanical. Counts and key names only (T-03-24). */
{
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
colorScheme: 'light',
deviceScaleFactor: 2,
});
await context.grantPermissions(['geolocation'], { origin: base });
await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });
const page = await context.newPage();
try {
await page.goto(`${base}/?mock=android&scheme=light`, { waitUntil: 'domcontentloaded', timeout: 60000 });
const signal = await Promise.race([
page.waitForEvent('console', {
predicate: (m) => m.text().startsWith('ARAHARAH-LIVE'),
timeout: SIGNAL_TIMEOUT_MS,
}).then(() => 'signal'),
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
]);
if (signal === 'timeout') throw new Error('GEO-03 probe leg: ARAHARAH-LIVE never arrived within 30000ms');
await page.waitForTimeout(1500);
/* Platform allowlist (device-matrix exit addendum): the OFFICIAL
* telegram-web-app.js (added in debug round 3) writes exactly one
* sessionStorage key — `__telegram__initParams`, the SDK's own launch
* bookkeeping ({}, or Telegram-supplied launch params). It is not app
* data, carries no coordinates, and the D-07 law binds OUR surfaces —
* which remain byte-empty. Any OTHER key still fails the probe. */
const probe = await page.evaluate(async () => ({
local: localStorage.length,
foreignSessionKeys: Object.keys(sessionStorage).filter((k) => k !== '__telegram__initParams'),
ssForeignOnly: Object.keys(sessionStorage).every((k) => k === '__telegram__initParams'),
cookie: document.cookie,
dbs: (await indexedDB.databases()).length,
lsKeys: Object.keys(localStorage),
}));
if (probe.local !== 0 || !probe.ssForeignOnly || probe.cookie !== "" || probe.dbs !== 0) {
failures.push(`GEO-03 runtime probe FAILED — localStorage=${probe.local} foreignSessionKeys=${JSON.stringify(probe.foreignSessionKeys)} cookie="${probe.cookie}" indexedDB=${probe.dbs} keys=${JSON.stringify(probe.lsKeys)}`);
} else {
log.push('GEO-03 runtime probe PASSED — app storage surfaces empty after boot-to-live (platform allowlist: SDK-owned __telegram__initParams only)');
}
} catch (err) {
failures.push(`GEO-03 runtime probe: ${err.message}`);
} finally {
await context.close();
}
}
/* ————— DSGN-02 composer (shared by legs 8 and 13) ————— */
async function composeCompare(target, implementedFile, implementedCaption) {
if (!existsSync(MOCKUP_COMPARE)) throw new Error(`committed mockup missing: ${MOCKUP_COMPARE}`);
if (!existsSync(implementedFile)) throw new Error(`implemented capture missing: ${implementedFile}`);
const liveB64 = readFileSync(implementedFile).toString('base64');
const mockB64 = readFileSync(MOCKUP_COMPARE).toString('base64');
const context = await browser.newContext({ viewport: { width: 880, height: 960 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
body { margin: 0; background: #1a1a1a; color: #d9d2c6; font-family: monospace; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px; }
figure { margin: 0; border: 1px solid #d9d2c6; }
figcaption { font-size: 10px; padding: 4px 6px; text-transform: uppercase; letter-spacing: 0.08em; word-break: break-all; }
.cell { width: 100%; height: 800px; object-fit: contain; display: block; background: #d9d2c6; }
.mockimg { background: #ffffff; }
</style></head><body><div class="row">
<figure><figcaption>IMPLEMENTED — ${implementedCaption}</figcaption>
<img class="cell" src="data:image/png;base64,${liveB64}"></figure>
<figure><figcaption>MOCKUP REFERENCE — stitch_minimalist_route_navigator/navigate_map_overview/screen.png</figcaption>
<img class="cell mockimg" src="data:image/png;base64,${mockB64}"></figure>
</div></body></html>`;
await page.goto(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`, { waitUntil: 'load', timeout: 30000 });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(300);
await page.screenshot({ path: target, fullPage: true });
await context.close();
if (sizeOf(target) < MIN_BYTES) throw new Error(`capture undersized (${sizeOf(target)}B < ${MIN_BYTES}B)`);
}
/* 8 — DSGN-02 fidelity artifact: the live-light capture beside the committed
* navigate_map_overview mockup, each column labeled with its source path. */
{
const target = join(SHOTS, 'gallery-compare.png');
try {
await composeCompare(
target,
join(SHOTS, 'shell-live-light.png'),
'web/screenshots/shell-live-light.png (?mock=android, harness build)',
);
log.push(`captured gallery-compare.png (${sizeOf(target)}B, DSGN-02 side-by-side)`);
} catch (err) {
failures.push(`gallery-compare.png: ${err.message}`);
}
}
/* 9–12 — the route experience (05-05). Every leg rides the ?fixpair knob:
* the mock's Telegram fix is the demo pair's PINNED PUBLIC origin resolved
* from the committed fixture corpus (D-12 — the harness introduces no user
* coordinate), paired with the ?dest carrier. All raced on the
* ARAHARAH-ROUTE state token + DOM text assertions. */
const routeQuery = `mock=android&dest=${demoPair.destinationId}&fixpair=${demoPair.pairId}`;
/* 9 — route drawn: orange casing stack + bottom card + endpoints, light. */
await captureLeg({
name: 'shell-route-fixture.png',
query: routeQuery,
colorScheme: 'light',
grantGeo: true,
waitMode: 'route',
expectState: 'drawn',
expectText: [routeDestEntry.name, 'MIN'],
});
/* 10 — alternative selection: tap the first chip → instant geometry swap +
* card re-render + camera refit (D-10 ALWAYS-refit). The demo pair is
* multi-route by selection above (RT10 floor 4); the degenerate arm proves
* the single-route law instead (single-route draws render NO chip row). */
await captureLeg({
name: 'shell-route-alt.png',
query: routeQuery,
colorScheme: 'light',
grantGeo: true,
waitMode: 'route',
expectState: 'drawn',
expectText: [routeDestEntry.name, 'MIN'],
interact: async (page) => {
const chip = page.locator('.route-chip').first();
if ((await chip.count()) > 0) {
await chip.click();
await page.waitForTimeout(800); // instant swap + 400ms refit + margin
const bodyText = await page.evaluate(() => document.body.textContent ?? '');
if (!/ROUTE \d\/\d/.test(bodyText)) {
throw new Error(`chip tap did not re-render the ROUTE {n}/{N} register — body text: ${JSON.stringify(bodyText.slice(0, 300))}`);
}
} else {
const chipCount = await page.locator('.route-chip').count();
if (chipCount !== 0) {
throw new Error(`single-route draw rendered ${chipCount} chips — the single-route law says none`);
}
log.push('shell-route-alt.png: degenerate single-route pair — no-chip law asserted');
}
},
});
/* 11 — router pending (D-03): an out-of-corpus destination renders the
* honest card — never a blank map, never a fabricated geometry (T-05-14).
* national-museum is RT4-guaranteed absent from corpus destinationIds
* (05-02's pinned probe carrier). */
await captureLeg({
name: 'shell-route-pending.png',
query: `mock=android&dest=national-museum&fixpair=${demoPair.pairId}`,
colorScheme: 'light',
grantGeo: true,
waitMode: 'route',
expectState: 'pending',
expectText: ['not wired up yet'],
});
/* 12 — dark theme (PX-02 cold-boot half for the route surface): chrome
* re-slots, route + basemap stay Rams-constant. */
await captureLeg({
name: 'shell-route-dark.png',
query: `${routeQuery}&scheme=dark`,
colorScheme: 'dark',
grantGeo: true,
waitMode: 'route',
expectState: 'drawn',
expectText: [routeDestEntry.name, 'MIN'],
});
/* 13 — DSGN-02 route fidelity artifact: the drawn-route capture beside the
* navigate_map_overview mockup (the same composer as leg 8). */
{
const target = join(SHOTS, 'route-gallery-compare.png');
try {
await composeCompare(
target,
join(SHOTS, 'shell-route-fixture.png'),
`web/screenshots/shell-route-fixture.png (?dest=${demoPair.destinationId}&fixpair=${demoPair.pairId}, harness build)`,
);
log.push(`captured route-gallery-compare.png (${sizeOf(target)}B, DSGN-02 route side-by-side)`);
} catch (err) {
failures.push(`route-gallery-compare.png: ${err.message}`);
}
}
} finally {
await browser.close();
server.close();
}
/* ————— report + blank gate ————— */
for (const line of log) console.log(`[capture] ${line}`);
const expected = [
'shell-boot-1.png',
'shell-boot-2.png',
'shell-live-light.png',
'shell-live-dark.png',
'shell-denied.png',
'shell-chat-pin.png',
'shell-dest-preload.png',
'shell-route-fixture.png',
'shell-route-alt.png',
'shell-route-pending.png',
'shell-route-dark.png',
'gallery-compare.png',
'route-gallery-compare.png',
];
const missing = expected.filter((f) => !existsSync(join(SHOTS, f)));
const undersized = expected.filter((f) => existsSync(join(SHOTS, f)) && sizeOf(join(SHOTS, f)) < MIN_BYTES);
if (missing.length || undersized.length || failures.length) {
for (const f of failures) console.error(`[capture] FAILED ${f}`);
for (const f of missing) console.error(`[capture] MISSING ${f}`);
for (const f of undersized) console.error(`[capture] UNDERSIZED ${f} (<${MIN_BYTES}B)`);
console.error(`CAPTURE FAILED — ${expected.length - missing.length}/${expected.length} usable captures`);
process.exit(1);
}
console.log(`CAPTURE OK — ${expected.length}/${expected.length} non-blank captures under web/screenshots/`);
process.exit(0);