diff --git a/web/src/mock/telegram-mock.ts b/web/src/mock/telegram-mock.ts index 537d4d8..9be745f 100644 --- a/web/src/mock/telegram-mock.ts +++ b/web/src/mock/telegram-mock.ts @@ -93,6 +93,11 @@ interface MockWebApp extends TelegramWebAppLike { __events: Map 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) ————— */ @@ -415,6 +420,11 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike __events: events, __backButtonClicks: backButtonClicks, __homeScreenAdded: 0, + __fullscreenRequested: 0, + __fullscreenExited: 0, + __orientationLocked: 0, + __orientationUnlocked: 0, + __isFullscreen: false, isVersionAtLeast(v: string) { return versionAtLeast(version, v); }, @@ -428,6 +438,25 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike 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); diff --git a/web/test/fullscreen.test.mjs b/web/test/fullscreen.test.mjs new file mode 100644 index 0000000..2053a71 --- /dev/null +++ b/web/test/fullscreen.test.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/* + * fullscreen.test.mjs — FS1..FS9, the PX-04 fullscreen gates (D-03). + * ------------------------------------------------------------------------------- + * The Telegram-native requestFullscreen/exitFullscreen wiring behind the + * Bot API 8.0 capability guard, driven with INJECTED FAKES (the + * homescreen.test.mjs template: build the fake, call, assert — globals + * stubbed per case and restored in a finally block, never leaked). + * + * Platform laws under test: + * - the capability guard is BOTH halves (method presence AND version) + * - a refused/throwing platform call is a supported degradation, never + * an error surface (T-07-06) — the guidance path continues + * - the old-client fallback (SC2.e): no fullscreen members → silent no-op, + * the client is still fully usable + * - fullscreenChanged / fullscreenFailed are observability, never + * correctness; a throwing observer cannot break the pump + * - the safe-area insets re-resolve on safeAreaChanged (PX-04.d) + * - NO DOM Fullscreen API, NO generic orientation lock anywhere + * (T-07-10, the FS9 source-scan ban) + */ +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 { requestGuidanceFullscreen, exitGuidanceFullscreen, watchFullscreenEvents } = + await import("../src/telegram/fullscreen.ts"); +const { watchSafeAreaChanges, applySafeArea, resolveSafeInsets } = + await import("../src/telegram/safe-area.ts"); + +/** Fake TelegramWebAppLike with the 8.0+ fullscreen members (the + * homescreen.test.mjs builder shape: segment-wise numeric version compare). */ +function fakeWebApp(opts = {}) { + const calls = { + requestFullscreen: 0, + exitFullscreen: 0, + lockOrientation: 0, + unlockOrientation: 0, + }; + const events = new Map(); + const wa = { + initData: "", + version: opts.version ?? "10.0", + isVersionAtLeast(v) { + const a = wa.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() {}, + onEvent(event, cb) { + const list = events.get(event) ?? []; + list.push(cb); + events.set(event, list); + }, + emitEvent(event) { + for (const cb of events.get(event) ?? []) cb(); + }, + }; + if (!opts.noRequestFullscreen) { + wa.requestFullscreen = opts.throwRequestFullscreen + ? () => { + calls.requestFullscreen += 1; + throw new Error("platform refused"); + } + : () => { + calls.requestFullscreen += 1; + }; + } + if (!opts.noExitFullscreen) { + wa.exitFullscreen = () => { + calls.exitFullscreen += 1; + }; + } + if (!opts.noLockOrientation) { + wa.lockOrientation = () => { + calls.lockOrientation += 1; + }; + } + if (!opts.noUnlockOrientation) { + wa.unlockOrientation = () => { + calls.unlockOrientation += 1; + }; + } + if (opts.safeAreaInset !== undefined) wa.safeAreaInset = opts.safeAreaInset; + wa.__calls = calls; + return wa; +} + +/* ————— FS1..FS4 — the request path (BOTH-halves guard + refusal law) ————— */ + +test("FS1 a client without requestFullscreen is a silent no-op — nothing thrown, the method never queried", () => { + const wa = fakeWebApp({ noRequestFullscreen: true }); + let result; + assert.doesNotThrow(() => { + result = requestGuidanceFullscreen(wa); + }); + assert.equal(result, false, "method-absent half of the guard → false"); + assert.equal(wa.__calls.lockOrientation, 0, "no orientation lock without fullscreen"); +}); + +test("FS2 an old client (7.9) with requestFullscreen PRESENT still gets false — the version guard ran BEFORE the call", () => { + const wa = fakeWebApp({ version: "7.9" }); + const result = requestGuidanceFullscreen(wa); + assert.equal(result, false, "version half of the guard → false"); + assert.equal(wa.__calls.requestFullscreen, 0, "the platform method was never invoked below Bot API 8.0"); +}); + +test("FS3 a Bot API 8.0+ client gets fullscreen requested exactly once — and the CURRENT orientation locked", () => { + const wa = fakeWebApp({ version: "8.0" }); + const result = requestGuidanceFullscreen(wa); + assert.equal(result, true); + assert.equal(wa.__calls.requestFullscreen, 1, "requested exactly once, never retried"); + assert.equal(wa.__calls.lockOrientation, 1, "Telegram's own orientation lock — CURRENT orientation only"); +}); + +test("FS4 a THROWING requestFullscreen (the platform refuses) does not throw out of the wrapper — it returns false and guidance continues", () => { + const wa = fakeWebApp({ throwRequestFullscreen: true }); + let result; + assert.doesNotThrow(() => { + result = requestGuidanceFullscreen(wa); + }, "a refused request must not break the guidance start"); + assert.equal(result, false, "the refusal is reported as false, never thrown"); +}); + +/* ————— FS5 — the exit mirror ————— */ + +test("FS5 exitGuidanceFullscreen mirrors the guard: absent method → false, old client → false, 8.0 → called exactly once", () => { + const absent = fakeWebApp({ version: "10.0", noExitFullscreen: true }); + assert.equal(exitGuidanceFullscreen(absent), false); + const old = fakeWebApp({ version: "7.9" }); + assert.equal(exitGuidanceFullscreen(old), false); + assert.equal(old.__calls.exitFullscreen, 0, "never invoked below Bot API 8.0"); + const fresh = fakeWebApp({ version: "9.0" }); + assert.equal(exitGuidanceFullscreen(fresh), true); + assert.equal(fresh.__calls.exitFullscreen, 1); + assert.equal(fresh.__calls.unlockOrientation, 1, "the orientation lock is released with it"); +}); + +/* ————— FS6 — the fullscreen events (observability, never correctness) ————— */ + +test("FS6 fullscreenChanged → onChange(true); fullscreenFailed → onChange(false); a THROWING observer cannot escape", () => { + const wa = fakeWebApp({ version: "10.0" }); + const seen = []; + watchFullscreenEvents(wa, (isFullscreen) => seen.push(isFullscreen)); + wa.emitEvent("fullscreenChanged"); + wa.emitEvent("fullscreenFailed"); + assert.deepEqual(seen, [true, false], "the category events map to the boolean"); + // a throwing client callback is contained — never breaks the pump + let escaped = false; + try { + watchFullscreenEvents(wa, () => { + throw new Error("observer exploded"); + }); + wa.emitEvent("fullscreenChanged"); + wa.emitEvent("fullscreenFailed"); + } catch { + escaped = true; + } + assert.equal(escaped, false, "the handler wraps the callback in try/catch"); +}); + +/* ————— FS7 — the OLD-CLIENT FALLBACK (SC2.e) ————— */ + +test("FS7 a version-7.0 client with NO fullscreen members: every call is a silent no-op, the client stays fully usable", () => { + const wa = fakeWebApp({ version: "7.0", noRequestFullscreen: true, noExitFullscreen: true }); + let ok = true; + try { + assert.equal(requestGuidanceFullscreen(wa), false); + assert.equal(exitGuidanceFullscreen(wa), false); + watchFullscreenEvents(wa, () => {}); + wa.emitEvent("fullscreenChanged"); // unknown events fire nothing — no throw + } catch { + ok = false; + } + assert.equal(ok, true, "no error surface on the oldest supported clients"); + assert.equal(wa.__calls.requestFullscreen, 0); + assert.equal(wa.__calls.exitFullscreen, 0); +}); + +/* ————— FS8 — the safe-area re-resolution (PX-04.d) ————— */ + +/** Minimal document stub exposing exactly the surface safe-area.ts touches: + * documentElement.style (the CSS-var record), body.appendChild (the env() + * probe host), createElement (the probe), getComputedStyle (reads 0). */ +function installSafeAreaDocumentStub() { + const recorded = new Map(); + const documentElementStyle = { + setProperty(k, v) { + recorded.set(k, v); + }, + getPropertyValue(k) { + return recorded.get(k) ?? ""; + }, + }; + const probe = { + style: { cssText: "" }, + remove() {}, + }; + globalThis.document = { + documentElement: { style: documentElementStyle }, + body: { + appendChild() {}, + }, + createElement() { + return probe; + }, + }; + globalThis.getComputedStyle = () => ({ + getPropertyValue() { + return "0px"; // env() resolves 0 in the stub — the union reads the Telegram half + }, + }); + return documentElementStyle; +} + +function restoreSafeAreaDocumentStub() { + delete globalThis.document; + delete globalThis.getComputedStyle; +} + +test("FS8 a safeAreaChanged event re-applies the NEW insets — the cockpit re-resolves on rotation/fullscreen", () => { + const style = installSafeAreaDocumentStub(); + try { + // boot-time value: the client reports top=10 + const wa = fakeWebApp({ version: "10.0", safeAreaInset: { top: 10, bottom: 0, left: 0, right: 0 } }); + applySafeArea(resolveSafeInsets(wa)); + assert.equal(style.getPropertyValue("--safe-top"), "10px", "the first value lands at boot"); + watchSafeAreaChanges(wa); + // the device rotates / enters fullscreen — the client now reports top=24 + wa.safeAreaInset = { top: 24, bottom: 4, left: 0, right: 0 }; + assert.doesNotThrow(() => wa.emitEvent("safeAreaChanged")); + assert.equal(style.getPropertyValue("--safe-top"), "24px", "the inset re-resolved through the union authority"); + assert.equal(style.getPropertyValue("--safe-bottom"), "4px"); + // and the contentSafeAreaChanged event re-applies too + wa.safeAreaInset = { top: 30, bottom: 0, left: 0, right: 0 }; + assert.doesNotThrow(() => wa.emitEvent("contentSafeAreaChanged")); + assert.equal(style.getPropertyValue("--safe-top"), "30px", "both inset events re-resolve"); + } finally { + restoreSafeAreaDocumentStub(); + } +}); + +/* ————— FS9 — the source-scan ban (T-07-10, the HS9 shape) ————— */ + +test("FS9 NO DOM-fullscreen / generic-orientation-lock escape hatches — the Telegram-native path is the only primary path", () => { + const file = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "telegram", "fullscreen.ts"); + const code = readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); + const banned = [ + "documentElement.requestFullscreen", + "screen.orientation.lock", + "webkitRequestFullscreen", + "mozRequestFullScreen", + "orientation.lock", + ]; + for (const token of banned) { + assert.ok(!code.includes(token), `fullscreen.ts code references ${token} — T-07-10 forbids the DOM escape hatches`); + } + assert.ok(!/document\s*\.\s*requestFullscreen\s*\(/.test(code), "requestFullscreen( may only be called on the Telegram WebApp, never on document"); + assert.ok(code.includes('isVersionAtLeast("8.0")'), "the 8.0 version half of the capability guard is NOT optional"); +});