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:
1 parent
c9b363d60d
commit
1dd584cd82
27 files changed
+334
-67
No files matched your search
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+147
-56
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
Reference in new issue
Block a user