#!/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"); });