Skip to content

Commit 864fef6

Browse files
betegonclaude
andcommitted
refactor(test): replace mock.module() with spyOn() for init tests
Convert init-interactive and init-wizard-runner tests from isolated mock.module() pattern to spyOn() on namespace imports, eliminating mock leakage without process isolation. Also fix CI coverage merge to deduplicate LCOV entries via merge-lcov.sh. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 07905aa commit 864fef6

File tree

3 files changed

+233
-178
lines changed

3 files changed

+233
-178
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ jobs:
174174
- name: Isolated Tests
175175
run: bun run test:isolated
176176
- name: Merge Coverage
177-
run: cat coverage/unit-lcov.info coverage/lcov.info > coverage/merged-lcov.info
177+
run: |
178+
cat coverage/unit-lcov.info coverage/lcov.info > coverage/combined-lcov.info
179+
bash script/merge-lcov.sh coverage/combined-lcov.info > coverage/merged-lcov.info
178180
- name: Upload Coverage
179181
uses: getsentry/codecov-action@main
180182
with:
Lines changed: 70 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,28 @@
11
/**
2-
* Isolated tests for init wizard interactive prompts.
2+
* Interactive Dispatcher Tests
33
*
4-
* Uses mock.module() to stub @clack/prompts — kept isolated so the
5-
* module-level mock does not leak into other test files.
4+
* Tests for the init wizard interactive prompt handlers. Uses spyOn on
5+
* @clack/prompts namespace to intercept calls from named imports.
66
*/
77

8-
import { beforeEach, describe, expect, mock, test } from "bun:test";
9-
import type { WizardOptions } from "../../src/lib/init/types.js";
10-
11-
// Controllable mock implementations — reset per test via beforeEach
12-
let selectImpl: ReturnType<typeof mock>;
13-
let multiselectImpl: ReturnType<typeof mock>;
14-
let confirmImpl: ReturnType<typeof mock>;
15-
const logMock = { info: mock(), error: mock(), warn: mock() };
16-
const cancelMock = mock();
17-
18-
mock.module("@clack/prompts", () => ({
19-
select: (...args: unknown[]) => selectImpl(...args),
20-
multiselect: (...args: unknown[]) => multiselectImpl(...args),
21-
confirm: (...args: unknown[]) => confirmImpl(...args),
22-
log: logMock,
23-
cancel: (...args: unknown[]) => cancelMock(...args),
24-
isCancel: (v: unknown) => v === Symbol.for("cancel"),
25-
note: mock(),
26-
outro: mock(),
27-
intro: mock(),
28-
spinner: () => ({ start: mock(), stop: mock(), message: mock() }),
29-
}));
30-
31-
const { handleInteractive } = await import("../../src/lib/init/interactive.js");
8+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
9+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
10+
import * as clack from "@clack/prompts";
11+
import { handleInteractive } from "../../../src/lib/init/interactive.js";
12+
import type { WizardOptions } from "../../../src/lib/init/types.js";
13+
14+
const noop = () => {
15+
/* suppress clack output */
16+
};
17+
18+
let selectSpy: ReturnType<typeof spyOn>;
19+
let multiselectSpy: ReturnType<typeof spyOn>;
20+
let confirmSpy: ReturnType<typeof spyOn>;
21+
let logInfoSpy: ReturnType<typeof spyOn>;
22+
let logErrorSpy: ReturnType<typeof spyOn>;
23+
let logWarnSpy: ReturnType<typeof spyOn>;
24+
let cancelSpy: ReturnType<typeof spyOn>;
25+
let isCancelSpy: ReturnType<typeof spyOn>;
3226

3327
function makeOptions(overrides?: Partial<WizardOptions>): WizardOptions {
3428
return {
@@ -44,13 +38,33 @@ function makeOptions(overrides?: Partial<WizardOptions>): WizardOptions {
4438
}
4539

4640
beforeEach(() => {
47-
selectImpl = mock(() => Promise.resolve("default"));
48-
multiselectImpl = mock(() => Promise.resolve([]));
49-
confirmImpl = mock(() => Promise.resolve(true));
50-
logMock.info.mockClear();
51-
logMock.error.mockClear();
52-
logMock.warn.mockClear();
53-
cancelMock.mockClear();
41+
selectSpy = spyOn(clack, "select").mockImplementation(
42+
() => Promise.resolve("default") as any
43+
);
44+
multiselectSpy = spyOn(clack, "multiselect").mockImplementation(
45+
() => Promise.resolve([]) as any
46+
);
47+
confirmSpy = spyOn(clack, "confirm").mockImplementation(
48+
() => Promise.resolve(true) as any
49+
);
50+
logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop);
51+
logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop);
52+
logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop);
53+
cancelSpy = spyOn(clack, "cancel").mockImplementation(noop);
54+
isCancelSpy = spyOn(clack, "isCancel").mockImplementation(
55+
(v: unknown) => v === Symbol.for("cancel")
56+
);
57+
});
58+
59+
afterEach(() => {
60+
selectSpy.mockRestore();
61+
multiselectSpy.mockRestore();
62+
confirmSpy.mockRestore();
63+
logInfoSpy.mockRestore();
64+
logErrorSpy.mockRestore();
65+
logWarnSpy.mockRestore();
66+
cancelSpy.mockRestore();
67+
isCancelSpy.mockRestore();
5468
});
5569

