import { ChildProcess, spawn, exec } from "node:child_process"; import { createTest } from "node-harness"; import { tmpdir } from "node:os"; const { beforeAll, describe, expect, it, throws, assert, createCallCheckCtx, createDoneDotAll } = createTest( import.meta.path, ); const strictEqual = (a, b) => expect(a).toStrictEqual(b); const debug = process.env.DEBUG ? console.log : () => {}; const platformTmpDir = require("fs").realpathSync(tmpdir()); const TYPE_ERR_NAME = "TypeError"; // Copyright Joyent, Inc. and other Node contributors. // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the // "Software"), to deal in the Software without restriction, including // without limitation the rights to use, copy, modify, merge, publish, // distribute, sublicense, and/or sell copies of the Software, and to permit // persons to whom the Software is furnished to do so, subject to the // following conditions: // // The above copyright notice and this permission notice shall be included // in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. const common = { pwdCommand: ["pwd", []], }; describe("ChildProcess.constructor", () => { it("should be a function", () => { strictEqual(typeof ChildProcess, "function"); }); }); describe("ChildProcess.spawn()", () => { it("should throw on invalid options", () => { // Verify that invalid options to spawn() throw. const child = new ChildProcess(); [undefined, null, "foo", 0, 1, NaN, true, false].forEach(options => { throws( () => { child.spawn(options); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options" argument must be of type object.' + // `${common.invalidArgTypeHelper(options)}`, }, ); }); }); it("should throw if file is not a string", () => { // Verify that spawn throws if file is not a string. const child = new ChildProcess(); [undefined, null, 0, 1, NaN, true, false, {}].forEach(file => { throws( () => { child.spawn({ file }); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options.file" property must be of type string.' + // `${common.invalidArgTypeHelper(file)}`, }, ); }); }); it("should throw if envPairs is not an array or undefined", () => { // Verify that spawn throws if envPairs is not an array or undefined. const child = new ChildProcess(); [null, 0, 1, NaN, true, false, {}, "foo"].forEach(envPairs => { throws( () => { child.spawn({ envPairs, stdio: ["ignore", "ignore", "ignore", "ipc"], }); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options.envPairs" property must be an instance of Array.' + // common.invalidArgTypeHelper(envPairs), }, ); }); }); it("should throw if stdio is not an array or undefined", () => { // Verify that spawn throws if args is not an array or undefined. const child = new ChildProcess(); [null, 0, 1, NaN, true, false, {}, "foo"].forEach(args => { throws( () => { child.spawn({ file: "foo", args }); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options.args" property must be an instance of Array.' + // common.invalidArgTypeHelper(args), }, ); }); }); }); describe("ChildProcess.spawn", () => { function getChild() { const child = new ChildProcess(); child.spawn({ file: "node", // file: process.execPath, args: ["node", "--interactive"], cwd: process.cwd(), stdio: ["ignore", "ignore", "ignore"], }); return child; } it("should spawn a process", () => { const child = getChild(); // Test that we can call spawn strictEqual(Object.hasOwn(child, "pid"), true); assert(Number.isInteger(child.pid)); child.kill(); }); it("should throw error on invalid signal", () => { const child = getChild(); // Try killing with invalid signal throws( () => { child.kill("foo"); }, { code: "ERR_UNKNOWN_SIGNAL", name: TYPE_ERR_NAME }, ); }); }); describe("ChildProcess spawn bad stdio", () => { // Monkey patch spawn() to create a child process normally, but destroy the // stdout and stderr streams. This replicates the conditions where the streams // cannot be properly created. function createChild(options, callback, done, target) { var __originalSpawn = ChildProcess.prototype.spawn; ChildProcess.prototype.spawn = function () { const err = __originalSpawn.apply(this, arguments); this.stdout.destroy(); this.stderr.destroy(); return err; }; const { mustCall } = createCallCheckCtx(done); let cmd = `bun ${import.meta.dir}/spawned-child.js`; if (target) cmd += " " + target; const child = exec(cmd, options, mustCall(callback)); ChildProcess.prototype.spawn = __originalSpawn; return child; } it("should handle normal execution of child process", done => { createChild( {}, (err, stdout, stderr) => { strictEqual(err, null); strictEqual(stdout, ""); strictEqual(stderr, ""); }, done, ); }); it("should handle error event of child process", done => { const error = new Error(`Command failed: bun ${import.meta.dir}/spawned-child.js ERROR`); createChild( {}, (err, stdout, stderr) => { strictEqual(err.message, error.message); strictEqual(stdout, ""); strictEqual(stderr, ""); }, done, "ERROR", ); }); it("should handle killed process", done => { createChild( { timeout: 1 }, (err, stdout, stderr) => { strictEqual(err.killed, true); strictEqual(stdout, ""); strictEqual(stderr, ""); }, done, ); }); }); describe("child_process cwd", () => { // Spawns 'pwd' with given options, then test // - whether the child pid is undefined or number, // - whether the exit code equals expectCode, // - optionally whether the trimmed stdout result matches expectData function testCwd(options, { expectPidType, expectCode = 0, expectData }, done = () => {}) { const createDone = createDoneDotAll(done); const { mustCall } = createCallCheckCtx(createDone(1500)); const child = spawn(...common.pwdCommand, options); strictEqual(typeof child.pid, expectPidType); child.stdout.setEncoding("utf8"); // No need to assert callback since `data` is asserted. let data = ""; child.stdout.on("data", chunk => { data += chunk; }); child.stdout.on( "close", mustCall(() => { expectData && strictEqual(data.trim(), expectData); }), ); return child; } // TODO: Make sure this isn't important // Currently Bun.spawn will still spawn even though cwd doesn't exist // // Assume does-not-exist doesn't exist, expect exitCode=-1 and errno=ENOENT // it("should throw an error when given cwd doesn't exist", () => { // testCwd({ cwd: "does-not-exist" }, "undefined", -1).on( // "error", // mustCall(function (e) { // console.log(e); // strictEqual(e.code, "ENOENT"); // }) // ); // }); // TODO: Make sure this isn't an important test // it("should throw when cwd is a non-file url", () => { // throws(() => { // testCwd( // { // cwd: new URL("http://example.com/"), // }, // "number", // 0, // tmpdir.path // ); // }, /The URL must be of scheme file/); // // if (process.platform !== "win32") { // // throws(() => { // // testCwd( // // { // // cwd: new URL("file://host/dev/null"), // // }, // // "number", // // 0, // // tmpdir.path // // ); // // }, /File URL host must be "localhost" or empty on/); // // } // }); // it("should work for valid given cwd", done => { // const tmpdir = { path: platformTmpDir }; // const createDone = createDoneDotAll(done); // // Assume these exist, and 'pwd' gives us the right directory back // testCwd( // { cwd: tmpdir.path }, // { // expectPidType: "number", // expectCode: 0, // expectData: platformTmpDir, // }, // createDone(1500), // ); // const shouldExistDir = "/dev"; // testCwd( // { cwd: shouldExistDir }, // { // expectPidType: "number", // expectCode: 0, // expectData: shouldExistDir, // }, // createDone(1500), // ); // testCwd( // { cwd: Bun.pathToFileURL(tmpdir.path) }, // { // expectPidType: "number", // expectCode: 0, // expectData: platformTmpDir, // }, // createDone(1500), // ); // }); //TODO: on macOS M1 { errno: -32, code: 'EPIPE', syscall: 'write' } it.skip("shouldn't try to chdir to an invalid cwd", done => { const createDone = createDoneDotAll(done); // Spawn() shouldn't try to chdir() to invalid arg, so this should just work testCwd({ cwd: "" }, { expectPidType: "number" }, createDone(1500)); testCwd({ cwd: undefined }, { expectPidType: "number" }, createDone(1500)); testCwd({ cwd: null }, { expectPidType: "number" }, createDone(1500)); }); }); describe("child_process default options", () => { it("should use process.env as default env", done => { globalThis.process.env.TMPDIR = platformTmpDir; let child = spawn("printenv", [], {}); let response = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", chunk => { debug(`stdout: ${chunk}`); response += chunk; }); // NOTE: Original test used child.on("exit"), but this is unreliable // because the process can exit before the stream is closed and the data is read child.stdout.on("close", () => { expect(response.includes(`TMPDIR=${platformTmpDir}`)).toBe(true); done(); }); }); }); // describe("child_process double pipe", () => { // it("should allow two pipes to be used at once", done => { // const createDone = createDoneDotAll(done); // const { mustCall, mustCallAtLeast } = createCallCheckCtx(createDone(3000)); // const sedExitDone = createDone(3000); // const echoExitDone = createDone(3000); // const grepExitDone = createDone(3000); // let grep, sed, echo; // grep = spawn("grep", ["o"], { stdio: ["pipe", "pipe", "pipe"] }); // sed = spawn("sed", ["s/o/O/"]); // echo = spawn("echo", ["hello\nnode\nand\nworld\n"]); // // pipe grep | sed // grep.stdout.on( // "data", // mustCallAtLeast(data => { // console.log(`grep stdout ${data.length}`); // if (!sed.stdin.write(data)) { // grep.stdout.pause(); // } // }), // ); // // print sed's output // sed.stdout.on( // "data", // mustCallAtLeast(data => { // result += data.toString("utf8"); // debug(data); // }), // ); // echo.stdout.on( // "data", // mustCallAtLeast(data => { // debug(`grep stdin write ${data.length}`); // if (!grep.stdin.write(data)) { // debug("echo stdout pause"); // echo.stdout.pause(); // } // }), // ); // // TODO(Derrick): We don't implement the full API for this yet, // // So stdin has no 'drain' event. // // TODO(@jasnell): This does not appear to ever be // // emitted. It's not clear if it is necessary. // grep.stdin.on("drain", () => { // debug("echo stdout resume"); // echo.stdout.resume(); // }); // // Propagate end from echo to grep // echo.stdout.on( // "end", // mustCall(() => { // debug("echo stdout end"); // grep.stdin.end(); // }), // ); // echo.on( // "exit", // mustCall(() => { // debug("echo exit"); // echoExitDone(); // }), // ); // grep.on( // "exit", // mustCall(() => { // debug("grep exit"); // grepExitDone(); // }), // ); // sed.stdout.on( // "close", // mustCall(() => { // debug("sed exit"); // }), // ); // // TODO(@jasnell): This does not appear to ever be // // emitted. It's not clear if it is necessary. // sed.stdin.on("drain", () => { // grep.stdout.resume(); // }); // // Propagate end from grep to sed // grep.stdout.on( // "end", // mustCall(() => { // debug("grep stdout end"); // sed.stdin.end(); // }), // ); // let result = ""; // sed.stdout.on( // "end", // mustCall(() => { // debug("result: " + result); // strictEqual(result, `hellO\nnOde\nwOrld\n`); // sedExitDone(); // }), // ); // }); // });