import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { assembleStandalone, patchTurbopackChunks, syncStandaloneNativeAssets, syncStandaloneExtraModules, } from "../../../scripts/build/assembleStandalone.mjs"; /** Recursively list relative file paths under dir (forward-slash normalised). */ function listFiles(dir: string, rootDir: string = dir, out: string[] = []): string[] { let entries: string[] = []; try { entries = fs.readdirSync(dir); } catch { return out; } for (const entry of entries) { const full = path.join(dir, entry); if (fs.statSync(full).isDirectory()) { listFiles(full, rootDir, out); } else { out.push(path.relative(rootDir, full).replace(/\\/g, "/")); } } return out.sort(); } /** Build a synthetic projectRoot containing every sidecar source the assembler copies. */ function seedSidecarSources(root: string) { const files = [ "node_modules/wreq-js/rust/lib.so", "node_modules/better-sqlite3/build/Release/better_sqlite3.node", "src/mitm/tproxy/native/build/Release/transparent.node", "node_modules/@swc/helpers/package.json", "node_modules/pino-abstract-transport/index.js", "node_modules/pino-pretty/index.js", "node_modules/split2/index.js", "node_modules/playwright-core/index.js", "node_modules/sql.js/package.json", "node_modules/sql.js/dist/sql-wasm.js", "node_modules/sql.js/dist/sql-wasm.wasm", "node_modules/sqlite-vec/index.js", "node_modules/sqlite-vec-linux-x64/vec0.so", "src/lib/db/migrations/001_init.sql", "src/mitm/server.cjs", "scripts/dev/run-standalone.mjs", "scripts/dev/standalone-server-ws.mjs", "scripts/dev/peer-stamp.mjs", "scripts/dev/responses-ws-proxy.mjs", "scripts/build/runtime-env.mjs", "scripts/build/bootstrap-env.mjs", "scripts/dev/healthcheck.mjs", "public/logo.svg", ]; for (const rel of files) { const full = path.join(root, rel); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, `// ${rel}`); } } test("assembleStandalone copies standalone + static + public + sidecars into outDir", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-")); const distDir = path.join(tmp, ".build/next"); const outDir = path.join(tmp, "dist"); // minimal fake standalone tree fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true }); fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server"); fs.mkdirSync(path.join(distDir, "static"), { recursive: true }); fs.writeFileSync(path.join(distDir, "static", "x.js"), "x"); fs.mkdirSync(path.join(tmp, "public"), { recursive: true }); fs.writeFileSync(path.join(tmp, "public", "logo.svg"), ""); assembleStandalone({ distDir, outDir, projectRoot: tmp, sanitizePaths: false, copyNatives: false, }); assert.ok(fs.existsSync(path.join(outDir, "server.js")), "server.js copied"); // Static lands under the distDir path (.build/next/static), where the standalone // server.js — built with distDir baked into its config — serves /_next/static from. assert.ok( fs.existsSync(path.join(outDir, ".build/next/static/x.js")), "static copied under distDir" ); assert.ok( !fs.existsSync(path.join(outDir, ".next/static/x.js")), "static is NOT placed under a literal .next (would 404 against distDir server)" ); assert.ok(fs.existsSync(path.join(outDir, "public/logo.svg")), "public copied"); fs.rmSync(tmp, { recursive: true, force: true }); }); test("patchTurbopackChunks restores canonical external package names in a custom distDir", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-hash-strip-")); const chunkDir = path.join(tmp, ".build", "next", "server", "chunks"); const chunkPath = path.join(chunkDir, "instrumentation.js"); fs.mkdirSync(chunkDir, { recursive: true }); fs.writeFileSync( chunkPath, 'require("ws-a972e7ffa40ff725"); require("@ngrok/ngrok-0f98e1294a0b09d5");' ); const result = patchTurbopackChunks(tmp, ".build/next"); const patched = fs.readFileSync(chunkPath, "utf8"); assert.deepEqual(result, { patchedFiles: 1, patchedMatches: 2 }); assert.match(patched, /require\("ws"\)/); assert.match(patched, /require\("@ngrok\/ngrok"\)/); assert.doesNotMatch(patched, /-[0-9a-f]{16}/); fs.rmSync(tmp, { recursive: true, force: true }); }); // Drift guard: the async path (syncStandaloneNativeAssets / syncStandaloneExtraModules, // used by build-next-isolated) and the sync path (assembleStandalone copyNatives, used by // prepublish/electron) must copy the SAME sidecar tree. After the single-source refactor // both derive from NATIVE_ASSET_ENTRIES/EXTRA_MODULE_ENTRIES — this test fails if a future // edit reintroduces two divergent lists. test("async and sync sidecar copy paths produce identical bundle trees", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-parity-")); const projectRoot = path.join(tmp, "src-root"); seedSidecarSources(projectRoot); // Async path: copy into outAsync via the exported sync* helpers. const outAsync = path.join(tmp, "out-async"); fs.mkdirSync(outAsync, { recursive: true }); const silent = { log() {} }; // The exported helpers derive the standalone root from NEXT_DIST_DIR; point them at outAsync. await syncStandaloneNativeAssets(projectRoot, fs.promises, silent, outAsync); await syncStandaloneExtraModules(projectRoot, fs.promises, silent, outAsync); // Sync path: assembleStandalone(copyNatives) needs a standalone dir to copy first. const distDir = path.join(projectRoot, ".build/next"); fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true }); fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server"); const outSync = path.join(tmp, "out-sync"); assembleStandalone({ distDir, outDir: outSync, projectRoot, sanitizePaths: false, copyNatives: true, }); const asyncTree = listFiles(outAsync); // The sync path also copies the standalone server.js + patches package.json; compare only // the sidecar files the two paths share (drop server.js which is unique to assembleStandalone). const syncTree = listFiles(outSync).filter((f) => f !== "server.js"); assert.deepEqual( syncTree, asyncTree, "sync (assembleStandalone) and async (sync*ToDir) must copy the same sidecar tree" ); // The TPROXY native addon must land at the cwd-relative path the runtime loader // (transparentSocket.ts) resolves in the standalone bundle. assert.ok( asyncTree.includes("src/mitm/tproxy/native/build/Release/transparent.node"), "TPROXY transparent.node copied into the standalone bundle" ); for (const sqlJsFile of [ "node_modules/sql.js/package.json", "node_modules/sql.js/dist/sql-wasm.js", "node_modules/sql.js/dist/sql-wasm.wasm", ]) { assert.ok(asyncTree.includes(sqlJsFile), `sql.js runtime file copied: ${sqlJsFile}`); } fs.rmSync(tmp, { recursive: true, force: true }); }); test("the TPROXY addon source is skipped gracefully when it was not built (non-Linux)", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-skip-")); const projectRoot = path.join(tmp, "src-root"); // Seed everything EXCEPT the tproxy addon (simulating a non-Linux build). fs.mkdirSync(projectRoot, { recursive: true }); const out = path.join(tmp, "out"); fs.mkdirSync(out, { recursive: true }); // No throw even though src/mitm/tproxy/native/... is absent. await syncStandaloneNativeAssets(projectRoot, fs.promises, { log() {} }, out); assert.ok( !fs.existsSync(path.join(out, "src/mitm/tproxy/native/build/Release/transparent.node")), "absent addon is simply not copied (graceful skip)" ); fs.rmSync(tmp, { recursive: true, force: true }); }); // Regression guard (#deploy 2026-07-11): server-ws.mjs gained an import of // head-response-guard.cjs without a matching EXTRA_MODULE_ENTRIES entry, so every // build:release bundle crashed at boot with ERR_MODULE_NOT_FOUND. This test derives // the requirement from the source itself: EVERY relative import in // standalone-server-ws.mjs must be shipped into the bundle by the extra-module sync. test("every relative import of standalone-server-ws.mjs is shipped into the bundle", async () => { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const serverWsSrc = fs.readFileSync( path.join(repoRoot, "scripts/dev/standalone-server-ws.mjs"), "utf8" ); const relImports = [...serverWsSrc.matchAll(/from\s+"\.\/([^"]+)"/g)].map((m) => m[1]); assert.ok(relImports.length > 0, "server-ws.mjs has relative imports to check"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-serverws-")); await syncStandaloneExtraModules(repoRoot, fs.promises, { log() {} }, tmp); for (const imp of relImports) { assert.ok( fs.existsSync(path.join(tmp, imp)), `server-ws.mjs imports ./${imp} but EXTRA_MODULE_ENTRIES does not ship it — the bundle would crash at boot (ERR_MODULE_NOT_FOUND)` ); } fs.rmSync(tmp, { recursive: true, force: true }); }); // Regression guard (deploy 2026-08-19): under heavy concurrent build I/O the bulk // "standalone -> outDir" tree copy can already have carried a prior pass's result into // an EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES `dest` BEFORE that entry's own copy runs // — either an absolute symlink resolving to the exact same real path as `src` (a pnpm // store layout), or a stale node of a different type (file/symlink vs directory). Node's // fs.cpSync/fs.cp refuse both cases even with force:true, throwing ERR_FS_CP_EINVAL // ("src and dest cannot be the same") or ERR_FS_CP_DIR_TO_NON_DIR/ERR_FS_CP_NON_DIR_TO_DIR // respectively, crashing every one of copyNativeAssetsAndExtraModules, // repairEmptyExternalPackageDirs, syncNativeAssetsToDir, and syncExtraModulesToDir. test("copy passes tolerate a dest that already resolves to src, or a stale-typed dest", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-race-")); const projectRoot = path.join(tmp, "src-root"); seedSidecarSources(projectRoot); // Case 1 (sync path): dest already an absolute symlink resolving to src's realpath — // simulates the wreq-js entry after the .build/next/standalone bulk copy already // carried an absolute symlink over from an earlier standalone build. const distDir = path.join(projectRoot, ".build/next"); fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true }); fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server"); const outSync = path.join(tmp, "out-sync"); fs.mkdirSync(path.join(outSync, "node_modules"), { recursive: true }); fs.symlinkSync( path.join(projectRoot, "node_modules/wreq-js"), path.join(outSync, "node_modules/wreq-js") ); // Case 2 (sync path): dest already a plain FILE where src is a directory — // simulates @swc/helpers landing as a stray file from an unrelated earlier copy. fs.mkdirSync(path.join(outSync, "node_modules/@swc"), { recursive: true }); fs.writeFileSync(path.join(outSync, "node_modules/@swc/helpers"), "stale file, not a dir"); assert.doesNotThrow(() => { assembleStandalone({ distDir, outDir: outSync, projectRoot, sanitizePaths: false, copyNatives: true, }); }, "assembleStandalone must not throw on a same-realpath symlink or a stale-typed dest"); assert.ok( fs.existsSync(path.join(outSync, "node_modules/wreq-js/rust/lib.so")), "wreq-js content reachable through the pre-existing symlink" ); assert.ok( fs.statSync(path.join(outSync, "node_modules/@swc/helpers")).isDirectory(), "the stale file at @swc/helpers was replaced by the real directory" ); assert.ok( fs.existsSync(path.join(outSync, "node_modules/@swc/helpers/package.json")), "@swc/helpers content copied after clearing the stale file" ); // Case 3 (async path): same real-path-symlink collision hits syncStandaloneExtraModules. const outAsync = path.join(tmp, "out-async"); fs.mkdirSync(path.join(outAsync, "node_modules"), { recursive: true }); fs.symlinkSync( path.join(projectRoot, "node_modules/sql.js"), path.join(outAsync, "node_modules/sql.js") ); await assert.doesNotReject( () => syncStandaloneExtraModules(projectRoot, fs.promises, { log() {} }, outAsync), "syncStandaloneExtraModules must not throw on a same-realpath symlink" ); assert.ok( fs.existsSync(path.join(outAsync, "node_modules/sql.js/dist/sql-wasm.js")), "sql.js content reachable through the pre-existing symlink" ); fs.rmSync(tmp, { recursive: true, force: true }); });