Files
Jyotisha/frontend/tests/first-paint-fallback-contract.test.ts
T
jesse-ux f77a1b5a33
Independent Staging Quality Gate / validate (push) Failing after 10m8s
Independent Staging Quality Gate / publish (push) Skipped
fix(web): ignore a missing documentElement when marking the home screen revealed
2026-09-22 13:42:36 +08:00

306 lines
11 KiB
TypeScript

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<string, string>();
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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) {
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<string, unknown>; 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 <script>", -4);
assert.deepEqual(observation, {
category: HOME_BOOTSTRAP_FAILURE.apiError,
buildId: "unknown",
durationMs: 0,
});
assert.deepEqual(Object.keys(observation).sort(), ["buildId", "category", "durationMs"]);
});