test(cli): cover cloud reportApiError hint cascade (#1131)

reportApiError centralizes the HyperframesApiError -> Error -> String
reporting cascade for the cloud subverbs, including the curated
ERROR_CODE_HINTS table and its priority order (code-specific hint >
caller suggestion > bare code label > no third line). That priority
logic was previously untested; the module comment notes a past
regression where hyperframes_render_not_found was unreachable from
get/delete.

Add errors.test.ts covering: 404 + notFound short-circuit, known-code
hint, hint-wins-over-suggestion priority, suggestion fallback, bare
code label, no-third-line, extraHints merge and override, plain Error,
and non-Error stringification. Mocks errorBox and process.exit
following the sibling cloud/parsing.test.ts pattern.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
Carlos Alcaraz Gregor
2026-05-30 22:38:22 -04:00
committed by GitHub
co-authored by Carlos Alcaraz
parent 2362cf979d
commit f8abff2e1c
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// errorBox writes to the console; mock it so we can assert which title /
// message / third line the cascade picked without matching ANSI output.
vi.mock("../ui/format.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../ui/format.js")>()),
errorBox: vi.fn(),
}));
import { errorBox } from "../ui/format.js";
import { HyperframesApiError } from "./_gen/client.js";
import { reportApiError } from "./errors.js";
describe("cloud/errors reportApiError", () => {
// process.exit has signature `(code?) => never` which doesn't unify with
// vi.spyOn's inference; cast through `unknown` like cloud/parsing.test.ts.
let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } };
beforeEach(() => {
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy;
vi.mocked(errorBox).mockClear();
});
afterEach(() => {
exitSpy.mockRestore();
});
it("short-circuits a 404 to a Not found box when notFound is provided", () => {
const err = new HyperframesApiError({ status: 404, message: "missing" });
expect(() =>
reportApiError("Get failed", err, { notFound: "render hfr_x no longer exists" }),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Not found", "render hfr_x no longer exists");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("uses the code-specific hint when the error code is known", () => {
const err = new HyperframesApiError({
status: 429,
message: "slow down",
code: "rate_limit_exceeded",
});
expect(() => reportApiError("Submit failed", err)).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 429)",
"slow down",
"Retry after the duration in the Retry-After header.",
);
});
it("prefers the code-specific hint over a caller suggestion", () => {
const err = new HyperframesApiError({
status: 400,
message: "bad param",
code: "invalid_parameter",
});
expect(() => reportApiError("Submit failed", err, { suggestion: "see --help" })).toThrow(
"process.exit called",
);
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 400)",
"bad param",
"Check the listed parameter against `hyperframes cloud render --help` for the accepted values.",
);
});
it("falls back to the caller suggestion when the code has no hint", () => {
const err = new HyperframesApiError({
status: 500,
message: "boom",
code: "some_unmapped_code",
});
expect(() => reportApiError("List failed", err, { suggestion: "retry shortly" })).toThrow(
"process.exit called",
);
expect(errorBox).toHaveBeenCalledWith("List failed (HTTP 500)", "boom", "retry shortly");
});
it("falls back to a bare code label when there is no hint and no suggestion", () => {
const err = new HyperframesApiError({
status: 500,
message: "boom",
code: "some_unmapped_code",
});
expect(() => reportApiError("List failed", err)).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith(
"List failed (HTTP 500)",
"boom",
"code: some_unmapped_code",
);
});
it("omits the third line when there is no code, hint, or suggestion", () => {
const err = new HyperframesApiError({ status: 503, message: "unavailable" });
expect(() => reportApiError("Get failed", err)).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Get failed (HTTP 503)", "unavailable");
});
it("merges extraHints on top of the built-in table", () => {
const err = new HyperframesApiError({ status: 418, message: "teapot", code: "custom_code" });
expect(() =>
reportApiError("Brew failed", err, { extraHints: { custom_code: "use coffee instead" } }),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Brew failed (HTTP 418)", "teapot", "use coffee instead");
});
it("lets an extraHints entry override a built-in hint", () => {
const err = new HyperframesApiError({
status: 429,
message: "slow down",
code: "rate_limit_exceeded",
});
expect(() =>
reportApiError("Submit failed", err, {
extraHints: { rate_limit_exceeded: "custom backoff guidance" },
}),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 429)",
"slow down",
"custom backoff guidance",
);
});
it("reports a plain Error with the stage title and suggestion", () => {
expect(() =>
reportApiError("Upload failed", new Error("disk full"), { suggestion: "free up space" }),
).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Upload failed", "disk full", "free up space");
});
it("stringifies a non-Error thrown value", () => {
expect(() => reportApiError("Weird failure", "just a string")).toThrow("process.exit called");
expect(errorBox).toHaveBeenCalledWith("Weird failure", "just a string", undefined);
});
});