From 5c155e393da1588cb565d44e7a959f7d3bd410df Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Fri, 23 Oct 2026 00:37:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(03-04):=20Mock-Telegram=20runner=20page=20?= =?UTF-8?q?+=20Playwright=20capture=20harness=20=E2=80=94=207/7=20captures?= =?UTF-8?q?=20+=20GEO-03=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + web/index.html | 65 +++++- web/scripts/capture.mjs | 375 ++++++++++++++++++++++++++++++++++ web/src/mock/telegram-mock.ts | 46 ++++- 4 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 web/scripts/capture.mjs diff --git a/.gitignore b/.gitignore index c7e7fa8..ffeb9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ web/dist-harness/ # Design tooling captures (plan 03/04) theme/screenshots/ +web/screenshots/ # OS junk .DS_Store diff --git a/web/index.html b/web/index.html index 7336934..c98b2a3 100644 --- a/web/index.html +++ b/web/index.html @@ -7,6 +7,69 @@
- + diff --git a/web/scripts/capture.mjs b/web/scripts/capture.mjs new file mode 100644 index 0000000..e0704d0 --- /dev/null +++ b/web/scripts/capture.mjs @@ -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 = `
+
IMPLEMENTED — web/screenshots/shell-live-light.png (?mock=android, harness build)
+
+
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)`); + 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); diff --git a/web/src/mock/telegram-mock.ts b/web/src/mock/telegram-mock.ts index 55d8bee..d5ee2dd 100644 --- a/web/src/mock/telegram-mock.ts +++ b/web/src/mock/telegram-mock.ts @@ -26,6 +26,17 @@ export interface MockOptions { platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web"; version?: string; 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; webviewFix?: { lat: number; lng: number; accuracyM?: number } | null; grantLocation?: boolean; @@ -112,9 +123,26 @@ function versionAtLeast(have: string, want: string): boolean { let installed: MockWebApp | null = null; +/** + * Harness initData: a non-empty, correctly-SHAPED Telegram wire string + * (`user=&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 { const version = options.version ?? "8.0"; const telegramFix = options.telegramFix ?? null; + const delayMs = options.deliveryDelayMs ?? 0; const lm: MockLocationManager = { isInited: false, isLocationAvailable: options.grantLocation !== false, @@ -124,13 +152,25 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike setTimeout(() => { lm.isInited = true; cb(); - }, 0); + }, delayMs); }, getLocation(cb: (data: unknown | null) => void) { // grantLocation true → delivers the configured fix on a macrotask so the // 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) { - setTimeout(() => cb(telegramFix), 0); + setTimeout(() => cb({ + latitude: telegramFix.lat, + longitude: telegramFix.lng, + horizontal_accuracy: telegramFix.accuracyM ?? undefined, + course: telegramFix.headingDeg ?? undefined, + }), delayMs); } else { 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 void>>(); const backButtonClicks: Array<() => void> = []; const fake: MockWebApp = { - initData: "", + initData: buildMockInitData(options.languageCode), colorScheme: options.colorScheme ?? "light", themeParams: {}, version,