- guide wait mode: the ARAHARAH-GUIDE state-token race (expectGuide on_route|rerouting) + DOM text polls + the PX-02 theme-slot honesty check + the D-15 coordinate-hygiene scan over the WHOLE leg's console (assertNoCoordinateLines — state tokens only, never coordinates) - the engine-A-shaped /api/route stub (per-leg Playwright interception, installed pre-goto): POST #1 = the consult — the drive model built from the committed fixture corpus + the drive's recorded maneuver indices + street names snapToGraph'd off the LOCAL road graph (the road the turn puts you on); later POSTs = reroutes — the merge model held 5s so the calm REROUTING window is photographable; the REAL http-source validation still runs on every answer - legs 14-19 per the UI-SPEC capture table: active (banner slots + compact strip + card + progressbar + LIST default), list (scripted banner tap -> CLOSE + NEXT row lamp), rerouting (calm copy + dash + gantry + NO functional-red computed colors), confirm (bottom register + both CTAs + no red), tripend (center card + deletion clause + chrome cleared + strip returned), dark (matte-black chassis constant) - leg 20: guidance-gallery-compare.png vs active_route_guidance/screen.png (the composer generalized with mockup params; legs 8/13 unchanged) - prior 13 legs unbroken: 20/20 non-blank captures
1147 lines
56 KiB
JavaScript
1147 lines
56 KiB
JavaScript
#!/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)
|
||
*
|
||
* 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 }) {
|
||
const target = join(SHOTS, name);
|
||
const context = await browser.newContext({
|
||
viewport: { width: 390, height: 844 },
|
||
colorScheme,
|
||
deviceScaleFactor: 2,
|
||
});
|
||
if (grantGeo) {
|
||
await context.grantPermissions(['geolocation'], { origin: base });
|
||
await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });
|
||
}
|
||
const page = await context.newPage();
|
||
/* 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 {
|
||
await context.close();
|
||
}
|
||
}
|
||
|
||
try {
|
||
/* 1 — boot stage 1: the VALIDATING card held on screen by ?sessiondelay
|
||
* (localhost resolves the session in ms — the stage needs a hold to be
|
||
* photographed); shot after the 400ms module-settle. */
|
||
await captureLeg({ name: 'shell-boot-1.png', query: 'mock=android&sessiondelay=1000', colorScheme: 'light', grantGeo: true, waitMode: 'settle400' });
|
||
|
||
/* 2 — boot stage 2: the ACQUIRING card held by ?fixdelay (the LocationManager
|
||
* delivery waits), while the session completes instantly. */
|
||
await captureLeg({ name: 'shell-boot-2.png', query: 'mock=android&fixdelay=1200', colorScheme: 'light', grantGeo: true, waitMode: 'settle400' });
|
||
|
||
/* 3 — LIVE, light theme. */
|
||
await captureLeg({ name: 'shell-live-light.png', query: 'mock=android&scheme=light', colorScheme: 'light', grantGeo: true, waitMode: 'live' });
|
||
|
||
/* 4 — LIVE, dark theme (PX-02 cold-boot half). */
|
||
await captureLeg({ name: 'shell-live-dark.png', query: 'mock=android&scheme=dark', colorScheme: 'dark', grantGeo: true, waitMode: 'live' });
|
||
|
||
/* 5 — Desktop Telegram: no geolocation grant, no setGeolocation; the mock's
|
||
* grantLocation:false forces the chain through LINK 3's 60s chat-pin poll to
|
||
* the denied card with the ASK VIA CHAT CTA (GEO-02 exit point, issue #77). */
|
||
await captureLeg({ name: 'shell-denied.png', query: 'mock=tdesktop', colorScheme: 'light', grantGeo: false, waitMode: 'denied' });
|
||
|
||
/* 6 — chat-pin relay: ?pin=1 makes the runner's GET /api/location stub return
|
||
* a pin; the chain reaches LIVE offline — the automated Desktop-leg counterpart. */
|
||
await captureLeg({ name: 'shell-chat-pin.png', query: 'mock=tdesktop&pin=1', colorScheme: 'light', grantGeo: false, waitMode: 'live' });
|
||
|
||
/* 7 — ONBD-02: the destination-preload boot. ?dest=<id> carries the plain
|
||
* gazetteer id in the URL's own query string (the inline-button carrier —
|
||
* RESEARCH Pitfall 1). The ARAHARAH-DEST signal resolves the race. */
|
||
await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' });
|
||
|
||
/* GEO-03 runtime probe (its own live session) — GEO-03's "spot-checkable at
|
||
* phase exit" criterion made mechanical. Counts and key names only (T-03-24). */
|
||
{
|
||
const context = await browser.newContext({
|
||
viewport: { width: 390, height: 844 },
|
||
colorScheme: 'light',
|
||
deviceScaleFactor: 2,
|
||
});
|
||
await context.grantPermissions(['geolocation'], { origin: base });
|
||
await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });
|
||
const page = await context.newPage();
|
||
try {
|
||
await page.goto(`${base}/?mock=android&scheme=light`, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||
const signal = await Promise.race([
|
||
page.waitForEvent('console', {
|
||
predicate: (m) => m.text().startsWith('ARAHARAH-LIVE'),
|
||
timeout: SIGNAL_TIMEOUT_MS,
|
||
}).then(() => 'signal'),
|
||
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
|
||
]);
|
||
if (signal === 'timeout') throw new Error('GEO-03 probe leg: ARAHARAH-LIVE never arrived within 30000ms');
|
||
await page.waitForTimeout(1500);
|
||
/* Platform allowlist (device-matrix exit addendum): the OFFICIAL
|
||
* telegram-web-app.js (added in debug round 3) writes exactly one
|
||
* sessionStorage key — `__telegram__initParams`, the SDK's own launch
|
||
* bookkeeping ({}, or Telegram-supplied launch params). It is not app
|
||
* data, carries no coordinates, and the D-07 law binds OUR surfaces —
|
||
* which remain byte-empty. Any OTHER key still fails the probe. */
|
||
const probe = await page.evaluate(async () => ({
|
||
local: localStorage.length,
|
||
foreignSessionKeys: Object.keys(sessionStorage).filter((k) => k !== '__telegram__initParams'),
|
||
ssForeignOnly: Object.keys(sessionStorage).every((k) => k === '__telegram__initParams'),
|
||
cookie: document.cookie,
|
||
dbs: (await indexedDB.databases()).length,
|
||
lsKeys: Object.keys(localStorage),
|
||
}));
|
||
if (probe.local !== 0 || !probe.ssForeignOnly || probe.cookie !== "" || probe.dbs !== 0) {
|
||
failures.push(`GEO-03 runtime probe FAILED — localStorage=${probe.local} foreignSessionKeys=${JSON.stringify(probe.foreignSessionKeys)} cookie="${probe.cookie}" indexedDB=${probe.dbs} keys=${JSON.stringify(probe.lsKeys)}`);
|
||
} else {
|
||
log.push('GEO-03 runtime probe PASSED — app storage surfaces empty after boot-to-live (platform allowlist: SDK-owned __telegram__initParams only)');
|
||
}
|
||
} catch (err) {
|
||
failures.push(`GEO-03 runtime probe: ${err.message}`);
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
}
|
||
|
||
/* ————— DSGN-02 composer (shared by legs 8, 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}`);
|
||
}
|
||
}
|
||
} 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',
|
||
];
|
||
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);
|