feat: enforce CI coverage thresholds + colocate billing guidance (#2811)

- Move bunfig.toml to repo root with valid coverageThreshold syntax
  (line=80%, function=0 to avoid per-file false positives)
- Add --coverage flag to CI test step
- Delete packages/cli/bunfig.toml (superseded by root config)
- Add tests for packages/shared (type-guards, parse, result)
- Colocate billing config into each cloud directory (aws/billing.ts,
  gcp/billing.ts, hetzner/billing.ts, digitalocean/billing.ts)
- Refactor billing-guidance.ts: BillingConfig interface replaces
  cloud-string-keyed Record maps
- Bump CLI version to 0.25.1

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
A 2026-03-19 22:52:45 -07:00 committed by GitHub
parent aa4b2a23d6
commit 9ae3525030
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 601 additions and 133 deletions

View file

@ -24,7 +24,7 @@ jobs:
run: bun install
- name: Run mock tests
run: bun test
run: bun test --coverage
unit-tests:
name: Unit Tests

View file

@ -1,2 +1,3 @@
[test]
preload = ["./packages/cli/src/__tests__/preload.ts"]
coverageThreshold = { line = 0.8, function = 0 }

View file

@ -1,5 +0,0 @@
[test]
preload = ["./src/__tests__/preload.ts"]
[test.coverage]
threshold = { line = 80, function = 90 }

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.25.0",
"version": "0.25.1",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -1,6 +1,10 @@
import type { BillingGuidanceDeps } from "../shared/billing-guidance";
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { awsBilling } from "../aws/billing";
import { digitaloceanBilling } from "../digitalocean/billing";
import { gcpBilling } from "../gcp/billing";
import { hetznerBilling } from "../hetzner/billing";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
// ── Mock deps (injected via DI, not mock.module) ──────────────────────────
@ -21,34 +25,34 @@ function createMockDeps(): BillingGuidanceDeps {
describe("isBillingError", () => {
describe("hetzner", () => {
it("matches insufficient_funds", () => {
expect(isBillingError("hetzner", "insufficient funds")).toBe(true);
expect(isBillingError("hetzner", "insufficient_funds")).toBe(true);
expect(isBillingError(hetznerBilling, "insufficient funds")).toBe(true);
expect(isBillingError(hetznerBilling, "insufficient_funds")).toBe(true);
});
it("matches payment method required", () => {
expect(isBillingError("hetzner", "payment method required")).toBe(true);
expect(isBillingError(hetznerBilling, "payment method required")).toBe(true);
});
it("matches account locked/blocked", () => {
expect(isBillingError("hetzner", "account is locked")).toBe(true);
expect(isBillingError("hetzner", "account blocked")).toBe(true);
expect(isBillingError(hetznerBilling, "account is locked")).toBe(true);
expect(isBillingError(hetznerBilling, "account blocked")).toBe(true);
});
it("returns false for non-billing errors", () => {
expect(isBillingError("hetzner", "server limit reached")).toBe(false);
expect(isBillingError("hetzner", "server type unavailable")).toBe(false);
expect(isBillingError(hetznerBilling, "server limit reached")).toBe(false);
expect(isBillingError(hetznerBilling, "server type unavailable")).toBe(false);
});
});
describe("digitalocean", () => {
it("matches billing-related errors", () => {
expect(isBillingError("digitalocean", "insufficient funds")).toBe(true);
expect(isBillingError("digitalocean", "payment required")).toBe(true);
expect(isBillingError(digitaloceanBilling, "insufficient funds")).toBe(true);
expect(isBillingError(digitaloceanBilling, "payment required")).toBe(true);
});
it("returns false for non-billing errors", () => {
expect(isBillingError("digitalocean", "droplet limit reached")).toBe(false);
expect(isBillingError("digitalocean", "region unavailable")).toBe(false);
expect(isBillingError(digitaloceanBilling, "droplet limit reached")).toBe(false);
expect(isBillingError(digitaloceanBilling, "region unavailable")).toBe(false);
});
it("matches billing error embedded in doApi thrown error message (regression #2395)", () => {
@ -56,52 +60,57 @@ describe("isBillingError", () => {
// The response body contains the billing message — isBillingError must detect it.
const apiErr =
'DigitalOcean API error 403 for POST /droplets: {"id":"forbidden","message":"A payment on file is required to create resources."}';
expect(isBillingError("digitalocean", apiErr)).toBe(true);
expect(isBillingError(digitaloceanBilling, apiErr)).toBe(true);
});
it("returns false for non-billing 403 in doApi error format", () => {
const apiErr =
'DigitalOcean API error 403 for POST /droplets: {"id":"forbidden","message":"Droplet limit exceeded for this account."}';
expect(isBillingError("digitalocean", apiErr)).toBe(false);
expect(isBillingError(digitaloceanBilling, apiErr)).toBe(false);
});
});
describe("aws", () => {
it("matches activation/billing errors", () => {
expect(isBillingError("aws", "account not activated")).toBe(true);
expect(isBillingError("aws", "subscription required")).toBe(true);
expect(isBillingError("aws", "not been enabled")).toBe(true);
expect(isBillingError(awsBilling, "account not activated")).toBe(true);
expect(isBillingError(awsBilling, "subscription required")).toBe(true);
expect(isBillingError(awsBilling, "not been enabled")).toBe(true);
});
it("returns false for non-billing errors", () => {
expect(isBillingError("aws", "instance limit reached")).toBe(false);
expect(isBillingError("aws", "bundle unavailable")).toBe(false);
expect(isBillingError(awsBilling, "instance limit reached")).toBe(false);
expect(isBillingError(awsBilling, "bundle unavailable")).toBe(false);
});
});
describe("gcp", () => {
it("matches BILLING_DISABLED", () => {
expect(isBillingError("gcp", "BILLING_DISABLED")).toBe(true);
expect(isBillingError(gcpBilling, "BILLING_DISABLED")).toBe(true);
});
it("matches billing not enabled", () => {
expect(isBillingError("gcp", "billing is not enabled")).toBe(true);
expect(isBillingError("gcp", "billing disabled")).toBe(true);
expect(isBillingError(gcpBilling, "billing is not enabled")).toBe(true);
expect(isBillingError(gcpBilling, "billing disabled")).toBe(true);
});
it("matches billing account errors", () => {
expect(isBillingError("gcp", "no billing account linked")).toBe(true);
expect(isBillingError(gcpBilling, "no billing account linked")).toBe(true);
});
it("returns false for non-billing errors", () => {
expect(isBillingError("gcp", "quota exceeded")).toBe(false);
expect(isBillingError("gcp", "machine type unavailable")).toBe(false);
expect(isBillingError(gcpBilling, "quota exceeded")).toBe(false);
expect(isBillingError(gcpBilling, "machine type unavailable")).toBe(false);
});
});
describe("unknown cloud", () => {
it("returns false for unknown clouds", () => {
expect(isBillingError("unknown", "billing error")).toBe(false);
describe("empty config", () => {
it("returns false for config with no error patterns", () => {
const emptyConfig = {
billingUrl: "",
setupSteps: [],
errorPatterns: [],
};
expect(isBillingError(emptyConfig, "billing error")).toBe(false);
});
});
});
@ -122,21 +131,26 @@ describe("handleBillingError", () => {
it("opens billing URL and returns true when user presses Enter", async () => {
mockPrompt.mockImplementation(() => Promise.resolve(""));
const deps = createMockDeps();
const result = await handleBillingError("hetzner", deps);
const result = await handleBillingError(hetznerBilling, deps);
expect(result).toBe(true);
expect(deps.openBrowser).toHaveBeenCalledWith("https://console.hetzner.cloud/");
});
it("returns false when prompt throws (Ctrl+C)", async () => {
mockPrompt.mockImplementation(() => Promise.reject(new Error("cancelled")));
const result = await handleBillingError("digitalocean", createMockDeps());
const result = await handleBillingError(digitaloceanBilling, createMockDeps());
expect(result).toBe(false);
});
it("works for clouds without billing URL", async () => {
it("works for config without billing URL", async () => {
mockPrompt.mockImplementation(() => Promise.resolve(""));
const deps = createMockDeps();
const result = await handleBillingError("unknown", deps);
const emptyConfig = {
billingUrl: "",
setupSteps: [],
errorPatterns: [],
};
const result = await handleBillingError(emptyConfig, deps);
expect(result).toBe(true);
expect(deps.openBrowser).not.toHaveBeenCalled();
});
@ -157,7 +171,7 @@ describe("showNonBillingError", () => {
const deps = createMockDeps();
expect(() => {
showNonBillingError(
"hetzner",
hetznerBilling,
[
"Server limit reached for your account",
],

View file

@ -39,6 +39,7 @@ import {
shellQuote,
validateRegionName,
} from "../shared/ui";
import { awsBilling } from "./billing";
const DASHBOARD_URL = "https://lightsail.aws.amazon.com/";
@ -963,8 +964,8 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis
const errMsg = getErrorMessage(createResult.error);
logError(`Failed to create Lightsail instance: ${errMsg}`);
if (isBillingError("aws", errMsg)) {
const shouldRetry = await handleBillingError("aws");
if (isBillingError(awsBilling, errMsg)) {
const shouldRetry = await handleBillingError(awsBilling);
if (shouldRetry) {
logStep("Retrying instance creation...");
await lightsailCreateInstances(createParams);
@ -984,7 +985,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis
return await waitForInstance();
}
}
showNonBillingError("aws", [
showNonBillingError(awsBilling, [
"Lightsail not enabled: visit https://lightsail.aws.amazon.com/ls/webapp/home to activate",
"Instance limit reached for your account",
"Bundle unavailable in region",

View file

@ -0,0 +1,18 @@
import type { BillingConfig } from "../shared/billing-guidance";
export const awsBilling: BillingConfig = {
billingUrl: "https://lightsail.aws.amazon.com/",
setupSteps: [
"1. Open the AWS Lightsail console",
"2. Complete account activation if prompted",
"3. Add a payment method in AWS Billing",
"4. Return here and press Enter to retry",
],
errorPatterns: [
/billing[_ ]?disabled/i,
/not[_ ](?:been[_ ])?(?:activated|enabled)/i,
/payment[_ ]method[_ ]required/i,
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
/subscription[_ ]required/i,
],
};

View file

@ -0,0 +1,18 @@
import type { BillingConfig } from "../shared/billing-guidance";
export const digitaloceanBilling: BillingConfig = {
billingUrl: "https://cloud.digitalocean.com/account/billing",
setupSteps: [
"1. Open DigitalOcean Billing Settings",
"2. Add a credit card or PayPal account",
"3. Verify your email address if prompted",
"4. Return here and press Enter to retry",
],
errorPatterns: [
/insufficient[_ ]funds/i,
/payment[_ ]method[_ ]required/i,
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
/billing/i,
/payment/i,
],
};

View file

@ -51,6 +51,7 @@ import {
validateRegionName,
validateServerName,
} from "../shared/ui";
import { digitaloceanBilling } from "./billing";
const DO_API_BASE = "https://api.digitalocean.com/v2";
const DO_DASHBOARD_URL = "https://cloud.digitalocean.com/droplets";
@ -414,7 +415,7 @@ export async function checkAccountStatus(): Promise<void> {
// Re-check with new account
return;
}
const shouldRetry = await handleBillingError("digitalocean");
const shouldRetry = await handleBillingError(digitaloceanBilling);
if (!shouldRetry) {
throw new Error("DigitalOcean account is locked");
}
@ -1056,14 +1057,14 @@ export async function createServer(
const errMsg = createApiResult.error.message;
logError(`Failed to create DigitalOcean droplet: ${errMsg}`);
if (isBillingError("digitalocean", errMsg)) {
if (isBillingError(digitaloceanBilling, errMsg)) {
// Offer account switch before billing guidance
const switched = await promptSwitchAccount();
if (switched) {
logStep("Retrying droplet creation with new account...");
return createServer(name, tier, dropletSize, region, imageOverride);
}
const shouldRetry = await handleBillingError("digitalocean");
const shouldRetry = await handleBillingError(digitaloceanBilling);
if (shouldRetry) {
logStep("Retrying droplet creation...");
const retryText = await doApi("POST", "/droplets", body);
@ -1083,7 +1084,7 @@ export async function createServer(
logError(`Retry failed: ${String(retryData?.message || "Unknown error")}`);
}
} else {
showNonBillingError("digitalocean", [
showNonBillingError(digitaloceanBilling, [
"Region/size unavailable (try different DO_REGION or DO_DROPLET_SIZE)",
"Droplet limit reached (check account limits)",
]);
@ -1101,7 +1102,7 @@ export async function createServer(
if (!createData?.droplet?.id) {
logError("Failed to create DigitalOcean droplet: unexpected API response");
showNonBillingError("digitalocean", [
showNonBillingError(digitaloceanBilling, [
"Region/size unavailable (try different DO_REGION or DO_DROPLET_SIZE)",
"Droplet limit reached (check account limits)",
]);

View file

@ -0,0 +1,18 @@
import type { BillingConfig } from "../shared/billing-guidance";
export const gcpBilling: BillingConfig = {
billingUrl: "https://console.cloud.google.com/billing",
setupSteps: [
"1. Open the Google Cloud Billing page",
"2. Link a billing account to your project",
"3. Enable the Compute Engine API",
"4. Return here and press Enter to retry",
],
errorPatterns: [
/billing[_ ]?(?:is[_ ])?(?:not[_ ])?(?:enabled|disabled)/i,
/billing[_ ]account/i,
/BILLING_DISABLED/,
/project.*has.*no.*billing/i,
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
],
};

View file

@ -35,6 +35,7 @@ import {
selectFromList,
shellQuote,
} from "../shared/ui";
import { gcpBilling } from "./billing";
const DASHBOARD_URL = "https://console.cloud.google.com/compute/instances";
@ -567,7 +568,7 @@ export async function checkBillingEnabled(): Promise<void> {
const output = result.stdout.trim().toLowerCase();
if (output === "false") {
logWarn(`Billing is not enabled for project '${_state.project}'.`);
const shouldRetry = await handleBillingError("gcp");
const shouldRetry = await handleBillingError(gcpBilling);
if (!shouldRetry) {
throw new Error("GCP billing not enabled");
}
@ -794,8 +795,8 @@ export async function createInstance(
logError(`gcloud error: ${result.stderr}`);
}
if (isBillingError("gcp", errMsg)) {
const shouldRetry = await handleBillingError("gcp");
if (isBillingError(gcpBilling, errMsg)) {
const shouldRetry = await handleBillingError(gcpBilling);
if (shouldRetry) {
logStep("Retrying instance creation...");
const retryResult = await gcloud(args);
@ -841,7 +842,7 @@ export async function createInstance(
throw new Error("Instance creation failed");
}
} else {
showNonBillingError("gcp", [
showNonBillingError(gcpBilling, [
"Instance quota exceeded (try different GCP_ZONE)",
"Machine type unavailable (try different GCP_MACHINE_TYPE or GCP_ZONE)",
]);

View file

@ -0,0 +1,17 @@
import type { BillingConfig } from "../shared/billing-guidance";
export const hetznerBilling: BillingConfig = {
billingUrl: "https://console.hetzner.cloud/",
setupSteps: [
"1. Open the Hetzner Cloud Console",
"2. Go to Billing → Payment Methods",
"3. Add a credit card or PayPal account",
"4. Return here and press Enter to retry",
],
errorPatterns: [
/insufficient[_ ]funds/i,
/payment[_ ]method[_ ]required/i,
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
/billing/i,
],
};

View file

@ -39,6 +39,7 @@ import {
shellQuote,
validateRegionName,
} from "../shared/ui";
import { hetznerBilling } from "./billing";
const HETZNER_API_BASE = "https://api.hetzner.cloud/v1";
const HETZNER_DASHBOARD_URL = "https://console.hetzner.cloud/";
@ -606,8 +607,8 @@ export async function createServer(
logError(`Failed to create Hetzner server: ${errMsg}`);
if (isBillingError("hetzner", errMsg)) {
const shouldRetry = await handleBillingError("hetzner");
if (isBillingError(hetznerBilling, errMsg)) {
const shouldRetry = await handleBillingError(hetznerBilling);
if (shouldRetry) {
logStep("Retrying server creation...");
const retryResp = await hetznerApi("POST", "/servers", body);
@ -633,7 +634,7 @@ export async function createServer(
logError(`Retry failed: ${retryErr}`);
}
} else {
showNonBillingError("hetzner", [
showNonBillingError(hetznerBilling, [
"Server type or location unavailable",
"Server limit reached for your account",
]);

View file

@ -3,83 +3,20 @@
import { asyncTryCatch, unwrapOr } from "./result.js";
import { logInfo, logStep, logWarn, openBrowser, prompt } from "./ui";
// ─── Billing URLs per cloud ─────────────────────────────────────────────────
// ─── BillingConfig interface ────────────────────────────────────────────────
const BILLING_URLS: Record<string, string> = {
hetzner: "https://console.hetzner.cloud/",
digitalocean: "https://cloud.digitalocean.com/account/billing",
aws: "https://lightsail.aws.amazon.com/",
gcp: "https://console.cloud.google.com/billing",
};
// ─── Setup steps per cloud ──────────────────────────────────────────────────
const SETUP_STEPS: Record<string, string[]> = {
hetzner: [
"1. Open the Hetzner Cloud Console",
"2. Go to Billing → Payment Methods",
"3. Add a credit card or PayPal account",
"4. Return here and press Enter to retry",
],
digitalocean: [
"1. Open DigitalOcean Billing Settings",
"2. Add a credit card or PayPal account",
"3. Verify your email address if prompted",
"4. Return here and press Enter to retry",
],
aws: [
"1. Open the AWS Lightsail console",
"2. Complete account activation if prompted",
"3. Add a payment method in AWS Billing",
"4. Return here and press Enter to retry",
],
gcp: [
"1. Open the Google Cloud Billing page",
"2. Link a billing account to your project",
"3. Enable the Compute Engine API",
"4. Return here and press Enter to retry",
],
};
// ─── Error patterns per cloud ───────────────────────────────────────────────
const ERROR_PATTERNS: Record<string, RegExp[]> = {
hetzner: [
/insufficient[_ ]funds/i,
/payment[_ ]method[_ ]required/i,
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
/billing/i,
],
digitalocean: [
/insufficient[_ ]funds/i,
/payment[_ ]method[_ ]required/i,
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
/billing/i,
/payment/i,
],
aws: [
/billing[_ ]?disabled/i,
/not[_ ](?:been[_ ])?(?:activated|enabled)/i,
/payment[_ ]method[_ ]required/i,
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
/subscription[_ ]required/i,
],
gcp: [
/billing[_ ]?(?:is[_ ])?(?:not[_ ])?(?:enabled|disabled)/i,
/billing[_ ]account/i,
/BILLING_DISABLED/,
/project.*has.*no.*billing/i,
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
],
};
export interface BillingConfig {
billingUrl: string;
setupSteps: string[];
errorPatterns: RegExp[];
}
/** Check if an error message matches known billing error patterns for a cloud. */
export function isBillingError(cloud: string, errorMsg: string): boolean {
const patterns = ERROR_PATTERNS[cloud];
if (!patterns) {
export function isBillingError(config: BillingConfig, errorMsg: string): boolean {
if (!config.errorPatterns || config.errorPatterns.length === 0) {
return false;
}
return patterns.some((p) => p.test(errorMsg));
return config.errorPatterns.some((p) => p.test(errorMsg));
}
/** Dependencies for billing-guidance functions (injectable for testing). */
@ -103,9 +40,12 @@ const defaultDeps: BillingGuidanceDeps = {
* Show billing guidance, open the billing page, and prompt user to retry.
* Returns true if user wants to retry, false otherwise.
*/
export async function handleBillingError(cloud: string, deps: BillingGuidanceDeps = defaultDeps): Promise<boolean> {
const billingUrl = BILLING_URLS[cloud];
const steps = SETUP_STEPS[cloud] || [];
export async function handleBillingError(
config: BillingConfig,
deps: BillingGuidanceDeps = defaultDeps,
): Promise<boolean> {
const billingUrl = config.billingUrl;
const steps = config.setupSteps;
process.stderr.write("\n");
deps.logWarn("Your account needs a payment method to create servers.");
@ -137,7 +77,7 @@ export async function handleBillingError(cloud: string, deps: BillingGuidanceDep
* Show non-billing error guidance with cloud-specific causes and dashboard link.
*/
export function showNonBillingError(
cloud: string,
config: BillingConfig,
causes: string[],
deps: Pick<BillingGuidanceDeps, "logInfo" | "logWarn"> = defaultDeps,
): void {
@ -147,8 +87,7 @@ export function showNonBillingError(
deps.logWarn(` - ${cause}`);
}
}
const billingUrl = BILLING_URLS[cloud];
if (billingUrl) {
deps.logInfo(`Dashboard: ${billingUrl}`);
if (config.billingUrl) {
deps.logInfo(`Dashboard: ${config.billingUrl}`);
}
}

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from "bun:test";
import * as v from "valibot";
import { parseJsonWith, parseJsonObj } from "../parse";
describe("parseJsonWith", () => {
const UserSchema = v.object({ id: v.number(), name: v.string() });
it("returns validated data for valid JSON matching schema", () => {
const result = parseJsonWith('{"id": 1, "name": "Alice"}', UserSchema);
expect(result).toEqual({ id: 1, name: "Alice" });
});
it("returns null for invalid JSON", () => {
expect(parseJsonWith("not json", UserSchema)).toBeNull();
expect(parseJsonWith("{broken", UserSchema)).toBeNull();
});
it("returns null for valid JSON that does not match schema", () => {
expect(parseJsonWith('{"id": "not-a-number", "name": "Alice"}', UserSchema)).toBeNull();
expect(parseJsonWith('{"wrong": "shape"}', UserSchema)).toBeNull();
});
it("works with simple schemas", () => {
const StringSchema = v.string();
expect(parseJsonWith('"hello"', StringSchema)).toBe("hello");
expect(parseJsonWith("42", StringSchema)).toBeNull();
});
it("works with array schemas", () => {
const ArraySchema = v.array(v.number());
expect(parseJsonWith("[1, 2, 3]", ArraySchema)).toEqual([1, 2, 3]);
expect(parseJsonWith('["a", "b"]', ArraySchema)).toBeNull();
});
});
describe("parseJsonObj", () => {
it("returns Record for valid JSON objects", () => {
expect(parseJsonObj('{"a": 1}')).toEqual({ a: 1 });
expect(parseJsonObj('{"nested": {"b": 2}}')).toEqual({ nested: { b: 2 } });
});
it("returns null for JSON arrays", () => {
expect(parseJsonObj("[1, 2]")).toBeNull();
});
it("returns null for JSON primitives", () => {
expect(parseJsonObj('"str"')).toBeNull();
expect(parseJsonObj("42")).toBeNull();
expect(parseJsonObj("true")).toBeNull();
expect(parseJsonObj("null")).toBeNull();
});
it("returns null for invalid JSON", () => {
expect(parseJsonObj("not json")).toBeNull();
expect(parseJsonObj("{broken")).toBeNull();
expect(parseJsonObj("")).toBeNull();
});
});

View file

@ -0,0 +1,247 @@
import { describe, it, expect } from "bun:test";
import {
Ok,
Err,
tryCatch,
asyncTryCatch,
tryCatchIf,
asyncTryCatchIf,
unwrapOr,
mapResult,
isFileError,
isNetworkError,
isOperationalError,
} from "../result";
describe("Ok", () => {
it("creates an Ok result", () => {
expect(Ok(42)).toEqual({ ok: true, data: 42 });
expect(Ok("hello")).toEqual({ ok: true, data: "hello" });
expect(Ok(null)).toEqual({ ok: true, data: null });
});
});
describe("Err", () => {
it("creates an Err result", () => {
const error = new Error("fail");
const result = Err(error);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(error);
}
});
});
describe("tryCatch", () => {
it("returns Ok for successful functions", () => {
const result = tryCatch(() => 42);
expect(result).toEqual({ ok: true, data: 42 });
});
it("returns Err for thrown Error instances", () => {
const result = tryCatch(() => {
throw new Error("boom");
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("boom");
}
});
it("wraps non-Error throws into Error", () => {
const result = tryCatch(() => {
throw "string error";
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(Error);
expect(result.error.message).toBe("string error");
}
});
});
describe("asyncTryCatch", () => {
it("returns Ok for successful async functions", async () => {
const result = await asyncTryCatch(async () => 42);
expect(result).toEqual({ ok: true, data: 42 });
});
it("returns Err for rejected promises", async () => {
const result = await asyncTryCatch(async () => {
throw new Error("async boom");
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("async boom");
}
});
it("wraps non-Error throws into Error", async () => {
const result = await asyncTryCatch(async () => {
throw 404;
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(Error);
expect(result.error.message).toBe("404");
}
});
});
describe("tryCatchIf", () => {
it("returns Ok for successful functions", () => {
const result = tryCatchIf(() => true, () => 42);
expect(result).toEqual({ ok: true, data: 42 });
});
it("catches errors matching the guard", () => {
const guard = (err: Error) => err.message === "expected";
const result = tryCatchIf(guard, () => {
throw new Error("expected");
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("expected");
}
});
it("re-throws errors not matching the guard", () => {
const guard = (err: Error) => err.message === "expected";
expect(() =>
tryCatchIf(guard, () => {
throw new Error("unexpected");
}),
).toThrow("unexpected");
});
});
describe("asyncTryCatchIf", () => {
it("returns Ok for successful async functions", async () => {
const result = await asyncTryCatchIf(() => true, async () => 42);
expect(result).toEqual({ ok: true, data: 42 });
});
it("catches errors matching the guard", async () => {
const guard = (err: Error) => err.message === "expected";
const result = await asyncTryCatchIf(guard, async () => {
throw new Error("expected");
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("expected");
}
});
it("re-throws errors not matching the guard", async () => {
const guard = (err: Error) => err.message === "expected";
expect(
asyncTryCatchIf(guard, async () => {
throw new Error("unexpected");
}),
).rejects.toThrow("unexpected");
});
});
describe("unwrapOr", () => {
it("returns data for Ok results", () => {
expect(unwrapOr(Ok(42), 0)).toBe(42);
expect(unwrapOr(Ok("hello"), "fallback")).toBe("hello");
});
it("returns fallback for Err results", () => {
expect(unwrapOr(Err(new Error("fail")), 0)).toBe(0);
expect(unwrapOr(Err(new Error("fail")), "fallback")).toBe("fallback");
});
});
describe("mapResult", () => {
it("transforms Ok data", () => {
const result = mapResult(Ok(2), (n) => n * 3);
expect(result).toEqual({ ok: true, data: 6 });
});
it("passes Err through unchanged", () => {
const error = new Error("fail");
const result = mapResult(Err<number>(error), (n) => n * 3);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(error);
}
});
});
describe("isFileError", () => {
it("returns true for file error codes", () => {
for (const code of ["ENOENT", "EACCES", "EISDIR", "ENOSPC", "EPERM", "ENOTDIR"]) {
const err = Object.assign(new Error("fail"), { code });
expect(isFileError(err)).toBe(true);
}
});
it("returns false for non-file error codes", () => {
const err = Object.assign(new Error("fail"), { code: "ECONNREFUSED" });
expect(isFileError(err)).toBe(false);
});
it("returns false for errors without code", () => {
expect(isFileError(new Error("fail"))).toBe(false);
});
});
describe("isNetworkError", () => {
it("returns true for network error codes", () => {
for (const code of ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EPIPE", "EAI_AGAIN"]) {
const err = Object.assign(new Error("fail"), { code });
expect(isNetworkError(err)).toBe(true);
}
});
it("returns true for AbortError", () => {
const err = new Error("aborted");
err.name = "AbortError";
expect(isNetworkError(err)).toBe(true);
});
it("returns true for TimeoutError", () => {
const err = new Error("timeout");
err.name = "TimeoutError";
expect(isNetworkError(err)).toBe(true);
});
it("returns true for TypeError with fetch/network/socket message", () => {
const err = new TypeError("fetch failed to connect");
expect(isNetworkError(err)).toBe(true);
const err2 = new TypeError("network connection lost");
expect(isNetworkError(err2)).toBe(true);
const err3 = new TypeError("socket hang up");
expect(isNetworkError(err3)).toBe(true);
});
it("returns true for errors with fetch failed message", () => {
const err = new Error("fetch failed");
expect(isNetworkError(err)).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(isNetworkError(new Error("something else"))).toBe(false);
expect(isNetworkError(new TypeError("Cannot read property"))).toBe(false);
});
});
describe("isOperationalError", () => {
it("returns true for file errors", () => {
const err = Object.assign(new Error("fail"), { code: "ENOENT" });
expect(isOperationalError(err)).toBe(true);
});
it("returns true for network errors", () => {
const err = Object.assign(new Error("fail"), { code: "ECONNREFUSED" });
expect(isOperationalError(err)).toBe(true);
});
it("returns false for non-operational errors", () => {
expect(isOperationalError(new Error("random"))).toBe(false);
});
});

View file

@ -0,0 +1,139 @@
import { describe, it, expect } from "bun:test";
import {
isPlainObject,
isString,
isNumber,
hasStatus,
getErrorMessage,
toRecord,
toObjectArray,
} from "../type-guards";
describe("isPlainObject", () => {
it("returns true for plain objects", () => {
expect(isPlainObject({})).toBe(true);
expect(isPlainObject({ a: 1 })).toBe(true);
expect(isPlainObject({ nested: { b: 2 } })).toBe(true);
});
it("returns false for null", () => {
expect(isPlainObject(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isPlainObject(undefined)).toBe(false);
});
it("returns false for arrays", () => {
expect(isPlainObject([])).toBe(false);
expect(isPlainObject([1, 2, 3])).toBe(false);
});
it("returns false for primitives", () => {
expect(isPlainObject("str")).toBe(false);
expect(isPlainObject(42)).toBe(false);
expect(isPlainObject(true)).toBe(false);
});
});
describe("isString", () => {
it("returns true for strings", () => {
expect(isString("")).toBe(true);
expect(isString("hello")).toBe(true);
});
it("returns false for non-strings", () => {
expect(isString(42)).toBe(false);
expect(isString(null)).toBe(false);
expect(isString(undefined)).toBe(false);
expect(isString({})).toBe(false);
});
});
describe("isNumber", () => {
it("returns true for numbers", () => {
expect(isNumber(0)).toBe(true);
expect(isNumber(42)).toBe(true);
expect(isNumber(-1)).toBe(true);
expect(isNumber(NaN)).toBe(true);
});
it("returns false for non-numbers", () => {
expect(isNumber("42")).toBe(false);
expect(isNumber(null)).toBe(false);
expect(isNumber(undefined)).toBe(false);
});
});
describe("hasStatus", () => {
it("returns true for objects with numeric status", () => {
expect(hasStatus({ status: 200 })).toBe(true);
expect(hasStatus({ status: 0 })).toBe(true);
expect(hasStatus({ status: 500, other: "field" })).toBe(true);
});
it("returns false for objects without numeric status", () => {
expect(hasStatus({ status: "ok" })).toBe(false);
expect(hasStatus({})).toBe(false);
expect(hasStatus({ status: undefined })).toBe(false);
});
it("returns false for non-objects", () => {
expect(hasStatus(null)).toBe(false);
expect(hasStatus(undefined)).toBe(false);
expect(hasStatus("string")).toBe(false);
});
});
describe("getErrorMessage", () => {
it("returns .message for Error-like objects", () => {
expect(getErrorMessage(new Error("boom"))).toBe("boom");
expect(getErrorMessage({ message: "custom error" })).toBe("custom error");
});
it("returns String(err) for non-Error values", () => {
expect(getErrorMessage("string error")).toBe("string error");
expect(getErrorMessage(42)).toBe("42");
expect(getErrorMessage(null)).toBe("null");
expect(getErrorMessage(undefined)).toBe("undefined");
});
});
describe("toRecord", () => {
it("returns plain objects as-is", () => {
const obj = { a: 1 };
expect(toRecord(obj)).toBe(obj);
expect(toRecord({})).toEqual({});
});
it("returns null for non-plain-objects", () => {
expect(toRecord(null)).toBeNull();
expect(toRecord(undefined)).toBeNull();
expect(toRecord([1, 2])).toBeNull();
expect(toRecord("str")).toBeNull();
expect(toRecord(42)).toBeNull();
});
});
describe("toObjectArray", () => {
it("filters non-plain-object items from arrays", () => {
const result = toObjectArray([{ a: 1 }, "skip", null, { b: 2 }, 42]);
expect(result).toEqual([{ a: 1 }, { b: 2 }]);
});
it("returns all items if all are plain objects", () => {
expect(toObjectArray([{ x: 1 }, { y: 2 }])).toEqual([{ x: 1 }, { y: 2 }]);
});
it("returns empty array for non-arrays", () => {
expect(toObjectArray("not array")).toEqual([]);
expect(toObjectArray(null)).toEqual([]);
expect(toObjectArray(undefined)).toEqual([]);
expect(toObjectArray(42)).toEqual([]);
expect(toObjectArray({})).toEqual([]);
});
it("returns empty array for array of only non-objects", () => {
expect(toObjectArray([1, "two", null, true])).toEqual([]);
});
});