Files
arah2/web/scripts/capture.mjs
araharah-executor 7536ce5d38 feat(07-02): landscape capture legs — cockpit + pending card in the driving posture (PX-04.e, SC2)
- captureLeg accepts a per-leg viewport (default 390x844 unchanged)
- shell-guidance-landscape.png: corridor drive leg verbatim at 844x390 — the
  @media (orientation: landscape) block exercised in a real browser
- shell-route-pending-landscape.png: the designed pending card in landscape
- both legs under the existing MIN_BYTES blank bar; capture 22/22 non-blank
2026-10-25 04:17:43 +08:00

1196 lines
58 KiB
JavaScript
Raw Permalink 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)
* 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=<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, 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 = `<!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 — ${mockupLabel}</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}`);
}
}
/* ————— 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);