- fireHaptic: the three D-08 cues (selectionChanged / impactOccurred medium / notificationOccurred success) via ?. chains; silent no-op without the API; header carries the no-haptic-on-reroute law
- engage/releaseCloseProtection: PX-03 layers gated BEFORE each call — isVersionAtLeast("7.7") body-swipe, ("6.2") native-close net; no-throw on capability-less clients (T-06-06)
- webapp.ts: HapticFeedback / vertical-swipes / closing-confirmation / isActive join as OPTIONAL members only — existing fakes untouched
- RS10 floor 16→24; BA9 input list += web/src/guidance (69507 / 204800 gz, printed)
250 lines
12 KiB
JavaScript
250 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* build-artifact.test.mjs — the D-14 build-artifact gate (T-03-20, T-03-21).
|
|
* ------------------------------------------------------------------------------
|
|
* Reads web/dist (the PRODUCTION build only — the [assets] binding serves
|
|
* exactly this directory) and proves the shipped bundle is fully
|
|
* self-contained and free of the Mock-Telegram installer:
|
|
*
|
|
* BA1 no emitted .js/.css file references a third-party CDN
|
|
* (cdn.tailwindcss.com, fonts.googleapis.com, fonts.gstatic.com,
|
|
* unpkg.com, cdn.jsdelivr.net) — T-03-20: a CDN slip would leak
|
|
* user navigation requests to third parties
|
|
* BA2 at least one self-hosted .woff2 font was copied through by Vite's
|
|
* url() resolution of @araharah/theme/dist/rams.css
|
|
* BA3 a hashed MapLibre worker chunk exists under web/dist/assets
|
|
* BA4 the built JS carries the inlined Rams style ("liberty-rams" or
|
|
* "ne2_shaded" — build-time import, never a runtime fetch)
|
|
* BA5 the PRODUCTION bundle contains "installMockWebApp"/"__ARAHARAH_MOCK__"
|
|
* in NO emitted file — .js AND .html (web/index.html is the ONE entry
|
|
* shared by both builds, so an unguarded runner-page profile block
|
|
* would ship the mock and the fetch stubs as inline HTML that a
|
|
* JS-only scan would miss entirely). Scans web/dist ONLY: the harness
|
|
* build emits to web/dist-harness/ and is deliberately NOT clean —
|
|
* BA5 pointed there would invert the gate it exists to enforce.
|
|
* BA6 no emitted HTML carries the runner page's /api/session or
|
|
* /api/location-fallback fetch stubs (same dead-code-elimination proof)
|
|
* BA7 the shipped JS carries EVERY gazetteer id from the committed
|
|
* dataset (search/src/data/kl-gazetteer.json) — the WEB half of the
|
|
* bot/web id-set parity gate (T-04-39): a stale web bundle ships
|
|
* without an id the bot just deep-linked, and the webview could
|
|
* never resolve it
|
|
* BA8 the shipped JS carries NO deep-link crypto (createHmac,
|
|
* crypto.subtle, sha256, HMAC() — the deep link is a plain public id
|
|
* (D-11, T-04-40). Scans the JS ASSETS only, never the HTML: the
|
|
* OFFICIAL Telegram SDK script is loaded EXTERNALLY from
|
|
* telegram.org (not bundled) and may contain crypto internals of
|
|
* its own.
|
|
* BA9 the route payload fits the D-11 bundle budget: the summed gzipped
|
|
* size of the fixture dataset (routing/src/data/route-fixtures.json),
|
|
* every routing/src *.ts, every web/src/route *.ts, and every
|
|
* web/src/guidance *.ts is ≤ 204800 bytes (200 KiB) — and the
|
|
* measurement is PRINTED on every run (the budget is visible, never
|
|
* discovered). T-05-12.
|
|
* BA10 the shipped JS carries EVERY pairId from the committed
|
|
* route-fixtures.json (the BA7 analog, T-05-12/D-11): a stale bundle
|
|
* that lost the fixtures could never serve a route.
|
|
*
|
|
* If web/dist does not exist, every case fails with a build-first message.
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import { gzipSync } from "node:zlib";
|
|
import { test } from "./harness.mjs";
|
|
import gazetteer from "../../search/src/data/kl-gazetteer.json" with { type: "json" };
|
|
import routeFixtures from "../../routing/src/data/route-fixtures.json" with { type: "json" };
|
|
|
|
const WEB = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..");
|
|
const DIST = join(WEB, "dist");
|
|
const DIST_ASSETS = join(DIST, "assets");
|
|
|
|
const CDN_HOSTS = [
|
|
"cdn.tailwindcss.com",
|
|
"fonts.googleapis.com",
|
|
"fonts.gstatic.com",
|
|
"unpkg.com",
|
|
"cdn.jsdelivr.net",
|
|
];
|
|
|
|
function claimBuildFirst() {
|
|
return new Error(
|
|
"web/dist does not exist — run `npm run build -w theme && npm run build -w web` before this suite",
|
|
);
|
|
}
|
|
|
|
/* walk web/dist recursively — DirEnt-based, no dependency */
|
|
function walk(dir) {
|
|
const out = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...walk(full));
|
|
else out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readIfExists(file) {
|
|
return existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
}
|
|
|
|
const allFiles = existsSync(DIST) ? walk(DIST) : [];
|
|
const jsFiles = allFiles.filter((f) => f.endsWith(".js"));
|
|
const cssFiles = allFiles.filter((f) => f.endsWith(".css"));
|
|
const htmlFiles = allFiles.filter((f) => f.endsWith(".html"));
|
|
|
|
test("BA1 no emitted .js/.css file references a third-party CDN (D-14)", () => {
|
|
assert.ok(existsSync(DIST), claimBuildFirst().message);
|
|
for (const file of [...jsFiles, ...cssFiles]) {
|
|
const content = readFileSync(file, "utf8");
|
|
for (const host of CDN_HOSTS) {
|
|
assert.ok(
|
|
!content.includes(host),
|
|
`${file} references CDN host ${host} (D-14 forbids CDN fallbacks)`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("BA2 web/dist carries self-hosted woff2 fonts copied at build time", () => {
|
|
assert.ok(existsSync(DIST), claimBuildFirst().message);
|
|
const woff2 = allFiles.filter((f) => f.endsWith(".woff2"));
|
|
assert.ok(woff2.length >= 1, `no .woff2 under web/dist — apply BA2's contingency (public/fonts copy or postbuild script), never a CDN link`);
|
|
});
|
|
|
|
test("BA3 web/dist/assets contains a hashed MapLibre worker chunk", () => {
|
|
assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message);
|
|
const worker = readdirSync(DIST_ASSETS).filter((f) => f.endsWith(".js") && /worker/i.test(f));
|
|
assert.ok(worker.length >= 1, `no MapLibre worker chunk in web/dist/assets (found: ${readdirSync(DIST_ASSETS).join(", ")})`);
|
|
});
|
|
|
|
test("BA4 built JS carries the inlined Rams style JSON (liberty-rams/ne2_shaded)", () => {
|
|
assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message);
|
|
const bundle = jsFiles
|
|
.filter((f) => f.startsWith(DIST_ASSETS))
|
|
.map((f) => readFileSync(f, "utf8"))
|
|
.join("");
|
|
const hasStyle = bundle.includes("liberty-rams") || bundle.includes("ne2_shaded");
|
|
assert.ok(hasStyle, "inlined Rams style marker absent from the JS bundle — style JSON was not inlined at build time");
|
|
});
|
|
|
|
test("BA5 production bundle carries NO Mock-Telegram installer in ANY emitted file (.js + .html)", () => {
|
|
assert.ok(existsSync(DIST), claimBuildFirst().message);
|
|
const jsAssets = jsFiles.map((f) => readFileSync(f, "utf8")).join("");
|
|
const allHtml = htmlFiles.map((f) => readFileSync(f, "utf8")).join("");
|
|
for (const token of ["installMockWebApp", "__ARAHARAH_MOCK__", "ROUTE_PROFILE_HARNESS_ONLY"]) {
|
|
assert.ok(
|
|
!jsAssets.includes(token),
|
|
`harness-leak gate: "${token}" found in a web/dist JS asset — the mock survived tree-shaking`,
|
|
);
|
|
assert.ok(
|
|
!allHtml.includes(token),
|
|
`harness-leak gate: "${token}" found in an emitted web/dist HTML (${htmlFiles.join(", ")}) — the runner page's profile block shipped`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("BA6 production HTML carries NO runner-page fetch stub endpoints", () => {
|
|
assert.ok(existsSync(DIST), claimBuildFirst().message);
|
|
const allHtml = htmlFiles.map((f) => readFileSync(f, "utf8")).join("");
|
|
for (const endpoint of ["/api/session", "/api/location-fallback"]) {
|
|
assert.ok(
|
|
!allHtml.includes(endpoint),
|
|
`runner-page fetch stub "${endpoint}" present in emitted web/dist HTML — an unguarded profile block shipped`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("BA7 the shipped bundle carries EVERY gazetteer id (the bot/web id-set parity gate, T-04-39)", () => {
|
|
assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message);
|
|
const bundle = jsFiles
|
|
.filter((f) => f.startsWith(DIST_ASSETS))
|
|
.map((f) => readFileSync(f, "utf8"))
|
|
.join("");
|
|
assert.ok(gazetteer.entries.length >= 30, `scanned a real dataset (found ${gazetteer.entries.length} entries)`);
|
|
for (const entry of gazetteer.entries) {
|
|
assert.ok(
|
|
bundle.includes(entry.id),
|
|
`gazetteer id "${entry.id}" is MISSING from the shipped web bundle — a stale bundle the webview cannot resolve against (T-04-39); ids are never renamed, only added`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("BA8 the shipped bundle carries NO deep-link crypto (D-11, T-04-40)", () => {
|
|
assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message);
|
|
const bundle = jsFiles
|
|
.filter((f) => f.startsWith(DIST_ASSETS))
|
|
.map((f) => readFileSync(f, "utf8"))
|
|
.join("");
|
|
for (const token of ["createHmac", "crypto.subtle", "sha256", "HMAC("]) {
|
|
assert.ok(
|
|
!bundle.includes(token),
|
|
`"${token}" found in a web/dist JS asset — the deep link is a PLAIN public id, no crypto anywhere in the path (D-11)`,
|
|
);
|
|
}
|
|
});
|
|
|
|
/* ————— the route family's production-build gates (05-05, D-11, T-05-12) ————— */
|
|
|
|
/* BA9's inputs: every committed file the route + guidance experience ADDS to
|
|
* the web bundle — the fixture dataset, the routing workspace sources, the
|
|
* web route modules, and (Phase 6) the web guidance modules (glyphs, banner,
|
|
* haptics, close-protection, guidance, eta, watch — the camera lands in plan
|
|
* 06 and rides the same list then). All are committed files,
|
|
* production-shaped by construction.
|
|
* The minified route code's gz size is approximated by its SOURCE gz size —
|
|
* the approximation is conservative in the gate's favor at these sizes
|
|
* (minified+gzipped code is smaller than source gzipped), and the 200 KiB
|
|
* ceiling carries an order-of-magnitude headroom (05-RESEARCH Finding 1:
|
|
* the fixtures dominate at ~16.6 KB gz). A silent fixture bloat (someone
|
|
* re-fetching with overview=full&steps=true) fails HERE, at the build gate —
|
|
* never surfaces as a slow webview (T-05-12). */
|
|
const ROUTE_INPUT_DIRS = [
|
|
join(WEB, "..", "routing", "src"),
|
|
join(WEB, "src", "route"),
|
|
join(WEB, "src", "guidance"),
|
|
];
|
|
const ROUTE_FIXTURES_FILE = join(WEB, "..", "routing", "src", "data", "route-fixtures.json");
|
|
|
|
function collectTsFiles(dir) {
|
|
const out = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...collectTsFiles(full));
|
|
else if (entry.name.endsWith(".ts")) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
test("BA9 the route payload fits the 200 KiB gz bundle budget, measured and printed (D-11, T-05-12)", () => {
|
|
assert.ok(existsSync(DIST), claimBuildFirst().message);
|
|
const inputs = [ROUTE_FIXTURES_FILE, ...ROUTE_INPUT_DIRS.flatMap((dir) => collectTsFiles(dir))];
|
|
assert.ok(inputs.length >= 10, `scanned a real input set (found ${inputs.length} files)`);
|
|
const total = inputs.reduce((sum, f) => sum + gzipSync(readFileSync(f)).length, 0);
|
|
/* The measurement is VISIBLE on every run (D-11: "enforced as a BA
|
|
* build-artifact gate" — a budget nobody sees is a hope, not a gate). */
|
|
console.log(`BA9 route bundle budget: ${total} / 204800 gz bytes`);
|
|
assert.ok(
|
|
total <= 204800,
|
|
`route payload is ${total} gz bytes — over the 204800-byte (200 KiB) D-11 budget by ${total - 204800}. ` +
|
|
`Shrink the fixtures (simplified geometry) or the route code before shipping (T-05-12).`,
|
|
);
|
|
});
|
|
|
|
test("BA10 the shipped bundle carries EVERY committed pairId (the BA7 fixture-parity analog, T-05-12)", () => {
|
|
assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message);
|
|
const bundle = jsFiles
|
|
.filter((f) => f.startsWith(DIST_ASSETS))
|
|
.map((f) => readFileSync(f, "utf8"))
|
|
.join("");
|
|
assert.ok(routeFixtures.pairs.length >= 10, `scanned a real dataset (found ${routeFixtures.pairs.length} pairs)`);
|
|
for (const pair of routeFixtures.pairs) {
|
|
assert.ok(
|
|
bundle.includes(pair.pairId),
|
|
`pairId "${pair.pairId}" is MISSING from the shipped web bundle — a stale bundle that lost the fixtures could never serve a route (T-05-12); fixtures are never renamed, only added`,
|
|
);
|
|
}
|
|
});
|