Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688#!/usr/bin/env node// Record/replay oracle for the lockfile-less npm resolver (src/kernel/npm).//// `record <case>` 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/<case>/ as recording.json.gz plus the resulting// package-lock.json. `replay <case>` 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 <case>` serves the in-memory registry of tests/fixtures/fake/<case>.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<url, { status, contentType, body: Uint8Array }> 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 /<name>` with its packument, `GET /<name>/<version>` with that version's * document and `GET /<name>/-/<file>.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 <args>`. 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 <record|replay|fake> <case>, 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); });}