Files
Jyotisha/frontend/tests/first-paint-fallback-contract.test.ts
T
jesse-ux 3dd48c0dc3
Independent Staging Quality Gate / validate (push) Failing after 11m16s
Independent Staging Quality Gate / publish (push) Skipped
fix(test): follow the home split in source contracts and expose element colors
Gate 2888 failed ten frontend contracts after the onboarding shell and bootstrap moved out of page.tsx, and the western element colors were missing from @theme. The scans now follow the new files and still require the home page to mount them.
2026-09-25 03:32:36 +08:00

314 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
// 原值: 首屏兜底只需晚于 8000ms 账户超时 + 4000ms prepare。
// 新值: 兜底必须晚于 20000ms 首页慢网错误屏并保留余量。
// 原因: bundle 正常运行时不能被未启动兜底脚本抢先揭幕。
assert.doesNotMatch(page, /\}, 8000\);/);
assert.match(bootstrap, /HOME_BOOTSTRAP_SLOW_MS = 20_000/);
assert.ok(FIRST_PAINT_FALLBACK_TIMEOUT_MS > 20_000);
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);
const bootstrapRun = read("src/lib/home-bootstrap-run.ts");
// 原值: 超时句写在 page.tsx。
// 新值: 超时句在 home-bootstrap-run.ts,page.tsx 仍挂载错误屏。
// 原因: 首页拆分把启动失败文案收成常量,兜底脚本仍不得抢先写出这句。
assert.match(page, /<HomeBootstrapError/);
assert.match(bootstrapRun, /连接云端服务超时/);
});
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"]);
});