diff --git a/packages/devtools/package-lock.json b/packages/devtools/package-lock.json index 776695f..69ef863 100644 --- a/packages/devtools/package-lock.json +++ b/packages/devtools/package-lock.json @@ -557,9 +557,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -574,9 +571,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -591,9 +585,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -608,9 +599,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -625,9 +613,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -642,9 +627,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -659,9 +641,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -676,9 +655,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -693,9 +669,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -710,9 +683,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -727,9 +697,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -744,9 +711,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -761,9 +725,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/packages/devtools/test/args.test.ts b/packages/devtools/test/args.test.ts new file mode 100644 index 0000000..8a26f5b --- /dev/null +++ b/packages/devtools/test/args.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { HELP, parseArgs } from "../src/args.js"; +import type { Flags } from "../src/types.js"; + +const allFalse: Flags = { + dryRun: false, + volumes: false, + stopDb: false, + yes: false, +}; + +describe("parseArgs — command positional", () => { + it("captures the first positional as command", () => { + expect(parseArgs(["up"]).command).toBe("up"); + }); + + it("returns undefined command for an empty argv", () => { + expect(parseArgs([]).command).toBeUndefined(); + }); + + it("captures only the first positional; extras go to unknown", () => { + const r = parseArgs(["up", "extra"]); + expect(r.command).toBe("up"); + expect(r.unknown).toEqual(["extra"]); + }); + + it("treats a dash-prefixed token as a flag even when it appears first", () => { + const r = parseArgs(["--dry-run", "up"]); + expect(r.command).toBe("up"); + expect(r.flags.dryRun).toBe(true); + }); + + it("does not treat a command-like token after a flag as unknown", () => { + const r = parseArgs(["-y", "down"]); + expect(r.command).toBe("down"); + expect(r.unknown).toEqual([]); + }); +}); + +describe("parseArgs — boolean flags default false", () => { + it("all flags start false for an empty argv", () => { + expect(parseArgs([]).flags).toEqual(allFalse); + }); +}); + +describe("parseArgs — long flags", () => { + it.each([ + ["--dry-run", "dryRun"], + ["--volumes", "volumes"], + ["--stop-db", "stopDb"], + ["--yes", "yes"], + ] as const)("sets %s → flags.%s", (flag, key) => { + expect(parseArgs([flag]).flags[key]).toBe(true); + }); + + it("only the named long flag is set", () => { + expect(parseArgs(["--volumes"]).flags).toEqual({ ...allFalse, volumes: true }); + }); +}); + +describe("parseArgs — short aliases", () => { + it("-n aliases --dry-run", () => { + expect(parseArgs(["-n"]).flags.dryRun).toBe(true); + }); + + it("-y aliases --yes", () => { + expect(parseArgs(["-y"]).flags.yes).toBe(true); + }); + + it("short aliases set no other flag", () => { + expect(parseArgs(["-n"]).flags).toEqual({ ...allFalse, dryRun: true }); + }); +}); + +describe("parseArgs — help", () => { + it("recognises --help", () => { + expect(parseArgs(["--help"]).help).toBe(true); + }); + + it("recognises -h", () => { + expect(parseArgs(["-h"]).help).toBe(true); + }); + + it("help defaults to false", () => { + expect(parseArgs([]).help).toBe(false); + }); + + it("help does not set any boolean flag", () => { + expect(parseArgs(["--help"]).flags).toEqual(allFalse); + }); + + it("help can combine with a command", () => { + const r = parseArgs(["up", "-h"]); + expect(r.help).toBe(true); + expect(r.command).toBe("up"); + }); + + it("help takes precedence visually but leaves command intact when --help comes first", () => { + const r = parseArgs(["-h", "up"]); + expect(r.help).toBe(true); + expect(r.command).toBe("up"); + }); +}); + +describe("parseArgs — unknown handling", () => { + it("collects unknown long flags", () => { + expect(parseArgs(["--bogus"]).unknown).toEqual(["--bogus"]); + }); + + it("collects surplus positionals as unknown", () => { + expect(parseArgs(["up", "extra1", "extra2"]).unknown).toEqual([ + "extra1", + "extra2", + ]); + }); + + it("preserves order of unknown tokens across flags and positionals", () => { + const r = parseArgs(["--bogus", "up", "extra", "--wat"]); + expect(r.unknown).toEqual(["--bogus", "extra", "--wat"]); + }); + + it("does not mutate flags for an unknown flag", () => { + expect(parseArgs(["--nope"]).flags).toEqual(allFalse); + }); + + it("a bare dash is treated as an unknown flag", () => { + const r = parseArgs(["-"]); + expect(r.unknown).toEqual(["-"]); + expect(r.command).toBeUndefined(); + }); +}); + +describe("parseArgs — combinations", () => { + it("parses a realistic up invocation", () => { + const r = parseArgs(["up", "--dry-run", "-y"]); + expect(r).toEqual({ + command: "up", + flags: { ...allFalse, dryRun: true, yes: true }, + help: false, + unknown: [], + }); + }); + + it("parses down --volumes --stop-db", () => { + const r = parseArgs(["down", "--volumes", "--stop-db"]); + expect(r.command).toBe("down"); + expect(r.flags.volumes).toBe(true); + expect(r.flags.stopDb).toBe(true); + expect(r.flags.dryRun).toBe(false); + expect(r.flags.yes).toBe(false); + }); + + it("flags can appear before the command", () => { + const r = parseArgs(["-n", "verify"]); + expect(r.command).toBe("verify"); + expect(r.flags.dryRun).toBe(true); + }); + + it("flags interleave freely with the command", () => { + const r = parseArgs(["--yes", "down", "--stop-db", "--volumes"]); + expect(r).toEqual({ + command: "down", + flags: { ...allFalse, yes: true, stopDb: true, volumes: true }, + help: false, + unknown: [], + }); + }); +}); + +describe("HELP text", () => { + it("is a non-empty string", () => { + expect(typeof HELP).toBe("string"); + expect(HELP.length).toBeGreaterThan(0); + }); + + it("documents every command", () => { + for (const cmd of ["up", "down", "verify", "provision-prod"]) { + expect(HELP).toContain(cmd); + } + }); + + it("documents the help flags", () => { + expect(HELP).toContain("--help"); + expect(HELP).toContain("-h"); + }); + + it("documents every boolean flag", () => { + expect(HELP).toContain("--dry-run"); + expect(HELP).toContain("--volumes"); + expect(HELP).toContain("--stop-db"); + expect(HELP).toContain("--yes"); + }); +}); diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index eb433b4..14ce347 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -558,9 +558,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -575,9 +572,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -592,9 +586,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -609,9 +600,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -626,9 +614,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -643,9 +628,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -660,9 +642,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -677,9 +656,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -694,9 +670,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -711,9 +684,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -728,9 +698,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -745,9 +712,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -762,9 +726,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 1411fb3..c428273 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -75,7 +75,8 @@ export async function fromResponse( } catch { // not JSON } - const message = parsedMessage ?? bodyText.slice(0, 200) ?? `${path} → ${res.status}`; + const message = + parsedMessage ?? (bodyText ? bodyText.slice(0, 200) : `${path} → ${res.status}`); if (res.status === 401) return new Unauthorized(message, path); if (res.status === 404) return new NotFound(message, path); diff --git a/packages/sdk/test/environments.test.ts b/packages/sdk/test/environments.test.ts new file mode 100644 index 0000000..5cd5936 --- /dev/null +++ b/packages/sdk/test/environments.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { AgentCoordinator } from "../src/index.js"; + +interface Call { + url: string; + method: string; + apiKey: string | null; + body: string | null; +} + +function clientWith(handler: (url: string, method: string) => Response) { + const calls: Call[] = []; + const fakeFetch = (async (input: unknown, init: RequestInit = {}) => { + const url = typeof input === "string" ? input : (input as Request).url; + calls.push({ + url, + method: (init.method ?? "GET").toUpperCase(), + apiKey: new Headers(init.headers).get("x-api-key"), + body: typeof init.body === "string" ? init.body : null, + }); + return handler(url, (init.method ?? "GET").toUpperCase()); + }) as unknown as typeof fetch; + const client = new AgentCoordinator({ + apiKey: "k-test", + url: "https://cp.test", + fetch: fakeFetch, + }); + return { client, calls }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("EnvironmentsResource.list", () => { + it("GETs /api/environments with no query string when no options", async () => { + const { client, calls } = clientWith(() => json([])); + await client.environments.list(); + expect(calls[0].url).toBe("https://cp.test/api/environments"); + expect(calls[0].method).toBe("GET"); + expect(calls[0].body).toBeNull(); + expect(calls[0].apiKey).toBe("k-test"); + }); + + it("appends ?repo= when a repo filter is given", async () => { + const { client, calls } = clientWith(() => json([])); + await client.environments.list({ repo: "acme/my repo" }); + expect(calls[0].url).toBe( + "https://cp.test/api/environments?repo=acme%2Fmy%20repo", + ); + }); + + it("omits the query string when repo is undefined", async () => { + const { client, calls } = clientWith(() => json([])); + await client.environments.list({ repo: undefined }); + expect(calls[0].url).toBe("https://cp.test/api/environments"); + }); + + it("returns the environment profiles array verbatim", async () => { + const { client } = clientWith(() => json([{ id: "env-1", name: "node" }])); + const result = await client.environments.list(); + expect(result).toEqual([{ id: "env-1", name: "node" }]); + }); +}); + +describe("EnvironmentsResource.get", () => { + it("GETs /api/environments/ with the id URL-encoded", async () => { + const { client, calls } = clientWith(() => json({ id: "a b" })); + await client.environments.get("a b"); + expect(calls[0].url).toBe("https://cp.test/api/environments/a%20b"); + expect(calls[0].method).toBe("GET"); + }); +}); + +describe("EnvironmentsResource.create", () => { + it("POSTs the environment input as JSON", async () => { + const { client, calls } = clientWith(() => json({ id: "e1" })); + const input = { + repoOwner: "acme", + repoName: "demo", + name: "node", + installCommands: ["npm ci"], + }; + const result = await client.environments.create(input); + expect(calls[0].url).toBe("https://cp.test/api/environments"); + expect(calls[0].method).toBe("POST"); + expect(JSON.parse(calls[0].body ?? "null")).toEqual(input); + expect(result).toEqual({ id: "e1" }); + }); +}); + +describe("EnvironmentsResource.update", () => { + it("PUTs the patch to /api/environments/", async () => { + const { client, calls } = clientWith(() => json({ id: "e1", name: "x" })); + await client.environments.update("e 1", { name: "renamed" }); + expect(calls[0].url).toBe("https://cp.test/api/environments/e%201"); + expect(calls[0].method).toBe("PUT"); + expect(JSON.parse(calls[0].body ?? "null")).toEqual({ name: "renamed" }); + }); + + it("forwards validationStatus through the patch", async () => { + const { client, calls } = clientWith(() => json({ id: "e1" })); + await client.environments.update("e1", { validationStatus: "valid" }); + expect(JSON.parse(calls[0].body ?? "null")).toEqual({ + validationStatus: "valid", + }); + }); + + it("URL-encodes the id in the path", async () => { + const { client, calls } = clientWith(() => json({ id: "e1" })); + await client.environments.update("env/with space", { name: "x" }); + expect(calls[0].url).toBe( + "https://cp.test/api/environments/env%2Fwith%20space", + ); + }); +}); + +describe("EnvironmentsResource.delete", () => { + it("returns true on a 204 success and issues a DELETE", async () => { + const { client, calls } = clientWith( + () => new Response(null, { status: 204 }), + ); + const ok = await client.environments.delete("e1"); + expect(ok).toBe(true); + expect(calls[0].method).toBe("DELETE"); + expect(calls[0].url).toBe("https://cp.test/api/environments/e1"); + }); + + it("returns true on a 200 JSON success", async () => { + const { client } = clientWith(() => json({ ok: true })); + const ok = await client.environments.delete("e1"); + expect(ok).toBe(true); + }); + + it("returns false on a 404 (already gone)", async () => { + const { client } = clientWith(() => json({ error: "not found" }, 404)); + const ok = await client.environments.delete("missing"); + expect(ok).toBe(false); + }); + + it("rethrows non-404 errors (500)", async () => { + const { client } = clientWith(() => json({ error: "boom" }, 500)); + await expect(client.environments.delete("e1")).rejects.toThrow("boom"); + }); + + it("rethrows a 401 as a typed error with status 401, not swallowed", async () => { + const { client } = clientWith(() => json({ error: "no" }, 401)); + await expect(client.environments.delete("e1")).rejects.toMatchObject({ + status: 401, + }); + }); + + it("URL-encodes the id in the DELETE path", async () => { + const { client, calls } = clientWith( + () => new Response(null, { status: 204 }), + ); + await client.environments.delete("a b/c"); + expect(calls[0].url).toBe("https://cp.test/api/environments/a%20b%2Fc"); + }); +}); + +describe("EnvironmentsResource.secretStatus", () => { + it("GETs /api/environments//secret-status", async () => { + const { client, calls } = clientWith(() => + json({ + environmentId: "e1", + required: ["A", "B"], + present: ["A"], + missing: ["B"], + }), + ); + const result = await client.environments.secretStatus("e1"); + expect(calls[0].url).toBe( + "https://cp.test/api/environments/e1/secret-status", + ); + expect(calls[0].method).toBe("GET"); + expect(result).toEqual({ + environmentId: "e1", + required: ["A", "B"], + present: ["A"], + missing: ["B"], + }); + }); +}); diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts new file mode 100644 index 0000000..6e52606 --- /dev/null +++ b/packages/sdk/test/errors.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { + AgentCoordinatorError, + BridgeError, + NotFound, + RunTimeout, + SandboxNotConnected, + Unauthorized, + fromResponse, +} from "../src/errors.js"; + +function response(body: string, status: number, contentType?: string): Response { + const headers = contentType ? { "content-type": contentType } : undefined; + return new Response(body, { status, headers }); +} + +describe("error class hierarchy", () => { + const samples = () => [ + new Unauthorized(), + new NotFound(), + new SandboxNotConnected(), + new RunTimeout(), + new BridgeError("boom"), + ]; + + it("every typed error is an AgentCoordinatorError and an Error", () => { + for (const e of samples()) { + expect(e).toBeInstanceOf(AgentCoordinatorError); + expect(e).toBeInstanceOf(Error); + } + }); + + it("sets a distinct name on each subclass", () => { + expect(new Unauthorized().name).toBe("Unauthorized"); + expect(new NotFound().name).toBe("NotFound"); + expect(new SandboxNotConnected().name).toBe("SandboxNotConnected"); + expect(new RunTimeout().name).toBe("RunTimeout"); + expect(new BridgeError("x").name).toBe("BridgeError"); + expect(new AgentCoordinatorError("x").name).toBe("AgentCoordinatorError"); + }); +}); + +describe("default + overridden messages", () => { + it("Unauthorized defaults to 'Unauthorized'", () => { + expect(new Unauthorized().message).toBe("Unauthorized"); + }); + it("NotFound defaults to 'Not found'", () => { + expect(new NotFound().message).toBe("Not found"); + }); + it("SandboxNotConnected defaults to 'Sandbox not connected'", () => { + expect(new SandboxNotConnected().message).toBe("Sandbox not connected"); + }); + it("RunTimeout defaults to 'Run timed out'", () => { + expect(new RunTimeout().message).toBe("Run timed out"); + }); + it("accepts a custom message override", () => { + expect(new Unauthorized("nope").message).toBe("nope"); + expect(new NotFound("gone").message).toBe("gone"); + expect(new SandboxNotConnected("offline").message).toBe("offline"); + }); + it("BridgeError requires an explicit message (no meaningful default)", () => { + expect(new BridgeError("bridge down").message).toBe("bridge down"); + }); +}); + +describe("status + path metadata", () => { + it("Unauthorized carries status 401 and the path", () => { + const e = new Unauthorized("x", "/api/me"); + expect(e.status).toBe(401); + expect(e.path).toBe("/api/me"); + }); + it("NotFound carries status 404 and the path", () => { + const e = new NotFound("x", "/api/sessions/1"); + expect(e.status).toBe(404); + expect(e.path).toBe("/api/sessions/1"); + }); + it("SandboxNotConnected carries status 409 and the path", () => { + const e = new SandboxNotConnected("x", "/api/sessions/1/files"); + expect(e.status).toBe(409); + expect(e.path).toBe("/api/sessions/1/files"); + }); + it("RunTimeout and BridgeError have no status (local errors)", () => { + expect(new RunTimeout().status).toBeUndefined(); + expect(new BridgeError("x").status).toBeUndefined(); + }); + it("base AgentCoordinatorError accepts status/path options", () => { + const e = new AgentCoordinatorError("boom", { status: 502, path: "/api/x" }); + expect(e.status).toBe(502); + expect(e.path).toBe("/api/x"); + }); + it("path is optional for the mapped subclasses", () => { + expect(new Unauthorized().path).toBeUndefined(); + expect(new NotFound().path).toBeUndefined(); + expect(new SandboxNotConnected().path).toBeUndefined(); + }); +}); + +describe("fromResponse — status mapping", () => { + it("401 → Unauthorized with the parsed message and path", async () => { + const e = await fromResponse( + response(JSON.stringify({ error: "bad key" }), 401), + "/api/me", + ); + expect(e).toBeInstanceOf(Unauthorized); + expect(e.status).toBe(401); + expect(e.message).toBe("bad key"); + expect(e.path).toBe("/api/me"); + }); + + it("404 → NotFound", async () => { + const e = await fromResponse( + response(JSON.stringify({ error: "nope" }), 404), + "/p", + ); + expect(e).toBeInstanceOf(NotFound); + expect(e.status).toBe(404); + }); + + it("409 → SandboxNotConnected", async () => { + const e = await fromResponse( + response(JSON.stringify({ error: "off" }), 409), + "/p", + ); + expect(e).toBeInstanceOf(SandboxNotConnected); + expect(e.status).toBe(409); + }); + + it("500 → base AgentCoordinatorError carrying the status", async () => { + const e = await fromResponse( + response(JSON.stringify({ error: "kaboom" }), 500), + "/p", + ); + expect(e).toBeInstanceOf(AgentCoordinatorError); + expect(e).not.toBeInstanceOf(Unauthorized); + expect(e).not.toBeInstanceOf(NotFound); + expect(e).not.toBeInstanceOf(SandboxNotConnected); + expect(e.status).toBe(500); + expect(e.message).toBe("kaboom"); + }); + + it("preserves the request path on every mapped status", async () => { + for (const status of [401, 404, 409, 500]) { + const e = await fromResponse(response("oops", status), "/api/thing"); + expect(e.path).toBe("/api/thing"); + } + }); +}); + +describe("fromResponse — message extraction", () => { + it("prefers the `error` field of a JSON body", async () => { + const e = await fromResponse( + response(JSON.stringify({ error: "from-error", message: "ignored" }), 400), + "/p", + ); + expect(e.message).toBe("from-error"); + }); + + it("falls back to the `message` field when there is no `error`", async () => { + const e = await fromResponse( + response(JSON.stringify({ message: "from-message" }), 400), + "/p", + ); + expect(e.message).toBe("from-message"); + }); + + it("uses the raw body text when the body is not JSON", async () => { + const e = await fromResponse(response("plain text failure", 400), "/p"); + expect(e.message).toBe("plain text failure"); + }); + + it("truncates long non-JSON bodies to 200 characters", async () => { + const long = "x".repeat(500); + const e = await fromResponse(response(long, 400), "/p"); + expect(e.message).toHaveLength(200); + expect(e.message).toBe("x".repeat(200)); + }); + + it("ignores non-string `error` fields and falls through to body text", async () => { + const body = JSON.stringify({ error: { code: 1 } }); + const e = await fromResponse(response(body, 400), "/p"); + expect(e.message).toBe(body); + }); + + it("falls back to '' when the body is empty", async () => { + const e = await fromResponse(response("", 500), "/api/thing"); + expect(e.message).toBe("/api/thing → 500"); + }); + + it("falls back to '' when the body is only whitespace-ish JSON parse failure", async () => { + // a bare "null"/non-object JSON still yields no .error/.message strings + const e = await fromResponse(response("null", 500), "/api/thing"); + expect(e.message).toBe("null"); + }); + + it("still maps an empty-body 401 to Unauthorized (with the fallback message)", async () => { + const e = await fromResponse(response("", 401), "/api/thing"); + expect(e).toBeInstanceOf(Unauthorized); + expect(e.message).toBe("/api/thing → 401"); + }); + + it("still maps an empty-body 404 to NotFound (with the fallback message)", async () => { + const e = await fromResponse(response("", 404), "/api/thing"); + expect(e).toBeInstanceOf(NotFound); + expect(e.message).toBe("/api/thing → 404"); + }); + + it("does not throw when res.text() rejects (unreadable body)", async () => { + const res = { + status: 502, + text: () => Promise.reject(new Error("stream gone")), + headers: new Headers(), + } as unknown as Response; + const e = await fromResponse(res, "/api/thing"); + // bodyText stays "" → fallback path→status message + expect(e.message).toBe("/api/thing → 502"); + expect(e.status).toBe(502); + }); + + it("uses error/message strings even when content-type is not JSON", async () => { + // fromResponse does not gate on content-type; it tries JSON.parse on the text + const e = await fromResponse( + response(JSON.stringify({ error: "still parsed" }), 400, "text/plain"), + "/p", + ); + expect(e.message).toBe("still parsed"); + }); +});