import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import vm from "node:vm"; import { FIRST_PAINT_FALLBACK_ACTION, FIRST_PAINT_FALLBACK_BODY, FIRST_PAINT_FALLBACK_TIMEOUT_MS, FIRST_PAINT_FALLBACK_TITLE, firstPaintFallbackBootScript, } from "../src/lib/first-paint-fallback.ts"; import { buildBootstrapObservation, classifyHomeBootstrapFailure, HOME_BOOTSTRAP_FAILURE, } from "../src/lib/home-bootstrap-failure.ts"; const projectRoot = new URL("../", import.meta.url); const read = (path: string) => readFileSync(new URL(path, projectRoot), "utf8"); class FakeNode { tag: string; className = ""; type = ""; src = ""; onclick: (() => void) | null = null; children: FakeNode[] = []; text: string; private readonly attributes = new Map(); constructor(tag: string, text = "") { this.tag = tag; this.text = text; } setAttribute(name: string, value: string) { this.attributes.set(name, value); } getAttribute(name: string) { return this.attributes.get(name) ?? null; } appendChild(child: FakeNode) { this.children.push(child); return child; } set innerHTML(value: string) { if (value === "") this.children = []; } querySelector(selector: string): FakeNode | null { for (const child of this.children) { if (hasClass(child, selector)) return child; const nested = child.querySelector(selector); if (nested) return nested; } return null; } } function hasClass(node: FakeNode, selector: string) { if (!selector.startsWith(".")) return false; return node.className.split(/\s+/).includes(selector.slice(1)); } function textOf(node: FakeNode): string { if (node.tag === "#text") return node.text; return node.children.map(textOf).join(""); } function findTag(node: FakeNode, tag: string): FakeNode | null { if (node.tag === tag) return node; for (const child of node.children) { const found = findTag(child, tag); if (found) return found; } return null; } type BootOptions = { hydrated?: boolean; withLoading?: boolean; online?: boolean; entries?: (name: string) => Array<{ decodedBodySize: number; transferSize: number }>; buildId?: string; }; function boot(options: BootOptions = {}) { const html = new FakeNode("html"); const body = new FakeNode("body"); const loading = new FakeNode("main"); loading.className = "app-loading"; loading.setAttribute("aria-busy", "true"); const slot = new FakeNode("div"); slot.className = "app-loading-content"; const oldTitle = new FakeNode("strong"); oldTitle.appendChild(new FakeNode("#text", "正在载入账户")); slot.appendChild(oldTitle); loading.appendChild(slot); body.appendChild(loading); html.appendChild(body); if (options.hydrated) html.setAttribute("data-hydrated", "1"); if (options.withLoading === false) body.children = []; let reloaded = false; let delay = 0; let fire: () => void = () => { throw new Error("fallback timer was not installed"); }; let onError: ((event: Record) => void) | null = null; let capture: boolean | undefined; const performance = { getEntriesByName(name: string) { return options.entries?.(name) ?? []; }, }; const window = { performance, location: { reload() { reloaded = true; }, }, setTimeout(fn: () => void, ms: number) { delay = ms; fire = fn; return 1; }, addEventListener(type: string, listener: (event: Record) => void, useCapture?: boolean) { assert.equal(type, "error"); onError = listener; capture = useCapture; }, }; const document = { documentElement: html, createElement(tag: string) { return new FakeNode(tag); }, createTextNode(text: string) { return new FakeNode("#text", text); }, querySelector(selector: string) { return html.querySelector(selector); }, }; const navigator = { onLine: options.online !== false }; vm.runInNewContext(firstPaintFallbackBootScript(options.buildId ?? "abcdef0"), { window, document, navigator, performance, }); return { html, loading, slot, delay, capture, fire, emitError(event: Record) { onError?.(event); }, reloaded: () => reloaded, observation: () => (window as { __jyotishaBootstrapObservation?: { category: string; buildId: string; durationMs: number } }).__jyotishaBootstrapObservation, }; } test("fallback script is classic ES5 and waits past the bootstrap budgets", () => { const page = read("src/app/(app)/page.tsx"); const bootstrap = read("src/lib/home-bootstrap.ts"); const layout = read("src/app/layout.tsx"); const hook = read("src/hooks/use-home-shell-registration.ts"); assert.match(page, /\}, 8000\);/); assert.match(bootstrap, /BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000/); assert.ok(FIRST_PAINT_FALLBACK_TIMEOUT_MS > 8000 + 4000); const script = firstPaintFallbackBootScript("abcdef0"); assert.match(script, new RegExp(String(FIRST_PAINT_FALLBACK_TIMEOUT_MS))); for (const forbidden of ["=>", "const ", "let ", "class ", "`", "${", "?.", "??"]) { assert.equal(script.includes(forbidden), false, forbidden); } assert.doesNotMatch(script, /type\s*=\s*["']module["']/); assert.equal(script.includes("连接云端服务超时"), false); assert.equal(script.includes(HOME_BOOTSTRAP_FAILURE.apiError), false); assert.equal(script.includes(FIRST_PAINT_FALLBACK_TITLE), true); assert.equal(script.includes(FIRST_PAINT_FALLBACK_BODY), true); assert.equal(script.includes(FIRST_PAINT_FALLBACK_ACTION), true); assert.match(layout, /firstPaintFallbackBootScript\(\)/); assert.doesNotMatch(layout, /firstPaintFallbackBootScript\(\)[\s\S]{0,80}type\s*=\s*["']module["']/); assert.match(hook, /!document\.documentElement\) return/); assert.match(hook, /document\.documentElement\.dataset\.hydrated = "1"/); assert.equal(page.includes("dataset.hydrated"), false); assert.match(page, /连接云端服务超时/); }); test("fallback replaces the loading screen only while it is still unhydrated", () => { const shown = boot(); assert.equal(shown.delay, FIRST_PAINT_FALLBACK_TIMEOUT_MS); assert.equal(shown.capture, true); shown.fire(); assert.equal(shown.reloaded(), false); assert.match(textOf(shown.slot), new RegExp(FIRST_PAINT_FALLBACK_TITLE)); assert.match(textOf(shown.slot), new RegExp(FIRST_PAINT_FALLBACK_BODY)); assert.doesNotMatch(textOf(shown.slot), /正在载入账户|SyntaxError|token|出生/); assert.match(shown.loading.className, /app-loading-error/); assert.equal(shown.loading.getAttribute("aria-busy"), "false"); assert.equal(shown.slot.getAttribute("role"), "alert"); const button = findTag(shown.slot, "button"); assert.ok(button); assert.equal(button.type, "button"); assert.equal(textOf(button), FIRST_PAINT_FALLBACK_ACTION); button.onclick?.(); assert.equal(shown.reloaded(), true); const observation = shown.observation(); assert.deepEqual(observation && Object.keys(observation).sort(), ["buildId", "category", "durationMs"]); assert.equal(observation?.category, HOME_BOOTSTRAP_FAILURE.hydrateTimeout); assert.equal(observation?.buildId, "abcdef0"); assert.equal(typeof observation?.durationMs, "number"); assert.equal(shown.html.getAttribute("data-bootstrap-failure"), HOME_BOOTSTRAP_FAILURE.hydrateTimeout); const revealed = boot({ hydrated: true }); revealed.fire(); assert.match(textOf(revealed.slot), /正在载入账户/); assert.equal(revealed.reloaded(), false); assert.equal(revealed.observation(), undefined); const otherPage = boot({ withLoading: false }); otherPage.fire(); assert.equal(otherPage.observation(), undefined); assert.equal(otherPage.reloaded(), false); }); test("fallback distinguishes script failures without treating them as a slow API", () => { const cases: Array<{ name: string; online?: boolean; event?: Record; cached?: boolean; category: string }> = [ { name: "parse", event: { message: "Uncaught SyntaxError: Unexpected token '{'", filename: "https://example/_next/static/chunks/app.js" }, category: HOME_BOOTSTRAP_FAILURE.chunkParse, }, { name: "missing", event: { message: "Loading chunk 12 failed.", filename: "https://example/_next/static/chunks/12.js" }, category: HOME_BOOTSTRAP_FAILURE.chunk404, }, { name: "cached", cached: true, event: { message: "Uncaught SyntaxError: Unexpected token", filename: "https://example/_next/static/chunks/stale.js" }, category: HOME_BOOTSTRAP_FAILURE.staleCache, }, { name: "offline", online: false, category: HOME_BOOTSTRAP_FAILURE.weakNetwork }, ]; for (const sample of cases) { const page = boot({ online: sample.online, buildId: "abc1234", entries: () => (sample.cached ? [{ decodedBodySize: 20, transferSize: 0 }] : []), }); if (sample.event) page.emitError(sample.event); page.fire(); assert.equal(page.observation()?.category, sample.category, sample.name); assert.equal(page.html.getAttribute("data-bootstrap-build"), "abc1234"); assert.doesNotMatch(textOf(page.slot), /SyntaxError|Loading chunk|Unexpected token/); } const noise = boot(); noise.emitError({ message: "ResizeObserver loop limit exceeded", filename: "" }); noise.fire(); assert.equal(noise.observation()?.category, HOME_BOOTSTRAP_FAILURE.hydrateTimeout); assert.equal(classifyHomeBootstrapFailure({ online: true, apiStatus: 500 }), HOME_BOOTSTRAP_FAILURE.apiError); assert.equal(classifyHomeBootstrapFailure({ online: true, apiTimedOut: true }), HOME_BOOTSTRAP_FAILURE.apiError); assert.equal( classifyHomeBootstrapFailure({ online: false, scriptMessage: "Loading chunk 3 failed.", scriptSource: "/_next/static/chunks/3.js" }), HOME_BOOTSTRAP_FAILURE.weakNetwork, ); assert.equal( classifyHomeBootstrapFailure({ online: true, scriptMessage: "SyntaxError: Unexpected token", resourceFromCache: true }), HOME_BOOTSTRAP_FAILURE.staleCache, ); assert.equal(classifyHomeBootstrapFailure({ online: true }), HOME_BOOTSTRAP_FAILURE.hydrateTimeout); const distinct = new Set([ classifyHomeBootstrapFailure({ online: true }), classifyHomeBootstrapFailure({ online: true, scriptMessage: "Loading chunk 1 failed.", scriptSource: "/_next/static/chunks/1.js" }), classifyHomeBootstrapFailure({ online: true, scriptMessage: "SyntaxError: Unexpected token" }), classifyHomeBootstrapFailure({ online: true, scriptMessage: "SyntaxError: Unexpected token", resourceFromCache: true }), classifyHomeBootstrapFailure({ online: false }), classifyHomeBootstrapFailure({ online: true, apiStatus: 503 }), ]); assert.equal(distinct.size, 6); assert.equal(distinct.has(HOME_BOOTSTRAP_FAILURE.apiError), true); const observation = buildBootstrapObservation(HOME_BOOTSTRAP_FAILURE.apiError, "not a sha