Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328// The guest npm command, run in the browser over the registry `npm-oracle.mjs cli` gave// real npm 11.7.0 (tests/fixtures/npm-cli.json), served through page.route. Each case is// held to what npm did: exit status, stdout and stderr (the timing replaced), the// package.json and package-lock.json it left and what node_modules holds. The script// cases (npm run, npm exec, npx) are held to npm's output and the npx cache it left.import { test, expect, type BrowserContext, type Page, type Route } from "@playwright/test";import type { Kernel } from "../src/host/index";import { cli, startingPackage, type CliCase, type ScriptCase } from "./npm-cli-cases";// @ts-expect-error: an untyped .mjs scriptimport { cliPackageText, cliRegistryResponse } from "../scripts/npm-oracle.mjs";
declare global { interface Window { __runtime?: Kernel }}
const CORS = { "access-control-allow-origin": "*" };
/** cliRegistryResponse's answer: its body is a Node Buffer, as route.fulfill takes. */type Hit = { contentType: string; body: NonNullable<Parameters<Route["fulfill"]>[0]>["body"] } | null;
/** Answer registry.npmjs.org from the cli registry; `gate` holds every answer until it settles. */async function routeRegistry(context: BrowserContext, gate?: Promise<void>): Promise<string[]> { const log: string[] = []; await context.route("https://registry.npmjs.org/**", async (route) => { const path = new URL(route.request().url()).pathname.slice(1); log.push(`/${path}`); await gate; const hit = cliRegistryResponse(cli.registry, path) as Hit; if (!hit) return route.fulfill({ status: 404, headers: CORS, contentType: "application/json", body: '{"error":"Not found"}' }); return route.fulfill({ headers: CORS, contentType: hit.contentType, body: hit.body }); }); return log;}
async function boot(page: Page): Promise<void> { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 });}
interface Run { status: number; stdout: string; stderr: string }
/** Run `entry` with `argv` in /app to its exit, its output collected. */async function run(page: Page, entry: string, argv: string[], env?: Record<string, string>): Promise<Run> { return await page.evaluate(async ({ entry, argv, env }) => { const proc = window.__runtime!.exec(entry, { argv, cwd: "/app", env }); const dec = new TextDecoder(); let stdout = ""; let stderr = ""; const out = proc.stdout.pipeTo(new WritableStream({ write(c) { stdout += dec.decode(c, { stream: true }); } })); const err = proc.stderr.pipeTo(new WritableStream({ write(c) { stderr += dec.decode(c, { stream: true }); } })); const status = await proc.exited; await Promise.all([out, err]); return { status, stdout, stderr }; }, { entry, argv, env });}
const npm = (page: Page, argv: string[]): Promise<Run> => run(page, "/usr/local/bin/npm", argv);
const timing = (text: string): string => text.replace(/ in \d+(?:\.\d+)?(?:ms|s|m|h)$/gm, " in {time}");
/** What the case left on disk, as npm-oracle.mjs records it. */async function project(page: Page): Promise<{ packageJson: string | null; lockfile: string | null; modules: string[] }> { return await page.evaluate(async () => { const fs = window.__runtime!.fs; const read = async (path: string) => { const bytes = await fs.readFile(path).catch(() => null); return bytes && new TextDecoder().decode(bytes as Uint8Array); }; const modules: string[] = []; const walk = async (dir: string): Promise<void> => { const entries = await fs.readdir(dir).catch(() => []); for (const entry of entries.map((e) => e.name).sort()) { if (entry.startsWith(".")) continue; const names = entry.startsWith("@") ? (await fs.readdir(`${dir}/${entry}`)).map((e) => `${entry}/${e.name}`).sort() : [entry]; for (const name of names) { const at = `${dir}/${name}`; modules.push(`${at.slice("/app/".length)}@${JSON.parse((await read(`${at}/package.json`))!).version}`); await walk(`${at}/node_modules`); } } }; await walk("/app/node_modules"); return { packageJson: await read("/app/package.json"), lockfile: await read("/app/package-lock.json"), modules }; });}
async function setUp(page: Page, c: CliCase): Promise<void> { if (c.seed) { await page.evaluate((text) => window.__runtime!.writeTree({ "/app/package.json": text }), cliPackageText(c, c.seed) as string); const seeded = await npm(page, ["install"]); expect(seeded.status, seeded.stderr).toBe(0); } const text = c.package && startingPackage(c); await page.evaluate((text) => window.__runtime!.writeTree(text ? { "/app/package.json": text } : {}), text);}
for (const [name, c] of Object.entries(cli.cases)) { test(`${name}: npm ${c.args.join(" ")} prints, exits and leaves what npm did (${c.description})`, async ({ page, context }) => { await routeRegistry(context); await boot(page); await setUp(page, c); const ran = await npm(page, c.args); expect({ ...ran, stdout: timing(ran.stdout), ...await project(page) }).toEqual({ status: c.npm.status, stdout: c.npm.stdout, stderr: c.npm.stderr, packageJson: c.npm.packageJson, lockfile: c.npm.lockfile, modules: c.npm.modules, }); });}
test("a guest script spawns npm install, which prints what npm did, and then requires what it installed", async ({ page, context }) => { await routeRegistry(context); await boot(page); await page.evaluate(() => window.__runtime!.writeTree({ "/app/package.json": '{\n "name": "app",\n "version": "1.0.0"\n}\n', "/app/add.cjs": ` const { spawn } = require("node:child_process"); const child = spawn("npm", ["install", "kit"]); let out = ""; child.stdout.on("data", (c) => out += c); child.on("close", (code) => console.log(JSON.stringify({ code, out, kit: require("kit"), zod: require("zod") }))); `, })); const ran = await run(page, "/app/add.cjs", []); expect([ran.status, ran.stderr]).toEqual([0, ""]); const { out, ...rest } = JSON.parse(ran.stdout) as { out: string }; expect({ out: timing(out), ...rest }).toEqual({ out: cli.cases["alias-add"]!.npm.stdout, code: 0, kit: "kit@1.0.0", zod: "zod@3.25.76" });});
test("npm with no command, an unknown one, and --version print what npm prints", async ({ page }) => { await boot(page); const [none, unknown, version, v] = [await npm(page, []), await npm(page, ["foo"]), await npm(page, ["--version"]), await npm(page, ["-v"])]; expect([none.status, none.stdout.split("\n")[0], none.stdout.trimEnd().split("\n").at(-1)]).toEqual([1, "npm <command>", "npm@11.7.0 /usr/local/lib/node_modules/npm"]); expect(unknown).toEqual({ status: 1, stdout: 'Unknown command: "foo"\n\nTo see a list of supported npm commands, run:\n npm help\n', stderr: "" }); expect([version, v]).toEqual([{ status: 0, stdout: "11.7.0\n", stderr: "" }, { status: 0, stdout: "11.7.0\n", stderr: "" }]);});
test("while one process waits on npm install's fetches, another's timers and HTTP server keep answering", async ({ page, context }) => { let release!: () => void; const gate = new Promise<void>((resolve) => (release = resolve)); const log = await routeRegistry(context, gate); await boot(page); await page.evaluate(() => window.__runtime!.writeTree({ "/app/package.json": '{\n "name": "app",\n "version": "1.0.0"\n}\n', "/app/server.cjs": ` let ticks = 0; setInterval(() => ticks++, 10); require("node:http").createServer((req, res) => res.end(String(ticks))).listen(3000); `, })); const url = await page.evaluate(async () => { window.__runtime!.exec("/app/server.cjs"); return await window.__runtime!.waitForPort(3000); }); const install = npm(page, ["install", "zod"]); await expect.poll(() => log.length).toBeGreaterThan(0); const ticks = await page.evaluate(async (url) => { const seen: number[] = []; for (let i = 0; i < 3; i++) { seen.push(Number(await (await fetch(url)).text())); await new Promise((r) => setTimeout(r, 100)); } return seen; }, url); expect(log).toEqual(["/zod"]); expect(ticks[2]! > ticks[1]! && ticks[1]! > ticks[0]!).toBe(true); release(); const done = await install; expect([done.status, timing(done.stdout)]).toEqual([0, cli.cases.add!.npm.stdout]);});
// ---- npm run, npm exec and npx ----
const HOME = "/home/user";
/** The guest's output as npm-oracle.mjs stores npm's: the project dir as {dir}. */const stable = (r: Run): Run => ({ status: r.status, stdout: r.stdout.replaceAll("/app", "{dir}"), stderr: r.stderr.replaceAll("/app", "{dir}") });
async function setUpScripts(page: Page, c: ScriptCase): Promise<void> { if (c.seed) { await page.evaluate((text) => window.__runtime!.writeTree({ "/app/package.json": text }), cliPackageText(c, c.seed) as string); const seeded = await npm(page, ["install"]); expect(seeded.status, seeded.stderr).toBe(0); } const tree = Object.fromEntries(Object.entries(c.files).map(([file, text]) => [`/app/${file}`, text])); if (c.package) tree["/app/package.json"] = cliPackageText(c) as string; await page.evaluate((tree) => window.__runtime!.writeTree(tree), tree);}
/** The npx cache under HOME, as npm-oracle.mjs records npm's. */async function npxCache(page: Page): Promise<ScriptCase["npm"]["npx"]> { return await page.evaluate(async (root) => { const fs = window.__runtime!.fs; const hashes = await fs.readdir(root).catch(() => null); if (!hashes) return null; const read = async (path: string) => { const bytes = await fs.readFile(path).catch(() => null); return bytes && new TextDecoder().decode(bytes as Uint8Array); }; const out: Record<string, { packageJson: string | null; lockfile: string | null }> = {}; for (const { name } of hashes.sort((a, b) => (a.name < b.name ? -1 : 1))) { out[name] = { packageJson: await read(`${root}/${name}/package.json`), lockfile: await read(`${root}/${name}/package-lock.json`) }; } return out; }, `${HOME}/.npm/_npx`);}
for (const [name, c] of Object.entries(cli.scripts)) { const bin = c.bin ?? "npm"; test(`${name}: ${bin} ${c.args.join(" ")} prints and exits as npm did (${c.description})`, async ({ page, context }) => { const log = await routeRegistry(context); await boot(page); await setUpScripts(page, c); const ran = await run(page, `/usr/local/bin/${bin}`, c.args, { HOME }); const { status, stdout, stderr, npx } = c.npm; expect({ ...stable(ran), npx: await npxCache(page) }).toEqual({ status, stdout, stderr, npx }); if (c.npm.again) { log.length = 0; const again = await run(page, `/usr/local/bin/${bin}`, c.args, { HOME }); const { requests, ...output } = c.npm.again; expect(stable(again)).toEqual(output); // npm asks for the packument again (prefer-online) and takes no tarball; the // kernel's registry keeps a packument for its max-age, so it may not ask at all. expect(requests.filter((r) => r.endsWith(".tgz"))).toEqual([]); expect(log.filter((r) => r.endsWith(".tgz"))).toEqual([]); } });}
test("a command that is neither node nor a bin fails as dash does, and the script goes on past ;", async ({ page }) => { await boot(page); await page.evaluate(() => window.__runtime!.writeTree({ "/app/package.json": JSON.stringify({ name: "app", version: "1.0.0", scripts: { go: "nosuch a; node ok.js\nnope", one: "nosuch" } }), "/app/ok.js": "console.log('ok');\n", })); expect(await npm(page, ["run", "go"])).toEqual({ status: 127, stdout: "\n> app@1.0.0 go\n> nosuch a; node ok.js\n> nope\n\nok\n", stderr: "sh: 1: nosuch: not found\nsh: 2: nope: not found\n", });});
test("a script using what only a shell has fails with ENOTSUP naming it", async ({ page }) => { await boot(page); await page.evaluate(() => window.__runtime!.writeTree({ "/app/package.json": JSON.stringify({ name: "app", version: "1.0.0", scripts: { pipe: "node a.js | grep x", sh: "./tool.sh" } }), "/app/tool.sh": "#!/bin/sh\necho hi\n", })); expect(await npm(page, ["run", "pipe"])).toEqual({ status: 1, stdout: "\n> app@1.0.0 pipe\n> node a.js | grep x\n\n", stderr: "npm error code ENOTSUP\nnpm error This runtime has no shell, so a script runs only simple commands joined by &&, || and ;\nnpm error `node a.js | grep x` uses a pipe (|)\n", }); expect((await npm(page, ["run", "sh"])).stderr).toBe("npm error code ENOTSUP\nnpm error ./tool.sh is a script for `/bin/sh`; this runtime runs node and nothing else\n");});
test("a CLI spawns npm run, whose script runs a dependency's bin, and reads what it printed", async ({ page, context }) => { await routeRegistry(context); await boot(page); await page.evaluate((text) => window.__runtime!.writeTree({ "/app/package.json": text }), cliPackageText(cli.scripts.bin!, cli.scripts.bin!.seed) as string); expect((await npm(page, ["install"])).status).toBe(0); await page.evaluate(() => window.__runtime!.writeTree({ "/app/cli.cjs": ` const { spawn } = require("node:child_process"); const child = spawn("npm", ["run", "hi"]); let out = ""; child.stdout.on("data", (c) => out += c); child.on("close", (code) => console.log(JSON.stringify({ code, out }))); `, })); const ran = await run(page, "/app/cli.cjs", []); expect([ran.status, ran.stderr, JSON.parse(ran.stdout)]).toEqual([0, "", { code: 0, out: cli.scripts.bin!.npm.stdout }]);});
test("a guest script spawns npx, which fills the npx cache; a second npx runs the bin from it without a tarball", async ({ page, context }) => { const log = await routeRegistry(context); await boot(page); await page.evaluate(() => window.__runtime!.writeTree({ "/app/package.json": '{\n "name": "app",\n "version": "1.0.0"\n}\n', "/app/twice.cjs": ` const { spawn } = require("node:child_process"); const npx = () => new Promise((done) => { const child = spawn("npx", ["--yes", "hello", "x"]); let out = ""; child.stdout.on("data", (c) => out += c); child.on("close", (code) => done({ code, out })); }); npx().then(async (first) => console.log(JSON.stringify([first, await npx()]))); `, })); const ran = await run(page, "/app/twice.cjs", [], { HOME }); const out = { code: 0, out: cli.scripts["npx-yes"]!.npm.stdout }; expect([ran.status, ran.stderr, JSON.parse(ran.stdout)]).toEqual([0, "", [out, out]]); expect(log.filter((r) => r.endsWith(".tgz"))).toEqual(["/hello/-/hello-1.0.0.tgz"]); expect(await npxCache(page)).toEqual(cli.scripts["npx-yes"]!.npm.npx);});
test("SIGTERM to npm reaches the script's process; npm exits as it did, or dies of the signal it died of", async ({ page }) => { await boot(page); const result = await page.evaluate(async () => { const runtime = window.__runtime!; runtime.writeTree({ "/app/package.json": JSON.stringify({ name: "app", version: "1.0.0", scripts: { handled: "node srv.js 4401 handle", killed: "node srv.js 4402" } }), "/app/srv.js": ` const [port, handle] = process.argv.slice(2); if (handle) process.on("SIGTERM", () => { console.log("child got SIGTERM"); process.exit(0); }); require("node:http").createServer((q, s) => s.end()).listen(Number(port)); `, }); const runUntilKilled = async (script: string, port: number) => { const proc = runtime.exec("/usr/local/bin/npm", { argv: ["run", script], cwd: "/app" }); let stdout = ""; const dec = new TextDecoder(); const out = proc.stdout.pipeTo(new WritableStream({ write(c) { stdout += dec.decode(c, { stream: true }); } })); await runtime.waitForPort(port); proc.kill("SIGTERM"); const status = await proc.exited; await out; return { status, last: stdout.trimEnd().split("\n").at(-1) }; }; return [await runUntilKilled("handled", 4401), await runUntilKilled("killed", 4402)]; }); expect(result).toEqual([{ status: 0, last: "child got SIGTERM" }, { status: 143, last: "> node srv.js 4402" }]);});