Files
cloudflare__vinext/tests/build-time-classification-integration.test.ts
T
Nathan Nguyen 34ffb948c8 chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6] (#843)
* chore(debug): add classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION

Operators tracing why a layout was flagged static or dynamic can opt in
with VINEXT_DEBUG_CLASSIFICATION=1 at build time. When active, the
plugin patches a second stub (__VINEXT_CLASS_REASONS) with a dispatch
table that returns per-layout ClassificationReason structures. The
runtime probe loop emits a debug line per layout when
debugClassification is wired, and the hot path remains allocation-free
when debug is off because the stub returns null.

The reasons machinery (types, report.ts tagged results, and
buildReasonsReplacement) already shipped in the previous PR; this PR
just enables the emission path and wires the debug consumer into the
runtime probe.

* test(debug): cover runtime-probe error path and Layer 2 reasons dispatch

The runtime-probe catch branch in `probeAppPageLayouts` emits
`{ layer: "runtime-probe", outcome: "dynamic", error: "..." }` when the
isolated dynamic scope throws. That emission had no coverage, so a
regression that stopped including the `error` field, or that skipped the
debug callback entirely on the error path, would not have tripped any
test.

The reasons sidecar integration test also only asserted the Layer 1
(`segment-config`) reason on the nested `force-dynamic` layout. Layer 2
(`module-graph`) on the pure-JSX root layout was exercised in the build
but never read, so a regression that stopped serializing the `result`
field or the `module-graph` discriminator for Layer 2 would slip through.

Extends the existing debug-on describe block to read reasons for layout
index 0 on `/force-dyn` and assert the `module-graph` + `result: "static"`
pair, keeping the structural `ReasonShape` so the test still survives
the #863 codegen reshape.

Also removes a pre-existing `as` on the narrowed Map return in favour of
a proper `in`-narrowing type predicate.
2026-04-21 10:29:10 +01:00

377 lines
14 KiB
TypeScript

/**
* Build-time layout classification integration tests.
*
* These tests build a real App Router fixture through the full Vite pipeline,
* then extract the generated __VINEXT_CLASS dispatch function from the emitted
* RSC chunk and evaluate it. They verify that Fix 2 (wiring the build-time
* classifier into the plugin's generateBundle hook) actually produces a
* populated dispatch table at the end of the build pipeline — previously every
* route fell back to the Layer 3 runtime probe because the plugin never ran
* the classifier.
*/
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import vm from "node:vm";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
const FIXTURE_PREFIX = "vinext-class-integration-";
type Dispatch = (routeIdx: number) => Map<unknown, unknown> | null;
type BuiltFixture = {
chunkSource: string;
dispatch: Dispatch;
routeIndexByPattern: Map<string, number>;
};
async function writeFile(file: string, source: string): Promise<void> {
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, source, "utf8");
}
/**
* Extracts the __VINEXT_CLASS function body from the RSC chunk source and
* evaluates it to a callable dispatch function. Throws if the stub is still
* the untouched `return null` form — the caller is expected to have patched
* it via the plugin's generateBundle hook.
*/
function extractDispatch(chunkSource: string): Dispatch {
const stubRe = /function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/;
if (stubRe.test(chunkSource)) {
throw new Error("__VINEXT_CLASS was not patched — still returns null unconditionally");
}
// Non-greedy match: assumes the inner dispatch body does not contain
// ')(routeIdx)' as a substring. Coupled to the codegen shape in
// route-classification-manifest.ts buildGenerateBundleReplacement.
const re =
/function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return\s+(\([\s\S]*?\))\(routeIdx\);\s*\}/;
const match = re.exec(chunkSource);
if (!match) {
throw new Error("Could not locate patched __VINEXT_CLASS in chunk source");
}
// Use vm.runInThisContext so the resulting Map instances share their
// prototype with the test process — `instanceof Map` would otherwise
// fail across v8 contexts.
const raw: unknown = vm.runInThisContext(match[1]!);
if (typeof raw !== "function") {
throw new Error("Patched __VINEXT_CLASS body did not evaluate to a function");
}
return (routeIdx: number) => {
const result: unknown = Reflect.apply(raw, null, [routeIdx]);
if (result === null) return null;
if (result instanceof Map) return result;
throw new Error(
`Dispatch returned unexpected value for routeIdx ${routeIdx}: ${JSON.stringify(result)}`,
);
};
}
/**
* Extracts the per-route route indices emitted in the `routes = [...]` table
* by matching `__VINEXT_CLASS(N)` call expressions alongside each pattern.
* Maps pattern strings (stable across test edits) to numeric indices.
*/
function extractRouteIndexByPattern(chunkSource: string): Map<string, number> {
const result = new Map<string, number>();
const re = /__buildTimeClassifications:\s*__VINEXT_CLASS\((\d+)\)[\s\S]*?pattern:\s*"([^"]+)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(chunkSource)) !== null) {
result.set(match[2]!, Number(match[1]!));
}
if (result.size === 0) {
throw new Error("No route entries with __VINEXT_CLASS + pattern found in chunk source");
}
return result;
}
async function buildMinimalFixture({
debug = false,
}: { debug?: boolean } = {}): Promise<BuiltFixture> {
const workspaceRoot = path.resolve(import.meta.dirname, "..");
const workspaceNodeModules = path.join(workspaceRoot, "node_modules");
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), FIXTURE_PREFIX));
// Root layout — plain JSX, no segment config, no dynamic shim imports.
// Layer 2 should prove this "static".
await writeFile(
path.join(tmpDir, "app", "layout.tsx"),
`export default function RootLayout({ children }) {
return <html><body>{children}</body></html>;
}`,
);
// "/" — force-dynamic layout above a plain page.
await writeFile(
path.join(tmpDir, "app", "page.tsx"),
`export default function Home() { return <div>home</div>; }`,
);
// "/dyn" — nested layout that uses next/headers, should remain unclassified
// (Layer 2 returns "needs-probe", filtered out).
await writeFile(
path.join(tmpDir, "app", "dyn", "layout.tsx"),
`import { headers } from "next/headers";
export default async function DynLayout({ children }) {
const h = await headers();
void h;
return <section>{children}</section>;
}`,
);
await writeFile(
path.join(tmpDir, "app", "dyn", "page.tsx"),
`export default function DynPage() { return <div>dyn</div>; }`,
);
// "/force-dyn" — segment config force-dynamic at the layout.
await writeFile(
path.join(tmpDir, "app", "force-dyn", "layout.tsx"),
`export const dynamic = "force-dynamic";
export default function ForceDynLayout({ children }) {
return <section>{children}</section>;
}`,
);
await writeFile(
path.join(tmpDir, "app", "force-dyn", "page.tsx"),
`export default function ForceDynPage() { return <div>fd</div>; }`,
);
// "/force-static" — segment config force-static at the layout.
await writeFile(
path.join(tmpDir, "app", "force-static", "layout.tsx"),
`export const dynamic = "force-static";
export default function ForceStaticLayout({ children }) {
return <section>{children}</section>;
}`,
);
await writeFile(
path.join(tmpDir, "app", "force-static", "page.tsx"),
`export default function ForceStaticPage() { return <div>fs</div>; }`,
);
// Symlink workspace node_modules so vinext, react, react-dom resolve.
await fsp.symlink(workspaceNodeModules, path.join(tmpDir, "node_modules"), "junction");
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), `${FIXTURE_PREFIX}out-`));
const rscOutDir = path.join(outDir, "server");
const ssrOutDir = path.join(outDir, "server", "ssr");
const clientOutDir = path.join(outDir, "client");
const { default: vinext } = await import(
pathToFileURL(path.join(workspaceRoot, "packages/vinext/src/index.ts")).href
);
const { createBuilder } = await import("vite");
const builder = await createBuilder({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })],
logLevel: "silent",
});
// The plugin reads `VINEXT_DEBUG_CLASSIFICATION` directly from `process.env`
// in its `generateBundle` hook. Save, override, and restore around the build
// so these tests are hermetic: asserting "stub stays null" works even when
// a developer has the flag set in their local shell, and the debug-on suite
// below can force the patched path without polluting the sibling suite.
const envKey = "VINEXT_DEBUG_CLASSIFICATION";
const prior = process.env[envKey];
if (debug) {
process.env[envKey] = "1";
} else {
delete process.env[envKey];
}
try {
await builder.buildApp();
} finally {
if (prior === undefined) {
delete process.env[envKey];
} else {
process.env[envKey] = prior;
}
}
// The RSC entry is emitted as either server/index.js or server/index.mjs
// depending on whether the fixture has a package.json with "type": "module".
// Our bespoke fixture has no package.json at all, so Vite falls back to .mjs.
const chunkDir = path.join(outDir, "server");
const entries = await fsp.readdir(chunkDir);
const chunkFile = entries.find((f) => /^index\.m?js$/.test(f));
if (!chunkFile) {
throw new Error(`No RSC entry chunk found in ${chunkDir}. Contents: ${entries.join(", ")}`);
}
const chunkSource = await fsp.readFile(path.join(chunkDir, chunkFile), "utf8");
return {
chunkSource,
dispatch: extractDispatch(chunkSource),
routeIndexByPattern: extractRouteIndexByPattern(chunkSource),
};
}
describe("build-time classification integration", () => {
let built: BuiltFixture;
beforeAll(async () => {
built = await buildMinimalFixture();
}, 120_000);
afterAll(() => {
// tmpdirs are left for post-mortem debugging; the test harness cleans
// os.tmpdir() periodically. Matching the pattern used by buildAppFixture.
});
it("patches __VINEXT_CLASS with a populated switch statement", () => {
// The untouched stub body is `{ return null; }`; the patched body must
// contain a switch dispatcher.
expect(built.chunkSource).toMatch(/function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{[^}]*switch/);
});
it("gates the reasons sidecar behind __classDebug in the route table", () => {
expect(built.chunkSource).toMatch(
/__buildTimeReasons:\s*__classDebug\s*\?\s*__VINEXT_CLASS_REASONS\(\d+\)\s*:\s*null/,
);
});
it("leaves __VINEXT_CLASS_REASONS as a null stub when build-time debug is off", () => {
expect(built.chunkSource).toMatch(
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/,
);
expect(built.chunkSource).not.toMatch(
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{[^}]*switch/,
);
});
it("classifies the force-dynamic layout at build time", () => {
const routeIdx = built.routeIndexByPattern.get("/force-dyn");
expect(routeIdx).toBeDefined();
const map = built.dispatch(routeIdx!);
expect(map).toBeInstanceOf(Map);
// Layout index 1 is the nested `/force-dyn/layout.tsx`; index 0 is root.
expect(map!.get(1)).toBe("dynamic");
});
it("classifies the force-static layout at build time", () => {
const routeIdx = built.routeIndexByPattern.get("/force-static");
expect(routeIdx).toBeDefined();
const map = built.dispatch(routeIdx!);
expect(map).toBeInstanceOf(Map);
expect(map!.get(1)).toBe("static");
});
it("omits layouts that import next/headers from the build-time map", () => {
const routeIdx = built.routeIndexByPattern.get("/dyn");
expect(routeIdx).toBeDefined();
const map = built.dispatch(routeIdx!);
// The nested layout at index 1 pulls in next/headers, so Layer 2 returns
// "needs-probe" — it must be filtered out and fall back to Layer 3 at
// request time.
if (map) {
expect(map.has(1)).toBe(false);
}
});
it("classifies layouts with no segment config and no dynamic shims as static", () => {
// The root layout at index 0 is pure JSX — Layer 2 should prove it static.
// This assertion holds for every route in the fixture since they all share
// the root layout.
const routeIdx = built.routeIndexByPattern.get("/");
expect(routeIdx).toBeDefined();
const map = built.dispatch(routeIdx!);
expect(map).toBeInstanceOf(Map);
expect(map!.get(0)).toBe("static");
});
});
/**
* Extracts and evaluates `__VINEXT_CLASS_REASONS` from a build output that was
* produced with `VINEXT_DEBUG_CLASSIFICATION=1`. Mirrors `extractDispatch` but
* targets the sibling reasons stub. Kept intentionally permissive about the
* emitted codegen shape so this test survives the #863 refactor.
*/
type ReasonShape = { layer: string; result?: string };
function isReasonShape(value: unknown): value is ReasonShape {
if (!value || typeof value !== "object") return false;
if (!("layer" in value)) return false;
return typeof value.layer === "string";
}
function extractReasonsDispatch(
chunkSource: string,
): (routeIdx: number) => Map<number, ReasonShape> | null {
const stubRe = /function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/;
if (stubRe.test(chunkSource)) {
throw new Error("__VINEXT_CLASS_REASONS was not patched despite VINEXT_DEBUG_CLASSIFICATION=1");
}
const re =
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{\s*return\s+(\([\s\S]*?\))\(routeIdx\);\s*\}/;
const match = re.exec(chunkSource);
if (!match) {
throw new Error("Could not locate patched __VINEXT_CLASS_REASONS in chunk source");
}
const raw: unknown = vm.runInThisContext(match[1]!);
if (typeof raw !== "function") {
throw new Error("Patched __VINEXT_CLASS_REASONS body did not evaluate to a function");
}
return (routeIdx: number) => {
const result: unknown = Reflect.apply(raw, null, [routeIdx]);
if (result === null) return null;
if (result instanceof Map) {
const narrowed = new Map<number, ReasonShape>();
for (const [key, value] of result) {
if (typeof key !== "number") {
throw new Error(`Reasons dispatch returned non-numeric key: ${String(key)}`);
}
if (!isReasonShape(value)) {
throw new Error(`Reasons dispatch returned malformed reason: ${JSON.stringify(value)}`);
}
narrowed.set(key, value);
}
return narrowed;
}
throw new Error(
`Reasons dispatch returned unexpected value for routeIdx ${routeIdx}: ${JSON.stringify(result)}`,
);
};
}
describe("build-time classification integration (debug on)", () => {
let built: BuiltFixture;
beforeAll(async () => {
built = await buildMinimalFixture({ debug: true });
}, 120_000);
it("patches __VINEXT_CLASS_REASONS with a populated dispatcher", () => {
expect(built.chunkSource).toMatch(
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{[^}]*switch/,
);
expect(built.chunkSource).not.toMatch(
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/,
);
});
it("emits both Layer 1 and Layer 2 reasons on the force-dyn route dispatch entry", () => {
// Only the discriminator + Layer 2 `result` are pinned so the test
// survives the #863 codegen reshape.
const reasonsFor = extractReasonsDispatch(built.chunkSource);
const routeIdx = built.routeIndexByPattern.get("/force-dyn");
expect(routeIdx).toBeDefined();
const reasons = reasonsFor(routeIdx!);
expect(reasons).toBeInstanceOf(Map);
const nestedReason = reasons!.get(1);
expect(nestedReason).toBeDefined();
expect(nestedReason!.layer).toBe("segment-config");
const rootReason = reasons!.get(0);
expect(rootReason).toBeDefined();
expect(rootReason!.layer).toBe("module-graph");
expect(rootReason!.result).toBe("static");
});
});