Files
arah2/web/test/fullscreen.test.mjs
araharah-executor aec8b07530 test(07-01): FS1-FS9 fullscreen gates + mock satisfies the grown contract
- 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
2026-10-25 03:45:49 +08:00

271 lines
11 KiB
JavaScript

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