Skip to content

Commit 4fad96b

Browse files
dcramerclaude
andcommitted
test(cli): Add missing command tests for export, dir, and version
Add dedicated test files for CLI commands that were missing coverage: - export.test.ts: 12 tests for GitHub export functionality - dir.test.ts: 3 tests for storage/home directory output - version.test.ts: 1 test verifying version matches package.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 50e19a5 commit 4fad96b

File tree

3 files changed

+348
-0
lines changed

3 files changed

+348
-0
lines changed

src/cli/dir.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { captureOutput, type CapturedOutput } from "./test-helpers.js";
3+
import { dirCommand } from "./dir.js";
4+
5+
describe("dir command", () => {
6+
let output: CapturedOutput;
7+
let originalDexHome: string | undefined;
8+
let originalStoragePath: string | undefined;
9+
10+
beforeEach(() => {
11+
output = captureOutput();
12+
originalDexHome = process.env.DEX_HOME;
13+
originalStoragePath = process.env.DEX_STORAGE_PATH;
14+
});
15+
16+
afterEach(() => {
17+
output.restore();
18+
if (originalDexHome !== undefined) {
19+
process.env.DEX_HOME = originalDexHome;
20+
} else {
21+
delete process.env.DEX_HOME;
22+
}
23+
if (originalStoragePath !== undefined) {
24+
process.env.DEX_STORAGE_PATH = originalStoragePath;
25+
} else {
26+
delete process.env.DEX_STORAGE_PATH;
27+
}
28+
});
29+
30+
it("prints storage path by default", () => {
31+
process.env.DEX_STORAGE_PATH = "/tmp/test-storage";
32+
33+
dirCommand([]);
34+
35+
const out = output.stdout.join("\n");
36+
expect(out).toBe("/tmp/test-storage");
37+
});
38+
39+
it("prints dex home directory with --global flag", () => {
40+
process.env.DEX_HOME = "/tmp/test-dex-home";
41+
42+
dirCommand(["--global"]);
43+
44+
const out = output.stdout.join("\n");
45+
expect(out).toBe("/tmp/test-dex-home");
46+
});
47+
48+
it("prints dex home directory with -g short flag", () => {
49+
process.env.DEX_HOME = "/tmp/test-dex-home-short";
50+
51+
dirCommand(["-g"]);
52+
53+
const out = output.stdout.join("\n");
54+
expect(out).toBe("/tmp/test-dex-home-short");
55+
});
56+
});

