diff --git a/web/scripts/capture.mjs b/web/scripts/capture.mjs index 745cba2..65bfad1 100644 --- a/web/scripts/capture.mjs +++ b/web/scripts/capture.mjs @@ -20,14 +20,19 @@ * 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=) - * gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png + * shell-boot-1.png boot stage 1 (validating session card over live map) + * shell-boot-2.png boot stage 2 (acquiring location card) + * shell-live-light.png LIVE state, Telegram light theme + * shell-live-dark.png LIVE state, Telegram dark theme (PX-02) + * shell-denied.png denied card + ASK VIA CHAT (GEO-02 entry point) + * shell-chat-pin.png LIVE via the chat-pin relay (?pin=1, stubbed /api/location) + * shell-dest-preload.png the Mini App booted WITH a preloaded destination (?dest=) + * shell-route-fixture.png route drawn from the demo pair's pinned origin (?fixpair=, 05-05) + * shell-route-alt.png alternative selected via chip tap (ROUTE {n}/{N} re-render, 05-05) + * shell-route-pending.png the honest ROUTE // ROUTER pending card (D-03, 05-05) + * shell-route-dark.png route drawn, Telegram dark theme (PX-02 route half, 05-05) + * gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png + * route-gallery-compare.png DSGN-02 route fidelity artifact (05-05, same composer) * * Also runs the GEO-03 runtime probe in-page after boot-to-live and fails * the run on any non-empty storage surface. @@ -163,7 +168,24 @@ log.push(`serving web/dist-harness/ → ${base}/`); const browser = await chromium.launch(); -const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE', 'ARAHARAH-DEST']; +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 @@ -174,8 +196,15 @@ const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE', 'ARAHARAH-DEST']; * '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 }) { +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 }, @@ -187,7 +216,7 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode }) { 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, live: 'no' }; + 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; @@ -195,7 +224,7 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode }) { 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'}; last console: ${JSON.stringify(consoleLines.slice(-5))}`; + `${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 }); @@ -244,6 +273,40 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode }) { { 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 @@ -262,6 +325,8 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode }) { 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) { @@ -349,17 +414,15 @@ try { } } - /* 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 { - if (!existsSync(MOCKUP_COMPARE)) throw new Error(`committed mockup missing: ${MOCKUP_COMPARE}`); - const liveB64 = readFileSync(join(SHOTS, 'shell-live-light.png')).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 = `
-
IMPLEMENTED — web/screenshots/shell-live-light.png (?mock=android, harness build)
+
IMPLEMENTED — ${implementedCaption}
MOCKUP REFERENCE — stitch_minimalist_route_navigator/navigate_map_overview/screen.png
`; - 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)`); + 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(); @@ -399,7 +565,12 @@ const expected = [ '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); @@ -412,5 +583,5 @@ if (missing.length || undersized.length || failures.length) { process.exit(1); } -console.log(`CAPTURE OK — 8/8 non-blank captures under web/screenshots/`); +console.log(`CAPTURE OK — ${expected.length}/${expected.length} non-blank captures under web/screenshots/`); process.exit(0);