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
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -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) +
|
||||||
|
|||||||
@@ -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;
|
||||||