From eab8c9458145573c915438fd859a0056cc9bd133 Mon Sep 17 00:00:00 2001 From: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> Date: Sat, 30 May 2026 00:59:17 -0300 Subject: [PATCH] test(cli): cover cloud reportApiError hint cascade 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. --- packages/cli/src/cloud/errors.test.ts | 138 ++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packages/cli/src/cloud/errors.test.ts diff --git a/packages/cli/src/cloud/errors.test.ts b/packages/cli/src/cloud/errors.test.ts new file mode 100644 index 000000000..bb3cd381c --- /dev/null +++ b/packages/cli/src/cloud/errors.test.ts @@ -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()), + 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); + }); +});