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.
314 lines
12 KiB
TypeScript
314 lines
12 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");
|
||
// 原值: 首屏兜底只需晚于 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"]);
|
||
});
|