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
This commit is contained in:
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user