#!/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) * gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png * * 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']; /* * 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) */ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode }) { 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, 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'}; 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 === '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 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' }); /* 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); const probe = await page.evaluate(async () => ({ local: localStorage.length, session: sessionStorage.length, cookie: document.cookie, dbs: (await indexedDB.databases()).length, lsKeys: Object.keys(localStorage), })); if (probe.local !== 0 || probe.session !== 0 || probe.cookie !== "" || probe.dbs !== 0) { failures.push(`GEO-03 runtime probe FAILED — localStorage=${probe.local} sessionStorage=${probe.session} cookie="${probe.cookie}" indexedDB=${probe.dbs} keys=${JSON.stringify(probe.lsKeys)}`); } else { log.push('GEO-03 runtime probe PASSED — localStorage/sessionStorage/cookies/IndexedDB all empty after boot-to-live'); } } catch (err) { failures.push(`GEO-03 runtime probe: ${err.message}`); } finally { await context.close(); } } /* 7 — 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 = `