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