Skip to content

Commit 068663d

Browse files
betegonclaude
andcommitted
test(init): improve patch coverage by including isolated tests in CI coverage
Include test/isolated in the test:unit coverage run so that existing comprehensive tests for wizard-runner and interactive modules count toward patch coverage. Add new tests for init command parsing, clack-utils utilities, cancel paths, and wizard-runner edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 20a4431 commit 068663d

File tree

6 files changed

+399
-2
lines changed

6 files changed

+399
-2
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@
6363
"typecheck": "tsc --noEmit",
6464
"lint": "bunx ultracite check",
6565
"lint:fix": "bunx ultracite fix",
66-
"test": "bun run test:unit && bun run test:isolated",
67-
"test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov",
66+
"test": "bun run test:unit",
67+
"test:unit": "bun test test/lib test/commands test/types test/isolated --coverage --coverage-reporter=lcov",
6868
"test:isolated": "bun test test/isolated",
6969
"test:e2e": "bun test test/e2e",
7070
"test:init-eval": "bun test test/init-eval --timeout 600000 --concurrency 6",

test/isolated/init-command.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Isolated tests for the `sentry init` command entry point.
3+
*
4+
* Mocks the same modules as init-wizard-runner.test.ts to avoid
5+
* mock.module() cross-file interference in bun's test runner.
6+
*/
7+
8+
import { beforeEach, describe, expect, mock, test } from "bun:test";
9+
import path from "node:path";
10+
11+
// ── Clack mocks (must match wizard-runner test to avoid leakage) ─────────
12+
const spinnerMock = { start: mock(), stop: mock(), message: mock() };
13+
14+
mock.module("@clack/prompts", () => ({
15+
spinner: () => spinnerMock,
16+
intro: mock(),
17+
log: { info: mock(), warn: mock(), error: mock() },
18+
cancel: mock(),
19+
note: mock(),
20+
outro: mock(),
21+
select: mock(),
22+
multiselect: mock(),
23+
confirm: mock(),
24+
isCancel: (v: unknown) => v === Symbol.for("cancel"),
25+
}));
26+
27+
// ── Handler mocks ────────────────────────────────────────────────────────
28+
mock.module("../../src/lib/init/local-ops.js", () => ({
29+
handleLocalOp: mock(() =>
30+
Promise.resolve({ ok: true, data: { results: [] } })
31+
),
32+
validateCommand: () => {
33+
/* noop mock */
34+
},
35+
}));
36+
37+
mock.module("../../src/lib/init/interactive.js", () => ({
38+
handleInteractive: mock(() => Promise.resolve({ action: "continue" })),
39+
}));
40+
41+
mock.module("../../src/lib/init/formatters.js", () => ({
42+
formatResult: mock(),
43+
formatError: mock(),
44+
}));
45+
46+
mock.module("../../src/lib/db/auth.js", () => ({
47+
getAuthToken: () => "fake-token",
48+
isAuthenticated: () => Promise.resolve(false),
49+
}));
50+
51+
mock.module("../../src/lib/help.js", () => ({
52+
formatBanner: () => "BANNER",
53+
}));
54+
55+
// ── MastraClient mock — startAsync captures the runWizard call args ──────
56+
let capturedInputData: Record<string, unknown> | undefined;
57+
58+
mock.module("@mastra/client-js", () => ({
59+
MastraClient: class {
60+
getWorkflow() {
61+
return {
62+
createRun: () =>
63+
Promise.resolve({
64+
startAsync: ({
65+
inputData,
66+
}: {
67+
inputData: Record<string, unknown>;
68+
}) => {
69+
capturedInputData = inputData;
70+
return Promise.resolve({ status: "success" });
71+
},
72+
resumeAsync: () => Promise.resolve({ status: "success" }),
73+
}),
74+
};
75+
}
76+
},
77+
}));
78+
79+
const { initCommand } = await import("../../src/commands/init.js");
80+
81+
const func = (await initCommand.loader()) as (
82+
this: {
83+
cwd: string;
84+
stdout: { write: () => boolean };
85+
stderr: { write: () => boolean };
86+
stdin: typeof process.stdin;
87+
},
88+
flags: Record<string, unknown>,
89+
directory?: string
90+
) => Promise<void>;
91+
92+
function makeContext(cwd = "/projects/app") {
93+
return {
94+
cwd,
95+
stdout: { write: () => true },
96+
stderr: { write: () => true },
97+
stdin: process.stdin,
98+
};
99+
}
100+
101+
beforeEach(() => {
102+
capturedInputData = undefined;
103+
});
104+
105+
describe("init command func", () => {
106+
describe("features parsing", () => {
107+
test("splits comma-separated features", async () => {
108+
const ctx = makeContext();
109+
await func.call(ctx, {
110+
force: false,
111+
yes: true,
112+
"dry-run": false,
113+
features: "errors,tracing,logs",
114+
});
115+
116+
expect(capturedInputData?.features).toEqual([
117+
"errors",
118+
"tracing",
119+
"logs",
120+
]);
121+
});
122+
123+
test("trims whitespace from features", async () => {
124+
const ctx = makeContext();
125+
await func.call(ctx, {
126+
force: false,
127+
yes: true,
128+
"dry-run": false,
129+
features: " errors , tracing ",
130+
});
131+
132+
expect(capturedInputData?.features).toEqual(["errors", "tracing"]);
133+
});
134+
135+
test("filters empty segments", async () => {
136+
const ctx = makeContext();
137+
await func.call(ctx, {
138+
force: false,
139+
yes: true,
140+
"dry-run": false,
141+
features: "errors,,tracing,",
142+
});
143+
144+
expect(capturedInputData?.features).toEqual(["errors", "tracing"]);
145+
});
146+
147+
test("passes undefined when features not provided", async () => {
148+
const ctx = makeContext();
149+
await func.call(ctx, {
150+
force: false,
151+
yes: true,
152+
"dry-run": false,
153+
});
154+
155+
expect(capturedInputData?.features).toBeUndefined();
156+
});
157+
});
158+
159+
describe("directory resolution", () => {
160+
test("defaults to cwd when no directory provided", async () => {
161+
const ctx = makeContext("/projects/app");
162+
await func.call(ctx, {
163+
force: false,
164+
yes: true,
165+
"dry-run": false,
166+
});
167+
168+
expect(capturedInputData?.directory).toBe("/projects/app");
169+
});
170+
171+
test("resolves relative directory against cwd", async () => {
172+
const ctx = makeContext("/projects/app");
173+
await func.call(
174+
ctx,
175+
{
176+
force: false,
177+
yes: true,
178+
"dry-run": false,
179+
},
180+
"sub/dir"
181+
);
182+
183+
expect(capturedInputData?.directory).toBe(
184+
path.resolve("/projects/app", "sub/dir")
185+
);
186+
});
187+
});
188+
189+
describe("flag forwarding", () => {
190+
test("forwards force, yes, and dry-run flags", async () => {
191+
const ctx = makeContext();
192+
await func.call(ctx, {
193+
force: true,
194+
yes: true,
195+
"dry-run": true,
196+
});
197+
198+
expect(capturedInputData?.force).toBe(true);
199+
expect(capturedInputData?.yes).toBe(true);
200+
expect(capturedInputData?.dryRun).toBe(true);
201+
});
202+
});
203+
});

