diff --git a/web/scripts/capture.mjs b/web/scripts/capture.mjs index 474a1e8..745cba2 100644 --- a/web/scripts/capture.mjs +++ b/web/scripts/capture.mjs @@ -26,6 +26,7 @@ * 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 * * Also runs the GEO-03 runtime probe in-page after boot-to-live and fails @@ -162,7 +163,7 @@ log.push(`serving web/dist-harness/ → ${base}/`); const browser = await chromium.launch(); -const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE']; +const SIGNALS = ['RAMS-BASEMAP-READY', 'ARAHARAH-LIVE', 'ARAHARAH-DEST']; /* * One capture leg = one context + one screenshot. The Phase-1 READY-race @@ -186,7 +187,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, live: 'no' }; + const seen = { 'RAMS-BASEMAP-READY': false, 'ARAHARAH-LIVE': false, 'ARAHARAH-DEST': false, live: 'no' }; const consoleLines = []; page.on('console', (m) => { for (const s of SIGNALS) if (m.text().startsWith(s)) seen[s] = true; @@ -194,7 +195,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'}; 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'}; last console: ${JSON.stringify(consoleLines.slice(-5))}`; try { await page.goto(`${base}/?${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -216,6 +217,33 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode }) { 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 === '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 @@ -268,6 +296,11 @@ try { * 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= 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). */ { @@ -316,7 +349,7 @@ try { } } - /* 7 — DSGN-02 fidelity artifact: the live-light capture beside the committed + /* 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'); @@ -365,6 +398,7 @@ const expected = [ 'shell-live-dark.png', 'shell-denied.png', 'shell-chat-pin.png', + 'shell-dest-preload.png', 'gallery-compare.png', ]; const missing = expected.filter((f) => !existsSync(join(SHOTS, f))); @@ -378,5 +412,5 @@ if (missing.length || undersized.length || failures.length) { process.exit(1); } -console.log(`CAPTURE OK — 7/7 non-blank captures under web/screenshots/`); +console.log(`CAPTURE OK — 8/8 non-blank captures under web/screenshots/`); process.exit(0); diff --git a/web/test/build-artifact.test.mjs b/web/test/build-artifact.test.mjs index 985c21b..53009ff 100644 --- a/web/test/build-artifact.test.mjs +++ b/web/test/build-artifact.test.mjs @@ -24,6 +24,17 @@ * BA5 pointed there would invert the gate it exists to enforce. * BA6 no emitted HTML carries the runner page's /api/session or * /api/location-fallback fetch stubs (same dead-code-elimination proof) + * BA7 the shipped JS carries EVERY gazetteer id from the committed + * dataset (search/src/data/kl-gazetteer.json) — the WEB half of the + * bot/web id-set parity gate (T-04-39): a stale web bundle ships + * without an id the bot just deep-linked, and the webview could + * never resolve it + * BA8 the shipped JS carries NO deep-link crypto (createHmac, + * crypto.subtle, sha256, HMAC() — the deep link is a plain public id + * (D-11, T-04-40). Scans the JS ASSETS only, never the HTML: the + * OFFICIAL Telegram SDK script is loaded EXTERNALLY from + * telegram.org (not bundled) and may contain crypto internals of + * its own. * * If web/dist does not exist, every case fails with a build-first message. */ @@ -33,6 +44,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { test } from "./harness.mjs"; +import gazetteer from "../../search/src/data/kl-gazetteer.json" with { type: "json" }; const WEB = path.join(fileURLToPath(new URL(import.meta.url)), "..", ".."); const DIST = join(WEB, "dist"); @@ -133,3 +145,32 @@ test("BA6 production HTML carries NO runner-page fetch stub endpoints", () => { ); } }); + +test("BA7 the shipped bundle carries EVERY gazetteer id (the bot/web id-set parity gate, T-04-39)", () => { + assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message); + const bundle = jsFiles + .filter((f) => f.startsWith(DIST_ASSETS)) + .map((f) => readFileSync(f, "utf8")) + .join(""); + assert.ok(gazetteer.entries.length >= 30, `scanned a real dataset (found ${gazetteer.entries.length} entries)`); + for (const entry of gazetteer.entries) { + assert.ok( + bundle.includes(entry.id), + `gazetteer id "${entry.id}" is MISSING from the shipped web bundle — a stale bundle the webview cannot resolve against (T-04-39); ids are never renamed, only added`, + ); + } +}); + +test("BA8 the shipped bundle carries NO deep-link crypto (D-11, T-04-40)", () => { + assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message); + const bundle = jsFiles + .filter((f) => f.startsWith(DIST_ASSETS)) + .map((f) => readFileSync(f, "utf8")) + .join(""); + for (const token of ["createHmac", "crypto.subtle", "sha256", "HMAC("]) { + assert.ok( + !bundle.includes(token), + `"${token}" found in a web/dist JS asset — the deep link is a PLAIN public id, no crypto anywhere in the path (D-11)`, + ); + } +}); diff --git a/web/test/no-storage.test.mjs b/web/test/no-storage.test.mjs index f1d5ae8..b9d2d5e 100644 --- a/web/test/no-storage.test.mjs +++ b/web/test/no-storage.test.mjs @@ -12,7 +12,7 @@ * tokens appear — code occurrences only. */ import { strict as assert } from "node:assert"; -import { readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; @@ -77,3 +77,28 @@ test("GEO3 web/src/session/store.ts assigns nothing to any storage object (the D // ...and keeps the named TTL constant the client revalidates against (D-03). assert.ok(code.includes("SESSION_TTL_SECONDS = 900"), "the 900s TTL mirror by name is present"); }); + +test("GEO3 web/src/search/ holds no persistence surface and no deep-link crypto", () => { + // The ONBD-02 tree (04-04): the resolved destination is user context and + // stays MEMORY-ONLY (GEO-03/D-07), and the deep-link path is a plain + // public id with NO crypto (D-11). The whole-tree GEO3 gate above already + // walks this directory; this explicit case pins the new tree + the + // no-crypto law so a future file there fails HERE, by name. + const searchDir = path.join(SRC, "search"); + assert.ok(existsSync(searchDir), "web/src/search exists (the ONBD-02 resolver tree)"); + for (const file of listFiles(searchDir, [".ts"])) { + const code = codeText(file); + for (const banned of BANNED) { + assert.ok( + !code.includes(banned), + `${path.relative(process.cwd(), file)} references ${banned} — GEO-03 violated (web/src/search must stay memory-only)`, + ); + } + for (const token of ["createHmac", "crypto.subtle", "sha256"]) { + assert.ok( + !code.includes(token), + `${path.relative(process.cwd(), file)} references ${token} — D-11 forbids crypto in the deep-link path`, + ); + } + } +});