From 05e171676c6865a1157f3da32757bfb4bd168e24 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 13 May 2026 13:32:14 -0600 Subject: [PATCH] refactor(test): standardize log capture on useCaptureLog and stabilize integration harness Replace ad-hoc log spy patterns across every command and lib test with the useCaptureLog helper. Refactor log.ts and spinner.ts so the helper can hook in cleanly, drop unused test-helper imports as a side effect. Add a harness test covering the lazy config-module loader so each test gets a fresh instance. --- .claude/rules/logging.md | 29 +- packages/cli-core/src/cli-program.ts | 4 + .../cli-core/src/commands/api/catalog.test.ts | 8 +- .../cli-core/src/commands/api/index.test.ts | 9 +- .../src/commands/api/interactive.test.ts | 8 +- packages/cli-core/src/commands/api/ls.test.ts | 8 +- .../cli-core/src/commands/apps/create.test.ts | 12 +- .../cli-core/src/commands/apps/list.test.ts | 8 +- .../cli-core/src/commands/auth/login.test.ts | 13 +- .../cli-core/src/commands/auth/logout.test.ts | 13 +- .../src/commands/billing/index.test.ts | 53 ++-- .../cli-core/src/commands/config/pull.test.ts | 9 +- .../cli-core/src/commands/config/push.test.ts | 37 +-- .../src/commands/config/schema.test.ts | 9 +- .../src/commands/deploy/index.test.ts | 13 +- .../src/commands/doctor/context.test.ts | 9 +- .../cli-core/src/commands/env/pull.test.ts | 9 +- .../cli-core/src/commands/init/index.test.ts | 12 +- .../cli-core/src/commands/init/scan.test.ts | 8 +- .../cli-core/src/commands/link/index.test.ts | 13 +- .../cli-core/src/commands/open/index.test.ts | 28 +- .../cli-core/src/commands/orgs/index.test.ts | 43 ++- .../src/commands/switch-env/index.test.ts | 8 +- .../src/commands/unlink/index.test.ts | 13 +- .../src/commands/users/create.test.ts | 8 +- .../cli-core/src/commands/users/list.test.ts | 8 +- .../cli-core/src/commands/users/menu.test.ts | 3 + .../cli-core/src/commands/users/open.test.ts | 58 ++-- .../src/commands/whoami/index.test.ts | 17 +- packages/cli-core/src/lib/auth-server.test.ts | 2 + packages/cli-core/src/lib/autoclaim.test.ts | 9 +- packages/cli-core/src/lib/autolink.test.ts | 9 +- .../cli-core/src/lib/bapi-command.test.ts | 36 ++- .../src/lib/first-application.test.ts | 3 + packages/cli-core/src/lib/keyless.test.ts | 47 ++-- packages/cli-core/src/lib/log.test.ts | 254 +++++------------- packages/cli-core/src/lib/log.ts | 48 ++-- packages/cli-core/src/lib/spinner.ts | 37 ++- .../src/test/integration/lib/harness.test.ts | 8 + .../src/test/integration/lib/harness.ts | 8 +- packages/cli-core/src/test/lib/stubs.ts | 64 +++-- 41 files changed, 412 insertions(+), 583 deletions(-) create mode 100644 packages/cli-core/src/test/integration/lib/harness.test.ts diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index b24b845c..d427ba76 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -24,6 +24,7 @@ Adjust the relative path to `lib/log.ts` based on the file's location under `pac | `log.error()` | stderr | Errors (red, auto-prefixed `error:`) | | `log.debug()` | stderr | Diagnostic info, only with `--verbose` | | `log.raw()` | stderr | Machine-readable JSON for agent mode | +| `log.ui()` | stderr | Pre-formatted UI (spinner, intro/outro brackets) | | `log.blank()` | stderr | Blank line | `log.data()` writes to **stdout** — this is what gets piped (e.g., `clerk apps list | jq`). Everything else writes to **stderr** as UI for humans. Never mix these. @@ -57,15 +58,27 @@ log.info("Linked to `my-app` on `development`"); ## Testing log output -Use `captureLog()` from `src/test/lib/stubs.ts`. Capture is scoped via `AsyncLocalStorage` — no teardown needed: +Use `useCaptureLog()` from `src/test/lib/stubs.ts` at file or `describe` scope. It registers `beforeEach`/`afterEach` hooks that install a fresh buffer for each test and clear it after — no per-test wiring needed: ```ts -import { captureLog } from "../../test/lib/stubs.ts"; - -test("outputs result", async () => { - const captured = captureLog(); - await captured.run(() => myCommand()); - expect(captured.out).toContain("expected stdout"); // log.data() - expect(captured.err).toContain("expected stderr"); // log.info/warn/etc. +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +describe("my command", () => { + const captured = useCaptureLog(); + + test("outputs result", async () => { + await myCommand(); + expect(captured.out).toContain("expected stdout"); // log.data() + expect(captured.err).toContain("expected stderr"); // log.info/warn/etc. + }); + + test("ignores setup noise", async () => { + await setUp(); + captured.clear(); + await myCommand(); + expect(captured.err).toContain("done"); + }); }); ``` + +Just suppressing log noise without assertions? Call `useCaptureLog()` without keeping the return. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a406fdcb..0f9818a4 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -93,6 +93,10 @@ export function createProgram() { .name("clerk") .description("Clerk CLI") .configureHelp(clerkHelpConfig()) + .configureOutput({ + writeOut: (msg) => log.data(msg.replace(/\n$/, "")), + writeErr: (msg) => log.ui(msg), + }) .version(getCurrentVersion(), "-v, --version", "Output the version number") .helpOption("-h, --help", "Display help for command") .addHelpCommand("help [command]", "Display help for command") diff --git a/packages/cli-core/src/commands/api/catalog.test.ts b/packages/cli-core/src/commands/api/catalog.test.ts index 92317f40..f92d3a07 100644 --- a/packages/cli-core/src/commands/api/catalog.test.ts +++ b/packages/cli-core/src/commands/api/catalog.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; -import { captureLog, stubFetch } from "../../test/lib/stubs.ts"; +import { useCaptureLog, stubFetch } from "../../test/lib/stubs.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -179,17 +179,15 @@ describe("loadCatalog", () => { const originalFetch = globalThis.fetch; let tempDir: string; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-catalog-test-")); _setCacheDir(tempDir); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); }); afterEach(async () => { - captured.teardown(); _setCacheDir(undefined); globalThis.fetch = originalFetch; errorSpy.mockRestore(); @@ -197,7 +195,7 @@ describe("loadCatalog", () => { }); function runLoadCatalog(options?: Parameters[0]) { - return captured.run(() => loadCatalog(options)); + return loadCatalog(options); } test("fetches and caches on first load", async () => { diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index d66b3613..711406b2 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { - captureLog, + useCaptureLog, credentialStoreStubs, gitStubs, configStubs, @@ -183,7 +183,7 @@ describe("api command", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const mockUsers = { data: [{ id: "user_1", email: "test@example.com" }] }; @@ -208,13 +208,10 @@ describe("api command", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); - captured = captureLog(); - stubFetch(async () => new Response(JSON.stringify(mockUsers), { status: 200 })); }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -232,7 +229,7 @@ describe("api command", () => { async function runApi(endpoint: string, options: Record = {}) { const { api } = await import("./index.ts"); - return captured.run(() => api(endpoint, undefined, options)); + return api(endpoint, undefined, options); } // --- GET requests --- diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index 0ddad502..15d1bd2a 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { captureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { useCaptureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; let _mode = "human"; mock.module("../../mode.ts", () => ({ @@ -77,7 +77,7 @@ describe("apiInteractive", () => { let errorSpy: ReturnType; let logSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const originalFetch = globalThis.fetch; const originalIsTTY = process.stdin.isTTY; @@ -105,7 +105,6 @@ describe("apiInteractive", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); - captured = captureLog(); // Capture fetch calls from the real api handler stubFetch(async (input, init) => { fetchCalls.push({ url: input.toString(), method: init?.method ?? "GET" }); @@ -120,7 +119,6 @@ describe("apiInteractive", () => { }); afterEach(async () => { - captured.teardown(); _setCacheDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -137,7 +135,7 @@ describe("apiInteractive", () => { async function runApiInteractive(options: Record = {}) { const { apiInteractive } = await import("./interactive.ts"); - return captured.run(() => apiInteractive(options)); + return apiInteractive(options); } test("shows help and returns in agent mode", async () => { diff --git a/packages/cli-core/src/commands/api/ls.test.ts b/packages/cli-core/src/commands/api/ls.test.ts index 547b9697..65f7c143 100644 --- a/packages/cli-core/src/commands/api/ls.test.ts +++ b/packages/cli-core/src/commands/api/ls.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { parseSpec, _setCacheDir } from "./catalog.ts"; -import { captureLog, stubFetch } from "../../test/lib/stubs.ts"; +import { useCaptureLog, stubFetch } from "../../test/lib/stubs.ts"; import { apiLs } from "./ls.ts"; const MINIMAL_SPEC = ` @@ -45,7 +45,7 @@ describe("apiLs", () => { let tempDir: string; let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const originalFetch = globalThis.fetch; beforeEach(async () => { @@ -59,14 +59,12 @@ describe("apiLs", () => { logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); stubFetch(async () => { throw new Error("Should not fetch"); }); }); afterEach(async () => { - captured.teardown(); _setCacheDir(undefined); globalThis.fetch = originalFetch; logSpy.mockRestore(); @@ -75,7 +73,7 @@ describe("apiLs", () => { }); function runApiLs(filter: string | undefined, options: Parameters[1]) { - return captured.run(() => apiLs(filter, options)); + return apiLs(filter, options); } test("prints all endpoints in table format", async () => { diff --git a/packages/cli-core/src/commands/apps/create.test.ts b/packages/cli-core/src/commands/apps/create.test.ts index e0a943c2..7553524b 100644 --- a/packages/cli-core/src/commands/apps/create.test.ts +++ b/packages/cli-core/src/commands/apps/create.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockCreateApplication = mock(); const mockFetchApplication = mock(); @@ -41,7 +41,7 @@ const mockApp = { describe("apps create", () => { let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { mockIsAgent.mockReturnValue(false); @@ -52,11 +52,9 @@ describe("apps create", () => { mockFetchApplication.mockResolvedValue(mockApp); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); }); afterEach(() => { - captured.teardown(); mockCreateApplication.mockReset(); mockFetchApplication.mockReset(); mockIsAgent.mockReset(); @@ -65,7 +63,7 @@ describe("apps create", () => { }); function runCreate(name: string, options?: Parameters[1]) { - return captured.run(() => create(name, options)); + return create(name, options); } test("calls createApplication then fetchApplication", async () => { @@ -156,14 +154,14 @@ describe("apps create", () => { test("propagates createApplication failure without fetching", async () => { mockCreateApplication.mockRejectedValue(new Error("Unprocessable Entity")); - await expect(create("Bad App")).rejects.toThrow("Unprocessable Entity"); + await expect(runCreate("Bad App")).rejects.toThrow("Unprocessable Entity"); expect(mockFetchApplication).not.toHaveBeenCalled(); }); test("propagates fetchApplication failure after create", async () => { mockFetchApplication.mockRejectedValue(new Error("Service Unavailable")); - await expect(create("My SaaS App")).rejects.toThrow("Service Unavailable"); + await expect(runCreate("My SaaS App")).rejects.toThrow("Service Unavailable"); }); }); }); diff --git a/packages/cli-core/src/commands/apps/list.test.ts b/packages/cli-core/src/commands/apps/list.test.ts index 86c35811..d9972405 100644 --- a/packages/cli-core/src/commands/apps/list.test.ts +++ b/packages/cli-core/src/commands/apps/list.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockListApplications = mock(); mock.module("../../lib/plapi.ts", () => ({ @@ -52,17 +52,15 @@ const mockApps = [ describe("apps list", () => { let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { mockIsAgent.mockReturnValue(false); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); }); afterEach(() => { - captured.teardown(); mockListApplications.mockReset(); mockIsAgent.mockReset(); logSpy.mockRestore(); @@ -70,7 +68,7 @@ describe("apps list", () => { }); function runList(options: Parameters[0] = {}) { - return captured.run(() => list(options)); + return list(options); } describe("compact table (default)", () => { diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 836d95d8..65c4c73f 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -1,6 +1,6 @@ -import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; import { AuthError } from "../../lib/errors.ts"; -import { captureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; +import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; const actualConstants = await import("../../lib/constants.ts"); const actualEnvironment = await import("../../lib/environment.ts"); @@ -94,15 +94,10 @@ const { login } = await import("./login.ts"); describe("login", () => { let consoleSpy: ReturnType; let consoleErrorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const origSpawn = Bun.spawn; - beforeEach(() => { - captured = captureLog(); - }); - afterEach(() => { - captured.teardown(); mockGetValidToken.mockReset(); mockStoreToken.mockReset(); mockCreateOAuthSession.mockReset(); @@ -129,7 +124,7 @@ describe("login", () => { }); function runLogin(options?: Parameters[0]) { - return captured.run(() => login(options)); + return login(options); } function mockBunSpawn() { diff --git a/packages/cli-core/src/commands/auth/logout.test.ts b/packages/cli-core/src/commands/auth/logout.test.ts index 086fcbc9..929a1805 100644 --- a/packages/cli-core/src/commands/auth/logout.test.ts +++ b/packages/cli-core/src/commands/auth/logout.test.ts @@ -1,5 +1,5 @@ -import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; +import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; +import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; const mockDeleteToken = mock(); const mockClearAuth = mock(); @@ -18,21 +18,16 @@ const { logout } = await import("./logout.ts"); describe("logout", () => { let consoleSpy: ReturnType; - let captured: ReturnType; - - beforeEach(() => { - captured = captureLog(); - }); + const captured = useCaptureLog(); afterEach(() => { - captured.teardown(); mockDeleteToken.mockReset(); mockClearAuth.mockReset(); consoleSpy?.mockRestore(); }); function runLogout() { - return captured.run(() => logout()); + return logout(); } test("deletes token and clears auth config", async () => { diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 6435aa8c..ada24d92 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; import { - captureLog, + useCaptureLog, credentialStoreStubs, gitStubs, promptsStubs, @@ -50,7 +50,7 @@ describe("clerk enable/disable billing", () => { let tempDir: string; let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-billing-test-")); @@ -60,8 +60,6 @@ describe("clerk enable/disable billing", () => { logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); - stubFetch(async () => { return new Response(JSON.stringify({}), { status: 200 }); }); @@ -69,7 +67,6 @@ describe("clerk enable/disable billing", () => { }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -97,7 +94,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"] })); + await billingEnable({ for: ["orgs"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); @@ -114,7 +111,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["users"] })); + await billingEnable({ for: ["users"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.user_enabled).toBe(true); @@ -131,7 +128,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs,users"] })); + await billingEnable({ for: ["orgs,users"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); @@ -150,7 +147,7 @@ describe("clerk enable/disable billing", () => { const { billingEnable } = await import("./index.ts"); // Commander variadic produces a string[] when the user writes // `--for orgs users` or `--for orgs --for users`. - await captured.run(() => billingEnable({ for: ["orgs", "users"] })); + await billingEnable({ for: ["orgs", "users"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); @@ -167,7 +164,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["org", "user"] })); + await billingEnable({ for: ["org", "user"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); @@ -184,7 +181,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({})); + await billingEnable({}); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); @@ -195,15 +192,13 @@ describe("clerk enable/disable billing", () => { test("enable rejects invalid --for token", async () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await expect(captured.run(() => billingEnable({ for: ["foo"] }))).rejects.toThrow( - 'Invalid --for value: "foo"', - ); + await expect(billingEnable({ for: ["foo"] })).rejects.toThrow('Invalid --for value: "foo"'); }); test("enable rejects empty --for value", async () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await expect(captured.run(() => billingEnable({ for: [","] }))).rejects.toThrow( + await expect(billingEnable({ for: [","] })).rejects.toThrow( "--for must include at least one of", ); }); @@ -217,7 +212,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: [" orgs , orgs , users "] })); + await billingEnable({ for: [" orgs , orgs , users "] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); @@ -227,7 +222,7 @@ describe("clerk enable/disable billing", () => { test("enable shows success message", async () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"] })); + await billingEnable({ for: ["orgs"] }); expect(captured.err).toContain("Billing enabled for organizations"); }); @@ -241,7 +236,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"], dryRun: true })); + await billingEnable({ for: ["orgs"], dryRun: true }); expect(capturedUrl).toContain("dry_run=true"); expect(captured.err).toContain("[dry-run]"); @@ -258,7 +253,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({ for: ["orgs"] })); + await billingDisable({ for: ["orgs"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(false); @@ -275,7 +270,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({ for: ["users"] })); + await billingDisable({ for: ["users"] }); const parsed = JSON.parse(capturedBody); expect(parsed.billing.user_enabled).toBe(false); @@ -298,7 +293,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({})); + await billingDisable({}); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(false); @@ -309,7 +304,7 @@ describe("clerk enable/disable billing", () => { test("disable shows success message", async () => { await setupProfile(); const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({ for: ["orgs"] })); + await billingDisable({ for: ["orgs"] }); expect(captured.err).toContain("Billing disabled for organizations"); }); @@ -326,7 +321,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({ dryRun: true })); + await billingDisable({ dryRun: true }); expect(capturedUrl).toContain("dry_run=true"); expect(captured.err).toContain("[dry-run]"); @@ -337,7 +332,7 @@ describe("clerk enable/disable billing", () => { test("enable installs the clerk-billing agent skill in agent mode", async () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"] })); + await billingEnable({ for: ["orgs"] }); expect(skillCalls).toEqual([{ source: "clerk/skills", skillNames: ["clerk-billing"] }]); }); @@ -345,7 +340,7 @@ describe("clerk enable/disable billing", () => { test("enable --no-skills suppresses the skill install", async () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"], skills: false })); + await billingEnable({ for: ["orgs"], skills: false }); expect(skillCalls).toHaveLength(0); }); @@ -353,7 +348,7 @@ describe("clerk enable/disable billing", () => { test("enable --dry-run does not install the skill", async () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"], dryRun: true })); + await billingEnable({ for: ["orgs"], dryRun: true }); expect(skillCalls).toHaveLength(0); }); @@ -363,7 +358,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: ["orgs"] })); + await billingEnable({ for: ["orgs"] }); expect(skillCalls).toHaveLength(0); }); @@ -371,7 +366,7 @@ describe("clerk enable/disable billing", () => { test("disable does not trigger the skill install", async () => { await setupProfile(); const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({ for: ["orgs"] })); + await billingDisable({ for: ["orgs"] }); expect(skillCalls).toHaveLength(0); }); @@ -389,7 +384,7 @@ describe("clerk enable/disable billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({})); + await billingEnable({}); expect(skillCalls).toHaveLength(0); expect(captured.err).toContain("No changes detected"); diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index 702d7527..7c1ec5b4 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { captureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); @@ -22,7 +22,7 @@ describe("config pull", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const mockConfig = { session: { lifetime: 604800 }, @@ -40,13 +40,10 @@ describe("config pull", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); - captured = captureLog(); - stubFetch(async () => new Response(JSON.stringify(mockConfig), { status: 200 })); }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -61,7 +58,7 @@ describe("config pull", () => { options: { app?: string; instance?: string; output?: string; keys?: string[] } = {}, ) { const { configPull } = await import("./pull.ts"); - return captured.run(() => configPull(options)); + return configPull(options); } test("errors when no profile is linked", async () => { diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 5b720dad..6e6aa105 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; import { - captureLog, + useCaptureLog, credentialStoreStubs, gitStubs, promptsStubs, @@ -26,7 +26,7 @@ describe("config push", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const mockResponse = { session: { lifetime: 3600 }, @@ -51,8 +51,6 @@ describe("config push", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); - captured = captureLog(); - stubFetch(async (_input, init) => { const isGet = !init?.method || init.method === "GET"; const body = isGet ? currentConfig : mockResponse; @@ -61,7 +59,6 @@ describe("config push", () => { }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -83,7 +80,7 @@ describe("config push", () => { } = {}, ) { const { configPatch } = await import("./push.ts"); - return captured.run(() => configPatch(options)); + return configPatch(options); } async function runConfigPut( @@ -98,7 +95,7 @@ describe("config push", () => { } = {}, ) { const { configPut } = await import("./push.ts"); - return captured.run(() => configPut(options)); + return configPut(options); } // --- Shared error cases --- @@ -675,7 +672,7 @@ describe("config push", () => { }); describe("printDiff", () => { - let captured: ReturnType; + const captured = useCaptureLog(); const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001b\[[0-9;]*m`, "g"); /** Return captured stderr lines with ANSI codes stripped. */ @@ -683,19 +680,11 @@ describe("printDiff", () => { return captured.stderr.map((l) => l.replace(ANSI_ESCAPE_PATTERN, "")); } - beforeEach(() => { - captured = captureLog(); - }); - - afterEach(() => { - captured.teardown(); - }); - test("patch mode: shows only changed leaf values", () => { const current = { session: { lifetime: 604800, cookie: "__session" } }; const patch = { session: { lifetime: 3600 } }; - captured.run(() => printDiff(current, patch, true)); + printDiff(current, patch, true); expect(lines()).toEqual([" session:", " lifetime:", " - 604800", " + 3600"]); }); @@ -704,7 +693,7 @@ describe("printDiff", () => { const current = { session: { lifetime: 3600 }, sign_up: { mode: "public" } }; const patch = { session: { lifetime: 3600 } }; - captured.run(() => printDiff(current, patch, true)); + printDiff(current, patch, true); expect(lines()).toEqual([]); }); @@ -713,7 +702,7 @@ describe("printDiff", () => { const current = {}; const patch = { session: { lifetime: 3600 } }; - captured.run(() => printDiff(current, patch, true)); + printDiff(current, patch, true); expect(lines()).toEqual([" session:", ' + {"lifetime":3600}']); }); @@ -722,7 +711,7 @@ describe("printDiff", () => { const current = { session: { lifetime: 604800 }, sign_up: { mode: "public" } }; const patch = { session: { lifetime: 3600 } }; - captured.run(() => printDiff(current, patch, true)); + printDiff(current, patch, true); // sign_up should not appear expect(lines().some((l) => l.includes("sign_up"))).toBe(false); @@ -732,7 +721,7 @@ describe("printDiff", () => { const current = { session: { lifetime: 604800 }, sign_up: { mode: "public" } }; const payload = { session: { lifetime: 604800 } }; - captured.run(() => printDiff(current, payload, false)); + printDiff(current, payload, false); // session is unchanged, sign_up is being removed expect(lines().some((l) => l.includes("sign_up"))).toBe(true); @@ -743,7 +732,7 @@ describe("printDiff", () => { const current = { session: { lifetime: 604800 } }; const payload = { session: { lifetime: 3600 } }; - captured.run(() => printDiff(current, payload, false)); + printDiff(current, payload, false); expect(lines()).toContainEqual(expect.stringContaining("- 604800")); expect(lines()).toContainEqual(expect.stringContaining("+ 3600")); @@ -753,7 +742,7 @@ describe("printDiff", () => { const current = { a: { b: { c: { d: 1 } } } }; const patch = { a: { b: { c: { d: 2 } } } }; - captured.run(() => printDiff(current, patch, true)); + printDiff(current, patch, true); expect(lines()).toEqual([" a:", " b.c.d:", " - 1", " + 2"]); }); @@ -762,7 +751,7 @@ describe("printDiff", () => { const current = { allowed: { origins: ["a.com", "b.com"] } }; const patch = { allowed: { origins: ["a.com", "c.com"] } }; - captured.run(() => printDiff(current, patch, true)); + printDiff(current, patch, true); expect(lines()).toContainEqual(expect.stringContaining('- ["a.com","b.com"]')); expect(lines()).toContainEqual(expect.stringContaining('+ ["a.com","c.com"]')); diff --git a/packages/cli-core/src/commands/config/schema.test.ts b/packages/cli-core/src/commands/config/schema.test.ts index 89f1d67a..aa22a24e 100644 --- a/packages/cli-core/src/commands/config/schema.test.ts +++ b/packages/cli-core/src/commands/config/schema.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { credentialStoreStubs, gitStubs, stubFetch, captureLog } from "../../test/lib/stubs.ts"; +import { credentialStoreStubs, gitStubs, stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); @@ -15,7 +15,7 @@ describe("config schema", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const mockSchema = { type: "object", @@ -38,13 +38,10 @@ describe("config schema", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); - captured = captureLog(); - stubFetch(async () => new Response(JSON.stringify(mockSchema), { status: 200 })); }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -58,7 +55,7 @@ describe("config schema", () => { options: { app?: string; instance?: string; output?: string; keys?: string[] } = {}, ) { const { configSchema } = await import("./schema.ts"); - return captured.run(() => configSchema(options)); + return configSchema(options); } test("errors when no profile is linked", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9805ccc2..eb78e367 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1,5 +1,5 @@ -import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; +import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; +import { useCaptureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -41,14 +41,9 @@ const { deploy } = await import("./index.ts"); describe("deploy", () => { let consoleSpy: ReturnType; - let captured: ReturnType; - - beforeEach(() => { - captured = captureLog(); - }); + const captured = useCaptureLog(); afterEach(() => { - captured.teardown(); _modeOverride = undefined; mockIsAgent.mockReset(); mockSelect.mockReset(); @@ -59,7 +54,7 @@ describe("deploy", () => { }); function runDeploy(options: Parameters[0]) { - return captured.run(() => deploy(options)); + return deploy(options); } describe("agent mode", () => { diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 2ed04159..a857c381 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -1,5 +1,11 @@ import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; -import { credentialStoreStubs, configStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { + useCaptureLog, + credentialStoreStubs, + configStubs, + gitStubs, + stubFetch, +} from "../../test/lib/stubs.ts"; import type { Application } from "../../lib/plapi.ts"; const mockGetToken = mock(); @@ -27,6 +33,7 @@ const { createDoctorContext } = await import("./context.ts"); describe("createDoctorContext", () => { const originalFetch = globalThis.fetch; + useCaptureLog(); beforeEach(() => { mockGetToken.mockReset(); diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 328ce0cd..2e061f18 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -7,7 +7,7 @@ import { gitStubs, configStubs, stubFetch, - captureLog, + useCaptureLog, } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); @@ -124,7 +124,7 @@ describe("env pull", () => { let errorSpy: ReturnType; let logSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const mockApplication = { application_id: "app_1", @@ -165,13 +165,10 @@ describe("env pull", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); - captured = captureLog(); - stubFetch(async () => new Response(JSON.stringify(mockApplication), { status: 200 })); }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; process.cwd = originalCwd; @@ -186,7 +183,7 @@ describe("env pull", () => { options: { app?: string; instance?: string; file?: string; cwd?: string } = {}, ) { const { pull } = await import("./pull.ts"); - return captured.run(() => pull(options)); + return pull(options); } test("errors when no profile is linked", async () => { diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index a109145c..330b30e3 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, spyOn } from "bun:test"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; // Pure spyOn approach — Bun's mock.module globally replaces modules for the // entire test run, which pollutes other test files (link, env/pull, config, @@ -79,10 +79,9 @@ function mockMiddlewareScaffold(): void { describe("init", () => { let spies: ReturnType[]; - let captured: ReturnType; + const captured = useCaptureLog(); afterEach(() => { - captured.teardown(); for (const s of spies) s.mockRestore(); }); @@ -91,9 +90,6 @@ describe("init", () => { const apiKey = overrides.apiKey ?? false; const agent = overrides.isAgent ?? false; const authed = email != null || apiKey; - - captured = captureLog(); - const gatherContextSpy = spyOn(context, "gatherContext").mockResolvedValue(null); spies = [ @@ -424,7 +420,7 @@ describe("init", () => { mockExistingProject(KEYLESS_CTX); mockMiddlewareScaffold(); - await captured.run(() => init({})); + await init({}); expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); expect(linkMod.link).not.toHaveBeenCalled(); @@ -519,7 +515,7 @@ describe("init", () => { postInstructions: [], }); - await captured.run(() => init({})); + await init({}); expect(linkMod.link).not.toHaveBeenCalled(); expect(pullMod.pull).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 1a19603b..a047eeb0 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; import { join } from "node:path"; import { mkdtemp, rm, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,20 +7,18 @@ import { detectAuthLibraries, scanForIssues } from "./scan.ts"; describe("detectAuthLibraries", () => { let consoleSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { - captured = captureLog(); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { - captured.teardown(); consoleSpy.mockRestore(); }); function runDetectAuthLibraries(deps: Parameters[0]) { - return captured.run(() => detectAuthLibraries(deps)); + return detectAuthLibraries(deps); } test("detects NextAuth", () => { diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index e7627af7..2e9acf08 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -1,6 +1,6 @@ -import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; import { - captureLog, + useCaptureLog, configStubs, credentialStoreStubs, autolinkStubs, @@ -125,14 +125,9 @@ const mockApp = { describe("link", () => { let consoleSpy: ReturnType; - let captured: ReturnType; - - beforeEach(() => { - captured = captureLog(); - }); + const captured = useCaptureLog(); afterEach(() => { - captured.teardown(); _modeOverride = undefined; mockIsAgent.mockReset(); mockGetToken.mockReset(); @@ -163,7 +158,7 @@ describe("link", () => { }); function runLink(options?: Parameters[0]) { - return captured.run(() => link(options)); + return link(options); } describe("agent mode", () => { diff --git a/packages/cli-core/src/commands/open/index.test.ts b/packages/cli-core/src/commands/open/index.test.ts index e13f3d90..94db3e62 100644 --- a/packages/cli-core/src/commands/open/index.test.ts +++ b/packages/cli-core/src/commands/open/index.test.ts @@ -1,7 +1,7 @@ import { test, expect, describe, afterEach, beforeEach, mock } from "bun:test"; import { setMode } from "../../mode.ts"; import { setCurrentEnv } from "../../lib/environment.ts"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; import { isKnownDashboardPath } from "./dashboard-paths.ts"; const mockResolveProfile = mock(); @@ -78,17 +78,15 @@ describe("buildDashboardUrl", () => { }); describe("openDashboard", () => { - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { setMode("human"); setCurrentEnv("production"); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); - captured = captureLog(); }); afterEach(() => { - captured.teardown(); mockResolveProfile.mockReset(); mockOpenBrowser.mockReset(); }); @@ -96,7 +94,7 @@ describe("openDashboard", () => { test("human mode: prints arrow + app + dim URL, opens browser", async () => { mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard(undefined)); + await openDashboard(undefined); expect(captured.err).toContain("Opening"); expect(captured.err).toContain("Test App"); @@ -113,7 +111,7 @@ describe("openDashboard", () => { test("human mode with subpath: shows target in header", async () => { mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard("users")); + await openDashboard("users"); expect(captured.err).toContain("→"); expect(captured.err).toContain("users"); @@ -122,7 +120,7 @@ describe("openDashboard", () => { test("--print: plain URL only on stdout, no browser", async () => { mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard(undefined, { print: true })); + await openDashboard(undefined, { print: true }); expect(captured.out).toBe("https://dashboard.clerk.com/apps/app_abc123/instances/ins_dev789"); expect(mockOpenBrowser).not.toHaveBeenCalled(); @@ -132,7 +130,7 @@ describe("openDashboard", () => { setMode("agent"); mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard("users")); + await openDashboard("users"); const payload = JSON.parse(captured.out); expect(payload).toEqual({ @@ -151,7 +149,7 @@ describe("openDashboard", () => { setMode("agent"); mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard(undefined)); + await openDashboard(undefined); const payload = JSON.parse(captured.out); expect(payload.subpath).toBeNull(); @@ -160,7 +158,7 @@ describe("openDashboard", () => { test("multi-segment known path (platform/api-keys) does not warn", async () => { mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard("platform/api-keys", { print: true })); + await openDashboard("platform/api-keys", { print: true }); expect(captured.err).not.toContain("not a known dashboard path"); expect(captured.out).toBe( @@ -171,7 +169,7 @@ describe("openDashboard", () => { test("known subpath does not warn", async () => { mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard("users", { print: true })); + await openDashboard("users", { print: true }); expect(captured.err).not.toContain("not a known dashboard path"); }); @@ -179,7 +177,7 @@ describe("openDashboard", () => { test("unknown subpath warns to stderr but still emits URL", async () => { mockResolveProfile.mockResolvedValue(PROFILE); - await captured.run(() => openDashboard("not-a-real-page", { print: true })); + await openDashboard("not-a-real-page", { print: true }); expect(captured.err).toContain("not a known dashboard path"); expect(captured.out).toBe( @@ -190,7 +188,7 @@ describe("openDashboard", () => { test("throws NOT_LINKED when no profile", async () => { mockResolveProfile.mockResolvedValue(null); - await expect(captured.run(() => openDashboard(undefined))).rejects.toThrow(/clerk link/); + await expect(openDashboard(undefined)).rejects.toThrow(/clerk link/); expect(mockOpenBrowser).not.toHaveBeenCalled(); }); @@ -203,9 +201,7 @@ describe("openDashboard", () => { }, }); - await expect(captured.run(() => openDashboard(undefined))).rejects.toThrow( - /development instance/, - ); + await expect(openDashboard(undefined)).rejects.toThrow(/development instance/); expect(mockOpenBrowser).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index 84a90250..89fe51d7 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; import { - captureLog, + useCaptureLog, credentialStoreStubs, gitStubs, promptsStubs, @@ -24,7 +24,7 @@ describe("clerk enable/disable orgs", () => { let tempDir: string; let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-orgs-test-")); @@ -34,15 +34,12 @@ describe("clerk enable/disable orgs", () => { logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); - stubFetch(async () => { return new Response(JSON.stringify({}), { status: 200 }); }); }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -70,7 +67,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({})); + await orgsEnable({}); const parsed = JSON.parse(capturedBody); expect(parsed.organization_settings.enabled).toBe(true); @@ -85,7 +82,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({ forceSelection: true })); + await orgsEnable({ forceSelection: true }); const parsed = JSON.parse(capturedBody); expect(parsed.organization_settings.force_organization_selection).toBe(true); @@ -100,7 +97,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({ maxMembers: "10" })); + await orgsEnable({ maxMembers: "10" }); const parsed = JSON.parse(capturedBody); expect(parsed.organization_settings.max_allowed_memberships).toBe(10); @@ -115,7 +112,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await expect(captured.run(() => orgsEnable({ maxMembers: "abc" }))).rejects.toThrow( + await expect(orgsEnable({ maxMembers: "abc" })).rejects.toThrow( "--max-members must be a positive integer", ); expect(calls).toBe(0); @@ -124,7 +121,7 @@ describe("clerk enable/disable orgs", () => { test("enable rejects partial-numeric --max-members like '12abc'", async () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await expect(captured.run(() => orgsEnable({ maxMembers: "12abc" }))).rejects.toThrow( + await expect(orgsEnable({ maxMembers: "12abc" })).rejects.toThrow( "--max-members must be a positive integer", ); }); @@ -132,7 +129,7 @@ describe("clerk enable/disable orgs", () => { test("enable rejects --max-members = 0", async () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await expect(captured.run(() => orgsEnable({ maxMembers: "0" }))).rejects.toThrow( + await expect(orgsEnable({ maxMembers: "0" })).rejects.toThrow( "--max-members must be a positive integer", ); }); @@ -146,7 +143,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({ domains: true })); + await orgsEnable({ domains: true }); const parsed = JSON.parse(capturedBody); expect(parsed.organization_settings.domains_enabled).toBe(true); @@ -161,7 +158,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({ autoCreate: true })); + await orgsEnable({ autoCreate: true }); const parsed = JSON.parse(capturedBody); expect( @@ -179,7 +176,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({ dryRun: true })); + await orgsEnable({ dryRun: true }); expect(capturedUrl).toContain("dry_run=true"); expect(captured.err).toContain("[dry-run]"); @@ -188,7 +185,7 @@ describe("clerk enable/disable orgs", () => { test("enable shows success message", async () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({})); + await orgsEnable({}); expect(captured.err).toContain("Organizations enabled"); }); @@ -205,7 +202,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); - await captured.run(() => orgsEnable({})); + await orgsEnable({}); expect(patchCalls).toBe(0); expect(captured.err).toContain("No changes detected"); @@ -213,7 +210,7 @@ describe("clerk enable/disable orgs", () => { test("enable errors when no profile is linked", async () => { const { orgsEnable } = await import("./index.ts"); - await expect(captured.run(() => orgsEnable({}))).rejects.toThrow("No Clerk project linked"); + await expect(orgsEnable({})).rejects.toThrow("No Clerk project linked"); }); // --- disable --- @@ -229,7 +226,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsDisable } = await import("./index.ts"); - await captured.run(() => orgsDisable({})); + await orgsDisable({}); const parsed = JSON.parse(capturedBody); expect(parsed.organization_settings.enabled).toBe(false); @@ -246,9 +243,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsDisable } = await import("./index.ts"); - await expect(captured.run(() => orgsDisable({}))).rejects.toThrow( - "Organization billing is enabled", - ); + await expect(orgsDisable({})).rejects.toThrow("Organization billing is enabled"); expect(patchCalls).toBe(0); }); @@ -263,7 +258,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsDisable } = await import("./index.ts"); - await captured.run(() => orgsDisable({ yes: true })); + await orgsDisable({ yes: true }); expect(captured.err).toContain("Organization billing is currently enabled"); const parsed = JSON.parse(capturedBody); @@ -281,7 +276,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsDisable } = await import("./index.ts"); - await captured.run(() => orgsDisable({ yes: true })); + await orgsDisable({ yes: true }); const parsed = JSON.parse(capturedBody); expect(parsed.organization_settings.enabled).toBe(false); @@ -296,7 +291,7 @@ describe("clerk enable/disable orgs", () => { await setupProfile(); const { orgsDisable } = await import("./index.ts"); - await captured.run(() => orgsDisable({})); + await orgsDisable({}); expect(captured.err).toContain("Organizations disabled"); }); diff --git a/packages/cli-core/src/commands/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index f51bdb90..9c85c59d 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { - captureLog, + useCaptureLog, configStubs, credentialStoreStubs, listageStubs, @@ -51,16 +51,14 @@ const { switchEnv } = await import("./index.ts"); describe("switch-env", () => { let logSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); const originalIsTTY = process.stdin.isTTY; beforeEach(() => { - captured = captureLog(); process.stdin.isTTY = true; }); afterEach(() => { - captured.teardown(); mockSetEnvironment.mockReset(); mockGetToken.mockReset(); mockSelect.mockReset(); @@ -71,7 +69,7 @@ describe("switch-env", () => { }); function runSwitchEnv(environment: string | undefined) { - return captured.run(() => switchEnv(environment)); + return switchEnv(environment); } test("prints current environment in non-interactive mode", async () => { diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index a523c8ea..84a742cb 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -1,5 +1,5 @@ -import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, configStubs, gitStubs, promptsStubs } from "../../test/lib/stubs.ts"; +import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; +import { useCaptureLog, configStubs, gitStubs, promptsStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); const mockIsHuman = mock(); @@ -50,14 +50,9 @@ describe("unlink", () => { let consoleSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; - let captured: ReturnType; - - beforeEach(() => { - captured = captureLog(); - }); + const captured = useCaptureLog(); afterEach(() => { - captured.teardown(); _modeOverride = undefined; mockIsAgent.mockReset(); mockIsHuman.mockReset(); @@ -72,7 +67,7 @@ describe("unlink", () => { }); function runUnlink(options?: Parameters[0]) { - return captured.run(() => unlink(options)); + return unlink(options); } describe("agent mode", () => { diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index f19a1c84..69a837ac 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, promptsStubs } from "../../test/lib/stubs.ts"; +import { useCaptureLog, promptsStubs } from "../../test/lib/stubs.ts"; import { BapiError, CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; const mockResolveBapiSecretKey = mock(); @@ -37,7 +37,7 @@ const { create } = await import("./create.ts"); describe("users create", () => { let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { mockIsAgent.mockReturnValue(false); @@ -51,11 +51,9 @@ describe("users create", () => { }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); }); afterEach(() => { - captured.teardown(); process.exitCode = 0; mockResolveBapiSecretKey.mockReset(); mockHandleBapiError.mockReset(); @@ -68,7 +66,7 @@ describe("users create", () => { }); function runCreate(options: Parameters[0]) { - return captured.run(() => create(options)); + return create(options); } test("curated flags override conflicting JSON payload fields and forward targeting to the secret key resolver", async () => { diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index a104e91a..1168a113 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -1,7 +1,7 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { popPrefix, pushPrefix } from "../../lib/log.ts"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockBapiRequest = mock(); mock.module("../../commands/api/bapi.ts", () => ({ @@ -62,7 +62,7 @@ const mockUsers = [ describe("users list", () => { let logSpy: ReturnType; let errorSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { mockIsAgent.mockReturnValue(false); @@ -75,11 +75,9 @@ describe("users list", () => { }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); - captured = captureLog(); }); afterEach(() => { - captured.teardown(); mockBapiRequest.mockReset(); mockResolveBapiSecretKey.mockReset(); mockResolveUsersInstanceContext.mockReset(); @@ -91,7 +89,7 @@ describe("users list", () => { }); function runList(options: Parameters[0] = {}) { - return captured.run(() => list(options)); + return list(options); } test("forwards targeting options when resolving the secret key", async () => { diff --git a/packages/cli-core/src/commands/users/menu.test.ts b/packages/cli-core/src/commands/users/menu.test.ts index caa8bc9e..f6d12096 100644 --- a/packages/cli-core/src/commands/users/menu.test.ts +++ b/packages/cli-core/src/commands/users/menu.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe, beforeEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockSelect = mock(); const mockIntro = mock(); @@ -36,6 +37,8 @@ const { __resetUsersActionRegistryForTesting, registerUsersAction } = await impo const { usersMenu } = await import("./menu.ts"); describe("usersMenu", () => { + useCaptureLog(); + beforeEach(() => { __resetUsersActionRegistryForTesting(); mockSelect.mockReset(); diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index b7bd1171..0b211258 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -1,7 +1,7 @@ import { test, expect, describe, afterEach, beforeEach, mock } from "bun:test"; import { setMode } from "../../mode.ts"; import { setCurrentEnv } from "../../lib/environment.ts"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockResolveAppContext = mock(); const mockResolveProfile = mock(); @@ -44,7 +44,7 @@ const CTX = { }; describe("users open", () => { - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { setMode("human"); @@ -62,11 +62,9 @@ describe("users open", () => { }); mockResolveUsersInstanceContext.mockResolvedValue(CTX); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); - captured = captureLog(); }); afterEach(() => { - captured.teardown(); mockResolveAppContext.mockReset(); mockResolveProfile.mockReset(); mockResolveInstanceId.mockReset(); @@ -76,7 +74,7 @@ describe("users open", () => { }); test("explicit user-id + linked profile: opens dashboard URL for that user", async () => { - await captured.run(() => open({ userId: "user_2x9k" })); + await open({ userId: "user_2x9k" }); expect(mockResolveAppContext).toHaveBeenCalledWith({ instance: undefined, @@ -93,7 +91,7 @@ describe("users open", () => { }); test("--print: plain URL only on stdout, no browser, no intro/outro", async () => { - await captured.run(() => open({ userId: "user_2x9k", print: true })); + await open({ userId: "user_2x9k", print: true }); expect(captured.out).toBe( "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", @@ -104,7 +102,7 @@ describe("users open", () => { test("agent mode without --print: emits structured JSON, no browser", async () => { setMode("agent"); - await captured.run(() => open({ userId: "user_2x9k" })); + await open({ userId: "user_2x9k" }); const payload = JSON.parse(captured.out); expect(payload).toEqual({ @@ -121,7 +119,7 @@ describe("users open", () => { test("agent mode with --print still wins: URL only, no JSON", async () => { setMode("agent"); - await captured.run(() => open({ userId: "user_2x9k", print: true })); + await open({ userId: "user_2x9k", print: true }); expect(captured.out).toBe( "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", @@ -131,9 +129,7 @@ describe("users open", () => { }); test("--secret-key alone: uses linked app context and direct BAPI auth", async () => { - await captured.run(() => - open({ secretKey: "sk_test_loose", userId: "user_2x9k", print: true }), - ); + await open({ secretKey: "sk_test_loose", userId: "user_2x9k", print: true }); expect(mockResolveAppContext).toHaveBeenCalledWith({ instance: undefined, @@ -162,9 +158,9 @@ describe("users open", () => { secretKey: "sk_test_loose", }); - await expect( - captured.run(() => open({ secretKey: "sk_test_loose", userId: "user_2x9k" })), - ).rejects.toThrow(/dashboard URL|--app/); + await expect(open({ secretKey: "sk_test_loose", userId: "user_2x9k" })).rejects.toThrow( + /dashboard URL|--app/, + ); expect(mockResolveUsersInstanceContext).toHaveBeenCalledTimes(2); expect(mockOpenBrowser).not.toHaveBeenCalled(); }); @@ -172,7 +168,7 @@ describe("users open", () => { test("no user-id + human mode: invokes pickUser and uses returned id", async () => { mockPickUser.mockResolvedValue("user_picked"); - await captured.run(() => open({ print: true })); + await open({ print: true }); expect(mockPickUser).toHaveBeenCalledWith({ secretKey: CTX.secretKey, @@ -186,7 +182,7 @@ describe("users open", () => { test("no user-id + agent mode: throws usage error, does not invoke pickUser", async () => { setMode("agent"); - await expect(captured.run(() => open({}))).rejects.toThrow(/User ID is required/); + await expect(open({})).rejects.toThrow(/User ID is required/); expect(mockPickUser).not.toHaveBeenCalled(); expect(mockOpenBrowser).not.toHaveBeenCalled(); }); @@ -194,9 +190,7 @@ describe("users open", () => { test("forwards --app and --instance to the resolver", async () => { mockResolveProfile.mockResolvedValue(undefined); - await captured.run(() => - open({ userId: "user_2x9k", app: "app_other", instance: "prod", print: true }), - ); + await open({ userId: "user_2x9k", app: "app_other", instance: "prod", print: true }); expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ app: "app_other", @@ -211,15 +205,13 @@ describe("users open", () => { .mockRejectedValueOnce(new Error("Not authenticated")) .mockResolvedValueOnce(CTX); - await captured.run(() => - open({ - userId: "user_2x9k", - print: true, - secretKey: "sk_test_direct", - app: "app_other", - instance: "prod", - }), - ); + await open({ + userId: "user_2x9k", + print: true, + secretKey: "sk_test_direct", + app: "app_other", + instance: "prod", + }); expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ app: "app_other", @@ -246,7 +238,7 @@ describe("users open", () => { }), ); - await expect(captured.run(() => open({ userId: "user_2x9k" }))).rejects.toThrow(/Not linked/); + await expect(open({ userId: "user_2x9k" })).rejects.toThrow(/Not linked/); }); test("registers an action in the users registry", async () => { @@ -258,13 +250,9 @@ describe("users open", () => { }); test("rejects malformed user IDs with a usage error", async () => { - await expect(captured.run(() => open({ userId: "../foo", print: true }))).rejects.toThrow( - /Invalid user ID/, - ); + await expect(open({ userId: "../foo", print: true })).rejects.toThrow(/Invalid user ID/); - await expect( - captured.run(() => open({ userId: "not-a-user-id", print: true })), - ).rejects.toThrow(/Invalid user ID/); + await expect(open({ userId: "not-a-user-id", print: true })).rejects.toThrow(/Invalid user ID/); expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); }); diff --git a/packages/cli-core/src/commands/whoami/index.test.ts b/packages/cli-core/src/commands/whoami/index.test.ts index d14a416f..a3b35659 100644 --- a/packages/cli-core/src/commands/whoami/index.test.ts +++ b/packages/cli-core/src/commands/whoami/index.test.ts @@ -1,5 +1,5 @@ -import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, credentialStoreStubs, tokenExchangeStubs } from "../../test/lib/stubs.ts"; +import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; +import { useCaptureLog, credentialStoreStubs, tokenExchangeStubs } from "../../test/lib/stubs.ts"; import { CliError } from "../../lib/errors.ts"; const mockGetValidToken = mock(); @@ -19,21 +19,16 @@ const { whoami } = await import("./index.ts"); describe("whoami", () => { let consoleSpy: ReturnType; - let captured: ReturnType; - - beforeEach(() => { - captured = captureLog(); - }); + const captured = useCaptureLog(); afterEach(() => { - captured.teardown(); mockGetValidToken.mockReset(); mockFetchUserInfo.mockReset(); consoleSpy?.mockRestore(); }); function runWhoami() { - return captured.run(() => whoami()); + return whoami(); } test("prints email when authenticated", async () => { @@ -53,7 +48,7 @@ describe("whoami", () => { mockGetValidToken.mockResolvedValue(null); await expect(runWhoami()).rejects.toThrow(CliError); - await expect(captured.run(() => whoami())).rejects.toThrow(/Not logged in/); + await expect(whoami()).rejects.toThrow(/Not logged in/); expect(captured.out).toBe(""); expect(mockFetchUserInfo).not.toHaveBeenCalled(); }); @@ -63,7 +58,7 @@ describe("whoami", () => { mockFetchUserInfo.mockRejectedValue(new Error("Unauthorized")); await expect(runWhoami()).rejects.toThrow(CliError); - await expect(captured.run(() => whoami())).rejects.toThrow(/Session expired/); + await expect(whoami()).rejects.toThrow(/Session expired/); expect(captured.out).toBe(""); }); }); diff --git a/packages/cli-core/src/lib/auth-server.test.ts b/packages/cli-core/src/lib/auth-server.test.ts index 0fb48331..00a53d7c 100644 --- a/packages/cli-core/src/lib/auth-server.test.ts +++ b/packages/cli-core/src/lib/auth-server.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { startAuthServer } from "./auth-server.ts"; +import { useCaptureLog } from "../test/lib/stubs.ts"; describe("auth-server", () => { let serveSpy: ReturnType | undefined; let clearTimeoutSpy: ReturnType | undefined; + useCaptureLog(); afterEach(() => { serveSpy?.mockRestore(); diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index f8e2d4b6..01812b4f 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join, basename } from "node:path"; import { tmpdir } from "node:os"; -import { stubFetch, captureLog } from "../test/lib/stubs.ts"; +import { stubFetch, useCaptureLog } from "../test/lib/stubs.ts"; import * as autolinkMod from "./autolink.ts"; import * as keylessMod from "./keyless.ts"; @@ -22,7 +22,7 @@ const MOCK_APP = { describe("attemptAutoclaim", () => { const originalFetch = globalThis.fetch; let tempDir: string; - let captured: ReturnType; + useCaptureLog(); let linkAppSpy: ReturnType; let readBreadcrumbSpy: ReturnType; let clearBreadcrumbSpy: ReturnType; @@ -32,8 +32,6 @@ describe("attemptAutoclaim", () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-autoclaim-test-")); process.env.CLERK_PLATFORM_API_KEY = "ak_test_key"; process.env.CLERK_PLATFORM_API_URL = "https://test-api.clerk.com"; - captured = captureLog(); - linkAppSpy = spyOn(autolinkMod, "linkApp").mockResolvedValue({ path: tempDir, profile: {} as Profile, @@ -44,7 +42,6 @@ describe("attemptAutoclaim", () => { }); afterEach(async () => { - captured.teardown(); delete process.env.CLERK_PLATFORM_API_KEY; delete process.env.CLERK_PLATFORM_API_URL; globalThis.fetch = originalFetch; @@ -60,7 +57,7 @@ describe("attemptAutoclaim", () => { } function run() { - return captured.run(() => attemptAutoclaim(tempDir)); + return attemptAutoclaim(tempDir); } test("returns not_keyless when no breadcrumb exists", async () => { diff --git a/packages/cli-core/src/lib/autolink.test.ts b/packages/cli-core/src/lib/autolink.test.ts index fbf739c0..8db67470 100644 --- a/packages/cli-core/src/lib/autolink.test.ts +++ b/packages/cli-core/src/lib/autolink.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { credentialStoreStubs, gitStubs, stubFetch, captureLog } from "../test/lib/stubs.ts"; +import { credentialStoreStubs, gitStubs, stubFetch, useCaptureLog } from "../test/lib/stubs.ts"; mock.module("./credential-store.ts", () => credentialStoreStubs); @@ -223,7 +223,7 @@ describe("autolink", () => { let tempDir: string; let errorSpy: ReturnType; let debugSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-autolink-test-")); @@ -238,13 +238,10 @@ describe("autolink", () => { mockGetGitNormalizedRemote.mockResolvedValue(undefined); errorSpy = spyOn(console, "error").mockImplementation(() => {}); debugSpy = spyOn(console, "debug").mockImplementation(() => {}); - captured = captureLog(); - stubFetch(async () => new Response(JSON.stringify(mockApps), { status: 200 })); }); afterEach(async () => { - captured.teardown(); _setConfigDir(undefined); process.env = { ...originalEnv }; globalThis.fetch = originalFetch; @@ -254,7 +251,7 @@ describe("autolink", () => { }); function runAutolink(cwd: string) { - return captured.run(() => autolink(cwd)); + return autolink(cwd); } test("returns undefined when no keys detected", async () => { diff --git a/packages/cli-core/src/lib/bapi-command.test.ts b/packages/cli-core/src/lib/bapi-command.test.ts index af421b2a..b14f86dc 100644 --- a/packages/cli-core/src/lib/bapi-command.test.ts +++ b/packages/cli-core/src/lib/bapi-command.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { BapiError, CliError, ERROR_CODE } from "./errors.ts"; -import { captureLog } from "../test/lib/stubs.ts"; +import { useCaptureLog } from "../test/lib/stubs.ts"; const configModule = await import("./config.ts"); const plapiModule = await import("./plapi.ts"); @@ -12,20 +12,18 @@ describe("bapi-command", () => { let resolveAppContextSpy: ReturnType; let fetchApplicationSpy: ReturnType; let validateKeyPrefixSpy: ReturnType; - let captured: ReturnType; + const captured = useCaptureLog(); beforeEach(() => { delete process.env.CLERK_SECRET_KEY; resolveAppContextSpy = spyOn(configModule, "resolveAppContext"); fetchApplicationSpy = spyOn(plapiModule, "fetchApplication"); validateKeyPrefixSpy = spyOn(plapiModule, "validateKeyPrefix"); - captured = captureLog(); }); afterEach(() => { delete process.env.CLERK_SECRET_KEY; process.exitCode = 0; - captured.teardown(); resolveAppContextSpy.mockRestore(); fetchApplicationSpy.mockRestore(); validateKeyPrefixSpy.mockRestore(); @@ -39,23 +37,19 @@ describe("bapi-command", () => { expect(normalizeBapiPath("/v1")).toBe("/v1"); }); - test("prints raw BAPI error bodies for machine use", async () => { - const handled = await captured.run(() => - Promise.resolve( - handleBapiError( - new BapiError( - 422, - JSON.stringify({ - errors: [ - { - code: "form_param_missing", - message: "email_address is required", - }, - ], - }), - new Headers(), - ), - ), + test("prints raw BAPI error bodies for machine use", () => { + const handled = handleBapiError( + new BapiError( + 422, + JSON.stringify({ + errors: [ + { + code: "form_param_missing", + message: "email_address is required", + }, + ], + }), + new Headers(), ), ); diff --git a/packages/cli-core/src/lib/first-application.test.ts b/packages/cli-core/src/lib/first-application.test.ts index 8d4122e6..cd8354e6 100644 --- a/packages/cli-core/src/lib/first-application.test.ts +++ b/packages/cli-core/src/lib/first-application.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; import { DEFAULT_FIRST_APPLICATION_NAME } from "./constants.ts"; +import { useCaptureLog } from "../test/lib/stubs.ts"; // Mock the plapi module so the tests don't hit a real server. Match the // project convention used elsewhere (see apps/create.test.ts, apps/list.test.ts). @@ -14,6 +15,8 @@ mock.module("./plapi.ts", () => ({ const { ensureFirstApplication } = await import("./first-application.ts"); describe("ensureFirstApplication", () => { + useCaptureLog(); + beforeEach(() => { mockListApplications.mockReset(); mockCreateApplication.mockReset(); diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts index b8da3885..7def4fe3 100644 --- a/packages/cli-core/src/lib/keyless.test.ts +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { stubFetch, captureLog } from "../test/lib/stubs.ts"; +import { stubFetch, useCaptureLog } from "../test/lib/stubs.ts"; const { parseClaimToken, @@ -32,6 +32,7 @@ describe("parseClaimToken", () => { }); describe("breadcrumb", () => { + useCaptureLog(); let tempDir: string; let debugSpy: ReturnType; @@ -61,18 +62,14 @@ describe("breadcrumb", () => { test("read returns undefined when breadcrumb is malformed JSON", async () => { await Bun.write(join(tempDir, ".clerk", "keyless.json"), "not json{{{"); - - const captured = captureLog(); - const result = await captured.run(() => readKeylessBreadcrumb(tempDir)); + const result = await readKeylessBreadcrumb(tempDir); expect(result).toBeUndefined(); }); test("read returns undefined and clears file when breadcrumb has wrong shape", async () => { const breadcrumbFile = join(tempDir, ".clerk", "keyless.json"); await Bun.write(breadcrumbFile, JSON.stringify({ claimToken: 12345, createdAt: "2024-01-01" })); - - const captured = captureLog(); - const result = await captured.run(() => readKeylessBreadcrumb(tempDir)); + const result = await readKeylessBreadcrumb(tempDir); expect(result).toBeUndefined(); expect(await Bun.file(breadcrumbFile).exists()).toBe(false); }); @@ -106,6 +103,7 @@ describe("breadcrumb", () => { }); describe("writeKeysToEnvFile", () => { + useCaptureLog(); let tempDir: string; beforeEach(async () => { @@ -117,13 +115,10 @@ describe("writeKeysToEnvFile", () => { }); test("writes keys to .env.local", async () => { - const captured = captureLog(); - await captured.run(() => - writeKeysToEnvFile(tempDir, { - publishableKey: "pk_test_123", - secretKey: "sk_test_456", - }), - ); + await writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_123", + secretKey: "sk_test_456", + }); const content = await Bun.file(join(tempDir, ".env.local")).text(); expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_123"); @@ -132,14 +127,10 @@ describe("writeKeysToEnvFile", () => { test("merges with existing env file content", async () => { await Bun.write(join(tempDir, ".env.local"), "EXISTING_VAR=hello\n"); - - const captured = captureLog(); - await captured.run(() => - writeKeysToEnvFile(tempDir, { - publishableKey: "pk_test_abc", - secretKey: "sk_test_def", - }), - ); + await writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_abc", + secretKey: "sk_test_def", + }); const content = await Bun.file(join(tempDir, ".env.local")).text(); expect(content).toContain("EXISTING_VAR=hello"); @@ -151,14 +142,10 @@ describe("writeKeysToEnvFile", () => { join(tempDir, "package.json"), JSON.stringify({ dependencies: { next: "latest" } }), ); - - const captured = captureLog(); - await captured.run(() => - writeKeysToEnvFile(tempDir, { - publishableKey: "pk_test_next", - secretKey: "sk_test_next", - }), - ); + await writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_next", + secretKey: "sk_test_next", + }); // Next.js declares envFile: ".env.local" in FRAMEWORK_MAP const content = await Bun.file(join(tempDir, ".env.local")).text(); diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index ddaafbeb..527af420 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -1,26 +1,6 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; -import { - log, - type CapturedLogs, - withCapturedLogs, - setLogLevel, - getLogLevel, - pushPrefix, - popPrefix, - type LogLevel, -} from "./log.ts"; - -function createCapture(): CapturedLogs { - return { stdout: [], stderr: [] }; -} - -function deferred() { - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} +import { log, setLogLevel, getLogLevel, pushPrefix, popPrefix, type LogLevel } from "./log.ts"; +import { useCaptureLog } from "../test/lib/stubs.ts"; let savedLevel: LogLevel; @@ -32,180 +12,86 @@ afterEach(() => { setLogLevel(savedLevel); }); -describe("withCapturedLogs", () => { - test("isolates interleaved async logging", async () => { - const first = createCapture(); - const second = createCapture(); - const firstMayFinish = deferred(); - const secondHasStarted = deferred(); - - const firstTask = withCapturedLogs(first, async () => { - log.info("first:start"); - secondHasStarted.resolve(); - await firstMayFinish.promise; - log.data("first:end"); - }); - - const secondTask = withCapturedLogs(second, async () => { - log.info("second:start"); - await secondHasStarted.promise; - log.data("second:end"); - firstMayFinish.resolve(); - }); - - await Promise.all([firstTask, secondTask]); - - expect(first.stderr).toEqual(["first:start"]); - expect(first.stdout).toEqual(["first:end"]); - expect(second.stderr).toEqual(["second:start"]); - expect(second.stdout).toEqual(["second:end"]); - }); - - test("restores the parent capture after nested scopes", async () => { - const outer = createCapture(); - const inner = createCapture(); - - await withCapturedLogs(outer, async () => { - log.info("outer:before"); - await withCapturedLogs(inner, async () => { - log.data("inner:data"); - }); - log.info("outer:after"); - }); - - expect(outer.stderr).toEqual(["outer:before", "outer:after"]); - expect(outer.stdout).toEqual([]); - expect(inner.stderr).toEqual([]); - expect(inner.stdout).toEqual(["inner:data"]); - }); -}); - describe("log levels", () => { + const captured = useCaptureLog(); + test("debug messages are hidden at default info level", () => { - const cap = createCapture(); setLogLevel("info"); - - withCapturedLogs(cap, () => { - log.debug("should be hidden"); - log.info("should be visible"); - }); - - expect(cap.stderr).toEqual(["should be visible"]); + log.debug("should be hidden"); + log.info("should be visible"); + expect(captured.stderr).toEqual(["should be visible"]); }); test("debug messages are shown at debug level", () => { - const cap = createCapture(); setLogLevel("debug"); - - withCapturedLogs(cap, () => { - log.debug("visible debug"); - }); - - expect(cap.stderr.length).toBe(1); - expect(cap.stderr[0]).toContain("visible debug"); + log.debug("visible debug"); + expect(captured.stderr.length).toBe(1); + expect(captured.stderr[0]).toContain("visible debug"); }); test("warn level hides info and debug", () => { - const cap = createCapture(); setLogLevel("warn"); - - withCapturedLogs(cap, () => { - log.debug("hidden"); - log.info("hidden"); - log.warn("visible"); - log.error("visible"); - }); - - expect(cap.stderr.length).toBe(2); + log.debug("hidden"); + log.info("hidden"); + log.warn("visible"); + log.error("visible"); + expect(captured.stderr.length).toBe(2); }); test("silent level hides everything", () => { - const cap = createCapture(); setLogLevel("silent"); - - withCapturedLogs(cap, () => { - log.error("hidden"); - log.warn("hidden"); - log.info("hidden"); - log.debug("hidden"); - }); - - expect(cap.stderr).toEqual([]); + log.error("hidden"); + log.warn("hidden"); + log.info("hidden"); + log.debug("hidden"); + expect(captured.stderr).toEqual([]); }); test("data output is never filtered by log level", () => { - const cap = createCapture(); setLogLevel("silent"); - - withCapturedLogs(cap, () => { - log.data("always visible"); - }); - - expect(cap.stdout).toEqual(["always visible"]); + log.data("always visible"); + expect(captured.stdout).toEqual(["always visible"]); }); }); describe("withTag", () => { + const captured = useCaptureLog(); + test("prefixes messages with dim tag", () => { - const cap = createCapture(); const tagged = log.withTag("api"); - - withCapturedLogs(cap, () => { - tagged.info("request sent"); - }); - - expect(cap.stderr.length).toBe(1); - expect(cap.stderr[0]).toContain("[api]"); - expect(cap.stderr[0]).toContain("request sent"); + tagged.info("request sent"); + expect(captured.stderr.length).toBe(1); + expect(captured.stderr[0]).toContain("[api]"); + expect(captured.stderr[0]).toContain("request sent"); }); test("nested tags combine with colon", () => { - const cap = createCapture(); const tagged = log.withTag("http").withTag("request"); - - withCapturedLogs(cap, () => { - tagged.info("GET /"); - }); - - expect(cap.stderr[0]).toContain("[http:request]"); - expect(cap.stderr[0]).toContain("GET /"); + tagged.info("GET /"); + expect(captured.stderr[0]).toContain("[http:request]"); + expect(captured.stderr[0]).toContain("GET /"); }); test("respects log level", () => { - const cap = createCapture(); setLogLevel("warn"); const tagged = log.withTag("api"); - - withCapturedLogs(cap, () => { - tagged.info("hidden"); - tagged.warn("visible"); - }); - - expect(cap.stderr.length).toBe(1); - expect(cap.stderr[0]).toContain("visible"); + tagged.info("hidden"); + tagged.warn("visible"); + expect(captured.stderr.length).toBe(1); + expect(captured.stderr[0]).toContain("visible"); }); test("data goes to stdout without tag prefix", () => { - const cap = createCapture(); const tagged = log.withTag("api"); - - withCapturedLogs(cap, () => { - tagged.data("raw output"); - }); - - expect(cap.stdout).toEqual(["raw output"]); + tagged.data("raw output"); + expect(captured.stdout).toEqual(["raw output"]); }); test("preserves outer color after dim tag", () => { - const cap = createCapture(); const tagged = log.withTag("api"); - - withCapturedLogs(cap, () => { - tagged.error("broken"); - }); - - expect(cap.stderr).toHaveLength(1); - const [output] = cap.stderr as [string]; + tagged.error("broken"); + expect(captured.stderr).toHaveLength(1); + const [output] = captured.stderr as [string]; const tagEnd = output.indexOf("]"); const afterTagBeforeMessage = output.slice(tagEnd + 1, output.indexOf("broken")); expect(afterTagBeforeMessage).not.toContain("\x1b[0m"); @@ -214,27 +100,19 @@ describe("withTag", () => { }); describe("inline highlighting", () => { - test("backtick spans are highlighted in cyan", () => { - const cap = createCapture(); - - withCapturedLogs(cap, () => { - log.info("Run `clerk link` to continue"); - }); + const captured = useCaptureLog(); - expect(cap.stderr.length).toBe(1); - expect(cap.stderr[0]).toContain("\x1b[36m"); - expect(cap.stderr[0]).toContain("clerk link"); + test("backtick spans are highlighted in cyan", () => { + log.info("Run `clerk link` to continue"); + expect(captured.stderr.length).toBe(1); + expect(captured.stderr[0]).toContain("\x1b[36m"); + expect(captured.stderr[0]).toContain("clerk link"); }); test("does not use full ANSI reset that breaks outer styles", () => { - const cap = createCapture(); - - withCapturedLogs(cap, () => { - log.info("Run `clerk link` then check"); - }); - - expect(cap.stderr).toHaveLength(1); - const [output] = cap.stderr as [string]; + log.info("Run `clerk link` then check"); + expect(captured.stderr).toHaveLength(1); + const [output] = captured.stderr as [string]; const beforeBacktick = output.indexOf("\x1b[36m"); const afterBacktick = output.indexOf("clerk link") + "clerk link".length; const highlightRegion = output.slice(beforeBacktick, afterBacktick + 10); @@ -243,34 +121,28 @@ describe("inline highlighting", () => { }); describe("blank", () => { - test("includes pipe prefix when inside intro/outro flow", () => { - const cap = createCapture(); - - withCapturedLogs(cap, () => { - pushPrefix(); - log.blank(); - popPrefix(); - }); + const captured = useCaptureLog(); - expect(cap.stderr.length).toBe(1); - expect(cap.stderr[0]).toContain("│"); + test("includes pipe prefix when inside intro/outro flow", () => { + pushPrefix(); + log.blank(); + popPrefix(); + expect(captured.stderr.length).toBe(1); + expect(captured.stderr[0]).toContain("│"); }); }); describe("raw", () => { + const captured = useCaptureLog(); + test("is never throttled for duplicate messages", () => { - const cap = createCapture(); const payload = JSON.stringify({ error: { code: "auth_required", message: "Not authenticated" }, }); - - withCapturedLogs(cap, () => { - log.raw(payload); - log.raw(payload); - log.raw(payload); - }); - - expect(cap.stderr.length).toBe(3); - expect(cap.stderr.every((line) => line === payload)).toBe(true); + log.raw(payload); + log.raw(payload); + log.raw(payload); + expect(captured.stderr.length).toBe(3); + expect(captured.stderr.every((line) => line === payload)).toBe(true); }); }); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 3530f132..2d25ce16 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,4 +1,3 @@ -import { AsyncLocalStorage } from "node:async_hooks"; import { dim, green, red, yellow } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -87,7 +86,7 @@ function shouldWrite(channel: "stdout" | "stderr", msg: string): boolean { // Only throttle stderr (UI messages), never stdout (data) if (channel === "stdout") return true; // Don't throttle in test capture mode - if (captureStorage.getStore()) return true; + if (activeCapture) return true; const key = msg; const now = Date.now(); @@ -133,20 +132,23 @@ export type CapturedLogs = { stderr: string[]; }; -const captureStorage = new AsyncLocalStorage(); +let activeCapture: CapturedLogs | null = null; -export function withCapturedLogs(captured: CapturedLogs, fn: () => T): T { - return captureStorage.run(captured, fn); +export function getActiveCapture(): CapturedLogs | null { + return activeCapture; +} + +export function setActiveCapture(captured: CapturedLogs | null): void { + activeCapture = captured; } function writeln(stream: NodeJS.WriteStream, channel: "stdout" | "stderr", msg: string) { - const captured = captureStorage.getStore(); - if (captured) { - captured[channel].push(msg); - } else { - if (!shouldWrite(channel, msg)) return; - stream.write(msg + "\n"); + if (activeCapture) { + activeCapture[channel].push(msg); + return; } + if (!shouldWrite(channel, msg)) return; + stream.write(msg + "\n"); } // ── Tagged child logger ────────────────────────────────────────────────── @@ -160,6 +162,7 @@ export interface Logger { blank(): void; raw(msg: string): void; data(msg: string): void; + ui(msg: string): void; withTag(childTag: string): Logger; } @@ -200,18 +203,16 @@ function createLogger(tag?: string): Logger { /** Blank line to stderr. Preserves pipe prefix inside intro/outro flow. */ blank() { const prefix = applyPrefix(""); - const captured = captureStorage.getStore(); - if (captured) { - captured.stderr.push(prefix); + if (activeCapture) { + activeCapture.stderr.push(prefix); } else { process.stderr.write(prefix + "\n"); } }, /** Raw stderr — no color, no prefix, no throttle. For machine-readable output (agent JSON). */ raw(msg: string) { - const captured = captureStorage.getStore(); - if (captured) { - captured.stderr.push(msg); + if (activeCapture) { + activeCapture.stderr.push(msg); } else { process.stderr.write(msg + "\n"); } @@ -220,6 +221,19 @@ function createLogger(tag?: string): Logger { data(msg: string) { writeln(process.stdout, "stdout", msg); }, + /** + * Pre-formatted UI to stderr (no color, no prefix, no throttle, no auto-newline). + * Callers include their own trailing `\n` for line-terminated output. Used by + * the spinner for in-place cursor writes (`\r`, `\x1b[?25l`, etc.) where an + * appended newline would break the redraw. + */ + ui(msg: string) { + if (activeCapture) { + activeCapture.stderr.push(msg); + } else { + process.stderr.write(msg); + } + }, /** Create a child logger with a tag prefix. */ withTag(childTag: string): Logger { const combined = tag ? `${tag}:${childTag}` : childTag; diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index d3bebfdc..bbb5d8b7 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -1,6 +1,6 @@ import { isHuman } from "../mode.ts"; import { dim, cyan, green, red } from "./color.ts"; -import { pushPrefix, popPrefix } from "./log.ts"; +import { log, pushPrefix, popPrefix } from "./log.ts"; const FRAMES = ["◒", "◐", "◓", "◑"]; const INTERVAL = 80; @@ -11,8 +11,7 @@ const S_BAR_END = "└"; const S_STEP_DONE = "◇"; const S_STEP_ERROR = "■"; -const stream = process.stderr; -const isInteractive = () => stream.isTTY && !process.env.CI; +const isInteractive = () => process.stderr.isTTY && !process.env.CI; // --- Public API --- @@ -20,7 +19,7 @@ const isInteractive = () => stream.isTTY && !process.env.CI; export function intro(title?: string) { if (!isHuman()) return; const line = title ? `${dim(S_BAR_START)} ${title}` : dim(S_BAR_START); - stream.write(`${line}\n`); + log.ui(`${line}\n`); pushPrefix(); } @@ -29,24 +28,24 @@ export function intro(title?: string) { export function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; popPrefix(); - stream.write(`${dim(S_BAR)}\n`); + log.ui(`${dim(S_BAR)}\n`); if (Array.isArray(messageOrSteps)) { - stream.write(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); + log.ui(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); for (const step of messageOrSteps) { - stream.write(` ${cyan("\u2192")} ${step}\n`); + log.ui(` ${cyan("→")} ${step}\n`); } - stream.write("\n"); + log.ui("\n"); } else { const label = messageOrSteps ?? "Done"; - stream.write(`${dim(S_BAR_END)} ${label}\n\n`); + log.ui(`${dim(S_BAR_END)} ${label}\n\n`); } } /** Print a bar separator: │ */ export function bar() { if (!isHuman()) return; - stream.write(`${dim(S_BAR)}\n`); + log.ui(`${dim(S_BAR)}\n`); } function createSpinner() { @@ -57,13 +56,13 @@ function createSpinner() { return { start(message: string) { if (!interactive) { - stream.write(`${S_STEP_DONE} ${message}\n`); + log.ui(`${S_STEP_DONE} ${message}\n`); return; } - stream.write("\x1b[?25l"); // hide cursor + log.ui("\x1b[?25l"); // hide cursor timer = setInterval(() => { const char = cyan(FRAMES[frame++ % FRAMES.length]!); - stream.write(`\r\x1b[K${char} ${message}`); + log.ui(`\r\x1b[K${char} ${message}`); }, INTERVAL); }, stop(finalMessage?: string) { @@ -72,11 +71,11 @@ function createSpinner() { timer = undefined; } if (!interactive) return; - stream.write(`\r\x1b[K`); + log.ui("\r\x1b[K"); if (finalMessage) { - stream.write(`${green(S_STEP_DONE)} ${finalMessage}\n`); + log.ui(`${green(S_STEP_DONE)} ${finalMessage}\n`); } - stream.write("\x1b[?25h"); // show cursor + log.ui("\x1b[?25h"); // show cursor }, error(finalMessage?: string) { if (timer) { @@ -84,9 +83,9 @@ function createSpinner() { timer = undefined; } if (!interactive) return; - stream.write(`\r\x1b[K`); - stream.write(`${red(S_STEP_ERROR)} ${finalMessage ?? "Failed"}\n`); - stream.write("\x1b[?25h"); + log.ui("\r\x1b[K"); + log.ui(`${red(S_STEP_ERROR)} ${finalMessage ?? "Failed"}\n`); + log.ui("\x1b[?25h"); }, }; } diff --git a/packages/cli-core/src/test/integration/lib/harness.test.ts b/packages/cli-core/src/test/integration/lib/harness.test.ts new file mode 100644 index 00000000..b29a2aa1 --- /dev/null +++ b/packages/cli-core/src/test/integration/lib/harness.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from "bun:test"; +import { join } from "node:path"; + +test("integration harness does not top-level await config imports", async () => { + const source = await Bun.file(join(import.meta.dir, "harness.ts")).text(); + + expect(source).not.toContain('await import("../../../lib/config.ts")'); +}); diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index a93c129b..47597eec 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -17,7 +17,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { capturedOutput } from "../../lib/stubs.ts"; -import { withCapturedLogs } from "../../../lib/log.ts"; +import { setActiveCapture } from "../../../lib/log.ts"; import { http } from "../../lib/http.ts"; import type { Application, ApplicationInstance } from "../../../lib/plapi.ts"; @@ -462,9 +462,7 @@ async function execCLI(...args: string[]): Promise { let exitCode = 0; try { - await withCapturedLogs(currentHarness.captured, () => - runProgram(program, args, { from: "user" }), - ); + await runProgram(program, args, { from: "user" }); } catch (error: unknown) { if ((error as any)?.code?.startsWith?.("commander.")) { exitCode = (error as any).exitCode ?? 1; @@ -564,6 +562,7 @@ export async function setupTest(): Promise { }); const captured = { stdout: [] as string[], stderr: [] as string[] }; + setActiveCapture(captured); const harness = { tempDir, logSpy, errorSpy, exitSpy, captured }; currentHarness = harness; return harness; @@ -578,6 +577,7 @@ export async function setupTest(): Promise { export async function teardownTest(harness: TestHarness): Promise { const { _setConfigDir } = await getConfigModule(); currentHarness = null; + setActiveCapture(null); assertPromptQueuesEmpty(); http.assertRoutesConsumed(); _setConfigDir(undefined); diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 93faff37..22efc1e1 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -1,33 +1,63 @@ -import type { spyOn } from "bun:test"; -import { withCapturedLogs } from "../../lib/log.ts"; +import { afterEach, beforeEach, type spyOn } from "bun:test"; +import { type CapturedLogs, setActiveCapture } from "../../lib/log.ts"; export function capturedOutput(spy: ReturnType): string { return spy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); } /** - * Create a scoped capture buffer for `log.*` calls. + * Capture `log.*` output for every test in the enclosing scope. * - * Use `run()` to execute code inside the capture context in unit tests that - * exercise migrated commands (which use `log.*` instead of `console.log`/`console.error`). + * Registers `beforeEach`/`afterEach` hooks that install a fresh buffer + * before each test and clear it after. The returned proxy exposes getters + * that always reflect the active test's buffer, plus a `clear()` helper + * for ignoring setup noise mid-test. + * + * @example + * ```ts + * const captured = useCaptureLog(); + * + * test("emits success", async () => { + * await myCommand(); + * expect(captured.err).toContain("done"); + * }); + * + * test("ignores setup noise", async () => { + * await setUp(); + * captured.clear(); + * await myCommand(); + * expect(captured.err).toContain("done"); + * }); + * ``` */ -export function captureLog() { - const captured = { stdout: [] as string[], stderr: [] as string[] }; +export function useCaptureLog() { + let buf: CapturedLogs = { stdout: [], stderr: [] }; + beforeEach(() => { + buf = { stdout: [], stderr: [] }; + setActiveCapture(buf); + }); + afterEach(() => { + setActiveCapture(null); + }); return { - ...captured, + get stdout(): string[] { + return buf.stdout; + }, + get stderr(): string[] { + return buf.stderr; + }, /** Joined stdout output. */ - get out() { - return captured.stdout.join("\n"); + get out(): string { + return buf.stdout.join("\n"); }, /** Joined stderr output. */ - get err() { - return captured.stderr.join("\n"); - }, - run(fn: () => T): T { - return withCapturedLogs(captured, fn); + get err(): string { + return buf.stderr.join("\n"); }, - teardown() { - // No-op: capture scope is tied to run(), not process-global state. + /** Reset the capture buffer mid-test (e.g., to ignore setup noise). */ + clear(): void { + buf.stdout.length = 0; + buf.stderr.length = 0; }, }; }