/** * Tests for `run` in `src/cli-run.ts`, which every CLI command goes through. */ import { PassThrough } from "node:stream"; import { describe, it, expect } from "vitest"; import { ApiError } from "../../src/api/client.js"; import { run } from "../../src/cli-run.js"; // A stream whose written text is kept in `text`; writes finish at once, so // nothing is left waiting to drain. const collector = (): { stream: PassThrough; text: () => string } => { const stream = new PassThrough(); const chunks: string[] = []; stream.on("data", (chunk: Buffer) => chunks.push(chunk.toString())); return { stream, text: () => chunks.join("") }; }; const runToExit = async ( command: Promise, ): Promise<{ code: number; stdout: string; stderr: string }> => { const stdout = collector(); const stderr = collector(); const code = await new Promise((resolve) => { void run(command, stdout.stream, stderr.stream, resolve); }); return { code, stdout: stdout.text(), stderr: stderr.text() }; }; describe("run", () => { it("exits with the code the command returns", async () => { const result = await runToExit(Promise.resolve(3)); expect(result).toEqual({ code: 3, stdout: "", stderr: "" }); }); it("prints a thrown error as one line without a stack trace and exits 1", async () => { const failing = async (): Promise => { throw new Error( "ENOTDIR: not a directory, mkdir '/dev/null/x/originals'", ); }; const result = await runToExit(failing()); expect(result.code).toBe(1); expect(result.stdout).toBe(""); expect(result.stderr).toBe( "quak: ENOTDIR: not a directory, mkdir '/dev/null/x/originals'\n", ); }); // Node's `fetch` rejects every failure below HTTP with a TypeError that // says only "fetch failed", and hangs the reason, with its errno, off // `cause`. The printed line carries the reason, so a cron log tells a // refused connection from a name that did not resolve. it("prints the reason a fetch failed, from the error's cause", async () => { const refused = new TypeError("fetch failed", { cause: Object.assign( new Error("connect ECONNREFUSED 1.2.3.4:443"), { code: "ECONNREFUSED" }, ), }); const result = await runToExit(Promise.reject(refused)); expect(result).toEqual({ code: 1, stdout: "", stderr: "quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443\n", }); }); // When the host has several addresses and every one refuses, Node's // `fetch` gives as the cause an AggregateError with an empty message and // one error per address. The printed line gives each address's reason. it("prints the reason for each address a fetch could not reach", async () => { const refusedAt = (address: string): Error => Object.assign(new Error(`connect ECONNREFUSED ${address}`), { code: "ECONNREFUSED", }); const refused = new TypeError("fetch failed", { cause: Object.assign( new AggregateError([ refusedAt("2001:db8::1:443"), refusedAt("1.2.3.4:443"), ]), { code: "ECONNREFUSED" }, ), }); const result = await runToExit(Promise.reject(refused)); expect(result).toEqual({ code: 1, stdout: "", stderr: "quak: fetch failed: connect ECONNREFUSED 2001:db8::1:443, connect ECONNREFUSED 1.2.3.4:443\n", }); }); // An error that already quotes its cause's message is printed as it is. it("does not print a cause's message twice", async () => { const reset = new Error("read ECONNRESET"); const wrapped = new Error(`download: file 7: ${reset.message}`, { cause: reset, }); const result = await runToExit(Promise.reject(wrapped)); expect(result.stderr).toBe("quak: download: file 7: read ECONNRESET\n"); }); // OpenSSL ends its message for a failed TLS connection, such as one to a // server that answers in plain HTTP, with a newline. The printed line is // still one line. it("prints a cause's message without the white space around it", async () => { const wrongVersion = new TypeError("fetch failed", { cause: Object.assign( new Error( "C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n", ), { code: "ERR_SSL_WRONG_VERSION_NUMBER" }, ), }); const result = await runToExit(Promise.reject(wrongVersion)); expect(result.stderr).toBe( "quak: fetch failed: C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n", ); }); it("prints a thrown value that is not an Error", async () => { const result = await runToExit(Promise.reject("offline")); expect(result).toEqual({ code: 1, stdout: "", stderr: "quak: offline\n", }); }); // A cause that is not an Error, such as a thrown string, is printed as a // thrown value that is not an Error is. it("prints a cause that is not an Error", async () => { const wrapped = new Error("ML data", { cause: "offline" }); const result = await runToExit(Promise.reject(wrapped)); expect(result.stderr).toBe("quak: ML data: offline\n"); }); it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => { const result = await runToExit( Promise.reject(new ApiError("unauthorized", 401)), ); expect(result).toEqual({ code: 3, stdout: "", stderr: `quak: the saved session is no longer valid; run "quak login"\n`, }); }); it("prints another HTTP error as it is and exits 1", async () => { const result = await runToExit( Promise.reject(new ApiError("forbidden", 403)), ); expect(result).toEqual({ code: 1, stdout: "", stderr: "quak: forbidden\n", }); }); });