aboutsummaryrefslogtreecommitdiff
path: root/test/bun.js/child_process-node.test.js
diff options
context:
space:
mode:
authorGravatar Derrick Farris <mr.dcfarris@gmail.com> 2022-11-06 15:43:42 -0600
committerGravatar GitHub <noreply@github.com> 2022-11-06 13:43:42 -0800
commitbe108c0fea8cef864c14edd048fde4e8c8f92760 (patch)
treed2f06704d9c5732b03705e6aa3e87e595390bf42 /test/bun.js/child_process-node.test.js
parent4e3fb8ed5bd8d2440513013c4e5483eda8437c5a (diff)
downloadbun-be108c0fea8cef864c14edd048fde4e8c8f92760.tar.gz
bun-be108c0fea8cef864c14edd048fde4e8c8f92760.tar.zst
bun-be108c0fea8cef864c14edd048fde4e8c8f92760.zip
feat(child_process): add node:child_process polyfill (#1424)
* feat(child_process): beginning of child_process, add ChildProcess and spawn base case * fix(child_process): remove invalid single arg array syntax (thanks Copilot) * refactor(child_process): unhack Readable.on, move stuff into node:stream * feat(child_process): add more params for spawn, refactor, add fromWeb() to Readable * feat(child_process): finish rest of exports (minus fork), refactor, add tests * cleanup(streams): remove a bunch of unnecessary stuff * cleanup(child_process): remove dead refs * fix(child_process): fix stdio * fix(child_process): change stdio to bunStdio * test(child_process): uncomment timeout test * test(child_process): fix hanging tests * test(child_process): remove stray console.log * test(child_process): fix cwd test for linux * refactor(child_process): divide paths for encoded vs raw execFile stdio * fix(child_process): fix logic for execFile slow path
Diffstat (limited to 'test/bun.js/child_process-node.test.js')
-rw-r--r--test/bun.js/child_process-node.test.js570
1 files changed, 570 insertions, 0 deletions
diff --git a/test/bun.js/child_process-node.test.js b/test/bun.js/child_process-node.test.js
new file mode 100644
index 000000000..10135affa
--- /dev/null
+++ b/test/bun.js/child_process-node.test.js
@@ -0,0 +1,570 @@
+import { describe, expect, it } from "bun:test";
+import { ChildProcess, spawn, exec } from "node:child_process";
+import { EOL } from "node:os";
+import assertNode from "node:assert";
+import { inspect } from "node:util";
+
+const debug = console.log;
+
+// 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 = {
+ // // TODO: Fix the implementations of these functions, they may be ruining everything...
+ // mustCallAtLeast: function mustCallAtLeast(callback) {
+ // return (...args) => {
+ // callback(...args);
+ // expect(true).toBe(true);
+ // };
+ // },
+ // mustCall: function mustCall(callback) {
+ // return (...args) => {
+ // callback(...args);
+ // expect(true).toBe(true);
+ // };
+ // },
+ pwdCommand: ["pwd", []],
+};
+
+const mustCallChecks = [];
+
+function runCallChecks(exitCode) {
+ if (exitCode !== 0) return;
+
+ const failed = mustCallChecks.filter(function (context) {
+ if ("minimum" in context) {
+ context.messageSegment = `at least ${context.minimum}`;
+ return context.actual < context.minimum;
+ }
+ context.messageSegment = `exactly ${context.exact}`;
+ return context.actual !== context.exact;
+ });
+
+ failed.forEach(function (context) {
+ console.log(
+ "Mismatched %s function calls. Expected %s, actual %d.",
+ context.name,
+ context.messageSegment,
+ context.actual
+ );
+ console.log(context.stack.split("\n").slice(2).join("\n"));
+ });
+
+ if (failed.length) process.exit(1);
+}
+
+function mustCall(fn, exact) {
+ return _mustCallInner(fn, exact, "exact");
+}
+
+function mustSucceed(fn, exact) {
+ return mustCall(function (err, ...args) {
+ assert.ifError(err);
+ if (typeof fn === "function") return fn.apply(this, args);
+ }, exact);
+}
+
+function mustCallAtLeast(fn, minimum) {
+ return _mustCallInner(fn, minimum, "minimum");
+}
+
+function _mustCallInner(fn, criteria = 1, field) {
+ if (process._exiting)
+ throw new Error("Cannot use common.mustCall*() in process exit handler");
+ if (typeof fn === "number") {
+ criteria = fn;
+ fn = noop;
+ } else if (fn === undefined) {
+ fn = noop;
+ }
+
+ if (typeof criteria !== "number")
+ throw new TypeError(`Invalid ${field} value: ${criteria}`);
+
+ const context = {
+ [field]: criteria,
+ actual: 0,
+ stack: inspect(new Error()),
+ name: fn.name || "<anonymous>",
+ };
+
+ // Add the exit listener only once to avoid listener leak warnings
+ if (mustCallChecks.length === 0) process.on("exit", runCallChecks);
+
+ mustCallChecks.push(context);
+
+ const _return = function () {
+ // eslint-disable-line func-style
+ context.actual++;
+ return fn.apply(this, arguments);
+ };
+ // Function instances have own properties that may be relevant.
+ // Let's replicate those properties to the returned function.
+ // Refs: https://tc39.es/ecma262/#sec-function-instances
+ Object.defineProperties(_return, {
+ name: {
+ value: fn.name,
+ writable: false,
+ enumerable: false,
+ configurable: true,
+ },
+ length: {
+ value: fn.length,
+ writable: false,
+ enumerable: false,
+ configurable: true,
+ },
+ });
+ return _return;
+}
+
+const strictEqual = (...args) => {
+ let error = null;
+ try {
+ assertNode.strictEqual(...args);
+ } catch (err) {
+ error = err;
+ }
+ expect(error).toBe(null);
+};
+
+const throws = (...args) => {
+ let error = null;
+ try {
+ assertNode.throws(...args);
+ } catch (err) {
+ error = err;
+ }
+ expect(error).toBe(null);
+};
+
+const assert = (...args) => {
+ let error = null;
+ try {
+ assertNode(...args);
+ } catch (err) {
+ error = err;
+ }
+ expect(error).toBe(null);
+};
+
+const assertOk = (...args) => {
+ let error = null;
+ try {
+ assertNode.ok(...args);
+ } catch (err) {
+ error = err;
+ }
+ expect(error).toBe(null);
+};
+
+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: "TypeError",
+ // 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: "TypeError",
+ // 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: "TypeError",
+ // 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: "TypeError",
+ // message:
+ // 'The "options.args" property must be an instance of Array.' +
+ // common.invalidArgTypeHelper(args),
+ }
+ );
+ });
+ });
+});
+
+describe("ChildProcess.spawn", () => {
+ const child = new ChildProcess();
+ child.spawn({
+ file: "bun",
+ // file: process.execPath,
+ args: ["--interactive"],
+ cwd: process.cwd(),
+ stdio: "pipe",
+ });
+
+ it("should spawn a process", () => {
+ // Test that we can call spawn
+
+ strictEqual(Object.hasOwn(child, "pid"), true);
+ assert(Number.isInteger(child.pid));
+ });
+
+ it("should throw error on invalid signal", () => {
+ // Try killing with invalid signal
+ throws(
+ () => {
+ child.kill("foo");
+ },
+ { code: "ERR_UNKNOWN_SIGNAL", name: "TypeError" }
+ );
+ });
+
+ it("should die when killed", () => {
+ strictEqual(child.kill(), true);
+ });
+});
+
+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.
+ const original = ChildProcess.prototype.spawn;
+
+ ChildProcess.prototype.spawn = function () {
+ const err = original.apply(this, arguments);
+
+ this.stdout.destroy();
+ this.stderr.destroy();
+ this.stdout = null;
+ this.stderr = null;
+
+ return err;
+ };
+
+ function createChild(options, callback) {
+ const cmd = `"${process.execPath}" "${import.meta.path}" child`;
+ return exec(cmd, options, mustCall(callback));
+ }
+
+ it("should handle normal execution of child process", () => {
+ createChild({}, (err, stdout, stderr) => {
+ strictEqual(err, null);
+ strictEqual(stdout, "");
+ strictEqual(stderr, "");
+ });
+ });
+
+ it("should handle error event of child process", () => {
+ const error = new Error("foo");
+ const child = createChild({}, (err, stdout, stderr) => {
+ strictEqual(err, error);
+ strictEqual(stdout, "");
+ strictEqual(stderr, "");
+ });
+
+ child.emit("error", error);
+ });
+
+ it("should handle killed process", () => {
+ createChild({ timeout: 1 }, (err, stdout, stderr) => {
+ strictEqual(err.killed, true);
+ strictEqual(stdout, "");
+ strictEqual(stderr, "");
+ });
+ });
+
+ ChildProcess.prototype.spawn = original;
+});
+
+describe("child_process cwd", () => {
+ const tmpdir = { path: Bun.env.TMPDIR };
+
+ // 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) {
+ 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", function (chunk) {
+ data += chunk;
+ });
+
+ // Can't assert callback, as stayed in to API:
+ // _The 'exit' event may or may not fire after an error has occurred._
+ child.on("exit", function (code, signal) {
+ strictEqual(code, expectCode).bind(this);
+ });
+
+ child.on(
+ "close",
+ mustCall(function () {
+ 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", () => {
+ // Assume these exist, and 'pwd' gives us the right directory back
+ testCwd({ cwd: tmpdir.path }, "number", 0, tmpdir.path);
+ const shouldExistDir = "/dev";
+ testCwd({ cwd: shouldExistDir }, "number", 0, shouldExistDir);
+ testCwd({ cwd: Bun.pathToFileURL(tmpdir.path) }, "number", 0, tmpdir.path);
+ });
+
+ it("shouldn't try to chdir to an invalid cwd", () => {
+ // Spawn() shouldn't try to chdir() to invalid arg, so this should just work
+ testCwd({ cwd: "" }, "number");
+ testCwd({ cwd: undefined }, "number");
+ testCwd({ cwd: null }, "number");
+ });
+});
+
+describe("child_process default options", () => {
+ process.env.HELLO = "WORLD";
+
+ let child = spawn("/usr/bin/env", [], {});
+ let response = "";
+
+ child.stdout.setEncoding("utf8");
+
+ it("should use process.env as default env", () => {
+ child.stdout.on("data", function (chunk) {
+ debug(`stdout: ${chunk}`);
+ response += chunk;
+ });
+
+ process.on("exit", function () {
+ assertOk(
+ response.includes("HELLO=WORLD"),
+ "spawn did not use process.env as default " +
+ `(process.env.HELLO = ${process.env.HELLO})`
+ );
+ });
+ });
+
+ delete process.env.HELLO;
+});
+
+describe("child_process double pipe", () => {
+ let grep, sed, echo;
+ grep = spawn("grep", ["o"]);
+ sed = spawn("sed", ["s/o/O/"]);
+ echo = spawn("echo", ["hello\nnode\nand\nworld\n"]);
+
+ it("should allow two pipes to be used at once", () => {
+ // pipe echo | grep
+ echo.stdout.on(
+ "data",
+ mustCallAtLeast((data) => {
+ debug(`grep stdin write ${data.length}`);
+ if (!grep.stdin.write(data)) {
+ 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", (data) => {
+ // echo.stdout.resume();
+ // });
+
+ // Propagate end from echo to grep
+ echo.stdout.on(
+ "end",
+ mustCall((code) => {
+ grep.stdin.end();
+ })
+ );
+
+ echo.on(
+ "exit",
+ mustCall(() => {
+ debug("echo exit");
+ })
+ );
+
+ grep.on(
+ "exit",
+ mustCall(() => {
+ debug("grep exit");
+ })
+ );
+
+ sed.on(
+ "exit",
+ mustCall(() => {
+ debug("sed exit");
+ })
+ );
+
+ // pipe grep | sed
+ grep.stdout.on(
+ "data",
+ mustCallAtLeast((data) => {
+ debug(`grep stdout ${data.length}`);
+ if (!sed.stdin.write(data)) {
+ grep.stdout.pause();
+ }
+ })
+ );
+
+ // // TODO(@jasnell): This does not appear to ever be
+ // // emitted. It's not clear if it is necessary.
+ // sed.stdin.on("drain", (data) => {
+ // grep.stdout.resume();
+ // });
+
+ // Propagate end from grep to sed
+ grep.stdout.on(
+ "end",
+ mustCall((code) => {
+ debug("grep stdout end");
+ sed.stdin.end();
+ })
+ );
+
+ let result = "";
+
+ // print sed's output
+ sed.stdout.on(
+ "data",
+ mustCallAtLeast((data) => {
+ result += data.toString("utf8", 0, data.length);
+ debug(data);
+ })
+ );
+
+ sed.stdout.on(
+ "end",
+ mustCall((code) => {
+ strictEqual(result, `hellO${EOL}nOde${EOL}wOrld${EOL}`);
+ })
+ );
+ });
+});