src/cli/export.test.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { runCli } from "./index.js";
3+
import type { CliTestFixture, GitHubMock } from "./test-helpers.js";
4+
import {
5+
createCliTestFixture,
6+
createTaskAndGetId,
7+
setupGitHubMock,
8+
cleanupGitHubMock,
9+
createIssueFixture,
10+
} from "./test-helpers.js";
11+
12+
// Mock git remote detection
13+
vi.mock("../core/github/remote.js", async (importOriginal) => {
14+
const original =
15+
await importOriginal<typeof import("../core/github/remote.js")>();
16+
return {
17+
...original,
18+
getGitHubRepo: vi.fn(() => ({ owner: "test-owner", repo: "test-repo" })),
19+
};
20+
});
21+
22+
// Mock execSync for git operations
23+
vi.mock("node:child_process", async (importOriginal) => {
24+
const original = await importOriginal<typeof import("node:child_process")>();
25+
return {
26+
...original,
27+
execSync: vi.fn((cmd: string) => {
28+
if (cmd.includes("gh auth token")) {
29+
throw new Error("gh not authenticated");
30+
}
31+
if (cmd.includes("git check-ignore")) {
32+
throw new Error("not ignored");
33+
}
34+
if (cmd.includes("git show origin/HEAD")) {
35+
throw new Error("not on remote");
36+
}
37+
return "";
38+
}),
39+
};
40+
});
41+
42+
describe("export command", () => {
43+
let fixture: CliTestFixture;
44+
let githubMock: GitHubMock;
45+
let originalEnv: string | undefined;
46+
47+
beforeEach(() => {
48+
fixture = createCliTestFixture();
49+
originalEnv = process.env.GITHUB_TOKEN;
50+
process.env.GITHUB_TOKEN = "test-token";
51+
githubMock = setupGitHubMock();
52+
});
53+
54+
afterEach(() => {
55+
fixture.cleanup();
56+
cleanupGitHubMock();
57+
if (originalEnv !== undefined) {
58+
process.env.GITHUB_TOKEN = originalEnv;
59+
} else {
60+
delete process.env.GITHUB_TOKEN;
61+
}
62+
});
63+
64+
async function createTask(
65+
name: string,
66+
opts: { description?: string; parent?: string } = {},
67+
): Promise<string> {
68+
return createTaskAndGetId(fixture, name, {
69+
description: opts.description ?? "ctx",
70+
parent: opts.parent,
71+
});
72+
}
73+
74+
it.each([["--help"], ["-h"]])("shows help with %s flag", async (flag) => {
75+
await runCli(["export", flag], { storage: fixture.storage });
76+
const out = fixture.output.stdout.join("\n");
77+
expect(out).toContain("dex export");
78+
expect(out).toContain("Export tasks to GitHub Issues");
79+
});
80+
81+
it("fails when no task IDs provided", async () => {
82+
await expect(
83+
runCli(["export"], { storage: fixture.storage }),
84+
).rejects.toThrow("process.exit");
85+
expect(fixture.output.stderr.join("\n")).toContain(
86+
"At least one task ID is required",
87+
);
88+
});
89+
90+
it("fails when task not found", async () => {
91+
githubMock.listIssues("test-owner", "test-repo", []);
92+
93+
await runCli(["export", "nonexist"], { storage: fixture.storage });
94+
95+
expect(fixture.output.stderr.join("\n")).toContain("not found");
96+
});
97+
98+
describe("dry-run mode", () => {
99+
it("previews export without creating issues", async () => {
100+
const taskId = await createTask("Test task", { description: "context" });
101+
102+
await runCli(["export", taskId, "--dry-run"], {
103+
storage: fixture.storage,
104+
});
105+
106+
const out = fixture.output.stdout.join("\n");
107+
expect(out).toContain("Would export");
108+
expect(out).toContain("test-owner/test-repo");
109+
expect(out).toContain("[create]");
110+
expect(out).toContain(taskId);
111+
});
112+
});
113+
114+
describe("export specific task", () => {
115+
it("exports a task to GitHub", async () => {
116+
const taskId = await createTask("Task to export", {
117+
description: "Some context",
118+
});
119+
120+
githubMock.listIssues("test-owner", "test-repo", []);
121+
githubMock.createIssue(
122+
"test-owner",
123+
"test-repo",
124+
createIssueFixture({
125+
number: 101,
126+
title: "Task to export",
127+
}),
128+
);
129+
130+
await runCli(["export", taskId], { storage: fixture.storage });
131+
132+
const out = fixture.output.stdout.join("\n");
133+
expect(out).toContain("Exported");
134+
expect(out).toContain(taskId);
135+
expect(out).toContain("test-owner/test-repo");
136+
expect(out).toContain("issues/101");
137+
});
138+
139+
it("skips task already synced to GitHub", async () => {
140+
const taskId = await createTask("Synced task", {
141+
description: "context",
142+
});
143+
144+
// First sync to create GitHub metadata
145+
githubMock.listIssues("test-owner", "test-repo", []);
146+
githubMock.createIssue(
147+
"test-owner",
148+
"test-repo",
149+
createIssueFixture({
150+
number: 42,
151+
title: "Synced task",
152+
}),
153+
);
154+
await runCli(["sync", taskId], { storage: fixture.storage });
155+
fixture.output.stdout.length = 0;
156+
157+
// Export should skip because task already has GitHub metadata
158+
await runCli(["export", taskId], { storage: fixture.storage });
159+
160+
const out = fixture.output.stdout.join("\n");
161+
expect(out).toContain("Skipped");
162+
expect(out).toContain("already synced");
163+
});
164+
165+
it("finds root task when exporting subtask", async () => {
166+
const parentId = await createTask("Parent task");
167+
const subtaskId = await createTask("Subtask", { parent: parentId });
168+
169+
githubMock.listIssues("test-owner", "test-repo", []);
170+
githubMock.createIssue(
171+
"test-owner",
172+
"test-repo",
173+
createIssueFixture({
174+
number: 102,
175+
title: "Parent task",
176+
}),
177+
);
178+
179+
await runCli(["export", subtaskId], { storage: fixture.storage });
180+
181+
const out = fixture.output.stdout.join("\n");
182+
expect(out).toContain("Exported");
183+
expect(out).toContain(parentId);
184+
});
185+
});
186+
187+
describe("export multiple tasks", () => {
188+
it("exports multiple tasks with summary", async () => {
189+
const taskId1 = await createTask("Task 1", { description: "ctx1" });
190+
const taskId2 = await createTask("Task 2", { description: "ctx2" });
191+
192+
githubMock.listIssues("test-owner", "test-repo", []);
193+
githubMock.createIssue(
194+
"test-owner",
195+
"test-repo",
196+
createIssueFixture({ number: 201, title: "Task 1" }),
197+
);
198+
githubMock.listIssues("test-owner", "test-repo", []);
199+
githubMock.createIssue(
200+
"test-owner",
201+
"test-repo",
202+
createIssueFixture({ number: 202, title: "Task 2" }),
203+
);
204+
205+
await runCli(["export", taskId1, taskId2], { storage: fixture.storage });
206+
207+
const out = fixture.output.stdout.join("\n");
208+
expect(out).toContain("Exported");
209+
expect(out).toContain("2 exported");
210+
});
211+
212+
it("shows combined summary with skipped tasks", async () => {
213+
const taskId1 = await createTask("Task 1", { description: "ctx1" });
214+
const taskId2 = await createTask("Task 2", { description: "ctx2" });
215+
216+
// Sync first task so it gets skipped
217+
githubMock.listIssues("test-owner", "test-repo", []);
218+
githubMock.createIssue(
219+
"test-owner",
220+
"test-repo",
221+
createIssueFixture({ number: 301, title: "Task 1" }),
222+
);
223+
await runCli(["sync", taskId1], { storage: fixture.storage });
224+
fixture.output.stdout.length = 0;
225+
226+
// Export both - first should be skipped, second exported
227+
githubMock.listIssues("test-owner", "test-repo", []);
228+
githubMock.createIssue(
229+
"test-owner",
230+
"test-repo",
231+
createIssueFixture({ number: 302, title: "Task 2" }),
232+
);
233+
234+
await runCli(["export", taskId1, taskId2], { storage: fixture.storage });
235+
236+
const out = fixture.output.stdout.join("\n");
237+
expect(out).toContain("1 exported");
238+
expect(out).toContain("1 skipped");
239+
});
240+
});
241+
242+
describe("error handling", () => {
243+
it("fails when GitHub token is missing", async () => {
244+
delete process.env.GITHUB_TOKEN;
245+
const taskId = await createTask("Task");
246+
247+
await expect(
248+
runCli(["export", taskId], { storage: fixture.storage }),
249+
).rejects.toThrow("process.exit");
250+
expect(fixture.output.stderr.join("\n")).toMatch(
251+
/GitHub token|GITHUB_TOKEN/i,
252+
);
253+
});
254+
255+
it("handles GitHub API error gracefully", async () => {
256+
const taskId = await createTask("Task");
257+
258+
githubMock.listIssues("test-owner", "test-repo", []);
259+
githubMock.createIssue500("test-owner", "test-repo");
260+
261+
await runCli(["export", taskId], { storage: fixture.storage });
262+
263+
expect(fixture.output.stderr.join("\n")).toContain("Error");
264+
});
265+
});
266+
});

src/cli/version.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { captureOutput, type CapturedOutput } from "./test-helpers.js";
3+
import { versionCommand } from "./version.js";
4+
import { createRequire } from "node:module";
5+
6+
const require = createRequire(import.meta.url);
7+
const pkg = require("../../package.json") as { version: string };
8+
9+
describe("version command", () => {
10+
let output: CapturedOutput;
11+
12+
beforeEach(() => {
13+
output = captureOutput();
14+
});
15+
16+
afterEach(() => {
17+
output.restore();
18+
});
19+
20+
it("outputs version matching package.json", () => {
21+
versionCommand();
22+
23+
const out = output.stdout.join("\n");
24+
expect(out).toBe(`dex v${pkg.version}`);
25+
});
26+
});

0 commit comments

Comments
 (0)