- telegram-mock gains requestFullscreen/exitFullscreen/lockOrientation/unlockOrientation + __fullscreen* state fields in the SAME change as the contract (contract + fake + test together) - FS1-FS7: both-halves guard vectors, the throwing-platform path (FS4), the exit mirror (FS5), the event categories (FS6), the old-client fallback (FS7, SC2.e) - FS8: safeAreaChanged re-resolution through the union authority, with a minimal document stub restored in finally - FS9: comment-stripped source-scan ban on the DOM-fullscreen/orientation-lock escape hatches (T-07-10) + the guard-is-not-optional assertion - suite 157 passed, 0 failed (148 baseline + 9); mutation check: neutering the 8.0 guard fails FS2/FS5
515 lines
21 KiB
TypeScript
515 lines
21 KiB
TypeScript
/*
|
||
* telegram-mock.ts — the D-11 Mock-Telegram harness.
|
||
* -------------------------------------------------
|
||
* Injects a fake `window.Telegram.WebApp` into the SAME window the app
|
||
* runs in — never an iframe. Bot API 10.2 (enforced 2026-07-20) rejects
|
||
* Mini App method calls from any origin other than the registered Mini
|
||
* App domain, so an iframe harness would silently fail (RESEARCH
|
||
* Pitfall 6).
|
||
*
|
||
* Dev-signed initData uses the SAME HMAC path as bot/src/security/initdata.ts
|
||
* (`HMAC(bot_token, "WebAppData")`) so `POST /api/session` accepts it
|
||
* unmodified against `bot/.dev.vars`. Changes nothing when the token
|
||
* is real, and never logs it.
|
||
*
|
||
* Signing runs on the Web Crypto API (crypto.subtle.importKey + sign,
|
||
* HMAC-SHA-256, TextEncoder) — the only crypto available in a browser
|
||
* — so `makeDevInitData` is async. The test suites use their own
|
||
* test-side signer instead.
|
||
*
|
||
* This module ships nothing to production: `no-storage.test.mjs` and
|
||
* the build-artifact gate both assert it is excluded from `web/dist`.
|
||
*
|
||
* Phase 4 (04-04): the mock satisfies the contract's three new OPTIONAL
|
||
* members in the SAME change that introduces them — `startParam` (appended
|
||
* to the fake initData as `&start_param=<encoded>`, the direct-link carrier
|
||
* the ONBD-02 harness leg exercises), `homeScreenStatus` (what the mock's
|
||
* checkHomeScreenStatus callback delivers; default "unknown", the iOS
|
||
* reality), and the addToHomeScreen stub whose `__homeScreenAdded` counter
|
||
* tests read (the `__events` / `__backButtonClicks` exposure precedent).
|
||
* The mock's initData is HMAC-unvalidated by design — the runner page
|
||
* stubs `/api/session`.
|
||
*
|
||
* The ?drive= replay knob (06-08, D-16's browser leg): `?drive=<name>` makes
|
||
* THIS mock replace navigator.geolocation with a replay source that streams
|
||
* a committed drive fixture (web/test/fixtures/drives/*.json — the same JSON
|
||
* the Node replays prove the brain with) through the LIVE shell — the real
|
||
* pump, real builders, real camera consume the fixes via the real
|
||
* createGuidanceWatch. The replay clock starts lazily at the FIRST
|
||
* watchPosition arm (the route draw — the pump's ensureWatch), so no fix is
|
||
* ever emitted before something consumes it, and plays REAL-TIME: the
|
||
* machine's grace/storm clocks read wall time (route.ts's now()), so a
|
||
* compressed playback would desync them. window.__ARAHARAH_DRIVE.pause()/
|
||
* resume() freeze/resume emission mid-stream — the capture script's freeze
|
||
* for stable scripted taps (confirm/tripend legs) and the REROUTING leg's
|
||
* window. A pause stops EMISSION only; the shell's wall clock keeps running
|
||
* (grace windows inflate across a pause — legs that pause close before any
|
||
* machine timing matters). Console emits NOTHING per fix; an unknown drive
|
||
* name is the knob's one honest token (`ARAHARAH-DRIVE state=unknown`) and
|
||
* falls back to the default geolocation — never a crash, never replayed
|
||
* arbitrary data (the fixture/kernel mismatch mitigation, T-06-08).
|
||
*/
|
||
import type { TelegramWebAppLike, HomeScreenStatus } from "../telegram/webapp.ts";
|
||
|
||
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;
|
||
/** The direct-link start parameter (04-04): appended to the mock initData
|
||
* as `&start_param=<encoded>` — the SECONDARY destination carrier's
|
||
* harness leg. The URL query carrier needs no mock: page.goto preserves
|
||
* `?dest=` in window.location.search. */
|
||
startParam?: string | null;
|
||
/** What the mock's checkHomeScreenStatus callback delivers (04-04).
|
||
* Defaults to "unknown" — the documented iOS reality, treated by the
|
||
* app as "supported but indeterminate", never an error. */
|
||
homeScreenStatus?: HomeScreenStatus;
|
||
}
|
||
|
||
interface MockLocationManager {
|
||
isInited: boolean;
|
||
isLocationAvailable: boolean;
|
||
isAccessRequested: boolean;
|
||
isAccessGranted: boolean;
|
||
init(cb: () => void): void;
|
||
getLocation(cb: (data: unknown | null) => void): void;
|
||
}
|
||
|
||
interface MockWebApp extends TelegramWebAppLike {
|
||
__events: Map<string, Array<() => void>>;
|
||
__backButtonClicks: Array<() => void>;
|
||
__homeScreenAdded: number;
|
||
__fullscreenRequested: number;
|
||
__fullscreenExited: number;
|
||
__orientationLocked: number;
|
||
__orientationUnlocked: number;
|
||
__isFullscreen: boolean;
|
||
}
|
||
|
||
/* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */
|
||
|
||
function utf8Bytes(value: string): Uint8Array {
|
||
return new TextEncoder().encode(value);
|
||
}
|
||
|
||
async function hmacBytes(message: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
|
||
const cryptoKey = await crypto.subtle.importKey(
|
||
"raw",
|
||
key as unknown as ArrayBuffer,
|
||
{ name: "HMAC", hash: "SHA-256" },
|
||
false,
|
||
["sign"],
|
||
);
|
||
const sig = await crypto.subtle.sign("HMAC", cryptoKey, message as unknown as ArrayBuffer);
|
||
return new Uint8Array(sig);
|
||
}
|
||
|
||
function bytesToHex(bytes: Uint8Array): string {
|
||
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
/** dataCheckString = fields minus hash/signature, sorted by key, `key=value`, joined with "\n". */
|
||
function dataCheckString(fields: Array<[string, string]>): string {
|
||
return fields
|
||
.filter(([key]) => key !== "hash" && key !== "signature")
|
||
.slice()
|
||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||
.map(([key, value]) => `${key}=${value}`)
|
||
.join("\n");
|
||
}
|
||
|
||
/** `POST /api/session` computes the same two-step HMAC: key = HMAC(botToken, salt). */
|
||
export async function makeDevInitData(
|
||
botToken: string,
|
||
user: { id: number; first_name?: string; language_code?: string },
|
||
): Promise<string> {
|
||
const fields: Array<[string, string]> = [
|
||
["user", JSON.stringify(user)],
|
||
["auth_date", String(Math.floor(Date.now() / 1000))],
|
||
];
|
||
const dcs = dataCheckString(fields);
|
||
// Step 1: secret key = HMAC(message = botToken bytes, key = the platform salt constant).
|
||
const secretKey = await hmacBytes(utf8Bytes(botToken), utf8Bytes("WebAppData"));
|
||
// Step 2: hash = HMAC(message = dataCheckString bytes, key = that secret key).
|
||
const hash = bytesToHex(await hmacBytes(utf8Bytes(dcs), secretKey));
|
||
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
|
||
return `${wire}&hash=${hash}`;
|
||
}
|
||
|
||
/* ————— install / uninstall ————— */
|
||
|
||
/** Dotted-numeric Bot API version compare: "8.0" >= "8.0", "9.1" >= "8.0". */
|
||
function versionAtLeast(have: string, want: string): boolean {
|
||
const a = have.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||
const b = want.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||
const av = a[i] ?? 0;
|
||
const bv = b[i] ?? 0;
|
||
if (av > bv) return true;
|
||
if (av < bv) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
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) — unless a startParam arrives
|
||
* (04-04), in which case the wire carries it as `&start_param=<encoded>`
|
||
* so the SECONDARY destination carrier is exercisable end-to-end.
|
||
*/
|
||
function buildMockInitData(languageCode?: string, startParam?: string | null): string {
|
||
if (languageCode === undefined && !startParam) return "";
|
||
const user = JSON.stringify({ language_code: languageCode ?? "en", id: 1, first_name: "Harness" });
|
||
const base = `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`;
|
||
return startParam ? `${base}&start_param=${encodeURIComponent(startParam)}` : base;
|
||
}
|
||
|
||
/* ————— the ?drive= fixture-replay source (06-08, D-16's browser leg) ————— */
|
||
|
||
/** A committed drive fixture's structural minimum (the 06-02 replay shape:
|
||
* { _notice, pairId, maneuverIdx, kinds, fixes[] } with Fix-shaped fixes). */
|
||
interface DriveFixture {
|
||
fixes: Array<{ lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null; ts: number; source?: string }>;
|
||
}
|
||
|
||
/** The pause/resume surface the capture script drives (window.__ARAHARAH_DRIVE). */
|
||
export interface DriveReplayControls {
|
||
/** Freeze the replay clock mid-stream — emission stops until resume(). */
|
||
pause(): void;
|
||
/** Continue mid-stream: unpaused wall time resumes driving the schedule. */
|
||
resume(): void;
|
||
readonly name: string;
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
/** Installed ONLY when ?drive= names a committed fixture — the capture
|
||
* script's freeze surface (and its diagnostics: .name). */
|
||
__ARAHARAH_DRIVE?: DriveReplayControls;
|
||
}
|
||
}
|
||
|
||
/** Drive names are slugs (the pairId/destinationId pattern — bounds the
|
||
* lookup against hostile query strings before the fixture record is read). */
|
||
const DRIVE_NAME_PATTERN = /^[a-z0-9-]{1,64}$/;
|
||
|
||
/** The committed D-16 drive replays, inlined into the HARNESS bundle at build
|
||
* time (eager glob, the route-profiles BA-exclusion posture: this module is
|
||
* imported by main.ts's dead harness branch and tree-shaken from production —
|
||
* BA5 asserts the absence). The pattern is ROOT-ABSOLUTE (/test/... — Vite 8's
|
||
* importGlob matches no relative pattern that escapes src/; verified by the
|
||
* 06-08 dev-server probe). */
|
||
const DRIVE_FIXTURES = import.meta.glob<unknown>("/test/fixtures/drives/*.json", {
|
||
eager: true,
|
||
import: "default",
|
||
});
|
||
|
||
/** Defensive shape check (the fixture-source isUsablePair posture): a
|
||
* malformed drive degrades to the honest unknown-fallback, never a crash
|
||
* and never a partial replay (T-06-08's Tampering mitigation). */
|
||
function isDriveFixture(value: unknown): value is DriveFixture {
|
||
if (value === null || typeof value !== "object") return false;
|
||
const fixes = (value as { fixes?: unknown }).fixes;
|
||
if (!Array.isArray(fixes) || fixes.length < 2) return false;
|
||
return fixes.every((f) => {
|
||
if (f === null || typeof f !== "object") return false;
|
||
const p = f as { lat?: unknown; lng?: unknown; ts?: unknown };
|
||
return (
|
||
typeof p.lat === "number" &&
|
||
Number.isFinite(p.lat) &&
|
||
typeof p.lng === "number" &&
|
||
Number.isFinite(p.lng) &&
|
||
typeof p.ts === "number" &&
|
||
Number.isFinite(p.ts)
|
||
);
|
||
});
|
||
}
|
||
|
||
function driveFixtureByName(name: string): DriveFixture | null {
|
||
if (!DRIVE_NAME_PATTERN.test(name)) return null;
|
||
const hit = DRIVE_FIXTURES[`/test/fixtures/drives/${name}.json`];
|
||
return isDriveFixture(hit) ? hit : null;
|
||
}
|
||
|
||
/** Position assembled from a fixture fix at a replayed wall timestamp. The
|
||
* watch (createGuidanceWatch) normalizes exactly the four consumed fields;
|
||
* the GeolocationPosition type's remaining members are cast — the harness
|
||
* source is the fixture itself, not a lying platform. */
|
||
function replayedPosition(f: DriveFixture["fixes"][number], timestamp: number): GeolocationPosition {
|
||
return {
|
||
coords: {
|
||
latitude: f.lat,
|
||
longitude: f.lng,
|
||
accuracy: f.accuracyM ?? undefined,
|
||
altitude: null,
|
||
altitudeAccuracy: null,
|
||
altitudeAngle: null,
|
||
heading: f.headingDeg ?? undefined,
|
||
speed: null,
|
||
},
|
||
timestamp,
|
||
} as unknown as GeolocationPosition;
|
||
}
|
||
|
||
/**
|
||
* Replace navigator.geolocation with the drive replay (harness-only): the
|
||
* REAL pump arms its REAL watch against this object, so the shell consumes
|
||
* the committed stream through the unmodified D-15 throttle — replay proves
|
||
* the shell, never bypassing it (the trust boundary's law).
|
||
*
|
||
* Clock semantics: the schedule is real-time (offset = fix.ts − fixes[0].ts;
|
||
* a 200ms tick delivers every fix whose offset has elapsed). pos.timestamp
|
||
* is t0 + offset so the machine's now()-anchored windows stay aligned with
|
||
* the fixes that drive them. Exhaustion stops the timer; a later re-arm
|
||
* delivers nothing (the drive is over).
|
||
*/
|
||
function installDriveReplay(name: string, fixture: DriveFixture): DriveReplayControls {
|
||
const fixes = fixture.fixes;
|
||
const first = fixes[0]!;
|
||
const offsets = fixes.map((f) => Math.max(0, f.ts - first.ts));
|
||
let clockStart: number | null = null; // performance.now() at the first watch arm
|
||
let wallT0 = 0; // Date.now() at the same instant — pos.timestamp = wallT0 + offset
|
||
let pausedAt: number | null = null;
|
||
let pausedTotal = 0;
|
||
let delivered = 0;
|
||
let nextId = 0;
|
||
let timer: ReturnType<typeof setInterval> | null = null;
|
||
const sinks = new Map<number, (pos: GeolocationPosition) => void>();
|
||
|
||
const elapsedMs = (): number => {
|
||
if (clockStart === null) return -1;
|
||
const nowP = performance.now();
|
||
return (nowP - clockStart) - pausedTotal - (pausedAt !== null ? nowP - pausedAt : 0);
|
||
};
|
||
const stopTimer = (): void => {
|
||
if (timer !== null) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
};
|
||
const tick = (): void => {
|
||
const e = elapsedMs();
|
||
if (e < 0) return;
|
||
while (delivered < fixes.length) {
|
||
const offset = offsets[delivered]!;
|
||
if (offset > e) break;
|
||
const fix = fixes[delivered]!;
|
||
const pos = replayedPosition(fix, wallT0 + offset);
|
||
for (const sink of sinks.values()) sink(pos);
|
||
delivered += 1;
|
||
}
|
||
if (delivered >= fixes.length) stopTimer(); // fully replayed — the drive is over
|
||
};
|
||
const ensureTimer = (): void => {
|
||
if (timer === null && delivered < fixes.length) timer = setInterval(tick, 200);
|
||
};
|
||
|
||
const fakeGeo = {
|
||
getCurrentPosition(success: PositionCallback, _error?: PositionErrorCallback | null, _opts?: PositionOptions): void {
|
||
// the chain's LINK-2 shape (unused while the Telegram mock serves LINK 1):
|
||
// the drive's FIRST fix, at wall-now, like a real one-shot read
|
||
const f = first;
|
||
setTimeout(() => success(replayedPosition(f, Date.now())), 0);
|
||
},
|
||
watchPosition(success: PositionCallback, _error?: PositionErrorCallback | null, _opts?: PositionOptions): number {
|
||
const id = ++nextId;
|
||
sinks.set(id, success);
|
||
if (clockStart === null) {
|
||
clockStart = performance.now();
|
||
wallT0 = Date.now();
|
||
}
|
||
ensureTimer(); // the first arm starts the clock; re-arms resume delivery
|
||
return id;
|
||
},
|
||
clearWatch(id: number): void {
|
||
sinks.delete(id);
|
||
if (sinks.size === 0) stopTimer(); // the pump stopped the watch — the replay stops with it
|
||
},
|
||
};
|
||
Object.defineProperty(navigator, "geolocation", { value: fakeGeo, configurable: true });
|
||
|
||
const controls: DriveReplayControls = {
|
||
get name(): string {
|
||
return name;
|
||
},
|
||
pause(): void {
|
||
if (pausedAt === null) pausedAt = performance.now();
|
||
},
|
||
resume(): void {
|
||
if (pausedAt !== null) {
|
||
pausedTotal += performance.now() - pausedAt;
|
||
pausedAt = null;
|
||
}
|
||
ensureTimer();
|
||
},
|
||
};
|
||
window.__ARAHARAH_DRIVE = controls;
|
||
return controls;
|
||
}
|
||
|
||
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,
|
||
isAccessRequested: options.grantLocation !== false,
|
||
isAccessGranted: options.grantLocation !== false,
|
||
init(cb: () => void) {
|
||
setTimeout(() => {
|
||
lm.isInited = true;
|
||
cb();
|
||
}, 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({
|
||
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
|
||
}
|
||
},
|
||
};
|
||
const events = new Map<string, Array<() => void>>();
|
||
const backButtonClicks: Array<() => void> = [];
|
||
let homeScreenAddedCount = 0;
|
||
const homeScreenStatus: HomeScreenStatus = options.homeScreenStatus ?? "unknown";
|
||
const fake: MockWebApp = {
|
||
initData: buildMockInitData(options.languageCode, options.startParam),
|
||
startParam: options.startParam ?? undefined,
|
||
colorScheme: options.colorScheme ?? "light",
|
||
themeParams: {},
|
||
version,
|
||
platform: options.platform ?? "android",
|
||
__events: events,
|
||
__backButtonClicks: backButtonClicks,
|
||
__homeScreenAdded: 0,
|
||
__fullscreenRequested: 0,
|
||
__fullscreenExited: 0,
|
||
__orientationLocked: 0,
|
||
__orientationUnlocked: 0,
|
||
__isFullscreen: false,
|
||
isVersionAtLeast(v: string) {
|
||
return versionAtLeast(version, v);
|
||
},
|
||
close() {
|
||
/* the runner page listens to window-event hooks; nothing to close here */
|
||
},
|
||
addToHomeScreen() {
|
||
homeScreenAddedCount += 1;
|
||
fake.__homeScreenAdded = homeScreenAddedCount;
|
||
},
|
||
checkHomeScreenStatus(cb: (status: HomeScreenStatus) => void) {
|
||
setTimeout(() => cb(homeScreenStatus), 0);
|
||
},
|
||
// PX-04/D-03 (07-01): the fullscreen members ship with the contract —
|
||
// the mock satisfies the grown contract in the SAME change ("contract +
|
||
// fake + test together"). The ?mock= profile's `version` below 8.0 makes
|
||
// the fullscreen path a silent no-op via the version guard — never
|
||
// hard-coded, the mock's own versionAtLeast drives the guard.
|
||
requestFullscreen() {
|
||
fake.__fullscreenRequested = (fake.__fullscreenRequested ?? 0) + 1;
|
||
fake.__isFullscreen = true;
|
||
},
|
||
exitFullscreen() {
|
||
fake.__fullscreenExited = (fake.__fullscreenExited ?? 0) + 1;
|
||
fake.__isFullscreen = false;
|
||
},
|
||
lockOrientation() {
|
||
fake.__orientationLocked = (fake.__orientationLocked ?? 0) + 1;
|
||
},
|
||
unlockOrientation() {
|
||
fake.__orientationUnlocked = (fake.__orientationUnlocked ?? 0) + 1;
|
||
},
|
||
onEvent(event: string, cb: () => void) {
|
||
const list = events.get(event) ?? [];
|
||
list.push(cb);
|
||
events.set(event, list);
|
||
},
|
||
offEvent(event: string, cb: () => void) {
|
||
events.set(event, (events.get(event) ?? []).filter((f) => f !== cb));
|
||
},
|
||
BackButton: {
|
||
show() {},
|
||
hide() {},
|
||
onClick(cb: () => void) {
|
||
backButtonClicks.push(cb);
|
||
},
|
||
offClick(cb: () => void) {
|
||
const i = backButtonClicks.indexOf(cb);
|
||
if (i >= 0) backButtonClicks.splice(i, 1);
|
||
},
|
||
},
|
||
LocationManager: lm,
|
||
};
|
||
installed = fake;
|
||
const w = window as unknown as { Telegram?: unknown };
|
||
w.Telegram = { WebApp: fake };
|
||
// The ?drive= replay knob (06-08): read from location.search HERE — the
|
||
// mock module is the harness surface, so the knob rides every install path
|
||
// (the runner page's profile install AND main.ts's plain-dev fallback)
|
||
// without a new carrier. Unknown name → one honest token + the default
|
||
// geolocation stays; never a crash, never replayed arbitrary data.
|
||
let driveName: string | null = null;
|
||
try {
|
||
driveName = new URLSearchParams(window.location?.search ?? "").get("drive");
|
||
} catch {
|
||
driveName = null;
|
||
}
|
||
if (driveName !== null) {
|
||
const fixture = driveFixtureByName(driveName);
|
||
if (fixture === null) console.log("ARAHARAH-DRIVE state=unknown");
|
||
else installDriveReplay(driveName, fixture);
|
||
}
|
||
return fake;
|
||
}
|
||
|
||
export function uninstallMockWebApp(): void {
|
||
installed = null;
|
||
const w = window as unknown as { Telegram?: unknown };
|
||
delete w.Telegram;
|
||
}
|
||
|
||
/** Fire the registered themeChanged listeners — theme.test.mjs PX-02 case 4. */
|
||
export function emitThemeChanged(): void {
|
||
const fake = installed;
|
||
if (!fake) throw new Error("installMockWebApp must run before emitThemeChanged");
|
||
for (const cb of fake.__events.get("themeChanged") ?? []) cb();
|
||
}
|