test(04-04): RED — the ONBD-03 home-screen gate suite (HS1-HS9)

Capability guard (both halves), the four status branches ('added'
hides, 'unknown' shows with the neutral prompt, 'missed' shows,
'unsupported' nothing), tap → addToHomeScreen, homeScreenAdded →
hidden, and the browser-install-API ban. Suite load fails until
web/src/telegram/homescreen.ts exists (RED verified: 45 passed,
1 failed suite-load).
This commit is contained in:
araharah-executor
2026-10-24 01:34:44 +08:00
parent 4f958411f3
commit 6e421e6b59
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env node
/*
* homescreen.test.mjs — HS1..HS9, the ONBD-03 home-screen gates (D-12).
* -------------------------------------------------------------------------------
* The Telegram-native addToHomeScreen / checkHomeScreenStatus wiring behind a
* Bot API 8.0 capability guard, driven with INJECTED FAKES (the
* location-chain.test.mjs style: build the fake, call, assert — globals
* stubbed per case and restored in a finally block, never leaked).
*
* The Node runner has no DOM, so each case installs a minimal document stub
* exposing exactly the surface homescreen.ts touches: querySelector(".strip")
* (the affordance host) and createElement(). The stub element records
* children/listeners — that record IS the assertion target.
*
* Platform laws under test:
* - the capability guard is BOTH halves (method presence AND version)
* - "added" hides the affordance (do not nag)
* - "unknown" (always iOS) is indeterminate — the affordance SHOWS, no error
* - "unsupported" shows nothing
* - NO browser install APIs anywhere (beforeinstallprompt does not exist
* inside the Telegram webview — a second icon Telegram doesn't know about
* is worse than none, T-04-38)
*/
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 { wireHomeScreen } = await import("../src/telegram/homescreen.ts");
const { strings } = await import("../src/i18n/strings.ts");
/* ————— minimal DOM stubs (install + restore per case) ————— */
function makeElement(tag) {
const listeners = {};
const el = {
tag,
children: [],
parent: null,
textContent: "",
className: "",
type: "",
dataset: {},
listeners,
appendChild(child) {
child.parent = el;
el.children.push(child);
return child;
},
remove() {
if (el.parent) {
const i = el.parent.children.indexOf(el);
if (i >= 0) el.parent.children.splice(i, 1);
el.parent = null;
}
},
addEventListener(type, fn) {
(listeners[type] ??= []).push(fn);
},
dispatch(type) {
for (const fn of listeners[type] ?? []) fn({});
},
};
return el;
}
function installDocumentStub() {
const strip = makeElement("div");
strip.className = "strip";
globalThis.document = {
querySelector(selector) {
return selector === ".strip" ? strip : null;
},
createElement(tag) {
return makeElement(tag);
},
};
return strip;
}
function restoreDocument() {
delete globalThis.document;
}
/** Fake TelegramWebAppLike with the 8.0+ home-screen members. */
function fakeWebApp(opts = {}) {
const calls = { addToHomeScreen: 0, checkedStatus: false };
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.noAddToHomeScreen) {
wa.addToHomeScreen = () => {
calls.addToHomeScreen += 1;
};
}
if (!opts.noCheckHomeScreenStatus) {
wa.checkHomeScreenStatus = (cb) => {
calls.checkedStatus = true;
cb(opts.homeScreenStatus ?? "unknown");
};
}
wa.__calls = calls;
return wa;
}
function affordanceIn(host) {
return host.children.find((c) => c.dataset?.role === "home-screen") ?? null;
}
/* ————— HS1..HS2 — the capability guard (BOTH halves) ————— */
test("HS1 a client without addToHomeScreen is a no-op — nothing thrown, no affordance", () => {
const strip = installDocumentStub();
try {
const wa = fakeWebApp({ noAddToHomeScreen: true });
assert.doesNotThrow(() => wireHomeScreen(wa, "en"));
assert.equal(affordanceIn(strip), null, "no affordance on a client missing the method");
} finally {
restoreDocument();
}
});
test("HS2 an old client (isVersionAtLeast 8.0 false) is a no-op", () => {
const strip = installDocumentStub();
try {
const wa = fakeWebApp({ version: "7.9" });
wireHomeScreen(wa, "en");
assert.equal(affordanceIn(strip), null, "no affordance below Bot API 8.0");
assert.equal(wa.__calls.checkedStatus, false, "the status was never even queried");
} finally {
restoreDocument();
}
});
/* ————— HS3..HS6 — the four status branches ————— */
test("HS3 status 'unsupported' hides the affordance", () => {
const strip = installDocumentStub();
try {
wireHomeScreen(fakeWebApp({ homeScreenStatus: "unsupported" }), "en");
assert.equal(affordanceIn(strip), null);
} finally {
restoreDocument();
}
});
test("HS4 status 'added' hides the affordance — the icon is there, do not nag", () => {
const strip = installDocumentStub();
try {
wireHomeScreen(fakeWebApp({ homeScreenStatus: "added" }), "en");
assert.equal(affordanceIn(strip), null, "'added' is the ONLY status that renders nothing but 'unsupported'");
} finally {
restoreDocument();
}
});
test("HS5 status 'unknown' is indeterminate — the affordance SHOWS with the neutral prompt, no error path", () => {
const strip = installDocumentStub();
try {
assert.doesNotThrow(() => wireHomeScreen(fakeWebApp({ homeScreenStatus: "unknown" }), "en"));
const btn = affordanceIn(strip);
assert.ok(btn !== null, "iOS's always-'unknown' must still offer the affordance");
assert.equal(btn.textContent, strings.en.addToHomeScreen, "the neutral prompt — never an error, never 'not added'");
} finally {
restoreDocument();
}
});
test("HS6 status 'missed' shows the affordance", () => {
const strip = installDocumentStub();
try {
wireHomeScreen(fakeWebApp({ homeScreenStatus: "missed" }), "en");
assert.ok(affordanceIn(strip) !== null);
} finally {
restoreDocument();
}
});
/* ————— HS7..HS8 — the tap + the added-event ————— */
test("HS7 tapping the affordance calls addToHomeScreen exactly once (best-effort)", () => {
const strip = installDocumentStub();
try {
const wa = fakeWebApp({ homeScreenStatus: "missed" });
wireHomeScreen(wa, "en");
const btn = affordanceIn(strip);
assert.ok(btn !== null);
btn.dispatch("click");
btn.dispatch("click");
assert.equal(wa.__calls.addToHomeScreen, 2, "each tap forwards to the platform call");
// a THROWING platform call is caught and ignored — the affordance stays (T-04-36)
wa.addToHomeScreen = () => {
throw new Error("platform refused");
};
assert.doesNotThrow(() => btn.dispatch("click"), "a rejected addToHomeScreen must not break the boot");
assert.ok(affordanceIn(strip) !== null, "the affordance stays available for a retry");
} finally {
restoreDocument();
}
});
test("HS8 the homeScreenAdded event hides the affordance", () => {
const strip = installDocumentStub();
try {
const wa = fakeWebApp({ homeScreenStatus: "unknown" });
wireHomeScreen(wa, "en");
assert.ok(affordanceIn(strip) !== null);
wa.emitEvent("homeScreenAdded");
assert.equal(affordanceIn(strip), null, "Telegram fires homeScreenAdded after the icon lands — the affordance hides");
} finally {
restoreDocument();
}
});
/* ————— HS9 — the browser-install-API ban (T-04-38) ————— */
test("HS9 NO browser install APIs — no install prompt, no manifest, no service worker", () => {
const file = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "telegram", "homescreen.ts");
const code = readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const banned of ["beforeinstallprompt", "serviceWorker", "navigator.install", "webmanifest"]) {
assert.ok(!code.includes(banned), `homescreen.ts code references ${banned} — D-12 forbids browser install APIs (T-04-38)`);
}
});