feat(03-02): D-11 Mock-Telegram harness + the four requirement gates (17 cases green)

- telegram-mock.ts: fake window.Telegram.WebApp injected into the SAME window
  (never an iframe — Bot API 10.2 origin restriction, Pitfall 6); Web-Crypto
  dev signer mirroring bot/src/security/initdata.ts's two-step HMAC (async
  makeDevInitData — node:crypto stays out of the browser bundle path);
  controllable LocationManager stream, themeChanged emitter, BackButton clicks
- location-chain.test.mjs (10 cases): GEO-01 version gate / null-WebApp guard
  / null-denial / hard timeout / stale-detector + one-read nudge; GEO-02 pin
  pickup / bounded 60s poll / Desktop no-geolocation leg ' same denial keys
- no-storage.test.mjs (3 cases): GEO-03 comment-stripped grep gate over
  web/src/**/*.{ts,html}, no-SW invariant, store.ts storage-assignment probe
- theme.test.mjs (4 cases): PX-02 colorScheme + luminance fallback + setStyle
  source-introspection (Pitfall 7) + themeChanged re-apply; document stub
  installed and restored per case
- Rule 1 fix in telegram.ts: looksStale/nudgeAndRetryTelegram accept an
  omitted lastAccepted (undefined normalized to null) ' acquireFix(deps)
  first-call contract
This commit is contained in:
araharah-executor
2026-10-22 23:46:17 +08:00
parent 0a35bce256
commit 2659b7f352
5 changed files with 687 additions and 3 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/*
* theme.test.mjs — PX-02: the Telegram theme signal → data-tg-theme slot switch.
* -----------------------------------------------------------------------------
* applyTheme/watchTheme touch `document.documentElement.dataset`, and the Node
* runner has no DOM — so every case installs a minimal document stub and
* restores globalThis.document in finally (a leaked stub would silently make a
* later pass/fail for the wrong reason). The stub's documentElement.dataset is
* a plain writable object — that IS the assertion target — and its style has a
* no-op setProperty because safe-area.ts's applySafeArea may be imported
* transitively.
*
* Pitfall 7's invariant is greppable: case 3 reads the shipped theme.ts source
* and asserts no setStyle call exists (source-introspection, the
* commands.test.mjs CS4 precedent).
*/
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 themeMod = await import("../src/telegram/theme.ts");
const { resolveBranch, applyTheme, watchTheme } = themeMod;
const realDocument = globalThis.document;
function installDocumentStub() {
const dataset = {};
globalThis.document = {
documentElement: { dataset, style: { setProperty() {} } },
};
return dataset;
}
function fakeWebApp(opts = {}) {
return {
initData: "",
colorScheme: opts.colorScheme,
themeParams: opts.themeParams ?? {},
isVersionAtLeast() {
return true;
},
close() {},
};
}
/* ————— PX02 — colorScheme paths ————— */
test("PX02 colorScheme 'dark' sets data-tg-theme=dark; 'light' sets light", () => {
const ds = installDocumentStub();
try {
applyTheme(fakeWebApp({ colorScheme: "dark" }));
assert.strictEqual(ds.tgTheme, "dark");
applyTheme(fakeWebApp({ colorScheme: "light" }));
assert.strictEqual(ds.tgTheme, "light");
} finally {
globalThis.document = realDocument;
}
});
test("PX02 a missing colorScheme falls back to header_bg_color luminance >= 0.5 (light) / < 0.5 (dark)", () => {
const ds = installDocumentStub();
try {
// Luminance of the Rams dark header (#121212 ≈ 0.07) vs a light one (#f0ede8).
const darkShell = fakeWebApp({ themeParams: { header_bg_color: "#121212" } });
assert.strictEqual(resolveBranch(darkShell), "dark");
const lightShell = fakeWebApp({ themeParams: { header_bg_color: "#f0ede8" } });
assert.strictEqual(lightShell && resolveBranch(lightShell), "light");
applyTheme(darkShell);
assert.strictEqual(ds.tgTheme, "dark");
applyTheme(lightShell);
assert.strictEqual(ds.tgTheme, "light");
// No usable signal at all → light default.
assert.strictEqual(resolveBranch(fakeWebApp()), "light");
} finally {
globalThis.document = realDocument;
}
});
test("PX02 applyTheme touches only the data-tg-theme attribute — the map style object is not re-set", () => {
// Source-introspection form of the invariant (CS4 precedent): if the shipped
// module ever acquires a setStyle call, this case fails the suite.
const themeSrc = readFileSync(
path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "telegram", "theme.ts"),
"utf8",
);
const code = themeSrc
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.ok(!code.includes("setStyle"), "theme.ts must never call setStyle (D-10 / Pitfall 7)");
assert.ok(!/import.*maplibre/i.test(code.split("\n").filter((l) => !l.trim().startsWith("*")).join("\n")),
"theme.ts must not import MapLibre — the map is out of its reach by construction");
const ds = installDocumentStub();
try {
applyTheme(fakeWebApp({ colorScheme: "dark" }));
const touched = Object.keys(ds);
assert.deepStrictEqual(touched, ["tgTheme"], "the attribute set is the ONLY observable mutation");
} finally {
globalThis.document = realDocument;
}
});
test("PX02 watchTheme re-applies on the themeChanged event", () => {
const ds = installDocumentStub();
try {
const listeners = [];
const wa = fakeWebApp({ colorScheme: "light" });
wa.onEvent = (event, cb) => {
if (event === "themeChanged") listeners.push(cb);
};
watchTheme(wa);
assert.strictEqual(ds.tgTheme, "light");
assert.strictEqual(listeners.length, 1, "exactly one themeChanged listener registered");
// The flip: colorScheme goes dark; the recomputation must land on the attribute.
wa.colorScheme = "dark";
listeners[0]();
assert.strictEqual(ds.tgTheme, "dark", "themeChanged re-applied the slot switch");
} finally {
globalThis.document = realDocument;
}
});