Files

383 lines
18 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)
* 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);
/* 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();
}
}
/* 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 = `<!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 — web/screenshots/shell-live-light.png (?mock=android, harness build)</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)`);
log.push(`captured gallery-compare.png (${sizeOf(target)}B, DSGN-02 side-by-side)`);
} catch (err) {
failures.push(`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',
'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 — 7/7 non-blank captures under web/screenshots/`);
process.exit(0);