feat(03-04): Mock-Telegram runner page + Playwright capture harness — 7/7 captures + GEO-03 probe

- runner page: ?mock/?scheme/?pin/?locale profile block wholly inside if (__ARAHARAH_HARNESS__) — installer + /api/session + /api/location + /api/location-fallback fetch stubs dead-code-eliminated from production; main.ts import stays outside the guard
- harness-only ?sessiondelay/?fixdelay boot-stage photograph knobs (localhost resolves the chain in ms — staged BOOT-01/BOOT-02 cards need a hold)
- capture.mjs extends theme/scripts/screenshot.mjs: MIN_BYTES, WR-05 docroot guard, READY-signal race, dist-harness docroot, DENIED_LEG_TIMEOUT_MS=90000 for LINK 3's 60s pre-denial poll, timeout = hard failure with a signal diagnostic
- 7 captures: boot-1/boot-2 stages, live light+dark (theme-slot attribute asserted), Desktop denied card w/ ASK VIA CHAT, chat-pin relay LIVE, DSGN-02 side-by-side vs navigate_map_overview
- GEO-03 runtime probe: localStorage/sessionStorage/cookies/IndexedDB asserted empty after boot-to-live
- mock fixes (Rule 1): LocationManager delivery now uses the Telegram wire shape {latitude,longitude,horizontal_accuracy,course} (authoring shape mapped to undefined → NaN LngLat pageerror); languageCode→initData so localeFromInitData parses the real wire format

