#!/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=) * 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) * shell-guidance-active.png the LIVE cockpit over a replayed drive (?drive=, 06-08) * shell-guidance-list.png the banner tap's expanded list + NEXT row lamp (06-08) * shell-guidance-rerouting.png captured INSIDE the REROUTING window, no red (06-08) * shell-guidance-confirm.png the abandon confirm card, bottom register (06-08) * shell-guidance-tripend.png the trip-ended card, chrome cleared (06-08) * shell-guidance-dark.png the cockpit, Telegram dark theme (PX-02, 06-08) * guidance-gallery-compare.png DSGN-02 guidance fidelity artifact vs active_route_guidance/screen.png (06-08) * shell-guidance-landscape.png the cockpit in landscape (driving posture) (07-02, PX-04) * shell-route-pending-landscape.png the designed pending card in landscape (07-02, SC2) * * 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'; import { decodeGraph, decodePolyline6, deriveCumulativeDistanceM, encodePolyline6, snapToGraph, } from '@araharah/routing/src/index.ts'; /* The pump's projection — ONE authority, imported (never re-derived): * the stub's merge answer walks the same forward-only projection the * cron and the replay tests use. */ import { projectAlongRoute } from '../src/guidance/guidance.ts'; 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'); /* 06-08 — the guidance gallery's mockup reference (the cockpit screen). */ const GUIDE_MOCKUP_COMPARE = resolve(REPOROOT, 'stitch_minimalist_route_navigator', 'active_route_guidance', '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; /* The guidance legs' race window (06-08): the missed-turn drive's excursion * fires its reroute ~26s into the replay (the D-15 throttle accepts a fix * every ~2s at the drive's pace, so the 3-consecutive + grace gates span * ~22-26s of drive time). 60s covers it with boot margin; a timeout is a * HARD FAILURE with diagnostics, never a blank-but-passing shot. */ const GUIDE_LEG_TIMEOUT_MS = 60000; /* The route stub's hold on REROUTE requests: the window must be comfortably * photographable — the leg polls state=rerouting and shoots inside it. The * CONSULT request answers instantly (the first POST only). */ const REROUTE_HOLD_MS = 5000; 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', 'ARAHARAH-GUIDE']; /* 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); } /* ————— 06-08 — the drive-replay legs' ground truth + the engine-A-shaped route stub ————— */ /* The D-16 browser leg replays a COMMITTED drive fixture through the LIVE * shell: the mock's ?drive= knob streams the fixture into the real watch. * The shell's route consult rides /api/route through the composed ladder — * intercepted HERE (Playwright route fulfilment) with an engine-A-SHAPED * answer built from the same committed data: the pair's ODbL geometry * (routing/src/data/route-fixtures.json) + the drive's recorded maneuver * indices + street names read off the LOCAL road graph * (routing/data/kl-roadgraph.v1.bin, the same OSM-derived source engine A * serves) via snapToGraph on the segment AFTER each maneuver — the road the * turn puts you on. A graph-absent fork degrades to unnamed steps (the * streetUnnamed fallback renders — honest). The first /api/route POST of a * page is the consult (the drive model); later POSTs are reroutes (a merge * model from the requesting fix, held REROUTE_HOLD_MS so the calm window is * photographable). The REAL http-source validation still runs on every * answer — the stub cannot smuggle a malformed payload past the client. */ let guideGraph = null; try { const buf = readFileSync(resolve(REPOROOT, 'routing/data/kl-roadgraph.v1.bin')); guideGraph = decodeGraph(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); } catch { guideGraph = null; // a fresh fork without the local graph: unnamed steps, honest } function buildDriveModel(driveName) { const fx = JSON.parse(readFileSync(join(REPOROOT, 'web/test/fixtures/drives', `${driveName}.json`), 'utf8')); const pair = routePairs.find((p) => p.pairId === fx.pairId); if (pair === undefined) throw new Error(`drive "${driveName}" names unknown pair ${fx.pairId}`); const points = decodePolyline6(pair.routes[0].geometry); const cumulative = deriveCumulativeDistanceM(points); const totalM = cumulative[cumulative.length - 1]; const steps = [{ kind: 'depart', distanceM: cumulative[fx.maneuverIdx[0]] }]; fx.maneuverIdx.forEach((idx, n) => { const nextAlong = n + 1 < fx.maneuverIdx.length ? cumulative[fx.maneuverIdx[n + 1]] : totalM; const step = { kind: fx.kinds[n], distanceM: nextAlong - cumulative[idx], geometry: encodePolyline6(points.slice(idx, idx + 2)), }; // the road the turn puts you on: the snap of the POST-maneuver segment's // midpoint (engine A names the FOLLOWING segment — deriveSteps semantics) try { const a = points[idx]; const b = points[idx + 1]; const mid = { lat: (a.lat + b.lat) / 2, lng: (a.lng + b.lng) / 2 }; const snap = guideGraph !== null ? snapToGraph(guideGraph, mid) : null; const name = snap !== null && snap.nameId !== null ? guideGraph.nameAt(snap.nameId) : undefined; if (name !== undefined) step.name = name; } catch { /* an absent/corrupt local graph degrades to unnamed steps — honest */ } steps.push(step); }); steps.push({ kind: 'arrive', distanceM: 0 }); return { points, cumulative, steps, destinationId: pair.destinationId, destinationName: routeGazetteer.entries.find((e) => e.id === pair.destinationId)?.name ?? pair.destinationId, durationS: pair.routes[0].durationS, }; } function routeOptionOf(model) { return { geometry: { points: model.points, cumulativeDistanceM: model.cumulative }, summary: { distanceM: model.cumulative[model.cumulative.length - 1], durationS: model.durationS }, steps: model.steps, }; } /** The reroute's engine-A-shaped answer (the 06-02 replay controller's * buildRerouteModel semantics): origin at the requesting fix, the original * line onward from the projection's next segment — terminal marks only. */ function rerouteResultOf(model, origin, destinationId) { const fix = { ...origin, accuracyM: null, headingDeg: null, ts: 0, source: 'webview' }; const proj = projectAlongRoute(model.points, model.cumulative, fix, 0); const startIdx = Math.min(proj.idx + 1, model.points.length - 1); const pts = [{ lat: origin.lat, lng: origin.lng }, ...model.points.slice(startIdx)]; const cumulative = deriveCumulativeDistanceM(pts); const totalM = cumulative[cumulative.length - 1]; const origTotalM = model.cumulative[model.cumulative.length - 1]; const durationS = origTotalM > 0 ? Math.round((totalM / origTotalM) * model.durationS) : model.durationS; return { destinationId, origin: { lat: origin.lat, lng: origin.lng }, backend: 'engine-a', options: [ { geometry: { points: pts, cumulativeDistanceM: cumulative }, summary: { distanceM: totalM, durationS }, steps: [ { kind: 'depart', distanceM: totalM }, { kind: 'arrive', distanceM: 0 }, ], }, ], }; } /** Install the guidance route stub on a leg's page. Deterministic per page: * POST #1 = the consult (the drive model), later POSTs = reroutes. */ async function stubGuidanceRoute(page, model) { let requestCount = 0; await page.route('**/api/route', async (route) => { let body = {}; try { body = route.request().postDataJSON() ?? {}; } catch { body = {}; } const destinationId = typeof body.destinationId === 'string' && body.destinationId.length > 0 ? body.destinationId : model.destinationId; requestCount += 1; if (requestCount === 1) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ destinationId, origin: body.origin ?? { lat: model.points[0].lat, lng: model.points[0].lng }, backend: 'engine-a', options: [routeOptionOf(model)], }), }); return; } // a reroute: hold the calm REROUTING window open, then the merge answer await new Promise((r) => setTimeout(r, REROUTE_HOLD_MS)); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rerouteResultOf(model, body.origin ?? model.points[0], destinationId)), }); }); } /* The guidance legs' console hygiene (D-15/T-06-04 carried into captures): * the replay's console carries ONLY state tokens — a coordinate-bearing * line is a captured BUG. The pattern: a lat,lng decimal pair, or a * lat/lng/latitude/longitude field read. */ const COORD_BEARING_RE = /\b\d{1,3}\.\d{4,}\b|\blat(itude)?\s*[:=]|\blng(gitude)?\s*[:=]/i; async function assertNoCoordinateLines(allLines, legName) { const offenders = allLines.filter((l) => COORD_BEARING_RE.test(l)); if (offenders.length > 0) { throw new Error(`${legName}: coordinate-bearing console lines (D-15 hygiene): ${JSON.stringify(offenders.slice(0, 3))}`); } } /* The no-red law (rerouting/confirm legs): functional-red #ED3F1C appears * NOWHERE in guidance chrome — computed colors are the honest check (class * lists cannot carry the token), plus a class-scan for the denied lamp. */ const FUNCTIONAL_RED_RGB = 'rgb(237, 63, 28)'; async function scanForRed(page, rootSelectors) { return page.evaluate((args) => { const red = args.red; const offenders = []; for (const sel of args.selectors) { for (const root of document.querySelectorAll(sel)) { const els = [root, ...root.querySelectorAll('*')]; for (const el of els) { const cs = getComputedStyle(el); if (cs.color === red) offenders.push(`${el.className}:color`); if (cs.backgroundColor === red) offenders.push(`${el.className}:background`); if (cs.borderTopColor === red) offenders.push(`${el.className}:border`); } if (/\b(red|denied)\b/.test(root.className)) offenders.push(`${root.className}:class`); } } return offenders; }, { red: FUNCTIONAL_RED_RGB, selectors: rootSelectors }); } /* ————— the guidance DOM asserts (each leg's interact runs them pre-shot) ————— */ const GUIDE_WAIT = { timeout: 15000, polling: 200 }; async function waitGuideRegister(page, expected) { await page.waitForFunction( (want) => document.querySelector('.guide-banner .guide-register')?.textContent === want, expected, GUIDE_WAIT, ); } async function assertCockpitChrome(page, destName) { const banner = await page.evaluate(() => { const b = document.querySelector('.guide-banner'); if (b === null) return null; return { register: b.querySelector('.guide-register')?.textContent ?? '', distance: b.querySelector('.guide-distance')?.textContent ?? '', street: b.querySelector('.guide-street')?.textContent ?? '', toggle: b.querySelector('.guide-toggle')?.textContent ?? '', arrow: b.querySelector('.guide-plate svg') !== null, bg: getComputedStyle(b).backgroundColor, }; }); if (banner === null) throw new Error('the guidance banner never mounted'); if (banner.register !== 'GUIDE // NEXT') throw new Error(`register=${banner.register}`); if (!/^\d+(\.\d+)?$/.test(banner.distance) || banner.distance === '0') { throw new Error(`distance=${banner.distance} — the glance datum must be a live number`); } if (banner.street.length === 0) throw new Error('the street slot is empty'); if (!banner.arrow) throw new Error('the arrow plate is missing'); if (banner.toggle !== 'LIST') throw new Error(`toggle=${banner.toggle} — the list must be collapsed by default`); const compacted = await page.evaluate(() => document.querySelector('main')?.dataset.guidance ?? null); if (compacted !== 'active') throw new Error(`data-guidance=${String(compacted)} — the strip never compacted`); const card = await page.evaluate(() => { const stack = document.querySelector('.route-stack[data-register="bottom"]'); const bar = stack?.querySelector('.guide-progress'); return { text: stack?.textContent ?? '', role: bar?.getAttribute('role') ?? null, fill: bar?.querySelector('.guide-progress-fill')?.style.width ?? '', }; }); for (const t of [destName, 'MIN', 'KM', 'ETA ', 'END TRIP']) { if (!card.text.includes(t)) throw new Error(`guidance card missing "${t}" — got ${JSON.stringify(card.text.slice(0, 200))}`); } if (card.role !== 'progressbar') throw new Error('the progress bar is missing'); if (!/^\d+(\.\d+)?%$/.test(card.fill)) throw new Error(`progress fill=${card.fill}`); return banner; } const guidePairFx = JSON.parse(readFileSync(join(REPOROOT, 'web/test/fixtures/drives/corridor-follow.json'), 'utf8')); const guidePair = routePairs.find((p) => p.pairId === guidePairFx.pairId); if (guidePair === undefined) { console.error(`[capture] FAILED the corridor drive names unknown pair "${guidePairFx.pairId}"`); process.exit(1); } const guideDestEntry = routeGazetteer.entries.find((e) => e.id === guidePair.destinationId); if (guideDestEntry === undefined) { console.error(`[capture] FAILED the guide pair destination "${guidePair.destinationId}" is not a gazetteer id`); process.exit(1); } const corridorModel = buildDriveModel('corridor-follow'); const missedModel = buildDriveModel('missed-turn-excursion'); const guideQuery = `mock=android&dest=${guidePair.destinationId}&fixpair=${guidePair.pairId}&drive=corridor-follow`; /* * 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) * 'guide' → ARAHARAH-GUIDE state-token race + expectGuide * (06-08: 'on_route'|'rerouting') + expectText DOM poll * + the D-15 coordinate-hygiene scan over the WHOLE * leg's console; the replay's ?drive= knob streams * the committed fixture through the real watch */ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact, viewport }) { const target = join(SHOTS, name); const context = await browser.newContext({ viewport: 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(); /* Per-leg setup runs BEFORE goto (06-08: the guidance legs install the * /api/route stub here — Playwright interception must exist before the * consult's fetch). */ if (setup) await setup(page); const seen = { 'RAMS-BASEMAP-READY': false, 'ARAHARAH-LIVE': false, 'ARAHARAH-DEST': false, 'ARAHARAH-ROUTE': false, 'ARAHARAH-GUIDE': false, live: 'no' }; const consoleLines = []; const allConsoleLines = []; // unbounded — the guidance legs' D-15 hygiene scan reads the WHOLE leg page.on('console', (m) => { for (const s of SIGNALS) if (m.text().startsWith(s)) seen[s] = true; consoleLines.push(m.text()); allConsoleLines.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'}; ${seen['ARAHARAH-GUIDE'] ? 'ARAHARAH-GUIDE yes' : 'ARAHARAH-GUIDE 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 === 'guide') { /* 06-08: race the ARAHARAH-GUIDE state token — a STATE TOKEN only * (D-15 hygiene: no coordinates in console; the drive replay emits * nothing per fix). expectGuide = 'on_route' | 'rerouting'. The * machine's deduped logger means the token appears exactly once, so * the race is the activation/window signal. */ const signal = await Promise.race([ page.waitForEvent('console', { predicate: (m) => m.text().startsWith('ARAHARAH-GUIDE') && m.text().includes(`state=${expectGuide}`), timeout: GUIDE_LEG_TIMEOUT_MS, }).then(() => 'signal'), new Promise((r) => setTimeout(() => r('timeout'), GUIDE_LEG_TIMEOUT_MS)), ]); if (signal === 'timeout') { throw new Error(`ARAHARAH-GUIDE state=${expectGuide} race timed out after ${GUIDE_LEG_TIMEOUT_MS}ms — ${signalDiagnostics()}`); } const guideLine = consoleLines.filter((l) => l.startsWith('ARAHARAH-GUIDE')).pop() ?? ''; if (!guideLine.includes(`state=${expectGuide}`)) { throw new Error(`guide state mismatch: "${guideLine}" — expected state=${expectGuide}`); } 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)}`); } /* D-15 console hygiene: the WHOLE leg's console carried no * coordinate-bearing line — the replay proves the state tokens, never * the fix stream. */ await assertNoCoordinateLines(allConsoleLines, name); await page.waitForTimeout(2000); // tile render settle (the locked pose eased 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 { try { await context.close(); } catch { // teardown race (context already disposed) — the capture itself was judged above } } } 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= 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, 13, and 06-08's guidance gallery) ————— */ async function composeCompare(target, implementedFile, implementedCaption, mockupFile, mockupCaption) { const mockupPath = mockupFile ?? MOCKUP_COMPARE; const mockupLabel = mockupCaption ?? 'stitch_minimalist_route_navigator/navigate_map_overview/screen.png'; if (!existsSync(mockupPath)) throw new Error(`committed mockup missing: ${mockupPath}`); if (!existsSync(implementedFile)) throw new Error(`implemented capture missing: ${implementedFile}`); const liveB64 = readFileSync(implementedFile).toString('base64'); const mockB64 = readFileSync(mockupPath).toString('base64'); const context = await browser.newContext({ viewport: { width: 880, height: 960 }, deviceScaleFactor: 1 }); const page = await context.newPage(); const html = `
IMPLEMENTED — ${implementedCaption}
MOCKUP REFERENCE — ${mockupLabel}
`; 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}`); } } /* ————— 14–20 — the guidance cockpit (06-08, D-16's browser leg) ————— * Every leg rides the ?drive= replay knob: the mock streams a committed * drive fixture through the REAL watch and the machine's start gate * activates the cockpit. The /api/route stub answers engine-A-shaped * routes built from the same committed data (real street names off the * local graph). Each leg races its ARAHARAH-GUIDE state token, asserts * the DOM the UI-SPEC names, and freezes the replay before the shot. */ /* 14 — guidance ACTIVE, light: banner + compact strip + guidance card + * progress bar + locked camera pose. */ await captureLeg({ name: 'shell-guidance-active.png', setup: (page) => stubGuidanceRoute(page, corridorModel), query: guideQuery, colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', expectText: [guideDestEntry.name, 'END TRIP'], interact: async (page) => { const banner = await assertCockpitChrome(page, guideDestEntry.name); log.push(`shell-guidance-active: banner ${JSON.stringify({ distance: banner.distance + ' M', street: banner.street, bg: banner.bg })}`); await page.waitForTimeout(300); // the frozen chrome settles }, }); /* 15 — the banner tap's LIST: expanded panel over the map + NEXT row lamp * (the collapsed-default proof is the active leg's LIST toggle). */ await captureLeg({ name: 'shell-guidance-list.png', setup: (page) => stubGuidanceRoute(page, corridorModel), query: guideQuery, colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', expectText: [guideDestEntry.name], interact: async (page) => { await assertCockpitChrome(page, guideDestEntry.name); await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause()); // stable chrome for the tap await page.click('.guide-banner'); // the whole band is the toggle await page.waitForFunction( () => document.getElementById('guide-maneuver-list') !== null, undefined, { timeout: 5000, polling: 100 }, ); const expanded = await page.evaluate(() => { const banner = document.querySelector('.guide-banner'); const row = document.querySelector('#guide-maneuver-list .guide-row[data-row-state="next"]'); return { ariaExpanded: banner?.getAttribute('aria-expanded') ?? null, toggle: banner?.querySelector('.guide-toggle')?.textContent ?? '', lamp: row?.querySelector('.guide-lamp') !== null, rowText: row?.textContent ?? '', }; }); if (expanded.ariaExpanded !== 'true') throw new Error(`aria-expanded=${String(expanded.ariaExpanded)}`); if (expanded.toggle !== 'CLOSE') throw new Error(`toggle=${expanded.toggle} — the expanded banner reads CLOSE`); if (!expanded.lamp) throw new Error('the NEXT row lamp is missing'); if (expanded.rowText.length === 0) throw new Error('the NEXT row carries no text'); log.push(`shell-guidance-list: NEXT row ${JSON.stringify(expanded.rowText.slice(0, 80))}`); await page.waitForTimeout(300); }, }); /* 16 — REROUTING (D-04's calm state): the missed-turn drive fires its * reroute ~26s in; the stub holds the window; the leg races state=rerouting * and asserts the calm banner + the NO-RED law. */ await captureLeg({ name: 'shell-guidance-rerouting.png', setup: (page) => stubGuidanceRoute(page, missedModel), query: `mock=android&dest=${guidePair.destinationId}&fixpair=${guidePair.pairId}&drive=missed-turn-excursion`, colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'rerouting', expectText: ['Finding a new route from where you are.'], interact: async (page) => { const calm = await page.evaluate(() => { const b = document.querySelector('.guide-banner'); if (b === null) return null; return { register: b.querySelector('.guide-register')?.textContent ?? '', distance: b.querySelector('.guide-distance')?.textContent ?? '', street: b.querySelector('.guide-street')?.textContent ?? '', gantry: b.querySelector('.guide-gantry') !== null, streetColor: getComputedStyle(b.querySelector('.guide-street')).color, }; }); if (calm === null) throw new Error('the banner never mounted'); if (calm.register !== 'GUIDE // REROUTING') throw new Error(`register=${calm.register}`); if (calm.distance !== '—') throw new Error(`distance=${calm.distance} — the calm state dashes`); if (calm.gantry !== true) throw new Error('the REROUTING gantry is missing'); const offenders = await scanForRed(page, ['.guide-banner', '.route-stack[data-register="bottom"] .card']); if (offenders.length > 0) throw new Error(`functional-red found in guidance chrome: ${offenders.join(', ')}`); await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause()); // freeze inside the window await page.waitForTimeout(300); }, }); /* 17 — the abandon confirm card (D-09/PX-03): scripted END TRIP tap → the * confirm card in the BOTTOM register, both CTAs, no red. */ await captureLeg({ name: 'shell-guidance-confirm.png', setup: (page) => stubGuidanceRoute(page, corridorModel), query: guideQuery, colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', expectText: [guideDestEntry.name, 'END TRIP'], interact: async (page) => { await assertCockpitChrome(page, guideDestEntry.name); await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause()); await page.click('.route-stack[data-register="bottom"] .card .card-actions .btn-secondary'); // END TRIP await page.waitForFunction( () => document.querySelector('.route-stack[data-register="bottom"] .card.guide-confirm') !== null, undefined, { timeout: 5000, polling: 100 }, ); const confirm = await page.evaluate(() => { const card = document.querySelector('.route-stack[data-register="bottom"] .card.guide-confirm'); return { register: card?.querySelector('.card-ref')?.textContent ?? '', headline: card?.querySelector('h2')?.textContent ?? '', primary: card?.querySelector('.btn-primary')?.textContent ?? '', secondary: card?.querySelector('.btn-secondary')?.textContent ?? '', }; }); if (confirm.register !== 'GUIDE // CONFIRM') throw new Error(`register=${confirm.register}`); if (confirm.headline !== 'Abandon route?') throw new Error(`headline=${confirm.headline}`); if (confirm.primary !== 'YES, ABANDON') throw new Error(`primary=${confirm.primary}`); if (confirm.secondary !== 'CONTINUE') throw new Error(`secondary=${confirm.secondary}`); const offenders = await scanForRed(page, ['.guide-confirm']); if (offenders.length > 0) throw new Error(`functional-red found in the confirm card: ${offenders.join(', ')}`); await page.waitForTimeout(300); }, }); /* 18 — the trip end (D-14): YES, ABANDON → the trip-ended card in the * CENTER register, the banner/list/cockpit cleared, the deletion clause * visible. */ await captureLeg({ name: 'shell-guidance-tripend.png', setup: (page) => stubGuidanceRoute(page, corridorModel), query: guideQuery, colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', expectText: [guideDestEntry.name, 'END TRIP'], interact: async (page) => { await assertCockpitChrome(page, guideDestEntry.name); await page.evaluate(() => window.__ARAHARAH_DRIVE?.pause()); await page.click('.route-stack[data-register="bottom"] .card .card-actions .btn-secondary'); // END TRIP await page.waitForFunction( () => document.querySelector('.route-stack[data-register="bottom"] .card.guide-confirm') !== null, undefined, { timeout: 5000, polling: 100 }, ); await page.click('.route-stack[data-register="bottom"] .card.guide-confirm .btn-primary'); // YES, ABANDON await page.waitForFunction( () => { const stack = document.querySelector('.route-stack[data-register="center"]'); return stack !== null && (stack.textContent ?? '').includes('Trip ended'); }, undefined, { timeout: 5000, polling: 100 }, ); const cleared = await page.evaluate(() => ({ banner: document.querySelector('.guide-banner') === null, list: document.getElementById('guide-maneuver-list') === null, guidanceAttr: document.querySelector('main')?.dataset.guidance, endText: document.querySelector('.route-stack[data-register="center"]')?.textContent ?? '', })); if (!cleared.banner) throw new Error('the banner survived the teardown'); if (!cleared.list) throw new Error('the list survived the teardown'); if (cleared.guidanceAttr !== undefined) throw new Error(`data-guidance=${String(cleared.guidanceAttr)} — the strip never returned`); if (!cleared.endText.includes('deleted')) { throw new Error(`the trip-end card is missing the deletion clause (PRIV-01): ${JSON.stringify(cleared.endText.slice(0, 200))}`); } log.push('shell-guidance-tripend: chrome cleared, deletion clause visible'); await page.waitForTimeout(300); }, }); /* 19 — the cockpit, dark theme (PX-02 cold-boot half): chrome re-slots, * the banner chassis stays Rams-constant matte-black. */ await captureLeg({ name: 'shell-guidance-dark.png', setup: (page) => stubGuidanceRoute(page, corridorModel), query: `${guideQuery}&scheme=dark`, colorScheme: 'dark', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', expectText: [guideDestEntry.name, 'END TRIP'], interact: async (page) => { const banner = await assertCockpitChrome(page, guideDestEntry.name); if (banner.bg !== 'rgb(26, 26, 26)') { throw new Error(`banner chassis=${banner.bg} — matte-black is Rams-constant under both themes`); } await page.waitForTimeout(300); }, }); /* 20 — DSGN-02 guidance fidelity artifact: the active-cockpit capture * beside the active_route_guidance mockup (the phase gallery's cockpit * row; fidelity review rides the blocking checkpoint). */ { const target = join(SHOTS, 'guidance-gallery-compare.png'); try { await composeCompare( target, join(SHOTS, 'shell-guidance-active.png'), 'web/screenshots/shell-guidance-active.png (?dest=' + guidePair.destinationId + '&drive=corridor-follow, harness build)', GUIDE_MOCKUP_COMPARE, 'stitch_minimalist_route_navigator/active_route_guidance/screen.png', ); log.push(`captured guidance-gallery-compare.png (${sizeOf(target)}B, DSGN-02 guidance side-by-side)`); } catch (err) { failures.push(`guidance-gallery-compare.png: ${err.message}`); } } /* 21 — 07-02 — the landscape leg (PX-04.e) ————— * The driving posture: the phone rotated. The cockpit geometry adapts via * the @media (orientation: landscape) block (07-02 task 1); the map stays * Rams-constant. A 844x390 viewport IS the landscape orientation the media * query matches (orientation: landscape + max-height: 560px). * The setup + query + waitMode/expectGuide are copied VERBATIM from the * existing corridor guidance leg — only the name, the scheme, and the * viewport differ. */ await captureLeg({ name: 'shell-guidance-landscape.png', setup: (page) => stubGuidanceRoute(page, corridorModel), query: guideQuery, colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', expectText: [guideDestEntry.name, 'END TRIP'], viewport: { width: 844, height: 390 }, interact: async (page) => { const banner = await assertCockpitChrome(page, guideDestEntry.name); log.push(`shell-guidance-landscape: banner ${JSON.stringify({ distance: banner.distance + ' M', street: banner.street, bg: banner.bg })}`); await page.waitForTimeout(300); // the frozen chrome settles }, }); /* 22 — 07-02 — the landscape failure-state leg (SC2) ————— * The pending card in landscape: the designed surface, never a blank map. * The query/waitMode/expectState/expectText are the shell-route-pending * leg's values verbatim (the empty/error route class) — only the * viewport differs. */ await captureLeg({ name: 'shell-route-pending-landscape.png', query: `mock=android&dest=national-museum&fixpair=${demoPair.pairId}`, colorScheme: 'light', grantGeo: true, waitMode: 'route', expectState: 'pending', expectText: ['not wired up yet'], viewport: { width: 844, height: 390 }, }); } 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', 'shell-guidance-active.png', 'shell-guidance-list.png', 'shell-guidance-rerouting.png', 'shell-guidance-confirm.png', 'shell-guidance-tripend.png', 'shell-guidance-dark.png', 'guidance-gallery-compare.png', 'shell-guidance-landscape.png', 'shell-route-pending-landscape.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);