test/isolated/init-interactive.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@ describe("handleMultiSelect", () => {
216216
expect(features).toContain("sessionReplay");
217217
});
218218

219+
test("throws WizardCancelledError when user cancels multi-select", async () => {
220+
multiselectImpl = mock(() => Promise.resolve(Symbol.for("cancel")));
221+
222+
await expect(
223+
handleInteractive(
224+
{
225+
type: "interactive",
226+
prompt: "Select features",
227+
kind: "multi-select",
228+
availableFeatures: ["errorMonitoring", "performanceMonitoring"],
229+
},
230+
makeOptions({ yes: false })
231+
)
232+
).rejects.toThrow("Setup cancelled");
233+
});
234+
219235
test("excludes errorMonitoring from multiselect options (always included)", async () => {
220236
multiselectImpl = mock(() => Promise.resolve(["performanceMonitoring"]));
221237

@@ -281,6 +297,21 @@ describe("handleConfirm", () => {
281297
expect(result).toEqual({ addExample: false });
282298
});
283299

300+
test("throws WizardCancelledError when user cancels confirm", async () => {
301+
confirmImpl = mock(() => Promise.resolve(Symbol.for("cancel")));
302+
303+
await expect(
304+
handleInteractive(
305+
{
306+
type: "interactive",
307+
prompt: "Continue with setup?",
308+
kind: "confirm",
309+
},
310+
makeOptions({ yes: false })
311+
)
312+
).rejects.toThrow("Setup cancelled");
313+
});
314+
284315
test("returns action: stop when user declines non-example prompt", async () => {
285316
confirmImpl = mock(() => Promise.resolve(false));
286317

test/isolated/init-wizard-runner.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,96 @@ describe("runWizard", () => {
322322
expect(errorMsg).toContain("alien");
323323
});
324324

325+
test("handles multiple suspend/resume iterations", async () => {
326+
// First iteration: local-op, second: interactive, third: success
327+
mockStartResult = {
328+
status: "suspended",
329+
suspended: [["detect-platform"]],
330+
steps: {
331+
"detect-platform": {
332+
suspendPayload: {
333+
type: "local-op",
334+
operation: "list-dir",
335+
cwd: "/app",
336+
params: { path: "." },
337+
},
338+
},
339+
},
340+
};
341+
mockResumeResults = [
342+
{
343+
status: "suspended",
344+
suspended: [["select-features"]],
345+
steps: {
346+
"select-features": {
347+
suspendPayload: {
348+
type: "interactive",
349+
kind: "multi-select",
350+
prompt: "Select features",
351+
availableFeatures: ["errorMonitoring"],
352+
},
353+
},
354+
},
355+
},
356+
{ status: "success" },
357+
];
358+
359+
await runWizard(makeOptions());
360+
361+
expect(mockHandleLocalOp).toHaveBeenCalledTimes(1);
362+
expect(mockHandleInteractive).toHaveBeenCalledTimes(1);
363+
expect(mockFormatResult).toHaveBeenCalled();
364+
});
365+
366+
test("handles non-Error exception in catch block", async () => {
367+
mockHandleLocalOp.mockImplementationOnce(() =>
368+
Promise.reject("string error")
369+
);
370+
371+
mockStartResult = {
372+
status: "suspended",
373+
suspended: [["detect-platform"]],
374+
steps: {
375+
"detect-platform": {
376+
suspendPayload: {
377+
type: "local-op",
378+
operation: "list-dir",
379+
cwd: "/app",
380+
params: { path: "." },
381+
},
382+
},
383+
},
384+
};
385+
386+
await runWizard(makeOptions());
387+
388+
expect(logMock.error).toHaveBeenCalledWith("string error");
389+
expect(cancelMock).toHaveBeenCalledWith("Setup failed");
390+
});
391+
392+
test("falls back to iterating steps when stepId key not found", async () => {
393+
// The suspend path references "step-a" but the payload is under "step-b"
394+
mockStartResult = {
395+
status: "suspended",
396+
suspended: [["step-a"]],
397+
steps: {
398+
"step-b": {
399+
suspendPayload: {
400+
type: "local-op",
401+
operation: "read-files",
402+
cwd: "/app",
403+
params: { paths: ["index.ts"] },
404+
},
405+
},
406+
},
407+
};
408+
mockResumeResults = [{ status: "success" }];
409+
410+
await runWizard(makeOptions());
411+
412+
expect(mockHandleLocalOp).toHaveBeenCalled();
413+
});
414+
325415
test("handles missing suspend payload", async () => {
326416
mockStartResult = {
327417
status: "suspended",

test/lib/help.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ describe("printCustomHelp", () => {
7979

8080
// Should include at least some core commands from routes
8181
expect(output).toContain("sentry");
82+
// Route map command (exercises isRouteMap branch)
8283
expect(output).toContain("auth");
84+
// Direct command with tuple positional (exercises isCommand + getPositionalPlaceholder)
85+
expect(output).toContain("init");
8386
});
8487

8588
test("output contains docs URL", async () => {

0 commit comments

Comments
 (0)