check / check (push) Waiting to run
Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks. Model: opus-5-5
169 lines
6.5 KiB
TypeScript
169 lines
6.5 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 { stderrLog } from "../../src/cli-log.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,
|
|
stderrLog(stderr.stream, "info"),
|
|
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");
|
|
});
|
|
|
|
// 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",
|
|
});
|
|
});
|
|
});
|