deno/cli/tests/unit_node/child_process_test.ts
Matt Mastracci ffbb1bad03
chore(cli): Use @test_util for relative path for unit tests (#22327)
This removes the majority of `../../../../../../test_util` relative
imports from the codebase, allowing us to move this code more easily in
the future.
2024-02-07 09:51:28 -07:00

774 lines
21 KiB
TypeScript

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import CP from "node:child_process";
import { Buffer } from "node:buffer";
import {
assert,
assertEquals,
assertExists,
assertNotStrictEquals,
assertStrictEquals,
assertStringIncludes,
} from "@test_util/std/assert/mod.ts";
import * as path from "@test_util/std/path/mod.ts";
const { spawn, spawnSync, execFile, execFileSync, ChildProcess } = CP;
function withTimeout<T>(
timeoutInMS = 10_000,
): ReturnType<typeof Promise.withResolvers<T>> {
const deferred = Promise.withResolvers<T>();
const timer = setTimeout(() => {
deferred.reject("Timeout");
}, timeoutInMS);
deferred.promise.then(() => {
clearTimeout(timer);
});
return deferred;
}
// TODO(uki00a): Once Node.js's `parallel/test-child-process-spawn-error.js` works, this test case should be removed.
Deno.test("[node/child_process spawn] The 'error' event is emitted when no binary is found", async () => {
const deferred = withTimeout<void>();
const childProcess = spawn("no-such-cmd");
childProcess.on("error", (_err: Error) => {
// TODO(@bartlomieju) Assert an error message.
deferred.resolve();
});
await deferred.promise;
});
Deno.test("[node/child_process spawn] The 'exit' event is emitted with an exit code after the child process ends", async () => {
const deferred = withTimeout<void>();
const childProcess = spawn(Deno.execPath(), ["--help"], {
env: { NO_COLOR: "true" },
});
try {
let exitCode = null;
childProcess.on("exit", (code: number) => {
deferred.resolve();
exitCode = code;
});
await deferred.promise;
assertStrictEquals(exitCode, 0);
assertStrictEquals(childProcess.exitCode, exitCode);
} finally {
childProcess.kill();
childProcess.stdout?.destroy();
childProcess.stderr?.destroy();
}
});
Deno.test("[node/child_process disconnect] the method exists", async () => {
const deferred = withTimeout<void>();
const childProcess = spawn(Deno.execPath(), ["--help"], {
env: { NO_COLOR: "true" },
});
try {
childProcess.disconnect();
childProcess.on("exit", () => {
deferred.resolve();
});
await deferred.promise;
} finally {
childProcess.kill();
childProcess.stdout?.destroy();
childProcess.stderr?.destroy();
}
});
Deno.test({
name: "[node/child_process spawn] Verify that stdin and stdout work",
fn: async () => {
const deferred = withTimeout<void>();
const childProcess = spawn(Deno.execPath(), ["fmt", "-"], {
env: { NO_COLOR: "true" },
stdio: ["pipe", "pipe"],
});
try {
assert(childProcess.stdin, "stdin should be defined");
assert(childProcess.stdout, "stdout should be defined");
let data = "";
childProcess.stdout.on("data", (chunk) => {
data += chunk;
});
childProcess.stdin.write(" console.log('hello')", "utf-8");
childProcess.stdin.end();
childProcess.on("close", () => {
deferred.resolve();
});
await deferred.promise;
assertStrictEquals(data, `console.log("hello");\n`);
} finally {
childProcess.kill();
}
},
});
Deno.test({
name: "[node/child_process spawn] stdin and stdout with binary data",
fn: async () => {
const deferred = withTimeout<void>();
const p = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"./testdata/binary_stdio.js",
);
const childProcess = spawn(Deno.execPath(), ["run", p], {
env: { NO_COLOR: "true" },
stdio: ["pipe", "pipe"],
});
try {
assert(childProcess.stdin, "stdin should be defined");
assert(childProcess.stdout, "stdout should be defined");
let data: Buffer;
childProcess.stdout.on("data", (chunk) => {
data = chunk;
});
const buffer = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
childProcess.stdin.write(buffer);
childProcess.stdin.end();
childProcess.on("close", () => {
deferred.resolve();
});
await deferred.promise;
assertEquals(new Uint8Array(data!), buffer);
} finally {
childProcess.kill();
}
},
});
async function spawnAndGetEnvValue(
inputValue: string | number | boolean,
): Promise<string> {
const deferred = withTimeout<string>();
const env = spawn(
`"${Deno.execPath()}" eval -p "Deno.env.toObject().BAZ"`,
{
env: { BAZ: String(inputValue), NO_COLOR: "true" },
shell: true,
},
);
try {
let envOutput = "";
assert(env.stdout);
env.on("error", (err: Error) => deferred.reject(err));
env.stdout.on("data", (data) => {
envOutput += data;
});
env.on("close", () => {
deferred.resolve(envOutput.trim());
});
return await deferred.promise;
} finally {
env.kill();
}
}
Deno.test({
ignore: Deno.build.os === "windows",
name:
"[node/child_process spawn] Verify that environment values can be numbers",
async fn() {
const envOutputValue = await spawnAndGetEnvValue(42);
assertStrictEquals(envOutputValue, "42");
},
});
Deno.test({
ignore: Deno.build.os === "windows",
name:
"[node/child_process spawn] Verify that environment values can be booleans",
async fn() {
const envOutputValue = await spawnAndGetEnvValue(false);
assertStrictEquals(envOutputValue, "false");
},
});
/* Start of ported part */
// Copyright Joyent and Node contributors. All rights reserved. MIT license.
// Ported from Node 15.5.1
// TODO(uki00a): Remove this case once Node's `parallel/test-child-process-spawn-event.js` works.
Deno.test("[child_process spawn] 'spawn' event", async () => {
const timeout = withTimeout<void>();
const subprocess = spawn(Deno.execPath(), ["eval", "console.log('ok')"]);
let didSpawn = false;
subprocess.on("spawn", function () {
didSpawn = true;
});
function mustNotBeCalled() {
timeout.reject(new Error("function should not have been called"));
}
const promises = [] as Promise<void>[];
function mustBeCalledAfterSpawn() {
const deferred = Promise.withResolvers<void>();
promises.push(deferred.promise);
return () => {
if (didSpawn) {
deferred.resolve();
} else {
deferred.reject(
new Error("function should be called after the 'spawn' event"),
);
}
};
}
subprocess.on("error", mustNotBeCalled);
subprocess.stdout!.on("data", mustBeCalledAfterSpawn());
subprocess.stdout!.on("end", mustBeCalledAfterSpawn());
subprocess.stdout!.on("close", mustBeCalledAfterSpawn());
subprocess.stderr!.on("data", mustNotBeCalled);
subprocess.stderr!.on("end", mustBeCalledAfterSpawn());
subprocess.stderr!.on("close", mustBeCalledAfterSpawn());
subprocess.on("exit", mustBeCalledAfterSpawn());
subprocess.on("close", mustBeCalledAfterSpawn());
try {
await Promise.race([Promise.all(promises), timeout.promise]);
timeout.resolve();
} finally {
subprocess.kill();
}
});
// TODO(uki00a): Remove this case once Node's `parallel/test-child-process-spawn-shell.js` works.
Deno.test("[child_process spawn] Verify that a shell is executed", async () => {
const deferred = withTimeout<void>();
const doesNotExist = spawn("does-not-exist", { shell: true });
try {
assertNotStrictEquals(doesNotExist.spawnfile, "does-not-exist");
doesNotExist.on("error", () => {
deferred.reject("The 'error' event must not be emitted.");
});
doesNotExist.on("exit", (code: number, signal: null) => {
assertStrictEquals(signal, null);
if (Deno.build.os === "windows") {
assertStrictEquals(code, 1); // Exit code of cmd.exe
} else {
assertStrictEquals(code, 127); // Exit code of /bin/sh });
}
deferred.resolve();
});
await deferred.promise;
} finally {
doesNotExist.kill();
doesNotExist.stdout?.destroy();
doesNotExist.stderr?.destroy();
}
});
// TODO(uki00a): Remove this case once Node's `parallel/test-child-process-spawn-shell.js` works.
Deno.test({
ignore: Deno.build.os === "windows",
name: "[node/child_process spawn] Verify that passing arguments works",
async fn() {
const deferred = withTimeout<void>();
const echo = spawn("echo", ["foo"], {
shell: true,
});
let echoOutput = "";
try {
assertStrictEquals(
echo.spawnargs[echo.spawnargs.length - 1].replace(/"/g, ""),
"echo foo",
);
assert(echo.stdout);
echo.stdout.on("data", (data) => {
echoOutput += data;
});
echo.on("close", () => {
assertStrictEquals(echoOutput.trim(), "foo");
deferred.resolve();
});
await deferred.promise;
} finally {
echo.kill();
}
},
});
// TODO(uki00a): Remove this case once Node's `parallel/test-child-process-spawn-shell.js` works.
Deno.test({
ignore: Deno.build.os === "windows",
name: "[node/child_process spawn] Verity that shell features can be used",
async fn() {
const deferred = withTimeout<void>();
const cmd = "echo bar | cat";
const command = spawn(cmd, {
shell: true,
});
try {
let commandOutput = "";
assert(command.stdout);
command.stdout.on("data", (data) => {
commandOutput += data;
});
command.on("close", () => {
assertStrictEquals(commandOutput.trim(), "bar");
deferred.resolve();
});
await deferred.promise;
} finally {
command.kill();
}
},
});
// TODO(uki00a): Remove this case once Node's `parallel/test-child-process-spawn-shell.js` works.
Deno.test({
ignore: Deno.build.os === "windows",
name:
"[node/child_process spawn] Verity that environment is properly inherited",
async fn() {
const deferred = withTimeout<void>();
const env = spawn(
`"${Deno.execPath()}" eval -p "Deno.env.toObject().BAZ"`,
{
env: { BAZ: "buzz", NO_COLOR: "true" },
shell: true,
},
);
try {
let envOutput = "";
assert(env.stdout);
env.on("error", (err: Error) => deferred.reject(err));
env.stdout.on("data", (data) => {
envOutput += data;
});
env.on("close", () => {
assertStrictEquals(envOutput.trim(), "buzz");
deferred.resolve();
});
await deferred.promise;
} finally {
env.kill();
}
},
});
/* End of ported part */
Deno.test({
name: "[node/child_process execFile] Get stdout as a string",
async fn() {
let child: unknown;
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"./testdata/exec_file_text_output.js",
);
const promise = new Promise<string | null>((resolve, reject) => {
child = execFile(Deno.execPath(), ["run", script], (err, stdout) => {
if (err) reject(err);
else if (stdout) resolve(stdout as string);
else resolve(null);
});
});
try {
const stdout = await promise;
assertEquals(stdout, "Hello World!\n");
} finally {
if (child instanceof ChildProcess) {
child.kill();
}
}
},
});
Deno.test({
name: "[node/child_process execFile] Get stdout as a buffer",
async fn() {
let child: unknown;
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"./testdata/exec_file_text_output.js",
);
const promise = new Promise<Buffer | null>((resolve, reject) => {
child = execFile(
Deno.execPath(),
["run", script],
{ encoding: "buffer" },
(err, stdout) => {
if (err) reject(err);
else if (stdout) resolve(stdout as Buffer);
else resolve(null);
},
);
});
try {
const stdout = await promise;
assert(Buffer.isBuffer(stdout));
assertEquals(stdout.toString("utf8"), "Hello World!\n");
} finally {
if (child instanceof ChildProcess) {
child.kill();
}
}
},
});
Deno.test({
name: "[node/child_process execFile] Get stderr",
async fn() {
let child: unknown;
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"./testdata/exec_file_text_error.js",
);
const promise = new Promise<
{ err: Error | null; stderr?: string | Buffer }
>((resolve) => {
child = execFile(Deno.execPath(), ["run", script], (err, _, stderr) => {
resolve({ err, stderr });
});
});
try {
const { err, stderr } = await promise;
if (child instanceof ChildProcess) {
assertEquals(child.exitCode, 1);
assertEquals(stderr, "yikes!\n");
} else {
throw err;
}
} finally {
if (child instanceof ChildProcess) {
child.kill();
}
}
},
});
Deno.test({
name: "[node/child_process execFile] Exceed given maxBuffer limit",
async fn() {
let child: unknown;
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"./testdata/exec_file_text_error.js",
);
const promise = new Promise<
{ err: Error | null; stderr?: string | Buffer }
>((resolve) => {
child = execFile(Deno.execPath(), ["run", script], {
encoding: "buffer",
maxBuffer: 3,
}, (err, _, stderr) => {
resolve({ err, stderr });
});
});
try {
const { err, stderr } = await promise;
if (child instanceof ChildProcess) {
assert(err);
assertEquals(
// deno-lint-ignore no-explicit-any
(err as any).code,
"ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
);
assertEquals(err.message, "stderr maxBuffer length exceeded");
assertEquals((stderr as Buffer).toString("utf8"), "yik");
} else {
throw err;
}
} finally {
if (child instanceof ChildProcess) {
child.kill();
}
}
},
});
Deno.test({
name: "[node/child_process] ChildProcess.kill()",
async fn() {
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"./testdata/infinite_loop.js",
);
const childProcess = spawn(Deno.execPath(), ["run", script]);
const p = withTimeout<void>();
const pStdout = withTimeout<void>();
const pStderr = withTimeout<void>();
childProcess.on("exit", () => p.resolve());
childProcess.stdout.on("close", () => pStdout.resolve());
childProcess.stderr.on("close", () => pStderr.resolve());
childProcess.kill("SIGKILL");
await p.promise;
await pStdout.promise;
await pStderr.promise;
assert(childProcess.killed);
assertEquals(childProcess.signalCode, "SIGKILL");
assertExists(childProcess.exitCode);
},
});
Deno.test({
ignore: true,
name: "[node/child_process] ChildProcess.unref()",
async fn() {
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"testdata",
"child_process_unref.js",
);
const childProcess = spawn(Deno.execPath(), [
"run",
"-A",
"--unstable",
script,
]);
const deferred = Promise.withResolvers<void>();
childProcess.on("exit", () => deferred.resolve());
await deferred.promise;
},
});
Deno.test({
ignore: true,
name: "[node/child_process] child_process.fork",
async fn() {
const testdataDir = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"testdata",
);
const script = path.join(
testdataDir,
"node_modules",
"foo",
"index.js",
);
const p = Promise.withResolvers<void>();
const cp = CP.fork(script, [], { cwd: testdataDir, stdio: "pipe" });
let output = "";
cp.on("close", () => p.resolve());
cp.stdout?.on("data", (data) => {
output += data;
});
await p.promise;
assertEquals(output, "foo\ntrue\ntrue\ntrue\n");
},
});
Deno.test("[node/child_process execFileSync] 'inherit' stdout and stderr", () => {
execFileSync(Deno.execPath(), ["--help"], { stdio: "inherit" });
});
Deno.test(
"[node/child_process spawn] supports windowsVerbatimArguments option",
{ ignore: Deno.build.os !== "windows" },
async () => {
const cmdFinished = Promise.withResolvers<void>();
let output = "";
const cp = spawn("cmd", ["/d", "/s", "/c", '"deno ^"--version^""'], {
stdio: "pipe",
windowsVerbatimArguments: true,
});
cp.on("close", () => cmdFinished.resolve());
cp.stdout?.on("data", (data) => {
output += data;
});
await cmdFinished.promise;
assertStringIncludes(output, "deno");
assertStringIncludes(output, "v8");
assertStringIncludes(output, "typescript");
},
);
Deno.test(
"[node/child_process spawn] supports stdio array option",
async () => {
const cmdFinished = Promise.withResolvers<void>();
let output = "";
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"testdata",
"child_process_stdio.js",
);
const cp = spawn(Deno.execPath(), ["run", "-A", script]);
cp.stdout?.on("data", (data) => {
output += data;
});
cp.on("close", () => cmdFinished.resolve());
await cmdFinished.promise;
assertStringIncludes(output, "foo");
assertStringIncludes(output, "close");
},
);
Deno.test(
"[node/child_process spawn] supports stdio [0, 1, 2] option",
async () => {
const cmdFinished = Promise.withResolvers<void>();
let output = "";
const script = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"testdata",
"child_process_stdio_012.js",
);
const cp = spawn(Deno.execPath(), ["run", "-A", script]);
cp.stdout?.on("data", (data) => {
output += data;
});
cp.on("close", () => cmdFinished.resolve());
await cmdFinished.promise;
assertStringIncludes(output, "foo");
assertStringIncludes(output, "close");
},
);
Deno.test({
name: "[node/child_process spawn] supports SIGIOT signal",
ignore: Deno.build.os === "windows",
async fn() {
// Note: attempting to kill Deno with SIGABRT causes the process to zombify on certain OSX builds
// eg: 22.5.0 Darwin Kernel Version 22.5.0: Mon Apr 24 20:53:19 PDT 2023; root:xnu-8796.121.2~5/RELEASE_ARM64_T6020 arm64
// M2 Pro running Ventura 13.4
// Spawn an infinite cat
const cp = spawn("cat", ["-"]);
const p = withTimeout<void>();
const pStdout = withTimeout<void>();
const pStderr = withTimeout<void>();
cp.on("exit", () => p.resolve());
cp.stdout.on("close", () => pStdout.resolve());
cp.stderr.on("close", () => pStderr.resolve());
cp.kill("SIGIOT");
await p.promise;
await pStdout.promise;
await pStderr.promise;
assert(cp.killed);
assertEquals(cp.signalCode, "SIGIOT");
},
});
// Regression test for https://github.com/denoland/deno/issues/20373
Deno.test(async function undefinedValueInEnvVar() {
const deferred = withTimeout<string>();
const env = spawn(
`"${Deno.execPath()}" eval -p "Deno.env.toObject().BAZ"`,
{
env: {
BAZ: "BAZ",
NO_COLOR: "true",
UNDEFINED_ENV: undefined,
// deno-lint-ignore no-explicit-any
NULL_ENV: null as any,
},
shell: true,
},
);
try {
let envOutput = "";
assert(env.stdout);
env.on("error", (err: Error) => deferred.reject(err));
env.stdout.on("data", (data) => {
envOutput += data;
});
env.on("close", () => {
deferred.resolve(envOutput.trim());
});
await deferred.promise;
} finally {
env.kill();
}
const value = await deferred.promise;
assertEquals(value, "BAZ");
});
// Regression test for https://github.com/denoland/deno/issues/20373
Deno.test(function spawnSyncUndefinedValueInEnvVar() {
const ret = spawnSync(
`"${Deno.execPath()}" eval -p "Deno.env.toObject().BAZ"`,
{
env: {
BAZ: "BAZ",
NO_COLOR: "true",
UNDEFINED_ENV: undefined,
// deno-lint-ignore no-explicit-any
NULL_ENV: null as any,
},
shell: true,
},
);
assertEquals(ret.status, 0);
assertEquals(ret.stdout.toString("utf-8").trim(), "BAZ");
});
Deno.test(function spawnSyncStdioUndefined() {
const ret = spawnSync(
`"${Deno.execPath()}" eval "console.log('hello');console.error('world')"`,
{
stdio: [undefined, undefined, undefined],
shell: true,
},
);
assertEquals(ret.status, 0);
assertEquals(ret.stdout.toString("utf-8").trim(), "hello");
assertEquals(ret.stderr.toString("utf-8").trim(), "world");
});
Deno.test(function spawnSyncExitNonZero() {
const ret = spawnSync(
`"${Deno.execPath()}" eval "Deno.exit(22)"`,
{ shell: true },
);
assertEquals(ret.status, 22);
});
// https://github.com/denoland/deno/issues/21630
Deno.test(async function forkIpcKillDoesNotHang() {
const testdataDir = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
"testdata",
);
const script = path.join(
testdataDir,
"node_modules",
"foo",
"index.js",
);
const p = Promise.withResolvers<void>();
const cp = CP.fork(script, [], {
cwd: testdataDir,
stdio: ["inherit", "inherit", "inherit", "ipc"],
});
cp.on("close", () => p.resolve());
cp.kill();
await p.promise;
});
Deno.test(async function execFileWithUndefinedTimeout() {
const { promise, resolve, reject } = Promise.withResolvers<void>();
CP.execFile(
"git",
["-v"],
{ timeout: undefined, encoding: "utf8" },
(err) => {
if (err) {
reject(err);
return;
}
resolve();
},
);
await promise;
});