5670
describe("handleInteractive dispatcher", () => {
@@ -76,7 +90,7 @@ describe("handleSelect", () => {
7690
);
7791

7892
expect(result).toEqual({ selectedApp: "my-app" });
79-
expect(logMock.info).toHaveBeenCalled();
93+
expect(logInfoSpy).toHaveBeenCalled();
8094
});
8195

8296
test("cancels with --yes when multiple options exist", async () => {
@@ -91,7 +105,7 @@ describe("handleSelect", () => {
91105
);
92106

93107
expect(result).toEqual({ cancelled: true });
94-
expect(logMock.error).toHaveBeenCalled();
108+
expect(logErrorSpy).toHaveBeenCalled();
95109
});
96110

97111
test("cancels when options list is empty", async () => {
@@ -123,7 +137,7 @@ describe("handleSelect", () => {
123137
});
124138

125139
test("calls clack select in interactive mode", async () => {
126-
selectImpl = mock(() => Promise.resolve("vue"));
140+
selectSpy.mockImplementation(() => Promise.resolve("vue") as any);
127141

128142
const result = await handleInteractive(
129143
{
@@ -136,11 +150,13 @@ describe("handleSelect", () => {
136150
);
137151

138152
expect(result).toEqual({ selectedApp: "vue" });
139-
expect(selectImpl).toHaveBeenCalled();
153+
expect(selectSpy).toHaveBeenCalled();
140154
});
141155

142156
test("throws WizardCancelledError on user cancellation", async () => {
143-
selectImpl = mock(() => Promise.resolve(Symbol.for("cancel")));
157+
selectSpy.mockImplementation(
158+
() => Promise.resolve(Symbol.for("cancel")) as any
159+
);
144160

145161
await expect(
146162
handleInteractive(
@@ -195,7 +211,9 @@ describe("handleMultiSelect", () => {
195211

196212
test("prepends errorMonitoring when available but not user-selected", async () => {
197213
// User selects only sessionReplay, but errorMonitoring is available (required)
198-
multiselectImpl = mock(() => Promise.resolve(["sessionReplay"]));
214+
multiselectSpy.mockImplementation(
215+
() => Promise.resolve(["sessionReplay"]) as any
216+
);
199217

200218
const result = await handleInteractive(
201219
{
@@ -217,7 +235,9 @@ describe("handleMultiSelect", () => {
217235
});
218236

219237
test("throws WizardCancelledError when user cancels multi-select", async () => {
220-
multiselectImpl = mock(() => Promise.resolve(Symbol.for("cancel")));
238+
multiselectSpy.mockImplementation(
239+
() => Promise.resolve(Symbol.for("cancel")) as any
240+
);
221241

222242
await expect(
223243
handleInteractive(
@@ -233,7 +253,9 @@ describe("handleMultiSelect", () => {
233253
});
234254

235255
test("excludes errorMonitoring from multiselect options (always included)", async () => {
236-
multiselectImpl = mock(() => Promise.resolve(["performanceMonitoring"]));
256+
multiselectSpy.mockImplementation(
257+
() => Promise.resolve(["performanceMonitoring"]) as any
258+
);
237259

238260
await handleInteractive(
239261
{
@@ -246,7 +268,7 @@ describe("handleMultiSelect", () => {
246268
);
247269

248270
// The options passed to multiselect should NOT include errorMonitoring
249-
const callArgs = multiselectImpl.mock.calls[0][0] as {
271+
const callArgs = multiselectSpy.mock.calls[0][0] as {
250272
options: Array<{ value: string }>;
251273
};
252274
const values = callArgs.options.map((o: { value: string }) => o.value);
@@ -283,7 +305,7 @@ describe("handleConfirm", () => {
283305
});
284306

285307
test("returns addExample based on user choice for example prompts", async () => {
286-
confirmImpl = mock(() => Promise.resolve(false));
308+
confirmSpy.mockImplementation(() => Promise.resolve(false) as any);
287309

288310
const result = await handleInteractive(
289311
{
@@ -298,7 +320,9 @@ describe("handleConfirm", () => {
298320
});
299321

300322
test("throws WizardCancelledError when user cancels confirm", async () => {
301-
confirmImpl = mock(() => Promise.resolve(Symbol.for("cancel")));
323+
confirmSpy.mockImplementation(
324+
() => Promise.resolve(Symbol.for("cancel")) as any
325+
);
302326

303327
await expect(
304328
handleInteractive(
@@ -313,7 +337,7 @@ describe("handleConfirm", () => {
313337
});
314338

315339
test("returns action: stop when user declines non-example prompt", async () => {
316-
confirmImpl = mock(() => Promise.resolve(false));
340+
confirmSpy.mockImplementation(() => Promise.resolve(false) as any);
317341

318342
const result = await handleInteractive(
319343
{

0 commit comments

Comments
 (0)