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:
@@ -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)`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user