- 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
271 lines
11 KiB
JavaScript
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");
|
|
});
|