| 1 | const node = (script: string): subprocess.Command => [process.execPath, "-e", script]; |
| 2 | /** like `node`, for a script that imports the library */ |
| 3 | const module = (script: string): subprocess.Command => [ |
| 4 | process.execPath, |
| 5 | "--no-warnings", |
| 6 | "--input-type=module", |
| 7 | "-e", |
| 8 | script, |
| 9 | ]; |
| 10 | const lib = (file: string) => JSON.stringify(new URL(file, import.meta.url).href); |
| 11 | |
| 12 | const messages: log.Message[] = []; |
| 13 | log.replaceGlobalMessageDestination((message) => messages.push(message)); |
| 14 | |
| 15 | async function until(condition: () => unknown) { |
| 16 | while (!condition()) await async.delay(5); |
| 17 | } |
| 18 | |
| 19 | describe("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 | |
| 54 | describe("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 | |
| 130 | describe("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 | |
| 164 | describe("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 | |
| 230 | import { strict as assert } from "node:assert"; |
| 231 | import * as fs from "node:fs"; |
| 232 | import * as os from "node:os"; |
| 233 | import * as path from "node:path"; |
| 234 | import * as process from "node:process"; |
| 235 | import { describe, test } from "vitest"; |
| 236 | import { UNWRAP } from "./assert.ts"; |
| 237 | import * as async from "./async.ts"; |
| 238 | import * as exception from "./exception.ts"; |
| 239 | import * as log from "./log.ts"; |
| 240 | import * as progress from "./progress.ts"; |
| 241 | import * as repl from "./repl.ts"; |
| 242 | import * as stream from "./stream.ts"; |
| 243 | import * as string from "./string.ts"; |
| 244 | import * as ansi from "./string/ansi.ts"; |
| 245 | import * as subprocess from "./subprocess.ts"; |