#!/usr/bin/env node // Record/replay oracle for the lockfile-less npm resolver (src/kernel/npm). // // `record ` runs real npm against a case's package.json (and its seed-lock.json as // package-lock.json, when it has one) through an in-process recording proxy in front // of https://registry.npmjs.org, and writes what npm saw to // tests/fixtures/registry// as recording.json.gz plus the resulting // package-lock.json. `replay ` reruns npm against only that recording (offline, // 502 on anything unrecorded) and asserts the lockfile it writes is byte-identical to // the stored one. That is the self-check that a recording is complete: a resolver // change is tested against the recording, never against the live registry, by the same // npm that made it. // // `fake ` serves the in-memory registry of tests/fixtures/fake/.json to real // npm and writes npm's verdict (the lockfile's packages and its text, or its stderr) // back into the file as `npm`, so a hand case's expected tree and lockfile are npm's own // on identical inputs. The case's `indent` and `newline` format its package.json. With a // `seed` package, npm runs on that first and the lockfile it writes becomes the seed // the second run (and the test) starts from. // // `cli [case]` runs npm commands (install, ci, uninstall with their flags) over a fake // registry that serves real tarballs, and stores what npm printed and left on disk in // tests/fixtures/npm-cli.json, for the guest npm to be held to. // // loadRecording(case) is exported for tests/resolve.spec.ts to page.route() with. import { createServer } from "node:http"; import { request as httpsRequest } from "node:https"; import { spawn, spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, existsSync, rmSync } from "node:fs"; import { createHash } from "node:crypto"; import { gzipSync, gunzipSync } from "node:zlib"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { oracleEnv } from "./pinned-node.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, ".."); const FIXTURES_DIR = join(REPO_ROOT, "tests/fixtures/registry"); const REGISTRY_HOST = "registry.npmjs.org"; const NPM_TIMEOUT_MS = 120_000; // --- small fs helpers ---------------------------------------------------- function caseDir(name) { const dir = join(FIXTURES_DIR, name); if (!existsSync(join(dir, "package.json"))) { throw new Error(`no case "${name}": ${join(dir, "package.json")} does not exist`); } return dir; } /** A temp project with the case's package.json, and its seed-lock.json (if it has one) * as package-lock.json, for npm to start from. */ function mkTempInstall(caseDirPath) { const dir = mkdtempSync(join(tmpdir(), "npm-oracle-")); writeFileSync(join(dir, "package.json"), readFileSync(join(caseDirPath, "package.json"))); const seed = join(caseDirPath, "seed-lock.json"); if (existsSync(seed)) writeFileSync(join(dir, "package-lock.json"), readFileSync(seed)); return dir; } function npmVersion() { const res = spawnSync("npm", ["-v"], { encoding: "utf8" }); if (res.status !== 0) throw new Error(`npm -v failed: ${res.stderr}`); return res.stdout.trim(); } // --- recording proxy (record mode) --------------------------------------- /** Forwards every request to https://registry.npmjs.org, recording the response * (status, content-type, body) keyed by request path. A URL requested more than once * (npm's own on-disk cache can revalidate mid-run) keeps whichever response actually * carries a body, over a later 304. Always asks upstream for identity encoding, so * recorded bodies are the plain document bytes, not registry-chosen compression. */ function startRecordingProxy() { const entries = new Map(); const server = createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const reqBody = Buffer.concat(chunks); const headers = { ...req.headers, host: REGISTRY_HOST, "accept-encoding": "identity" }; const upstream = httpsRequest( { hostname: REGISTRY_HOST, port: 443, path: req.url, method: req.method, headers }, (uRes) => { const respChunks = []; uRes.on("data", (c) => respChunks.push(c)); uRes.on("end", () => { const body = Buffer.concat(respChunks); const status = uRes.statusCode; const contentType = uRes.headers["content-type"] ?? null; const prev = entries.get(req.url); if (!prev || (status !== 304 && body.length > 0)) { entries.set(req.url, { status, contentType, body }); } res.writeHead(status, { "content-type": contentType ?? "application/octet-stream" }); res.end(body); }); }, ); upstream.on("error", (err) => { res.writeHead(502).end(String(err)); }); upstream.end(reqBody.length ? reqBody : undefined); }); }); return { server, entries }; } // --- replay server (replay mode) ------------------------------------------ /** Serves only what `entries` recorded, by exact request path. Anything else is a 502, * so an incomplete recording fails loudly instead of silently reaching the network. */ function startReplayServer(entries) { const misses = []; const server = createServer((req, res) => { const rec = entries.get(req.url); if (!rec) { misses.push(req.url); res.writeHead(502, { "content-type": "text/plain" }).end(`unrecorded: ${req.url}`); return; } res.writeHead(rec.status, { "content-type": rec.contentType ?? "application/octet-stream" }); res.end(Buffer.from(rec.body)); }); return { server, misses }; } function listen(server) { return new Promise((resolve, reject) => { server.on("error", reject); server.listen(0, "127.0.0.1", () => resolve(server.address().port)); }); } function close(server) { return new Promise((resolve) => server.close(resolve)); } // --- recording.json.gz on disk -------------------------------------------- // What arborist, npm-pick-manifest and the lockfile writer read from a packument. The // rest (exports, typesVersions, readmes, devDependencies, licence text) is most of the // bytes and none of the decision; replay proves it by rerunning npm on what is kept. const PACKUMENT_FIELDS = ["_id", "name", "dist-tags", "versions", "modified", "time"]; const VERSION_FIELDS = [ "_id", "name", "version", "dependencies", "optionalDependencies", "peerDependencies", "peerDependenciesMeta", "bundleDependencies", "bundledDependencies", "bin", "directories", "engines", "os", "cpu", "libc", "license", "funding", "deprecated", "dist", "gypfile", "_hasShrinkwrap", "hasInstallScript", ]; const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"]; const pick = (obj, keys) => Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, obj[k]])); function prunePackument(body) { const doc = JSON.parse(body.toString("utf8")); if (!doc.versions) return body; const out = pick(doc, PACKUMENT_FIELDS); out.versions = Object.fromEntries(Object.entries(doc.versions).map(([v, m]) => { const kept = pick(m, VERSION_FIELDS); const scripts = m.scripts && pick(m.scripts, INSTALL_SCRIPTS); if (scripts && Object.keys(scripts).length) kept.scripts = scripts; return [v, kept]; })); return Buffer.from(JSON.stringify(out)); } function writeRecording(dir, npmVer, entries) { const obj = { npmVersion: npmVer, entries: {} }; for (const [url, rec] of entries) { const json = rec.status === 200 && rec.contentType?.includes("json"); obj.entries[url] = { status: rec.status, contentType: rec.contentType, body: (json ? prunePackument(Buffer.from(rec.body)) : Buffer.from(rec.body)).toString("base64"), }; } const json = JSON.stringify(obj); const gz = gzipSync(Buffer.from(json), { level: 9 }); writeFileSync(join(dir, "recording.json.gz"), gz); return { rawBytes: Buffer.byteLength(json), gzBytes: gz.length, count: entries.size }; } /** Map for a case's recording, for * Playwright's page.route to serve directly. */ export function loadRecording(name) { const dir = caseDir(name); const gz = readFileSync(join(dir, "recording.json.gz")); const obj = JSON.parse(gunzipSync(gz).toString("utf8")); const map = new Map(); for (const [url, rec] of Object.entries(obj.entries)) { map.set(url, { status: rec.status, contentType: rec.contentType, body: Buffer.from(rec.body, "base64") }); } return map; } function readRecordingRaw(name) { const dir = caseDir(name); const gz = readFileSync(join(dir, "recording.json.gz")); return JSON.parse(gunzipSync(gz).toString("utf8")); } // --- npm invocation -------------------------------------------------------- function runNpmInstall(installDir, registryUrl, extraEnv) { return runNpm(installDir, registryUrl, ["install", "--package-lock-only"], extraEnv); } /** npm `command` in `installDir` against `registryUrl`, with an empty user config and * cache in `configDir`, and no scripts, audit, funding message or update check. With * `scripts`, scripts run and the flags go first, where neither `--` nor npx's command * can take them as its own arguments; `bin` is npm or npx. */ function runNpm(installDir, registryUrl, command, extraEnv = {}, configDir = installDir, { scripts = false, bin = "npm" } = {}) { const userconfig = join(configDir, "empty.npmrc"); writeFileSync(userconfig, ""); const cacheDir = join(configDir, "cache"); mkdirSync(cacheDir, { recursive: true }); const flags = [ ...(scripts ? [] : ["--ignore-scripts"]), "--no-audit", "--no-fund", "--no-update-notifier", `--registry=${registryUrl}`, `--userconfig=${userconfig}`, `--cache=${cacheDir}`, ]; const args = scripts ? [...flags, ...command] : [...command, ...flags]; // Async: the proxy npm talks to runs on this process's event loop, which spawnSync // would block, so npm's first request would never be answered. return new Promise((resolve) => { const child = spawn(bin, args, { cwd: installDir, env: oracleEnv(extraEnv) }); let stdout = ""; let stderr = ""; child.stdout.on("data", (c) => (stdout += c)); child.stderr.on("data", (c) => (stderr += c)); const timer = setTimeout(() => child.kill("SIGKILL"), NPM_TIMEOUT_MS); child.on("error", (error) => resolve({ status: null, stdout, stderr, error })); child.on("close", (status) => { clearTimeout(timer); resolve({ status, stdout, stderr }); }); }); } // --- record ------------------------------------------------------------ async function record(name) { const dir = caseDir(name); const installDir = mkTempInstall(dir); const { server, entries } = startRecordingProxy(); const port = await listen(server); const ver = npmVersion(); console.log(`recording ${name} with npm ${ver} (proxy on 127.0.0.1:${port})`); let res; try { res = await runNpmInstall(installDir, `http://127.0.0.1:${port}/`, {}); } finally { await close(server); } if (res.status !== 0 || res.error) { console.error(res.stdout ?? ""); console.error(res.stderr ?? res.error ?? ""); rmSync(installDir, { recursive: true, force: true, maxRetries: 3 }); throw new Error(`npm install failed for case "${name}" (exit ${res.status})`); } const lockPath = join(installDir, "package-lock.json"); const lock = readFileSync(lockPath); writeFileSync(join(dir, "package-lock.json"), lock); const stats = writeRecording(dir, ver, entries); rmSync(installDir, { recursive: true, force: true, maxRetries: 3 }); console.log( `recorded ${name}: ${stats.count} urls, ${(stats.rawBytes / 1024).toFixed(1)} KiB raw, ` + `${(stats.gzBytes / 1024).toFixed(1)} KiB gzipped -> ${join(dir, "recording.json.gz")}`, ); return stats; } // --- replay ------------------------------------------------------------ async function replay(name) { const dir = caseDir(name); const expectedLockPath = join(dir, "package-lock.json"); if (!existsSync(expectedLockPath)) { throw new Error(`no recorded package-lock.json for case "${name}"; run record first`); } const recording = readRecordingRaw(name); const currentVer = npmVersion(); if (recording.npmVersion !== currentVer) { throw new Error( `case "${name}" was recorded with npm ${recording.npmVersion}, but this machine runs npm ` + `${currentVer}. Resolution can differ between npm versions, so replay refuses to run; ` + `record the case again with npm ${currentVer}, or run replay with npm ${recording.npmVersion}.`, ); } const entries = loadRecording(name); const { server, misses } = startReplayServer(entries); const port = await listen(server); const installDir = mkTempInstall(dir); // Prove the replay is offline: real internet traffic goes to a dead port, except the // loopback replay server itself. const deadProxy = "http://127.0.0.1:1"; const env = { HTTPS_PROXY: deadProxy, HTTP_PROXY: deadProxy, NO_PROXY: "127.0.0.1,localhost", }; let res; try { res = await runNpmInstall(installDir, `http://127.0.0.1:${port}/`, env); } finally { await close(server); } if (res.status !== 0 || res.error) { console.error(res.stdout ?? ""); console.error(res.stderr ?? res.error ?? ""); rmSync(installDir, { recursive: true, force: true, maxRetries: 3 }); if (misses.length) console.error(`unrecorded URLs requested:\n ${misses.join("\n ")}`); throw new Error(`npm install failed replaying case "${name}" (exit ${res.status})`); } const actualLock = readFileSync(join(installDir, "package-lock.json")); const expectedLock = readFileSync(expectedLockPath); rmSync(installDir, { recursive: true, force: true, maxRetries: 3 }); if (misses.length) { throw new Error(`replay for case "${name}" hit ${misses.length} unrecorded URL(s):\n ${misses.join("\n ")}`); } if (!actualLock.equals(expectedLock)) { printLockDiff(expectedLock, actualLock); throw new Error(`replayed package-lock.json for case "${name}" is not byte-identical to the recorded one`); } console.log(`replay ${name}: byte-identical to ${expectedLockPath} (${entries.size} urls served, 0 misses)`); } /** Diffs two lockfiles' `packages` maps by location: which locations were added or * removed, and for shared locations, which fields differ. Key order inside a shared * object is ignored. */ function printLockDiff(expectedBuf, actualBuf) { const expected = JSON.parse(expectedBuf.toString("utf8")); const actual = JSON.parse(actualBuf.toString("utf8")); const expectedKeys = new Set(Object.keys(expected.packages ?? {})); const actualKeys = new Set(Object.keys(actual.packages ?? {})); const onlyExpected = [...expectedKeys].filter((k) => !actualKeys.has(k)); const onlyActual = [...actualKeys].filter((k) => !expectedKeys.has(k)); if (onlyExpected.length) console.error(`only in recorded lockfile (${onlyExpected.length}):`, onlyExpected); if (onlyActual.length) console.error(`only in replayed lockfile (${onlyActual.length}):`, onlyActual); for (const k of expectedKeys) { if (!actualKeys.has(k)) continue; const a = expected.packages[k]; const b = actual.packages[k]; if (JSON.stringify(sortedKeys(a)) === JSON.stringify(sortedKeys(b))) continue; console.error(`DIFF ${k === "" ? "(root)" : k}:`); const fields = new Set([...Object.keys(a), ...Object.keys(b)]); for (const f of fields) { if (JSON.stringify(a[f]) !== JSON.stringify(b[f])) { console.error(` ${f}: ${JSON.stringify(a[f])} -> ${JSON.stringify(b[f])}`); } } } } function sortedKeys(obj) { return Object.keys(obj) .sort() .map((k) => [k, obj[k]]); } // --- fake registry (fake mode) ------------------------------------------ const FAKE_DIR = join(REPO_ROOT, "tests/fixtures/fake"); /** A full packument for `name` from `{ "1.0.0": { dependencies, peerDependencies, ... }, latest? }`. * tests/npm-registry.ts builds its fake registry from the same function. */ export function fakePackument(name, { latest, ...versions }) { const keys = Object.keys(versions); return { name, "dist-tags": { latest: latest ?? keys[keys.length - 1] }, versions: Object.fromEntries(keys.map((v) => [v, { name, version: v, dist: { tarball: `https://registry.npmjs.org/${name}/-/${name.split("/").pop()}-${v}.tgz`, integrity: `sha512-${Buffer.from(`${name}@${v}`).toString("base64")}`, }, ...versions[v], }])), }; } /** The package.json text a fake case gives npm: its `indent` (default 2) and `newline`. */ export function packageText(doc, pkg = doc.package) { return JSON.stringify(pkg, null, doc.indent ?? 2).replace(/\n/g, doc.newline ?? "\n"); } function startFakeRegistry(registry) { const server = createServer((req, res) => { const name = decodeURIComponent(req.url.slice(1).split("?")[0]); const pkg = registry[name]; if (!pkg) { res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "Not found" })); return; } res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(fakePackument(name, pkg))); }); return server; } async function fake(name) { const file = join(FAKE_DIR, `${name}.json`); const doc = JSON.parse(readFileSync(file, "utf8")); const server = startFakeRegistry(doc.registry); const port = await listen(server); const ver = npmVersion(); const installDir = mkdtempSync(join(tmpdir(), "npm-fake-")); const url = `http://127.0.0.1:${port}/`; const run = async (pkg) => { writeFileSync(join(installDir, "package.json"), packageText(doc, pkg)); const res = await runNpmInstall(installDir, url, {}); const lockPath = join(installDir, "package-lock.json"); const text = existsSync(lockPath) ? readFileSync(lockPath, "utf8") : null; return { status: res.status, stderr: res.stderr, text, lock: text && JSON.parse(text) }; }; try { const npm = { version: ver }; if (doc.seed) { const seeded = await run(doc.seed); if (seeded.status !== 0) throw new Error(`seed install failed:\n${seeded.stderr}`); npm.seedLock = seeded.lock; } const res = await run(doc.package); npm.status = res.status; if (res.status === 0) { const { "": _root, ...packages } = res.lock.packages; npm.packages = packages; npm.lockfile = res.text; } // Only npm's own lines, without the "npm warn"/"npm error" prefixes, and without the // advice and log paths that follow an explanation (they name per-run temp files). const lines = res.stderr.split("\n").filter((l) => /^npm (warn|error) ?/.test(l)).map((l) => l.replace(/^npm (warn|error) ?/, "")); const advice = lines.findIndex((l) => /^(Fix the upstream dependency conflict|A complete log of this run)/.test(l)); npm.stderr = (advice === -1 ? lines : lines.slice(0, advice)).join("\n").trimEnd(); doc.npm = npm; writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`); console.log(`fake ${name}: npm ${ver} exited ${res.status}, ${npm.packages ? Object.keys(npm.packages).length : 0} packages -> ${file}`); } finally { await close(server); rmSync(installDir, { recursive: true, force: true, maxRetries: 3 }); } } // --- npm commands over a fake registry with real tarballs (cli mode) ----------------- const CLI_FILE = join(REPO_ROOT, "tests/fixtures/npm-cli.json"); /** A ustar header block for a regular file at `path`, mode 0644, mtime 0. */ function tarHeader(path, size) { const block = Buffer.alloc(512); const field = (at, len, text) => block.write(text, at, len, "utf8"); const octal = (at, len, n) => field(at, len, `${n.toString(8).padStart(len - 1, "0")}\0`); field(0, 100, path); octal(100, 8, 0o644); octal(108, 8, 0); octal(116, 8, 0); octal(124, 12, size); octal(136, 12, 0); block.fill(0x20, 148, 156); field(156, 1, "0"); field(257, 8, "ustar\x0000"); let sum = 0; for (const b of block) sum += b; field(148, 8, `${sum.toString(8).padStart(6, "0")}\0 `); return block; } /** * The tarball the cli registry serves for `name@version`: package/package.json with the * version's dependency fields, a package/index.js exporting "name@version", and each * `bin` file, a node script printing the package, its argv and its lifecycle event. Stored * (level 0) gzip, so the bytes, and so the integrity npm locks, do not depend on the * zlib that made them. */ export function fakeTarball(name, version, fields = {}) { const files = [ ["package/package.json", `${JSON.stringify({ name, version, ...fields }, null, 2)}\n`], ["package/index.js", `module.exports = ${JSON.stringify(`${name}@${version}`)};\n`], ...[...new Set(Object.values(fields.bin ?? {}))].map((file) => [ `package/${file}`, `#!/usr/bin/env node\nconsole.log(${JSON.stringify(`${name}@${version}`)}, JSON.stringify(process.argv.slice(2)), process.env.npm_lifecycle_event);\n`, ]), ]; const blocks = []; for (const [path, text] of files) { const data = Buffer.from(text); blocks.push(tarHeader(path, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512)); } blocks.push(Buffer.alloc(1024)); const bytes = gzipSync(Buffer.concat(blocks), { level: 0 }); const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; return { bytes, integrity }; } /** fakePackument with every version's dist pointing at its fakeTarball. */ export function cliPackument(name, versions) { const doc = fakePackument(name, versions); for (const [v, mani] of Object.entries(doc.versions)) { const { name: _n, version: _v, dist: _d, ...fields } = mani; mani.dist = { tarball: mani.dist.tarball, integrity: fakeTarball(name, v, fields).integrity }; } return doc; } /** `GET /` with its packument, `GET //` with that version's * document and `GET //-/.tgz` with that tarball, for the registry of * tests/fixtures/npm-cli.json. Null for anything else. */ export function cliRegistryResponse(registry, path) { const raw = decodeURIComponent(path); const at = raw.indexOf("/", raw.startsWith("@") ? raw.indexOf("/") + 1 : 0); const name = at === -1 ? raw : raw.slice(0, at); const rest = at === -1 ? "" : raw.slice(at); const versions = registry[name]; if (!versions) return null; const doc = cliPackument(name, versions); const json = (body) => ({ contentType: "application/json", body: Buffer.from(JSON.stringify(body)) }); if (rest === "") return json(doc); if (!rest.startsWith("/-/")) return doc.versions[rest.slice(1)] ? json(doc.versions[rest.slice(1)]) : null; const mani = Object.values(doc.versions).find((m) => m.dist.tarball.endsWith(rest)); if (!mani) return null; const { name: _n, version, dist: _d, ...fields } = mani; return { contentType: "application/octet-stream", body: fakeTarball(name, version, fields).bytes }; } /** The text npm-oracle.mjs gave npm as package.json for a cli case. */ export function cliPackageText(c, pkg = c.package) { return `${JSON.stringify(pkg, null, c.indent ?? 2).replace(/\n/g, c.newline ?? "\n")}\n`; } /** Every package under node_modules as "location@version", from the package.json files. */ function installedPackages(dir) { const out = []; const walk = (modules) => { if (!existsSync(modules)) return; for (const entry of readdirSync(modules).sort()) { if (entry.startsWith(".")) continue; const names = entry.startsWith("@") ? readdirSync(join(modules, entry)).sort().map((n) => `${entry}/${n}`) : [entry]; for (const n of names) { const at = join(modules, n); out.push(`${at.slice(dir.length + 1)}@${JSON.parse(readFileSync(join(at, "package.json"), "utf8")).version}`); walk(join(at, "node_modules")); } } }; walk(join(dir, "node_modules")); return out; } /** npm's output with what changes per run replaced: the timing and the temp paths; and * the fake registry's URL as the default registry's, which npm would print in its place. */ function stable(text, dir, registryUrl) { return text .replaceAll(registryUrl, `https://${REGISTRY_HOST}/`) .replaceAll(realpathSync(dir), "{dir}") .replaceAll(dir, "{dir}") .replace(/ in \d+(?:\.\d+)?(?:ms|s|m|h)$/gm, " in {time}") .replace(/^npm error A complete log of this run can be found in: .*\n/m, ""); } /** What npm left in its npx cache under `configDir`: each install dir by its hash, with * the package.json and package-lock.json there. Null when nothing was installed. */ function npxCache(configDir) { const root = join(configDir, "cache", "_npx"); if (!existsSync(root)) return null; const read = (path) => (existsSync(path) ? readFileSync(path, "utf8") : null); return Object.fromEntries(readdirSync(root).sort().map((hash) => [hash, { packageJson: read(join(root, hash, "package.json")), lockfile: read(join(root, hash, "package-lock.json")), }])); } /** * `cli [case]` runs each case of tests/fixtures/npm-cli.json (or the one named) with real * npm: in a fresh project, `npm install` of the case's `seed` package.json first when * it has one (that lockfile and node_modules are what the command starts from), then * the case's `package` (if any) as package.json, then `npm `. It stores npm's exit * status, stdout and stderr, the package.json and package-lock.json it left, and what * node_modules holds, back into the file as each case's `npm`. * * The `scripts` cases run scripts, `npm exec` and npx: each writes its `files` into the * project, runs its `bin` (npm, else npx), and stores the status and output, plus the * npx cache it left. With `again`, the command runs a second time and `again` holds * that run's status, output and every registry path it requested. */ async function cli(only) { const doc = JSON.parse(readFileSync(CLI_FILE, "utf8")); const requests = []; const server = createServer((req, res) => { const path = req.url.slice(1).split("?")[0]; requests.push(`/${path}`); const hit = cliRegistryResponse(doc.registry, path); if (!hit) return void res.writeHead(404, { "content-type": "application/json" }).end('{"error":"Not found"}'); res.writeHead(200, { "content-type": hit.contentType }).end(hit.body); }); const port = await listen(server); const url = `http://127.0.0.1:${port}/`; const ver = npmVersion(); const cases = [ ...Object.entries(doc.cases).map(([name, c]) => [name, c, false]), ...Object.entries(doc.scripts).map(([name, c]) => [name, c, true]), ]; try { for (const [name, c, scripts] of cases) { if (only && name !== only) continue; // Real: on macOS the temp dir is under the /var link, which arborist would lock // the npx cache through as a link to /private/var. const base = realpathSync(mkdtempSync(join(tmpdir(), "npm-cli-"))); const dir = join(base, "app"); const configDir = join(base, "config"); mkdirSync(dir); mkdirSync(configDir); try { const npm = { version: ver }; if (c.seed) { writeFileSync(join(dir, "package.json"), cliPackageText(c, c.seed)); const seeded = await runNpm(dir, url, ["install"], {}, configDir); if (seeded.status !== 0) throw new Error(`${name}: seed install failed:\n${seeded.stderr}`); npm.seedLock = readFileSync(join(dir, "package-lock.json"), "utf8"); } if (c.package) writeFileSync(join(dir, "package.json"), cliPackageText(c)); for (const [file, text] of Object.entries(c.files ?? {})) writeFileSync(join(dir, file), text); const options = { scripts, bin: c.bin ?? "npm" }; const res = await runNpm(dir, url, c.args, {}, configDir, options); const read = (file) => (existsSync(join(dir, file)) ? readFileSync(join(dir, file), "utf8") : null); const output = (r) => ({ status: r.status, stdout: stable(r.stdout, dir, url), stderr: stable(r.stderr, dir, url) }); Object.assign(npm, output(res)); if (scripts) { npm.npx = npxCache(configDir); if (c.again) { requests.length = 0; npm.again = { ...output(await runNpm(dir, url, c.args, {}, configDir, options)), requests: [...requests] }; } } else { Object.assign(npm, { packageJson: read("package.json"), lockfile: read("package-lock.json"), modules: installedPackages(dir) }); } c.npm = npm; console.log(`cli ${name}: ${options.bin} ${c.args.join(" ")} exited ${res.status}`); } finally { rmSync(base, { recursive: true, force: true, maxRetries: 3 }); } } writeFileSync(CLI_FILE, `${JSON.stringify(doc, null, 2)}\n`); } finally { await close(server); } } // --- CLI --------------------------------------------------------------- async function main() { const [mode, name] = process.argv.slice(2); if (mode === "cli") return await cli(name); if (!mode || !name || !["record", "replay", "fake"].includes(mode)) { console.error("usage: node scripts/npm-oracle.mjs , or cli [case]"); process.exit(1); } if (mode === "record") await record(name); else if (mode === "fake") await fake(name); else await replay(name); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(err.message ?? err); process.exit(1); }); }