#!/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)`); } });