306 lines
11 KiB
TypeScript
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"]);
|
|
});
|