diff --git a/web/test/homescreen.test.mjs b/web/test/homescreen.test.mjs new file mode 100644 index 0000000..c8b80ee --- /dev/null +++ b/web/test/homescreen.test.mjs @@ -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)`); + } +});