1const node = (script: string): subprocess.Command => [process.execPath, "-e", script];
2/** like `node`, for a script that imports the library */
3const module = (script: string): subprocess.Command => [
4 process.execPath,
5 "--no-warnings",
6 "--input-type=module",
7 "-e",
8 script,
9];
10const lib = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
11
12const messages: log.Message[] = [];
13log.replaceGlobalMessageDestination((message) => messages.push(message));
14
15async function until(condition: () => unknown) {
16 while (!condition()) await async.delay(5);
17}
18
19describe("execute", () => {
20 test("returns stdout", async () => {
21 assert.equal(await subprocess.execute(node(`console.log("hello")`)), "hello\n");
22 });
23
24 test("does not stall on output larger than a pipe buffer", async () => {
25 const script = `process.stderr.write("e".repeat(300000)); process.stdout.write("o".repeat(300000))`;
26 assert.equal((await subprocess.execute(node(script))).length, 300000);
27 });
28
29 test("a failure carries the exit code, the command, and stderr", async () => {
30 const cmd = node(`console.error("it broke"); process.exit(3)`);
31 const err = await subprocess.execute(cmd).catch((err) => err);
32
33 assert.ok(err instanceof subprocess.ExitError);
34 assert.equal(err.message, `${path.basename(process.execPath)} failed with code 3`);
35 assert.equal(err.code, 3);
36 assert.equal(err.signal, null);
37 assert.equal(err.stderr, "it broke\n");
38 assert.equal(err.cmd, `${process.execPath} -e 'console.error("it broke"); process.exit(3)'`);
39 });
40
41 test("a missing program rejects with ENOENT", async () => {
42 const err = await subprocess.execute(["clover-lib-missing-program"]).catch((err) => err);
43 assert.equal(exception.code(err), "ENOENT");
44 });
45
46 test("env is added to the environment, and cwd is used", async () => {
47 const script = `console.log(process.env.CLOVER_TEST, !!process.env.PATH, process.cwd())`;
48 const cwd = fs.realpathSync(os.tmpdir());
49 const out = await subprocess.execute(node(script), { env: { CLOVER_TEST: "yes" }, cwd });
50 assert.equal(out, `yes true ${cwd}\n`);
51 });
52});
53
54describe("spawn", () => {
55 test("wait resolves on exit code zero", async () => {
56 await subprocess.spawn(node("")).wait();
57 });
58
59 test("a failure nobody waits for is not an unhandled rejection", async () => {
60 const proc = subprocess.spawn(node(`process.exit(1)`));
61 await async.delay(300);
62 await assert.rejects(proc.wait(), { message: /failed with code 1$/ });
63 });
64
65 test("stdin and stdout are web streams when piped", async () => {
66 const proc = subprocess.spawn(node(`process.stdin.pipe(process.stdout)`), {
67 stdin: "pipe",
68 stdout: "pipe",
69 });
70 const writer = proc.stdin.getWriter();
71 await writer.write(string.encodeUtf8("round trip"));
72 await writer.close();
73
74 assert.equal(await new Response(proc.stdout).text(), "round trip");
75 assert.equal(proc.stderr, null);
76 await proc.wait();
77 });
78
79 test("a status that redraws itself is read before its next line is written", async () => {
80 const script = `
81 process.stderr.write("frame=1\\r");
82 process.stdin.resume().on("end", () => process.stderr.write("frame=2\\rdone\\n"));
83 `;
84 const proc = subprocess.spawn(node(script), { stdin: "pipe", stderr: "pipe" });
85 const seen: string[] = [];
86 for await (const line of stream.lines(proc.stderr)) {
87 seen.push(line);
88 if (line === "frame=1") await proc.stdin.close();
89 }
90 await proc.wait();
91
92 assert.deepEqual(seen, ["frame=1", "frame=2", "done"]);
93 });
94
95 test("kill is reported as the signal", async () => {
96 const proc = subprocess.spawn(node(`setInterval(() => {}, 1000)`));
97 proc.kill("SIGKILL");
98 const err = await proc.wait().catch((err) => err);
99
100 assert.ok(err instanceof subprocess.ExitError);
101 assert.equal(err.signal, "SIGKILL");
102 assert.equal(err.code, null);
103 assert.match(err.message, /failed with signal SIGKILL$/);
104 });
105
106 test("aborting kills the process and rejects with the reason", async () => {
107 const ctrl = new AbortController();
108 const reason = new Error("rescheduled");
109 const proc = subprocess.spawn(node(`setInterval(() => {}, 1000)`), { signal: ctrl.signal });
110 ctrl.abort(reason);
111
112 assert.equal(await proc.wait().catch((err) => err), reason);
113 assert.throws(() => subprocess.spawn(node(""), { signal: ctrl.signal }), reason);
114 await assert.rejects(subprocess.execute(node(""), { signal: ctrl.signal }), reason);
115 });
116
117 test("disposing kills a running process, without having read its pipe", async () => {
118 let escaped: subprocess.Subprocess;
119 {
120 await using proc = subprocess.spawn(
121 node(`process.stdout.write("x".repeat(300000)); setInterval(() => {}, 1000)`),
122 { stdout: "pipe" },
123 );
124 escaped = proc;
125 }
126 await assert.rejects(escaped.wait(), { signal: "SIGTERM" });
127 });
128});
129
130describe("ipc", () => {
131 test("messages go both ways, and the end of the channel is heard", async () => {
132 const proc = subprocess.spawn(node(`process.on("message", (m) => process.send({ echo: m }))`), {
133 ipc: true,
134 });
135 const reply = new Promise((resolve) => proc.on("message", resolve));
136 const disconnect = new Promise<void>((resolve) => proc.on("disconnect", resolve));
137 proc.send("hello");
138
139 assert.deepEqual(await reply, { echo: "hello" });
140 proc.kill();
141 await disconnect;
142 const error = await new Promise((resolve) => proc.send("too late", resolve));
143 assert.equal(exception.code(error), "ERR_IPC_CHANNEL_CLOSED");
144 });
145
146 test("send needs the option", async () => {
147 await using proc = subprocess.spawn(node(""));
148 assert.throws(() => proc.send("hello"), /ipc/);
149 });
150
151 test("a prompt here can evaluate in the child", async () => {
152 const script = `
153 import * as repl from ${lib("./repl.ts")};
154 repl.serve(repl.javascript({ globals: { answer: 42 } }), process);
155 `;
156 await using proc = subprocess.spawn(module(script), { ipc: true });
157 const printed: string[] = [];
158 await repl.remote(proc).eval("answer + 1", (value) => printed.push(String(value)));
159
160 assert.deepEqual(printed.map(ansi.strip), ["43"]);
161 });
162});
163
164describe("progress", () => {
165 const script = `
166 import * as log from ${lib("./log.ts")};
167 import * as progress from ${lib("./progress.ts")};
168 log.info("from the child");
169 const task = progress.start("child task", { total: 2 });
170 task.log.warn("on the task");
171 task.value = 1;
172 process.on("message", () => {
173 progress.start("started last").log.error("on a node at exit");
174 log.error("last words");
175 process.exit(1);
176 });
177 `;
178
179 test("the child starts on the node it was handed, and logs through this process", async () => {
180 const root = new progress.Root();
181 const owner = root.start("owner");
182 const proc = subprocess.spawn(module(script), { progress: owner, ipc: true });
183 const children = () => UNWRAP(root.active[0]).children;
184 await until(() => children()[0]?.value === 1);
185
186 const task = UNWRAP(children()[0]);
187 assert.deepEqual([task.text, task.total], ["child task", 2]);
188 assert.deepEqual(task.logs.map((m) => [m.level, m.text]), [["warn", "on the task"]]);
189 assert.deepEqual(messages.map((m) => [m.level, m.text]), [["info", "from the child"]]);
190
191 // what is said right before exiting is in by the time `wait` is over
192 const ended: string[] = [];
193 root.on("node-end", (node) => ended.push(`${node.text}: ${node.logs.map((m) => m.text)}`));
194 proc.send("finish");
195 await assert.rejects(proc.wait(), subprocess.ExitError);
196
197 assert.deepEqual(messages.at(-1)?.text, "last words");
198 assert.deepEqual(ended.sort(), ["child task: on the task", "started last: on a node at exit"]);
199 assert.deepEqual(children(), []);
200 });
201
202 test("only the child that was handed the node reports to it", async () => {
203 // the grandchild inherits the environment, but not the pipe
204 const between = `
205 import * as child_process from "node:child_process";
206 const args = ["--no-warnings", "--input-type=module", "-e", process.argv[1]];
207 child_process.spawnSync(process.execPath, args, { stdio: "inherit" });
208 `;
209 const grandchild = `
210 import * as log from ${lib("./log.ts")};
211 import * as progress from ${lib("./progress.ts")};
212 progress.start("grandchild task");
213 log.info("from the grandchild");
214 `;
215 const root = new progress.Root();
216 const started: string[] = [];
217 root.on("node-start", (node) => started.push(node.text));
218 const proc = subprocess.spawn([...module(between), "--", grandchild], {
219 progress: root,
220 stdout: "pipe",
221 });
222 const stdout = await new Response(proc.stdout).text();
223 await proc.wait();
224
225 assert.match(stdout, /from the grandchild/);
226 assert.deepEqual(started, []);
227 });
228});
229
230import { strict as assert } from "node:assert";
231import * as fs from "node:fs";
232import * as os from "node:os";
233import * as path from "node:path";
234import * as process from "node:process";
235import { describe, test } from "vitest";
236import { UNWRAP } from "./assert.ts";
237import * as async from "./async.ts";
238import * as exception from "./exception.ts";
239import * as log from "./log.ts";
240import * as progress from "./progress.ts";
241import * as repl from "./repl.ts";
242import * as stream from "./stream.ts";
243import * as string from "./string.ts";
244import * as ansi from "./string/ansi.ts";
245import * as subprocess from "./subprocess.ts";