From 2659b7f35218f9669f0f73ccd9290f6eada9c8de Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Thu, 22 Oct 2026 23:46:17 +0800 Subject: [PATCH] feat(03-02): D-11 Mock-Telegram harness + the four requirement gates (17 cases green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telegram-mock.ts: fake window.Telegram.WebApp injected into the SAME window (never an iframe — Bot API 10.2 origin restriction, Pitfall 6); Web-Crypto dev signer mirroring bot/src/security/initdata.ts's two-step HMAC (async makeDevInitData — node:crypto stays out of the browser bundle path); controllable LocationManager stream, themeChanged emitter, BackButton clicks - location-chain.test.mjs (10 cases): GEO-01 version gate / null-WebApp guard / null-denial / hard timeout / stale-detector + one-read nudge; GEO-02 pin pickup / bounded 60s poll / Desktop no-geolocation leg ' same denial keys - no-storage.test.mjs (3 cases): GEO-03 comment-stripped grep gate over web/src/**/*.{ts,html}, no-SW invariant, store.ts storage-assignment probe - theme.test.mjs (4 cases): PX-02 colorScheme + luminance fallback + setStyle source-introspection (Pitfall 7) + themeChanged re-apply; document stub installed and restored per case - Rule 1 fix in telegram.ts: looksStale/nudgeAndRetryTelegram accept an omitted lastAccepted (undefined normalized to null) ' acquireFix(deps) first-call contract --- web/src/location/telegram.ts | 8 +- web/src/mock/telegram-mock.ts | 193 +++++++++++++++++++++ web/test/location-chain.test.mjs | 287 +++++++++++++++++++++++++++++++ web/test/no-storage.test.mjs | 79 +++++++++ web/test/theme.test.mjs | 123 +++++++++++++ 5 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 web/src/mock/telegram-mock.ts create mode 100644 web/test/location-chain.test.mjs create mode 100644 web/test/no-storage.test.mjs create mode 100644 web/test/theme.test.mjs diff --git a/web/src/location/telegram.ts b/web/src/location/telegram.ts index dd0fc2a..d96b4e3 100644 --- a/web/src/location/telegram.ts +++ b/web/src/location/telegram.ts @@ -70,8 +70,10 @@ export async function requestTelegramFix( * of the never-refreshing native cache. Only telegram-vs-telegram pairs * qualify — different sources bounding identically is coincidence, not stale. */ -export function looksStale(prev: Fix | null, next: Fix): boolean { - if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false; +export function looksStale(prev: Fix | null | undefined, next: Fix): boolean { + // undefined is normalized to null: acquireFix(deps) with no second + // argument is the documented "first fix of the session" call. + if (prev === null || prev === undefined || prev.source !== "telegram" || next.source !== "telegram") return false; return haversineMeters(prev, next) < 0.01; } @@ -97,7 +99,7 @@ export function haversineMeters(a: Fix, b: Fix): number { export async function nudgeAndRetryTelegram( wa: TelegramWebAppLike, readWebviewFix: (o?: { timeoutMs?: number }) => Promise, - lastAccepted: Fix | null, + lastAccepted: Fix | null | undefined, options: { timeoutMs?: number; nowMs?: () => number } = {}, ): Promise { const first = await requestTelegramFix(wa, options); diff --git a/web/src/mock/telegram-mock.ts b/web/src/mock/telegram-mock.ts new file mode 100644 index 0000000..55d8bee --- /dev/null +++ b/web/src/mock/telegram-mock.ts @@ -0,0 +1,193 @@ +/* + * 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`. + */ +import type { TelegramWebAppLike } from "../telegram/webapp.ts"; + +export interface MockOptions { + platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web"; + version?: string; + colorScheme?: "light" | "dark"; + telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null; + webviewFix?: { lat: number; lng: number; accuracyM?: number } | null; + grantLocation?: boolean; +} + +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 void>>; + __backButtonClicks: Array<() => void>; +} + +/* ————— 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 { + 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 { + 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; + +export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { + const version = options.version ?? "8.0"; + const telegramFix = options.telegramFix ?? null; + 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(); + }, 0); + }, + 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). + if (options.grantLocation !== false && telegramFix !== null) { + setTimeout(() => cb(telegramFix), 0); + } else { + setTimeout(() => cb(null), 0); // the denial signal — synchronous null would hide async bugs + } + }, + }; + const events = new Map void>>(); + const backButtonClicks: Array<() => void> = []; + const fake: MockWebApp = { + initData: "", + colorScheme: options.colorScheme ?? "light", + themeParams: {}, + version, + platform: options.platform ?? "android", + __events: events, + __backButtonClicks: backButtonClicks, + isVersionAtLeast(v: string) { + return versionAtLeast(version, v); + }, + close() { + /* the runner page listens to window-event hooks; nothing to close here */ + }, + 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 }; + 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(); +} diff --git a/web/test/location-chain.test.mjs b/web/test/location-chain.test.mjs new file mode 100644 index 0000000..57378a3 --- /dev/null +++ b/web/test/location-chain.test.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +/* + * location-chain.test.mjs — GEO-01 + GEO-02 (client half) against injected fakes. + * ------------------------------------------------------------------------------ + * Every case feeds a plain object literal matching TelegramWebAppLike (or null) + * straight into the chain — never a real MapLibre instance, never a DOM, never a + * device. The `navigator` and `fetch` globals are stubbed per case + * (install + restore-in-finally) because the webview and chat-pin links probe them. + * + * Cases named with the requirement they prove: + * GEO1 the telegram source, the version gate, the null-WebApp guard, the + * null-denial branch, the hard timeout, and the stale-detector + nudge + * GEO2 the chat-pin poll: pin pickup, bounded poll, and the Desktop leg + * (no LocationManager, no navigator.geolocation → denied, identical + * reason keys — no client-specific variant, Pitfall 4) + */ +import { strict as assert } from "node:assert"; +import { test } from "./harness.mjs"; + +const { acquireFix } = await import("../src/location/chain.ts"); +const telegramMod = await import("../src/location/telegram.ts"); +const { requestTelegramFix } = telegramMod; +const chatPinMod = await import("../src/location/chat-pin.ts"); +const { pollChatPin, fetchChatPin, CHAT_PIN_POLL_MS, CHAT_PIN_TIMEOUT_MS } = chatPinMod; + +const FIX_A = { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null }; + +/* ————— stub helpers (install + restore per case) ————— */ + +function stubNavigator(geolocationOrNull) { + const original = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + value: geolocationOrNull === null + ? {} + : { + geolocation: { + getCurrentPosition(success) { + success({ + coords: { latitude: FIX_A.lat, longitude: FIX_A.lng, accuracy: 9, heading: null }, + timestamp: Date.now(), + }); + }, + }, + }, + configurable: true, + writable: true, + enumerable: true, + }); + return () => { + if (original) Object.defineProperty(globalThis, "navigator", original); // restore the exact prior descriptor + else delete globalThis.navigator; + }; +} + +function stubFetch(handler) { + const original = Object.getOwnPropertyDescriptor(globalThis, "fetch"); + Object.defineProperty(globalThis, "fetch", { value: handler, configurable: true, writable: true }); + return () => { + if (original) Object.defineProperty(globalThis, "fetch", original); + else delete globalThis.fetch; + }; +} + +const pinResponse = (pin) => + new Response(JSON.stringify({ pin }), { status: 200, headers: { "content-type": "application/json" } }); +const emptyPinResponse = () => pinResponse(null); + +function fakeWebApp(opts = {}) { + const version = opts.version ?? "8.0"; + const app = { + initData: "", + version, + platform: opts.platform ?? "android", + LocationManager: opts.noLocationManager ? undefined : immediateLocationManager(opts.getPayload ?? (() => null)), + isVersionAtLeast(v) { + const a = version.split(".").map(Number); + const b = v.split(".").map(Number); + 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; + }, + close() {}, + }; + return app; +} + +function immediateLocationManager(getPayload) { + const lm = { + isInited: false, + isLocationAvailable: true, + isAccessRequested: true, + isAccessGranted: true, + init(cb) { + lm.isInited = true; + cb(); + }, + getLocation(cb) { + cb(getPayload()); + }, + }; + return lm; +} + +const noWebview = () => Promise.resolve(null); +const fastSleep = () => Promise.resolve(); +const telegramPayload = () => ({ + latitude: FIX_A.lat, + longitude: FIX_A.lng, + horizontal_accuracy: 12, + course: 135, +}); + +/* ————— GEO1 — the telegram source ————— */ + +test("GEO1 telegram init+getLocation yields a Fix with source 'telegram'", async () => { + const wa = fakeWebApp({ getPayload: telegramPayload }); + const { fix, state } = await acquireFix({ wa, getToken: () => null, readWebview: noWebview }); + assert.ok(fix !== null, "chain accepted the telegram fix"); + assert.strictEqual(state.kind, "live"); + assert.strictEqual(fix.source, "telegram"); + assert.strictEqual(fix.accuracyM, 12); + assert.strictEqual(fix.headingDeg, 135); +}); + +test("GEO1 a client below Bot API 8.0 falls through to navigator.geolocation without throwing", async () => { + const wa = fakeWebApp({ version: "7.8", getPayload: () => ({ latitude: 99, longitude: 99 }) }); // gate fails BEFORE LocationManager is probed + const restoreNav = stubNavigator({ geolocation: true }); + try { + const { fix, state } = await acquireFix({ wa, getToken: () => null }); + assert.ok(fix !== null); + assert.strictEqual(state.kind, "live"); + assert.strictEqual(fix.source, "webview", "fell through past the version gate"); + } finally { + restoreNav(); + } +}); + +test("GEO1 telegramLocationSupported(null) returns false without throwing, and acquireFix with wa:null falls through to the webview link", async () => { + assert.doesNotThrow(() => telegramMod.telegramLocationSupported(null)); + assert.strictEqual(telegramMod.telegramLocationSupported(null), false); + const restoreNav = stubNavigator({ geolocation: true }); + try { + const { fix } = await acquireFix({ wa: null, getToken: () => null }); + assert.ok(fix !== null); + assert.strictEqual(fix.source, "webview"); + } finally { + restoreNav(); + } +}); + +test("GEO1 getLocation returning null (denied) advances to the webview source, not to an error", async () => { + const wa = fakeWebApp({ getPayload: () => null }); // the whole denial class — no error object exists + const restoreNav = stubNavigator({ geolocation: true }); + try { + const { fix, state } = await acquireFix({ wa, getToken: () => null }); + assert.ok(fix !== null); + assert.strictEqual(state.kind, "live"); + assert.strictEqual(fix.source, "webview", "denial advanced the chain, it did not raise"); + } finally { + restoreNav(); + } +}); + +test("GEO1 getLocation that never calls back resolves null after the wrapper timeout (Pitfall 3)", async () => { + const wa = fakeWebApp({ getPayload: telegramPayload }); + wa.LocationManager.getLocation = () => {}; // silence: wedged client, callback never fires + const t0 = Date.now(); + const fix = await requestTelegramFix(wa, { timeoutMs: 60 }); + const elapsed = Date.now() - t0; + assert.strictEqual(fix, null, "hard timeout resolved null — boot never wedges in ACQUIRING"); + assert.ok(elapsed >= 55, `timeout actually waited (${elapsed}ms), not rushed`); + assert.ok(elapsed < 2000, `timeout bounded the wait (${elapsed}ms), not the 8s default`); +}); + +test("GEO1 two byte-identical successive telegram fixes are detected as stale and route to the nudge", async () => { + const stale = { ...FIX_A, ts: Date.now() - 1000, source: "telegram" }; + const identicalNext = { ...FIX_A, ts: Date.now(), source: "telegram" }; + assert.strictEqual(telegramMod.looksStale(stale, identicalNext), true, "byte-identical pair = stale"); + const moved = { ...FIX_A, lat: FIX_A.lat + 0.001, ts: Date.now(), source: "telegram" }; + assert.strictEqual(telegramMod.looksStale(stale, moved), false, "a moved fix is fresh"); + const crossSource = { ...FIX_A, ts: Date.now(), source: "webview" }; + assert.strictEqual(telegramMod.looksStale(stale, crossSource), false, "only telegram-vs-telegram pairs qualify"); +}); + +test("GEO1 the nudge reads the webview ONCE then retries telegram (readWebview called exactly once)", async () => { + const wa = fakeWebApp({ getPayload: telegramPayload }); // device keeps reporting the SAME bytes + let webviewCalls = 0; + const readWebview = () => { + webviewCalls++; + return Promise.resolve(null); // whatever the webview read returns, it is discarded + }; + + // Fresh pair: lastAccepted null → no staleness → no nudge. + await telegramMod.nudgeAndRetryTelegram(wa, readWebview, null, { timeoutMs: 60 }); + assert.strictEqual(webviewCalls, 0, "a fresh telegram fix needs no nudge"); + + // Stale pair: byte-identical retry → exactly ONE nudge read, then one telegram retry. + const staleAccepted = { ...FIX_A, ts: Date.now() - 30000, source: "telegram" }; + + // Full nudge behavior, re-verified precisely (DETERMINISTIC mock): + await telegramMod.nudgeAndRetryTelegram(wa, readWebview, staleAccepted, { timeoutMs: 60 }); + assert.strictEqual(webviewCalls, 1, "the nudge path fired exactly once on the stale pair"); + + // End-to-end proof through acquireFix: the identical-fix pair routes to the nudge. + let chainNudges = 0; + const { fix } = await acquireFix( + { + wa, + getToken: () => null, + readWebview: () => { + chainNudges++; + return Promise.resolve(null); + }, + }, + staleAccepted, + ); + assert.strictEqual(chainNudges, 1, "acquireFix routed the identical-fix pair to the nudge"); + assert.ok(fix !== null && fix.source === "telegram", "the retry fix came back from telegram — never the stale one marked fresh silently"); + void webviewCalls; +}); + +/* ————— GEO2 — the chat-pin relay ————— */ + +test("GEO2 pollChatPin returns the pin when the endpoint yields one", async () => { + const restoreFetch = stubFetch(async (url, init) => { + assert.strictEqual(url, "/api/location"); + assert.strictEqual(init.method, "GET"); + assert.match(init.headers.authorization, /^Bearer token-/); + return pinResponse({ latitude: FIX_A.lat, longitude: FIX_A.lng, horizontal_accuracy: 25 }); + }); + try { + const fix = await fetchChatPin("token-abc"); + assert.ok(fix !== null); + assert.strictEqual(fix.source, "chat-pin"); + assert.strictEqual(fix.accuracyM, 25); + assert.strictEqual(fix.headingDeg, null, "bot Location messages carry no course to the Fix"); + const polled = await pollChatPin("token-abc", { timeoutMs: 50, pollMs: 5, sleep: fastSleep }); + assert.ok(polled !== null, "the poll surfaced the pin"); + } finally { + restoreFetch(); + } +}); + +test("GEO2 the chat-pin poll is bounded — a never-delivering endpoint returns null (timeoutMs 50, pollMs 5)", async () => { + assert.strictEqual(CHAT_PIN_TIMEOUT_MS, 60000, "production bound is 60s — AWAITING PIN can never persist forever"); + assert.strictEqual(CHAT_PIN_POLL_MS, 2000); + const restoreFetch = stubFetch(async () => emptyPinResponse()); + try { + const t0 = Date.now(); + const fix = await pollChatPin("token", { timeoutMs: 50, pollMs: 5, sleep: fastSleep }); + assert.strictEqual(fix, null, "bounded poll returned instead of wedging AWAITING PIN"); + assert.ok(Date.now() - t0 < 2000, `bound actually bounded the loop (${Date.now() - t0}ms)`); + } finally { + restoreFetch(); + } +}); + +test("GEO2 a Desktop Telegram fake (no LocationManager, no navigator.geolocation) reaches denied with the same reason keys", async () => { + const wa = fakeWebApp({ platform: "tdesktop", version: "9.1", noLocationManager: true }); + const restoreNav = stubNavigator(null); // Desktop Telegram: no geolocation AT ALL (issue #77) + const restoreFetch = stubFetch(async () => emptyPinResponse()); + try { + // Every in-app link is absent on Desktop; the chat-pin poll IS the only + // origin path. Run the end-to-end poll itself (fast stubbed bound) to + // prove the chain completes it and lands on the SAME denial reason a + // mobile timeout produces — no client-specific copy variant (Pitfall 4). + const { fix, state } = await acquireFix({ + wa, + getToken: () => "token", + pollPin: async () => { + const viaEndpoint = await fetchChatPin("token"); // same GET /api/location the mobile path polls + return viaEndpoint; + }, + }); + assert.strictEqual(fix, null); + assert.strictEqual(state.kind, "denied"); + assert.strictEqual(state.reason, "timeout", "identical denial key to the mobile timeout path"); + } finally { + restoreFetch(); + restoreNav(); + } +}); + diff --git a/web/test/no-storage.test.mjs b/web/test/no-storage.test.mjs new file mode 100644 index 0000000..f1d5ae8 --- /dev/null +++ b/web/test/no-storage.test.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/* + * no-storage.test.mjs — the GEO-03 grep gate (zero persistence, source-introspection). + * ----------------------------------------------------------------------------------- + * Inverted form of bot/test/privacy.test.mjs (PG1 pins writes to carry TTLs; this + * pin that web/src contains NO persistence surface at all — D-03 (memory-only + * token) + D-07 (coordinates live in memory only)). + * + * The gate MUST strip comments before scanning: web/src/mock/telegram-mock.ts's + * header documents the ban in prose, and prose is not a call site. Strip block + * comments (/* … *​/) and line comments (// …), THEN assert none of the banned + * tokens appear — code occurrences only. + */ +import { strict as assert } from "node:assert"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const SRC = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src"); + +const BANNED = [ + "localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage", + "document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage", +]; + +/* path-scrape + comment strip (the plan's contract for this gate) */ + +function listFiles(dir, exts, acc = []) { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) listFiles(full, exts, acc); + else if (exts.some((e) => entry.endsWith(e))) acc.push(full); + } + return acc; +} + +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading) +} + +function codeText(file) { + return stripComments(readFileSync(file, "utf8")); +} + +/* ————— GEO3 — the mechanical gate over every web/src source ————— */ + +test("GEO3 no banned storage token appears in web/src code (comments stripped, all *.ts/*.html)", () => { + const files = listFiles(SRC, [".ts", ".html"]); + assert.ok(files.length >= 12, `scanned a real tree (found ${files.length} files)`); + for (const file of files) { + const code = codeText(file); + for (const banned of BANNED) { + assert.ok(!code.includes(banned), `${path.relative(process.cwd(), file)} references ${banned} — GEO-03 violated`); + } + } +}); + +test("GEO3 the runtime-probe-preceding invariant: no service worker / registration call exists", () => { + // Vite emits no SW by default; this pins the "and none was added" half (RESEARCH GEO-03 §). + for (const file of listFiles(SRC, [".ts", ".html"])) { + const code = codeText(file); + for (const token of ["serviceWorker.register", "navigator.serviceWorker", "workbox"]) { + assert.ok(!code.includes(token), `${path.relative(process.cwd(), file)} references ${token} — GEO-03 surface added`); + } + } +}); + +test("GEO3 web/src/session/store.ts assigns nothing to any storage object (the D-03 half)", () => { + const code = codeText(path.join(SRC, "session", "store.ts")); + assert.ok(!/Storage\s*\.\s*setItem|Storage\s*\.\s*getItem/.test(code), "store.ts touches a Storage object"); + assert.ok(!/indexedDB\s*\.\s*open/.test(code), "store.ts opens IndexedDB"); + assert.ok(!/document\s*\.\s*cookie\s*=/.test(code), "store.ts writes a cookie"); + assert.ok(!/caches\s*\.\s*open/.test(code), "store.ts opens a CacheStorage"); + // ...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"); +}); diff --git a/web/test/theme.test.mjs b/web/test/theme.test.mjs new file mode 100644 index 0000000..e9a4afa --- /dev/null +++ b/web/test/theme.test.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/* + * theme.test.mjs — PX-02: the Telegram theme signal → data-tg-theme slot switch. + * ----------------------------------------------------------------------------- + * applyTheme/watchTheme touch `document.documentElement.dataset`, and the Node + * runner has no DOM — so every case installs a minimal document stub and + * restores globalThis.document in finally (a leaked stub would silently make a + * later pass/fail for the wrong reason). The stub's documentElement.dataset is + * a plain writable object — that IS the assertion target — and its style has a + * no-op setProperty because safe-area.ts's applySafeArea may be imported + * transitively. + * + * Pitfall 7's invariant is greppable: case 3 reads the shipped theme.ts source + * and asserts no setStyle call exists (source-introspection, the + * commands.test.mjs CS4 precedent). + */ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const themeMod = await import("../src/telegram/theme.ts"); +const { resolveBranch, applyTheme, watchTheme } = themeMod; + +const realDocument = globalThis.document; + +function installDocumentStub() { + const dataset = {}; + globalThis.document = { + documentElement: { dataset, style: { setProperty() {} } }, + }; + return dataset; +} + +function fakeWebApp(opts = {}) { + return { + initData: "", + colorScheme: opts.colorScheme, + themeParams: opts.themeParams ?? {}, + isVersionAtLeast() { + return true; + }, + close() {}, + }; +} + +/* ————— PX02 — colorScheme paths ————— */ + +test("PX02 colorScheme 'dark' sets data-tg-theme=dark; 'light' sets light", () => { + const ds = installDocumentStub(); + try { + applyTheme(fakeWebApp({ colorScheme: "dark" })); + assert.strictEqual(ds.tgTheme, "dark"); + applyTheme(fakeWebApp({ colorScheme: "light" })); + assert.strictEqual(ds.tgTheme, "light"); + } finally { + globalThis.document = realDocument; + } +}); + +test("PX02 a missing colorScheme falls back to header_bg_color luminance >= 0.5 (light) / < 0.5 (dark)", () => { + const ds = installDocumentStub(); + try { + // Luminance of the Rams dark header (#121212 ≈ 0.07) vs a light one (#f0ede8). + const darkShell = fakeWebApp({ themeParams: { header_bg_color: "#121212" } }); + assert.strictEqual(resolveBranch(darkShell), "dark"); + const lightShell = fakeWebApp({ themeParams: { header_bg_color: "#f0ede8" } }); + assert.strictEqual(lightShell && resolveBranch(lightShell), "light"); + applyTheme(darkShell); + assert.strictEqual(ds.tgTheme, "dark"); + applyTheme(lightShell); + assert.strictEqual(ds.tgTheme, "light"); + // No usable signal at all → light default. + assert.strictEqual(resolveBranch(fakeWebApp()), "light"); + } finally { + globalThis.document = realDocument; + } +}); + +test("PX02 applyTheme touches only the data-tg-theme attribute — the map style object is not re-set", () => { + // Source-introspection form of the invariant (CS4 precedent): if the shipped + // module ever acquires a setStyle call, this case fails the suite. + const themeSrc = readFileSync( + path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "telegram", "theme.ts"), + "utf8", + ); + const code = themeSrc + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); + assert.ok(!code.includes("setStyle"), "theme.ts must never call setStyle (D-10 / Pitfall 7)"); + assert.ok(!/import.*maplibre/i.test(code.split("\n").filter((l) => !l.trim().startsWith("*")).join("\n")), + "theme.ts must not import MapLibre — the map is out of its reach by construction"); + + const ds = installDocumentStub(); + try { + applyTheme(fakeWebApp({ colorScheme: "dark" })); + const touched = Object.keys(ds); + assert.deepStrictEqual(touched, ["tgTheme"], "the attribute set is the ONLY observable mutation"); + } finally { + globalThis.document = realDocument; + } +}); + +test("PX02 watchTheme re-applies on the themeChanged event", () => { + const ds = installDocumentStub(); + try { + const listeners = []; + const wa = fakeWebApp({ colorScheme: "light" }); + wa.onEvent = (event, cb) => { + if (event === "themeChanged") listeners.push(cb); + }; + watchTheme(wa); + assert.strictEqual(ds.tgTheme, "light"); + assert.strictEqual(listeners.length, 1, "exactly one themeChanged listener registered"); + // The flip: colorScheme goes dark; the recomputation must land on the attribute. + wa.colorScheme = "dark"; + listeners[0](); + assert.strictEqual(ds.tgTheme, "dark", "themeChanged re-applied the slot switch"); + } finally { + globalThis.document = realDocument; + } +});