From 9ae35250309b1a15875e482530f88c1c5c59972e Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:52:45 -0700 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- bunfig.toml | 1 + packages/cli/bunfig.toml | 5 - packages/cli/package.json | 2 +- .../src/__tests__/billing-guidance.test.ts | 78 +++--- packages/cli/src/aws/aws.ts | 7 +- packages/cli/src/aws/billing.ts | 18 ++ packages/cli/src/digitalocean/billing.ts | 18 ++ packages/cli/src/digitalocean/digitalocean.ts | 11 +- packages/cli/src/gcp/billing.ts | 18 ++ packages/cli/src/gcp/gcp.ts | 9 +- packages/cli/src/hetzner/billing.ts | 17 ++ packages/cli/src/hetzner/hetzner.ts | 7 +- packages/cli/src/shared/billing-guidance.ts | 97 ++----- packages/shared/src/__tests__/parse.test.ts | 58 ++++ packages/shared/src/__tests__/result.test.ts | 247 ++++++++++++++++++ .../shared/src/__tests__/type-guards.test.ts | 139 ++++++++++ 17 files changed, 601 insertions(+), 133 deletions(-) delete mode 100644 packages/cli/bunfig.toml create mode 100644 packages/cli/src/aws/billing.ts create mode 100644 packages/cli/src/digitalocean/billing.ts create mode 100644 packages/cli/src/gcp/billing.ts create mode 100644 packages/cli/src/hetzner/billing.ts create mode 100644 packages/shared/src/__tests__/parse.test.ts create mode 100644 packages/shared/src/__tests__/result.test.ts create mode 100644 packages/shared/src/__tests__/type-guards.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83044e69..be633636 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: run: bun install - name: Run mock tests - run: bun test + run: bun test --coverage unit-tests: name: Unit Tests diff --git a/bunfig.toml b/bunfig.toml index 394a0567..620c1e0c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,3 @@ [test] preload = ["./packages/cli/src/__tests__/preload.ts"] +coverageThreshold = { line = 0.8, function = 0 } diff --git a/packages/cli/bunfig.toml b/packages/cli/bunfig.toml deleted file mode 100644 index 03be6075..00000000 --- a/packages/cli/bunfig.toml +++ /dev/null @@ -1,5 +0,0 @@ -[test] -preload = ["./src/__tests__/preload.ts"] - -[test.coverage] -threshold = { line = 80, function = 90 } diff --git a/packages/cli/package.json b/packages/cli/package.json index d7268043..15a3a91e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.25.0", + "version": "0.25.1", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/billing-guidance.test.ts b/packages/cli/src/__tests__/billing-guidance.test.ts index b757d0a4..ed97e410 100644 --- a/packages/cli/src/__tests__/billing-guidance.test.ts +++ b/packages/cli/src/__tests__/billing-guidance.test.ts @@ -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", ], diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index 72371fde..3b4c5c1e 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -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", diff --git a/packages/cli/src/aws/billing.ts b/packages/cli/src/aws/billing.ts new file mode 100644 index 00000000..77424212 --- /dev/null +++ b/packages/cli/src/aws/billing.ts @@ -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, + ], +}; diff --git a/packages/cli/src/digitalocean/billing.ts b/packages/cli/src/digitalocean/billing.ts new file mode 100644 index 00000000..6ebd4b71 --- /dev/null +++ b/packages/cli/src/digitalocean/billing.ts @@ -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, + ], +}; diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 71a58ff4..396445ac 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -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 { // 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)", ]); diff --git a/packages/cli/src/gcp/billing.ts b/packages/cli/src/gcp/billing.ts new file mode 100644 index 00000000..dbf5dc47 --- /dev/null +++ b/packages/cli/src/gcp/billing.ts @@ -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, + ], +}; diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 3adaad34..5e0a6f1c 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -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 { 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)", ]); diff --git a/packages/cli/src/hetzner/billing.ts b/packages/cli/src/hetzner/billing.ts new file mode 100644 index 00000000..72603a64 --- /dev/null +++ b/packages/cli/src/hetzner/billing.ts @@ -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, + ], +}; diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index 18d3bdfc..f44a6465 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -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", ]); diff --git a/packages/cli/src/shared/billing-guidance.ts b/packages/cli/src/shared/billing-guidance.ts index 5c8caa91..b8195322 100644 --- a/packages/cli/src/shared/billing-guidance.ts +++ b/packages/cli/src/shared/billing-guidance.ts @@ -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 = { - 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 = { - 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 = { - 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 { - const billingUrl = BILLING_URLS[cloud]; - const steps = SETUP_STEPS[cloud] || []; +export async function handleBillingError( + config: BillingConfig, + deps: BillingGuidanceDeps = defaultDeps, +): Promise { + 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 = 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}`); } } diff --git a/packages/shared/src/__tests__/parse.test.ts b/packages/shared/src/__tests__/parse.test.ts new file mode 100644 index 00000000..e8f582c6 --- /dev/null +++ b/packages/shared/src/__tests__/parse.test.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/__tests__/result.test.ts b/packages/shared/src/__tests__/result.test.ts new file mode 100644 index 00000000..90646a52 --- /dev/null +++ b/packages/shared/src/__tests__/result.test.ts @@ -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(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); + }); +}); diff --git a/packages/shared/src/__tests__/type-guards.test.ts b/packages/shared/src/__tests__/type-guards.test.ts new file mode 100644 index 00000000..a5dfc060 --- /dev/null +++ b/packages/shared/src/__tests__/type-guards.test.ts @@ -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([]); + }); +});