import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import tailwind from "@tailwindcss/postcss"; import { build } from "esbuild"; import postcss from "postcss"; const root = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../..", ); const fixtures = path.join(root, "e2e/ui/fixtures"); const port = Number(process.env.UI_TEST_PORT || 3107); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error("Invalid UI_TEST_PORT"); const actions = new Set([ "@/actions/catalog", "@/actions/import-furni", "@/actions/events", "@/actions/admin-settings", "@/actions/admin-table-views", "@/actions/article-recovery", "@/actions/admin-media", ]); const nodeStubs = path.join(root, "e2e/ui/node-stubs.mjs"); const cacheDir = path.join(root, "node_modules/.cache/nexst-ui-fixture"); const startedAt = Date.now(); async function listFiles(dir) { const entries = await readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const target = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...(await listFiles(target))); else files.push(target); } return files; } async function fixtureCacheKey() { const files = [ ...(await listFiles(path.join(root, "src"))), ...(await listFiles(fixtures)), nodeStubs, path.join(root, "e2e/ui/process-shim.mjs"), fileURLToPath(import.meta.url), path.join(root, "package.json"), ].sort(); const hash = createHash("sha256"); for (const file of files) { hash.update(file.slice(root.length)); hash.update(await readFile(file)); } return hash.digest("hex"); } async function readCache(key) { try { const [js, css] = await Promise.all([ readFile(path.join(cacheDir, `${key}.app.js`), "utf8"), 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"), { from: path.join(root, "src/app/globals.css"), }) .then((result) => result.css), ]); const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text; const css = globalCss + "\n" + bundle.outputFiles .filter((file) => file.path.endsWith(".css")) .map((file) => file.text) .join("\n"); return { js, css }; } 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 = '