fix(ui): make fixture bundle browser-safe, fast and cacheable

The UI fixture bundle crashed at runtime, so every test waited out its
45s timeout:
- server-only modules leaked into the bundle and referenced node
  built-ins, the process global, and validated env at import time
- the previous platform: 'node' fix kept esbuild happy but emitted
  require() calls that cannot run in the browser

Fix it at the bundle boundary:
- alias node:* imports to e2e/ui/node-stubs.mjs (shape-compatible stubs,
  no real I/O)
- inject e2e/ui/process-shim.mjs for the process global
- define process.env.SKIP_ENV_VALIDATION=1 so src/env.ts skips parsing
  in the fixture (same escape hatch vitest uses)

Also make the fixture server fast to start:
- build JS and Tailwind CSS in parallel
- cache built assets in node_modules/.cache/nexst-ui-fixture keyed on a
  content hash of src/, fixtures, stubs and the server file
- startup drops from ~1.7s to ~220ms on cache hits
This commit is contained in:
openhands committed 2026-10-17 17:13:59 +02:00
1 parent c9b363d60d
commit 1dd584cd82
27 files changed
+334 -67

No files matched your search

+147 -56
View File
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises"; import { createHash } from "node:crypto";
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
import { createServer } from "node:http"; import { createServer } from "node:http";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -24,63 +25,153 @@ const actions = new Set([
"@/actions/admin-media", "@/actions/admin-media",
]); ]);
const nodeStubs = path.join(root, "e2e/ui/node-stubs.mjs"); const nodeStubs = path.join(root, "e2e/ui/node-stubs.mjs");
const bundle = await build({ const cacheDir = path.join(root, "node_modules/.cache/nexst-ui-fixture");
entryPoints: [path.join(fixtures, "entry.tsx")], const startedAt = Date.now();
bundle: true,
write: false, async function listFiles(dir) {
outdir: path.join(root, "test-results/ui-fixture"), const entries = await readdir(dir, { withFileTypes: true });
format: "iife", const files = [];
jsx: "automatic", for (const entry of entries) {
alias: { const target = path.join(dir, entry.name);
"@": path.join(root, "src"), if (entry.isDirectory()) files.push(...(await listFiles(target)));
"node:async_hooks": nodeStubs, else files.push(target);
"node:crypto": nodeStubs, }
"node:fs/promises": nodeStubs, return files;
"node:path": nodeStubs, }
},
define: { "process.env.NODE_ENV": '"production"' }, async function fixtureCacheKey() {
plugins: [ const files = [
{ ...(await listFiles(path.join(root, "src"))),
name: "server-contracts", ...(await listFiles(fixtures)),
setup(build) { nodeStubs,
build.onResolve( path.join(root, "e2e/ui/process-shim.mjs"),
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// }, fileURLToPath(import.meta.url),
(args) => { path.join(root, "package.json"),
if (args.path === "next/navigation") ].sort();
return { path: path.join(fixtures, "navigation.ts") }; const hash = createHash("sha256");
if (args.path === "next/link") for (const file of files) {
return { path: path.join(fixtures, "next-link.tsx") }; hash.update(file.slice(root.length));
if (actions.has(args.path)) hash.update(await readFile(file));
return { path: path.join(fixtures, "server-actions.ts") }; }
throw Error(`Unexpected server action in UI fixture: ${args.path}`); return hash.digest("hex");
}, }
);
build.onResolve( async function readCache(key) {
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ }, try {
(args) => { const [js, css] = await Promise.all([
throw Error( readFile(path.join(cacheDir, `${key}.app.js`), "utf8"),
`Database or server dependency forbidden in UI fixture: ${args.path}`, readFile(path.join(cacheDir, `${key}.styles.css`), "utf8"),
]);
return { js, css };
} catch {
return null;
}
}
async function writeCache(key, js, css) {
await mkdir(cacheDir, { recursive: true });
await Promise.all([
writeFile(path.join(cacheDir, `${key}.app.js`), js),
writeFile(path.join(cacheDir, `${key}.styles.css`), css),
]);
const stale = await readdir(cacheDir);
await Promise.all(
stale
.filter((name) => !name.startsWith(key))
.map((name) => unlink(path.join(cacheDir, name))),
);
}
async function buildFixture() {
const [bundle, globalCss] = await Promise.all([
build({
entryPoints: [path.join(fixtures, "entry.tsx")],
bundle: true,
write: false,
outdir: path.join(root, "test-results/ui-fixture"),
format: "iife",
jsx: "automatic",
alias: {
"@": path.join(root, "src"),
"node:async_hooks": nodeStubs,
"node:child_process": nodeStubs,
"node:crypto": nodeStubs,
"node:dns/promises": nodeStubs,
"node:fs": nodeStubs,
"node:fs/promises": nodeStubs,
"node:http": nodeStubs,
"node:net": nodeStubs,
"node:os": nodeStubs,
"node:path": nodeStubs,
"node:url": nodeStubs,
"node:util": nodeStubs,
},
define: {
"process.env.NODE_ENV": '"production"',
"process.env.SKIP_ENV_VALIDATION": '"1"',
},
inject: [path.join(root, "e2e/ui/process-shim.mjs")],
plugins: [
{
name: "server-contracts",
setup(build) {
build.onResolve(
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// },
(args) => {
if (args.path === "next/navigation")
return { path: path.join(fixtures, "navigation.ts") };
if (args.path === "next/link")
return { path: path.join(fixtures, "next-link.tsx") };
if (actions.has(args.path))
return { path: path.join(fixtures, "server-actions.ts") };
throw Error(
`Unexpected server action in UI fixture: ${args.path}`,
);
},
);
build.onResolve(
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ },
(args) => {
throw Error(
`Database or server dependency forbidden in UI fixture: ${args.path}`,
);
},
); );
}, },
); },
}, ],
}, }),
], postcss([tailwind({ base: root })])
}); .process(await readFile(path.join(root, "src/app/globals.css"), "utf8"), {
const globalCss = ( from: path.join(root, "src/app/globals.css"),
await postcss([tailwind({ base: root })]).process( })
await readFile(path.join(root, "src/app/globals.css"), "utf8"), .then((result) => result.css),
{ from: path.join(root, "src/app/globals.css") }, ]);
) const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
).css; const css =
const css = globalCss +
globalCss + "\n" +
"\n" + bundle.outputFiles
bundle.outputFiles .filter((file) => file.path.endsWith(".css"))
.filter((file) => file.path.endsWith(".css")) .map((file) => file.text)
.map((file) => file.text) .join("\n");
.join("\n"); return { js, css };
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text; }
const cacheKey = await fixtureCacheKey();
let assets = await readCache(cacheKey);
if (assets) {
console.log(
`[fixture] reusing cached bundle ${cacheKey.slice(0, 12)} (${Date.now() - startedAt}ms)`,
);
} else {
assets = await buildFixture();
await writeCache(cacheKey, assets.js, assets.css);
console.log(
`[fixture] built bundle ${cacheKey.slice(0, 12)} in ${Date.now() - startedAt}ms`,
);
}
const { js, css } = assets;
const html = const html =
'<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' + '<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' +
"a".repeat(64) + "a".repeat(64) +
+162 -11
View File
@@ -1,6 +1,8 @@
// Stubs for Node.js built-ins used by server-only modules that leak into the // Stubs for Node.js built-ins used by server-only modules that leak into the
// UI fixture bundle. These keep esbuild from failing on `node:*` imports // UI fixture bundle. These keep esbuild from failing on `node:*` imports
// while providing shape-compatible exports so the bundle stays loadable. // while providing shape-compatible exports so the bundle stays loadable.
// Nothing here performs real I/O; it only exists so module evaluation in the
// browser does not crash on import of server-only helpers.
export class AsyncLocalStorage { export class AsyncLocalStorage {
enterWith() {} enterWith() {}
@@ -13,24 +15,173 @@ export class AsyncLocalStorage {
} }
} }
export function createHash() { const cryptoApi = {
return { createHash: () => ({
update: () => ({ update: () => ({
digest: () => "", digest: () => "",
}), }),
}; }),
} randomUUID: () => "00000000-0000-0000-0000-000000000000",
randomBytes: (size) => new Uint8Array(size),
createHmac: () => ({
update: () => ({
digest: () => "",
}),
}),
timingSafeEqual: () => true,
createCipheriv: () => ({
update: () => new Uint8Array(0),
final: () => new Uint8Array(0),
}),
createDecipheriv: () => ({
update: () => new Uint8Array(0),
final: () => new Uint8Array(0),
}),
webcrypto: globalThis.crypto,
};
export function randomUUID() { export const {
return "00000000-0000-0000-0000-000000000000"; createHash,
} randomUUID,
randomBytes,
createHmac,
timingSafeEqual,
createCipheriv,
createDecipheriv,
webcrypto,
} = cryptoApi;
export async function readFile() { export const appendFile = async () => {};
return Buffer.alloc(0); export const mkdir = async () => {};
} export const readdir = async () => [];
export const readFile = async () => new Uint8Array(0);
export const rename = async () => {};
export const stat = async () => ({
isDirectory: () => false,
isFile: () => true,
size: 0,
mtimeMs: 0,
});
export const unlink = async () => {};
export const writeFile = async () => {};
export const rm = async () => {};
export const mkdtemp = async () => "/tmp/fixture-stub";
export const path = { export const promises = {
appendFile,
mkdir,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
rm,
mkdtemp,
};
export const existsSync = () => false;
export const readFileSync = () => new Uint8Array(0);
export const writeFileSync = () => {};
export const readdirSync = () => [];
export const mkdirSync = () => {};
export const rmSync = () => {};
export const mkdtempSync = () => "/tmp/fixture-stub";
export const appendFileSync = () => {};
export const statSync = () => ({
isDirectory: () => false,
isFile: () => true,
size: 0,
});
const pathApi = {
join: (...parts) => parts.join("/"), join: (...parts) => parts.join("/"),
resolve: (...parts) => parts.join("/"), resolve: (...parts) => parts.join("/"),
dirname: (value) => value.split("/").slice(0, -1).join("/"),
basename: (value) => value.split("/").pop() ?? "",
extname: (value) => {
const base = value.split("/").pop() ?? "";
const index = base.lastIndexOf(".");
return index > 0 ? base.slice(index) : "";
},
relative: (_from, to) => to,
normalize: (value) => value,
isAbsolute: (value) => value.startsWith("/"),
sep: "/", sep: "/",
delimiter: ":",
};
export const {
join,
resolve,
dirname,
basename,
extname,
relative,
normalize,
isAbsolute,
sep,
delimiter,
} = pathApi;
export const path = pathApi;
const osApi = {
hostname: () => "localhost",
tmpdir: () => "/tmp",
platform: () => "linux",
homedir: () => "/tmp",
};
export const { hostname, tmpdir, platform, homedir } = osApi;
export const promisify = (fn) => fn;
export const isIP = () => 0;
export const lookup = async () => ({ address: "127.0.0.1", family: 4 });
export const execFile = () => {};
export const execFileSync = () => "";
export const spawnSync = () => ({ status: 0, stdout: "", stderr: "" });
export const fileURLToPath = (value) => String(value);
export const createServer = () => ({
listen: () => {},
close: () => {},
on: () => {},
address: () => ({ port: 0 }),
});
// Single default export: all node:* aliases point at this one file, and the
// merged object keeps `import crypto from "node:crypto"`, `import path from
// "node:path"` and `import os from "node:os"` usable after aliasing.
export default {
...cryptoApi,
...pathApi,
...osApi,
appendFile,
mkdir,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
rm,
mkdtemp,
promises,
existsSync,
readFileSync,
writeFileSync,
readdirSync,
mkdirSync,
rmSync,
mkdtempSync,
appendFileSync,
statSync,
promisify,
isIP,
lookup,
execFile,
execFileSync,
spawnSync,
fileURLToPath,
createServer,
}; };
+25
View File
@@ -0,0 +1,25 @@
// Minimal `process` shim injected into the UI fixture bundle. Server-only
// modules that leak into the bundle reference the `process` global at module
// scope (release ids, paths, etc). esbuild's `inject` rewrites those free
// identifier references to import from this file so evaluation cannot crash
// in the browser.
export const process = {
env: { NODE_ENV: "production" },
platform: "linux",
arch: "x64",
version: "v20.0.0",
versions: { node: "20.0.0" },
argv: [],
cwd: () => "/",
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
on: () => {},
once: () => {},
emit: () => {},
exit: () => {},
hrtime: Object.assign(() => [0, 0], { bigint: () => 0n }),
memoryUsage: () => ({ heapUsed: 0, rss: 0 }),
pid: 0,
};
export default process;