check / check (push) Waiting to run
Node's `fetch` rejects every failure below HTTP as `fetch failed` and puts the reason on `cause`. `describeError` in `src/errors.ts` gives the message followed by the messages on the cause chain, and for an `AggregateError`, which Node gives when every address of a host fails, the message of each address. `run` prints it, and the backup logs and records it. `classify` reads that text and calls a failure transient when `hasTransportCode`, now exported by `src/retry.ts`, finds a transport errno on the chain. A failed ML data fetch keeps its error as the cause. The bounded walk along the chain moved from `src/retry.ts` to `src/errors.ts`. A truncated download's message leaves the authentication failure to its cause. Model: opus-5-5
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
/**
|
|
* 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<number>,
|
|
): Promise<{ code: number; stdout: string; stderr: string }> => {
|
|
const stdout = collector();
|
|
const stderr = collector();
|
|
const code = await new Promise<number>((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<number> => {
|
|
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");
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|