Skip to content

Commit 1102614

Browse files
committed
refactor(log): standardize test log capture on useCaptureLog
Replace AsyncLocalStorage-backed log capture with a module-level activeCapture slot and a useCaptureLog() lifecycle helper that registers beforeEach/afterEach hooks to install and clear a fresh buffer per test. Tests no longer wrap emitting calls with captured.run(); the entire test body is captured. - lib/log.ts: drop node:async_hooks dependency; expose setActiveCapture and getActiveCapture for harness setup. log.ui() no longer appends a trailing newline so spinner cursor-control writes survive. - test/lib/stubs.ts: replace captureLog() with useCaptureLog(), adding a clear() method for mid-test resets when ignoring setup noise. - test/integration/lib/harness.ts: drop the per-call withCapturedLogs wrapper; setupTest/teardownTest manage the slot directly. - spinner.ts: each line-output log.ui() callsite now includes its own trailing \n; in-place cursor writes (\r, \x1b[?25l, \x1b[?25h) stay raw. Removes the latent double-newline bug after the dirty switch from raw stream.write to log.ui. - cli-program.ts: drop the writeErr newline-stripping workaround now that log.ui passes msg through verbatim. - 33 unit test files: migrate from captureLog().run(() => fn()) to describe-scope useCaptureLog() with plain fn() calls. - .claude/rules/logging.md: document the new test pattern.
1 parent 479dee4 commit 1102614

40 files changed

Lines changed: 399 additions & 578 deletions

.claude/rules/logging.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Adjust the relative path to `lib/log.ts` based on the file's location under `pac
2424
| `log.error()` | stderr | Errors (red, auto-prefixed `error:`) |
2525
| `log.debug()` | stderr | Diagnostic info, only with `--verbose` |
2626
| `log.raw()` | stderr | Machine-readable JSON for agent mode |
27+
| `log.ui()` | stderr | Pre-formatted UI (spinner, intro/outro brackets) |
2728
| `log.blank()` | stderr | Blank line |
2829

2930
`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`");
5758

5859
## Testing log output
5960

60-
Use `captureLog()` from `src/test/lib/stubs.ts`. Capture is scoped via `AsyncLocalStorage` — no teardown needed:
61+
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:
6162

6263
```ts
63-
import { captureLog } from "../../test/lib/stubs.ts";
64-
65-
test("outputs result", async () => {
66-
const captured = captureLog();
67-
await captured.run(() => myCommand());
68-
expect(captured.out).toContain("expected stdout"); // log.data()
69-
expect(captured.err).toContain("expected stderr"); // log.info/warn/etc.
64+
import { useCaptureLog } from "../../test/lib/stubs.ts";
65+
66+
describe("my command", () => {
67+
const captured = useCaptureLog();
68+
69+
test("outputs result", async () => {
70+
await myCommand();
71+
expect(captured.out).toContain("expected stdout"); // log.data()
72+
expect(captured.err).toContain("expected stderr"); // log.info/warn/etc.
73+
});
74+
75+
test("ignores setup noise", async () => {
76+
await setUp();
77+
captured.clear();
78+
await myCommand();
79+
expect(captured.err).toContain("done");
80+
});
7081
});
7182
```
83+
84+
Just suppressing log noise without assertions? Call `useCaptureLog()` without keeping the return.

packages/cli-core/src/cli-program.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export function createProgram() {
9393
.name("clerk")
9494
.description("Clerk CLI")
9595
.configureHelp(clerkHelpConfig())
96+
.configureOutput({
97+
writeOut: (msg) => log.data(msg.replace(/\n$/, "")),
98+
writeErr: (msg) => log.ui(msg),
99+
})
96100
.version(getCurrentVersion(), "-v, --version", "Output the version number")
97101
.helpOption("-h, --help", "Display help for command")
98102
.addHelpCommand("help [command]", "Display help for command")

packages/cli-core/src/commands/api/catalog.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test";
2-
import { captureLog, stubFetch } from "../../test/lib/stubs.ts";
2+
import { useCaptureLog, stubFetch } from "../../test/lib/stubs.ts";
33
import { mkdtemp, rm } from "node:fs/promises";
44
import { join } from "node:path";
55
import { tmpdir } from "node:os";
@@ -179,25 +179,23 @@ describe("loadCatalog", () => {
179179
const originalFetch = globalThis.fetch;
180180
let tempDir: string;
181181
let errorSpy: ReturnType<typeof spyOn>;
182-
let captured: ReturnType<typeof captureLog>;
182+
const captured = useCaptureLog();
183183

184184
beforeEach(async () => {
185185
tempDir = await mkdtemp(join(tmpdir(), "clerk-catalog-test-"));
186186
_setCacheDir(tempDir);
187187
errorSpy = spyOn(console, "error").mockImplementation(() => {});
188-
captured = captureLog();
189188
});
190189

191190
afterEach(async () => {
192-
captured.teardown();
193191
_setCacheDir(undefined);
194192
globalThis.fetch = originalFetch;
195193
errorSpy.mockRestore();
196194
await rm(tempDir, { recursive: true, force: true });
197195
});
198196

199197
function runLoadCatalog(options?: Parameters<typeof loadCatalog>[0]) {
200-
return captured.run(() => loadCatalog(options));
198+
return loadCatalog(options);
201199
}
202200

203201
test("fetches and caches on first load", async () => {

packages/cli-core/src/commands/api/index.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
66
import {
7-
captureLog,
7+
useCaptureLog,
88
credentialStoreStubs,
99
gitStubs,
1010
configStubs,
@@ -183,7 +183,7 @@ describe("api command", () => {
183183
let logSpy: ReturnType<typeof spyOn>;
184184
let errorSpy: ReturnType<typeof spyOn>;
185185
let exitSpy: ReturnType<typeof spyOn>;
186-
let captured: ReturnType<typeof captureLog>;
186+
const captured = useCaptureLog();
187187

188188
const mockUsers = { data: [{ id: "user_1", email: "test@example.com" }] };
189189

@@ -208,13 +208,10 @@ describe("api command", () => {
208208
exitSpy = spyOn(process, "exit").mockImplementation(() => {
209209
throw new Error("process.exit");
210210
});
211-
captured = captureLog();
212-
213211
stubFetch(async () => new Response(JSON.stringify(mockUsers), { status: 200 }));
214212
});
215213

216214
afterEach(async () => {
217-
captured.teardown();
218215
_setConfigDir(undefined);
219216
process.env = { ...originalEnv };
220217
globalThis.fetch = originalFetch;
@@ -232,7 +229,7 @@ describe("api command", () => {
232229

233230
async function runApi(endpoint: string, options: Record<string, unknown> = {}) {
234231
const { api } = await import("./index.ts");
235-
return captured.run(() => api(endpoint, undefined, options));
232+
return api(endpoint, undefined, options);
236233
}
237234

238235
// --- GET requests ---

packages/cli-core/src/commands/api/interactive.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:
22
import { mkdtemp, rm } from "node:fs/promises";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
5-
import { captureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts";
5+
import { useCaptureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts";
66

77
let _mode = "human";
88
mock.module("../../mode.ts", () => ({
@@ -77,7 +77,7 @@ describe("apiInteractive", () => {
7777
let errorSpy: ReturnType<typeof spyOn>;
7878
let logSpy: ReturnType<typeof spyOn>;
7979
let exitSpy: ReturnType<typeof spyOn>;
80-
let captured: ReturnType<typeof captureLog>;
80+
const captured = useCaptureLog();
8181
const originalFetch = globalThis.fetch;
8282
const originalIsTTY = process.stdin.isTTY;
8383

@@ -105,7 +105,6 @@ describe("apiInteractive", () => {
105105
exitSpy = spyOn(process, "exit").mockImplementation(() => {
106106
throw new Error("process.exit");
107107
});
108-
captured = captureLog();
109108
// Capture fetch calls from the real api handler
110109
stubFetch(async (input, init) => {
111110
fetchCalls.push({ url: input.toString(), method: init?.method ?? "GET" });
@@ -120,7 +119,6 @@ describe("apiInteractive", () => {
120119
});
121120

122121
afterEach(async () => {
123-
captured.teardown();
124122
_setCacheDir(undefined);
125123
process.env = { ...originalEnv };
126124
globalThis.fetch = originalFetch;
@@ -137,7 +135,7 @@ describe("apiInteractive", () => {
137135

138136
async function runApiInteractive(options: Record<string, unknown> = {}) {
139137
const { apiInteractive } = await import("./interactive.ts");
140-
return captured.run(() => apiInteractive(options));
138+
return apiInteractive(options);
141139
}
142140

143141
test("shows help and returns in agent mode", async () => {

packages/cli-core/src/commands/api/ls.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import { parseSpec, _setCacheDir } from "./catalog.ts";
6-
import { captureLog, stubFetch } from "../../test/lib/stubs.ts";
6+
import { useCaptureLog, stubFetch } from "../../test/lib/stubs.ts";
77
import { apiLs } from "./ls.ts";
88

99
const MINIMAL_SPEC = `
@@ -45,7 +45,7 @@ describe("apiLs", () => {
4545
let tempDir: string;
4646
let logSpy: ReturnType<typeof spyOn>;
4747
let errorSpy: ReturnType<typeof spyOn>;
48-
let captured: ReturnType<typeof captureLog>;
48+
const captured = useCaptureLog();
4949
const originalFetch = globalThis.fetch;
5050

5151
beforeEach(async () => {
@@ -59,14 +59,12 @@ describe("apiLs", () => {
5959

6060
logSpy = spyOn(console, "log").mockImplementation(() => {});
6161
errorSpy = spyOn(console, "error").mockImplementation(() => {});
62-
captured = captureLog();
6362
stubFetch(async () => {
6463
throw new Error("Should not fetch");
6564
});
6665
});
6766

6867
afterEach(async () => {
69-
captured.teardown();
7068
_setCacheDir(undefined);
7169
globalThis.fetch = originalFetch;
7270
logSpy.mockRestore();
@@ -75,7 +73,7 @@ describe("apiLs", () => {
7573
});
7674

7775
function runApiLs(filter: string | undefined, options: Parameters<typeof apiLs>[1]) {
78-
return captured.run(() => apiLs(filter, options));
76+
return apiLs(filter, options);
7977
}
8078

8179
test("prints all endpoints in table format", async () => {

packages/cli-core/src/commands/apps/create.test.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
2-
import { captureLog } from "../../test/lib/stubs.ts";
2+
import { useCaptureLog } from "../../test/lib/stubs.ts";
33

44
const mockCreateApplication = mock();
55
const mockFetchApplication = mock();
@@ -41,7 +41,7 @@ const mockApp = {
4141
describe("apps create", () => {
4242
let logSpy: ReturnType<typeof spyOn>;
4343
let errorSpy: ReturnType<typeof spyOn>;
44-
let captured: ReturnType<typeof captureLog>;
44+
const captured = useCaptureLog();
4545

4646
beforeEach(() => {
4747
mockIsAgent.mockReturnValue(false);
@@ -52,11 +52,9 @@ describe("apps create", () => {
5252
mockFetchApplication.mockResolvedValue(mockApp);
5353
logSpy = spyOn(console, "log").mockImplementation(() => {});
5454
errorSpy = spyOn(console, "error").mockImplementation(() => {});
55-
captured = captureLog();
5655
});
5756

5857
afterEach(() => {
59-
captured.teardown();
6058
mockCreateApplication.mockReset();
6159
mockFetchApplication.mockReset();
6260
mockIsAgent.mockReset();
@@ -65,7 +63,7 @@ describe("apps create", () => {
6563
});
6664

6765
function runCreate(name: string, options?: Parameters<typeof create>[1]) {
68-
return captured.run(() => create(name, options));
66+
return create(name, options);
6967
}
7068

7169
test("calls createApplication then fetchApplication", async () => {
@@ -156,14 +154,14 @@ describe("apps create", () => {
156154
test("propagates createApplication failure without fetching", async () => {
157155
mockCreateApplication.mockRejectedValue(new Error("Unprocessable Entity"));
158156

159-
await expect(create("Bad App")).rejects.toThrow("Unprocessable Entity");
157+
await expect(runCreate("Bad App")).rejects.toThrow("Unprocessable Entity");
160158
expect(mockFetchApplication).not.toHaveBeenCalled();
161159
});
162160

163161
test("propagates fetchApplication failure after create", async () => {
164162
mockFetchApplication.mockRejectedValue(new Error("Service Unavailable"));
165163

166-
await expect(create("My SaaS App")).rejects.toThrow("Service Unavailable");
164+
await expect(runCreate("My SaaS App")).rejects.toThrow("Service Unavailable");
167165
});
168166
});
169167
});

packages/cli-core/src/commands/apps/list.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
2-
import { captureLog } from "../../test/lib/stubs.ts";
2+
import { useCaptureLog } from "../../test/lib/stubs.ts";
33

44
const mockListApplications = mock();
55
mock.module("../../lib/plapi.ts", () => ({
@@ -52,25 +52,23 @@ const mockApps = [
5252
describe("apps list", () => {
5353
let logSpy: ReturnType<typeof spyOn>;
5454
let errorSpy: ReturnType<typeof spyOn>;
55-
let captured: ReturnType<typeof captureLog>;
55+
const captured = useCaptureLog();
5656

5757
beforeEach(() => {
5858
mockIsAgent.mockReturnValue(false);
5959
logSpy = spyOn(console, "log").mockImplementation(() => {});
6060
errorSpy = spyOn(console, "error").mockImplementation(() => {});
61-
captured = captureLog();
6261
});
6362

6463
afterEach(() => {
65-
captured.teardown();
6664
mockListApplications.mockReset();
6765
mockIsAgent.mockReset();
6866
logSpy.mockRestore();
6967
errorSpy.mockRestore();
7068
});
7169

7270
function runList(options: Parameters<typeof list>[0] = {}) {
73-
return captured.run(() => list(options));
71+
return list(options);
7472
}
7573

7674
describe("compact table (default)", () => {

packages/cli-core/src/commands/auth/login.test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
22
import { AuthError } from "../../lib/errors.ts";
3-
import { captureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";
3+
import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";
44

55
const actualConstants = await import("../../lib/constants.ts");
66
const actualEnvironment = await import("../../lib/environment.ts");
@@ -94,15 +94,10 @@ const { login } = await import("./login.ts");
9494
describe("login", () => {
9595
let consoleSpy: ReturnType<typeof spyOn>;
9696
let consoleErrorSpy: ReturnType<typeof spyOn>;
97-
let captured: ReturnType<typeof captureLog>;
97+
const captured = useCaptureLog();
9898
const origSpawn = Bun.spawn;
9999

100-
beforeEach(() => {
101-
captured = captureLog();
102-
});
103-
104100
afterEach(() => {
105-
captured.teardown();
106101
mockGetValidToken.mockReset();
107102
mockStoreToken.mockReset();
108103
mockCreateOAuthSession.mockReset();
@@ -129,7 +124,7 @@ describe("login", () => {
129124
});
130125

131126
function runLogin(options?: Parameters<typeof login>[0]) {
132-
return captured.run(() => login(options));
127+
return login(options);
133128
}
134129

135130
function mockBunSpawn() {

packages/cli-core/src/commands/auth/logout.test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
2-
import { captureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";
2+
import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";
33

44
const mockDeleteToken = mock();
55
const mockClearAuth = mock();
@@ -18,21 +18,16 @@ const { logout } = await import("./logout.ts");
1818

1919
describe("logout", () => {
2020
let consoleSpy: ReturnType<typeof spyOn>;
21-
let captured: ReturnType<typeof captureLog>;
22-
23-
beforeEach(() => {
24-
captured = captureLog();
25-
});
21+
const captured = useCaptureLog();
2622

2723
afterEach(() => {
28-
captured.teardown();
2924
mockDeleteToken.mockReset();
3025
mockClearAuth.mockReset();
3126
consoleSpy?.mockRestore();
3227
});
3328

3429
function runLogout() {
35-
return captured.run(() => logout());
30+
return logout();
3631
}
3732

3833
test("deletes token and clears auth config", async () => {

0 commit comments

Comments
 (0)