CAPTURE OK — 7/7 non-blank captures; GEO-03 probe PASSED
This commit is contained in:
araharah-executor
2026-10-23 00:37:58 +08:00
parent 8b39ea70c3
commit 5c155e393d
4 changed files with 483 additions and 4 deletions
+1
View File
@@ -22,6 +22,7 @@ web/dist-harness/
# Design tooling captures (plan 03/04) # Design tooling captures (plan 03/04)
theme/screenshots/ theme/screenshots/
web/screenshots/
# OS junk # OS junk
.DS_Store .DS_Store
+64 -1
View File
@@ -7,6 +7,69 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module">
/* D-11 Mock-Telegram profile: ?mock=android|ios|tdesktop|macos|web + ?scheme=light|dark + ?pin=1 + ?locale=en|ms + ?fixdelay + ?sessiondelay
Harness-only: served from web/dist-harness, never deployed.
The WHOLE block below is inside `if (__ARAHARAH_HARNESS__)` — Vite's `define`
replaces that identifier with `false` in the production build, so the branch is
dead-code-eliminated and web/dist/index.html carries neither installMockWebApp
nor the fetch stubs (BA5 scans ALL of web/dist HTML as well as its JS assets).
No storage writes may appear here — the harness profile lives in a
module-scope global (window.__ARAHARAH_MOCK__), never in browser storage (D-07). */
if (__ARAHARAH_HARNESS__) {
const q = new URLSearchParams(location.search);
const profile = q.get("mock") || "android";
const scheme = q.get("scheme") === "dark" ? "dark" : "light";
const locale = q.get("locale") === "ms" ? "ms" : "en";
/* Boot-stage photography knobs (harness-only): at localhost speeds the
whole session→chain→fix sequence resolves in a few ms, so the staged
BOOT-01 (validating) and BOOT-02 (acquiring) cards cannot hold on
screen. ?sessiondelay=<ms> holds the validating card; ?fixdelay=<ms>
holds the acquiring card (a delayed LocationManager delivery).
Production and the plain dev fallback never pass either. */
const sessionDelayMs = Number.parseInt(q.get("sessiondelay") || "0", 10);
const fixDelayMs = Number.parseInt(q.get("fixdelay") || "0", 10);
// Set BEFORE main.ts is imported — this is the marker main.ts's install guard checks.
window.__ARAHARAH_MOCK__ = { profile, scheme, locale };
// Stub the endpoints the app calls, OUTSIDE any dead-code-eliminated branch. These
// live in the runner page (not in main.ts) precisely so no import.meta.env guard can
// strip them: the harness bundle must reach LIVE without a Worker in the loop.
const realFetch = window.fetch.bind(window);
const fakeToken = "00000000-0000-4000-8000-000000000001";
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : (input && input.url) || "";
if (url.includes("/api/session") && (!init || init.method === "POST")) {
// The session gate is HMAC-validated server-side; offline the harness
// short-circuits it with a fake token the stubbed /api/location also accepts.
if (sessionDelayMs > 0) await new Promise((r) => setTimeout(r, sessionDelayMs));
return new Response(JSON.stringify({ token: fakeToken }), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.includes("/api/location") && (!init || init.method === "GET")) {
const body = q.get("pin") === "1"
? { pin: { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 } }
: { pin: null };
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.includes("/api/location-fallback")) {
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
}
return realFetch(input, init);
};
const { installMockWebApp } = await import("/src/mock/telegram-mock.ts");
installMockWebApp({
platform: profile,
colorScheme: scheme,
languageCode: locale,
grantLocation: profile !== "tdesktop",
deliveryDelayMs: fixDelayMs > 0 ? fixDelayMs : undefined,
telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
});
}
await import("/src/main.ts");
</script>
</body> </body>
</html> </html>
+375
View File
@@ -0,0 +1,375 @@
#!/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 = `<!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);
+43 -3
View File
@@ -26,6 +26,17 @@ export interface MockOptions {
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web"; platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
version?: string; version?: string;
colorScheme?: "light" | "dark"; colorScheme?: "light" | "dark";
/** Telegram language_code carried in the fake initData's user JSON —
* lets main.ts's localeFromInitData exercise its real parsing path
* and lets the runner page switch locales via ?locale=. */
languageCode?: string;
/** Artificial delivery delay (ms) for LocationManager.init()/getLocation()
* callbacks — the runner page's ?fixdelay= knob. At localhost speeds the
* whole three-source chain resolves in a few ms, so the staged boot cards
* (BOOT-01 validating / BOOT-02 acquiring) cannot otherwise be photographed;
* a delayed fix delivery holds the earlier stage on screen (03-04 capture
* harness only — defaults to 0, the fallback dev install is unchanged). */
deliveryDelayMs?: number;
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null; telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null; webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
grantLocation?: boolean; grantLocation?: boolean;
@@ -112,9 +123,26 @@ function versionAtLeast(have: string, want: string): boolean {
let installed: MockWebApp | null = null; let installed: MockWebApp | null = null;
/**
* Harness initData: a non-empty, correctly-SHAPED Telegram wire string
* (`user=<url-encoded JSON>&auth_date=1&query_id=h&hash=harness`). The
* capture runner stubs POST /api/session OUTSIDE any dead-code strip, so
* this never reaches an HMAC validator — it exists so main.ts's
* localeFromInitData parses the real wire format instead of falling
* back to "en" (locale = options.languageCode, defaulting to "en").
* Untouched when no languageCode is given (the plain-dev fallback mock
* keeps initData "" exactly as before).
*/
function buildMockInitData(languageCode?: string): string {
if (languageCode === undefined) return "";
const user = JSON.stringify({ language_code: languageCode, id: 1, first_name: "Harness" });
return `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`;
}
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike {
const version = options.version ?? "8.0"; const version = options.version ?? "8.0";
const telegramFix = options.telegramFix ?? null; const telegramFix = options.telegramFix ?? null;
const delayMs = options.deliveryDelayMs ?? 0;
const lm: MockLocationManager = { const lm: MockLocationManager = {
isInited: false, isInited: false,
isLocationAvailable: options.grantLocation !== false, isLocationAvailable: options.grantLocation !== false,
@@ -124,13 +152,25 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike
setTimeout(() => { setTimeout(() => {
lm.isInited = true; lm.isInited = true;
cb(); cb();
}, 0); }, delayMs);
}, },
getLocation(cb: (data: unknown | null) => void) { getLocation(cb: (data: unknown | null) => void) {
// grantLocation true → delivers the configured fix on a macrotask so the // grantLocation true → delivers the configured fix on a macrotask so the
// callback is asynchronous (a wedged client is cb(null) or silence). // callback is asynchronous (a wedged client is cb(null) or silence).
// The DELIVERY SHAPE is the Telegram LocationManager wire format —
// { latitude, longitude, horizontal_accuracy, course } — because the
// consumer is requestTelegramFix, which maps exactly those keys. The
// MockOptions.telegramFix keys (lat/lng/accuracyM/headingDeg) are the
// authoring shape; delivering them verbatim maps to undefined → a NaN
// LngLat pageerror in marker.update (found by the 03-04 capture harness,
// the first end-to-end consumer of this callback).
if (options.grantLocation !== false && telegramFix !== null) { if (options.grantLocation !== false && telegramFix !== null) {
setTimeout(() => cb(telegramFix), 0); setTimeout(() => cb({
latitude: telegramFix.lat,
longitude: telegramFix.lng,
horizontal_accuracy: telegramFix.accuracyM ?? undefined,
course: telegramFix.headingDeg ?? undefined,
}), delayMs);
} else { } else {
setTimeout(() => cb(null), 0); // the denial signal — synchronous null would hide async bugs setTimeout(() => cb(null), 0); // the denial signal — synchronous null would hide async bugs
} }
@@ -139,7 +179,7 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike
const events = new Map<string, Array<() => void>>(); const events = new Map<string, Array<() => void>>();
const backButtonClicks: Array<() => void> = []; const backButtonClicks: Array<() => void> = [];
const fake: MockWebApp = { const fake: MockWebApp = {
initData: "", initData: buildMockInitData(options.languageCode),
colorScheme: options.colorScheme ?? "light", colorScheme: options.colorScheme ?? "light",
themeParams: {}, themeParams: {},
version, version,