diff --git a/packages/cli/src/__tests__/digitalocean-token.test.ts b/packages/cli/src/__tests__/digitalocean-token.test.ts index e0d329129..004cf2ef8 100644 --- a/packages/cli/src/__tests__/digitalocean-token.test.ts +++ b/packages/cli/src/__tests__/digitalocean-token.test.ts @@ -88,23 +88,17 @@ describe("doApi 401 OAuth recovery", () => { it("attempts OAuth recovery on 401 before throwing", async () => { state.token = "expired-token"; - let callCount = 0; + let apiCalls = 0; + let oauthConnChecks = 0; globalThis.fetch = mock((url: string | URL | Request) => { - callCount++; const urlStr = String(url); - // First call: the actual API call returning 401 - if (callCount === 1) { - return Promise.resolve( - new Response("Unauthorized", { - status: 401, - }), - ); - } - // Second call: OAuth connectivity check — fail it so tryDoOAuth returns null quickly - // (avoids starting a real Bun.serve OAuth server) + // OAuth connectivity check — fail it so tryDoOAuth returns null quickly if (urlStr.includes("cloud.digitalocean.com")) { + oauthConnChecks++; return Promise.reject(new Error("network unavailable")); } + // API calls to DigitalOcean — return 401 + apiCalls++; return Promise.resolve( new Response("Unauthorized", { status: 401, @@ -114,8 +108,9 @@ describe("doApi 401 OAuth recovery", () => { // OAuth recovery fails (connectivity check fails), so doApi throws the 401 await expect(doApi("GET", "/account", undefined, 1)).rejects.toThrow("DigitalOcean API error 401"); - // Verify recovery was attempted: 1 API call + 1 connectivity check = 2 - expect(callCount).toBe(2); + // Verify recovery was attempted: at least 1 API call + 1 connectivity check + expect(apiCalls).toBeGreaterThanOrEqual(1); + expect(oauthConnChecks).toBeGreaterThanOrEqual(1); }); it("succeeds after OAuth recovery provides a new token", async () => { diff --git a/packages/cli/src/__tests__/hetzner-cov.test.ts b/packages/cli/src/__tests__/hetzner-cov.test.ts index ed1c050f1..0b6fea1f7 100644 --- a/packages/cli/src/__tests__/hetzner-cov.test.ts +++ b/packages/cli/src/__tests__/hetzner-cov.test.ts @@ -585,11 +585,11 @@ describe("hetzner/createServer", () => { }, }, }; - let callCount = 0; - global.fetch = mock(() => { - callCount++; - if (callCount <= 1) { - // Token validation + let createAttempts = 0; + global.fetch = mock((url: string | URL | Request) => { + const urlStr = String(url instanceof Request ? url.url : url); + // Token validation — GET /servers?per_page=1 + if (urlStr.includes("/servers") && urlStr.includes("per_page=1")) { return Promise.resolve( new Response( JSON.stringify({ @@ -598,8 +598,8 @@ describe("hetzner/createServer", () => { ), ); } - if (callCount <= 2) { - // SSH keys + // SSH keys (paginated) + if (urlStr.includes("/ssh_keys")) { return Promise.resolve( new Response( JSON.stringify({ @@ -608,24 +608,28 @@ describe("hetzner/createServer", () => { ), ); } - if (callCount <= 3) { - // First create attempt — resource_limit_exceeded (HTTP 403) - return Promise.resolve( - new Response( - JSON.stringify({ - error: { - code: "resource_limit_exceeded", - message: "primary_ip_limit", + // POST /servers — create attempt (matches /servers without query params) + if (urlStr.endsWith("/servers")) { + createAttempts++; + if (createAttempts <= 1) { + return Promise.resolve( + new Response( + JSON.stringify({ + error: { + code: "resource_limit_exceeded", + message: "primary_ip_limit", + }, + }), + { + status: 403, }, - }), - { - status: 403, - }, - ), - ); + ), + ); + } + return Promise.resolve(new Response(JSON.stringify(serverResp))); } - if (callCount <= 4) { - // List primary IPs for cleanup + // List primary IPs for cleanup + if (urlStr.includes("/primary_ips")) { return Promise.resolve( new Response( JSON.stringify({ @@ -645,23 +649,18 @@ describe("hetzner/createServer", () => { ), ); } - if (callCount <= 5) { - // Delete orphaned IP 100 - return Promise.resolve( - new Response("", { - status: 204, - }), - ); - } - // Retry create — success - return Promise.resolve(new Response(JSON.stringify(serverResp))); + // DELETE orphaned IP (or any other call) + return Promise.resolve( + new Response("", { + status: 204, + }), + ); }); const { ensureHcloudToken, createServer } = await import("../hetzner/hetzner"); await ensureHcloudToken(); const conn = await createServer("test-retry", "cx23", "fsn1"); expect(conn.ip).toBe("10.0.0.5"); - // Should have called: token(1), ssh_keys(2), create-fail(3), list-ips(4), delete-ip(5), create-ok(6) - expect(callCount).toBeGreaterThanOrEqual(6); + expect(createAttempts).toBe(2); }); it("throws with guidance when resource limit hit and no orphaned IPs to clean", async () => { diff --git a/packages/cli/src/__tests__/preload.ts b/packages/cli/src/__tests__/preload.ts index 7251c247d..9e229afec 100644 --- a/packages/cli/src/__tests__/preload.ts +++ b/packages/cli/src/__tests__/preload.ts @@ -60,6 +60,10 @@ cleanupStrayTestFiles(); const TEST_HOME = mkdtempSync(join(tmpdir(), "spawn-test-home-")); +// Disable telemetry in tests to prevent fire-and-forget fetch calls from +// interfering with other test files' global.fetch mocks. +process.env.SPAWN_TELEMETRY = "0"; + // Redirect all user-directory env vars to the isolated temp process.env.HOME = TEST_HOME; process.env.XDG_CACHE_HOME = join(TEST_HOME, ".cache"); diff --git a/packages/cli/src/shared/telemetry.ts b/packages/cli/src/shared/telemetry.ts index f9d45dde4..3c458142b 100644 --- a/packages/cli/src/shared/telemetry.ts +++ b/packages/cli/src/shared/telemetry.ts @@ -242,6 +242,11 @@ export function captureError(type: string, err: unknown): void { /** Send a single event to PostHog immediately. Fire-and-forget. */ function sendEvent(event: string, properties: Record): void { + // Re-check at send time — guards against singleton state leaking in tests + // where initTelemetry() was called with _enabled=true but env was later restored. + if (process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test" || process.env.SPAWN_TELEMETRY === "0") { + return; + } const body = JSON.stringify({ api_key: POSTHOG_TOKEN, batch: [