Skip to content

Commit c04d52a

Browse files
committed
test: add declarative CLI tests
1 parent fc4786e commit c04d52a

File tree

7 files changed

+312
-20
lines changed

7 files changed

+312
-20
lines changed

src/cli/commands/add.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import fs from "fs";
22
import path from "path";
33
import * as prompts from "@clack/prompts";
44
import { z } from "zod";
5-
import { add, Config } from "@spader/dotllm/core";
5+
import { add, Config, sync } from "@spader/dotllm/core";
66
import { defaultTheme as t } from "@spader/dotllm/cli/theme";
7+
import { Prompt } from "@spader/dotllm/cli/prompt";
78
import type { Command } from "@spader/dotllm/cli/yargs";
89

910
const RepoShape = z.object({
@@ -139,7 +140,7 @@ function autoLink(name: string): void {
139140
if (!repo) return;
140141

141142
Config.Local.write(Config.Local.add(local, repo));
142-
prompts.log.step(`linked ${t.primary(name)}`);
143+
Prompt.sync(sync());
143144
}
144145

145146
async function run(uri: string, name?: string, description?: string): Promise<void> {

src/cli/commands/link.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import * as prompts from "@clack/prompts";
2-
import { Config, link, unlink, sync, type SyncResult } from "@spader/dotllm/core";
2+
import { Config, link, unlink, sync } from "@spader/dotllm/core";
33
import { defaultTheme as t } from "@spader/dotllm/cli/theme";
4+
import { Prompt } from "@spader/dotllm/cli/prompt";
45
import type { Command } from "@spader/dotllm/cli/yargs";
56

6-
function printResult(result: SyncResult): void {
7-
const parts: string[] = [];
8-
if (result.linked.length > 0) parts.push(`${result.linked.length} added`);
9-
if (result.removed.length > 0) parts.push(`${result.removed.length} removed`);
10-
if (result.unchanged.length > 0) parts.push(`${result.unchanged.length} unchanged`);
11-
if (parts.length === 0) return;
12-
prompts.log.step(parts.join(", "));
13-
}
14-
157
export const command: Command = {
168
description: "Interactively pick repos to link into .llm/reference/, or add/remove one by name",
179
summary: "Link references",
@@ -55,7 +47,7 @@ export const command: Command = {
5547

5648
const local = Config.Local.read();
5749
Config.Local.write(Config.Local.add(local, repo));
58-
printResult(sync());
50+
Prompt.sync(sync());
5951
return;
6052
}
6153

@@ -92,6 +84,6 @@ export const command: Command = {
9284
? selected.filter((value): value is string => typeof value === "string")
9385
: [];
9486

95-
printResult(link(names));
87+
Prompt.sync(link(names));
9688
},
9789
};

src/cli/commands/sync.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as prompts from "@clack/prompts";
22
import { pull, sync } from "@spader/dotllm/core";
33
import { defaultTheme as t } from "@spader/dotllm/cli/theme";
4+
import { Prompt } from "@spader/dotllm/cli/prompt";
45
import type { Command } from "@spader/dotllm/cli/yargs";
56

67
export const command: Command = {
@@ -16,12 +17,7 @@ export const command: Command = {
1617
return;
1718
}
1819

19-
const parts: string[] = [];
20-
if (result.linked.length > 0) parts.push(`${result.linked.length} added`);
21-
if (result.removed.length > 0) parts.push(`${result.removed.length} removed`);
22-
if (result.unchanged.length > 0) parts.push(`${result.unchanged.length} unchanged`);
23-
if (result.missing.length > 0) parts.push(`${result.missing.length} missing`);
24-
if (parts.length > 0) prompts.log.step(parts.join(", "));
20+
Prompt.sync(result);
2521

2622
const refs = [...new Set([...result.unchanged, ...result.linked])];
2723
if (refs.length === 0) {

src/cli/prompt.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as prompts from "@clack/prompts";
2+
import type { SyncResult } from "@spader/dotllm/core";
3+
4+
export namespace Prompt {
5+
export function sync(result: SyncResult): void {
6+
const parts: string[] = [];
7+
if (result.linked.length > 0) parts.push(`${result.linked.length} added`);
8+
if (result.removed.length > 0) parts.push(`${result.removed.length} removed`);
9+
if (result.unchanged.length > 0) parts.push(`${result.unchanged.length} unchanged`);
10+
if (result.missing.length > 0) parts.push(`${result.missing.length} missing`);
11+
if (parts.length > 0) prompts.log.step(parts.join(", "));
12+
}
13+
}

test/cli/add.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { test } from "bun:test"
2+
import { fixture } from "../fixture"
3+
import { run } from "./runner"
4+
5+
test("adds local repo with explicit name and description", async () => {
6+
await using env = await fixture({
7+
repos: { "my-lib": {} },
8+
})
9+
10+
await run({
11+
command: "add",
12+
argv: { uri: env.dir("my-lib"), name: "my-lib", description: "A cool library" },
13+
expect: {
14+
global: { "my-lib": { kind: "file", uri: env.dir("my-lib"), description: "A cool library" } },
15+
store: { "my-lib": { symlink: env.dir("my-lib") } },
16+
},
17+
})
18+
})
19+
20+
test("registers without local config when dotllm.json absent", async () => {
21+
await using env = await fixture({
22+
repos: { "my-lib": {} },
23+
})
24+
25+
await run({
26+
command: "add",
27+
argv: { uri: env.dir("my-lib"), name: "my-lib", description: "" },
28+
expect: {
29+
global: { "my-lib": { kind: "file", uri: env.dir("my-lib") } },
30+
store: { "my-lib": { symlink: env.dir("my-lib") } },
31+
localAbsent: ["my-lib"],
32+
refAbsent: ["my-lib"],
33+
},
34+
})
35+
})
36+
37+
test("auto-links when dotllm.json exists", async () => {
38+
await using env = await fixture({
39+
repos: { "my-lib": {} },
40+
local: [],
41+
})
42+
43+
await run({
44+
command: "add",
45+
argv: { uri: env.dir("my-lib"), name: "my-lib", description: "" },
46+
expect: {
47+
global: { "my-lib": { kind: "file", uri: env.dir("my-lib") } },
48+
store: { "my-lib": { symlink: env.dir("my-lib") } },
49+
local: { "my-lib": { name: "my-lib" } },
50+
ref: ["my-lib"],
51+
},
52+
})
53+
})
54+
55+
test("second add is idempotent", async () => {
56+
await using env = await fixture({
57+
repos: { "my-lib": {} },
58+
local: [],
59+
})
60+
61+
const spec = {
62+
command: "add",
63+
argv: { uri: env.dir("my-lib"), name: "my-lib", description: "desc" },
64+
expect: {
65+
global: { "my-lib": { kind: "file" as const, uri: env.dir("my-lib"), description: "desc" } },
66+
store: { "my-lib": { symlink: env.dir("my-lib") } },
67+
local: { "my-lib": { name: "my-lib" } },
68+
ref: ["my-lib"],
69+
},
70+
}
71+
72+
await run(spec)
73+
await run(spec)
74+
})
75+
76+
test("derives name from git remote", async () => {
77+
await using env = await fixture({
78+
repos: { "checkout": { remote: "git@github.com:acme/cool-lib.git" } },
79+
})
80+
81+
await run({
82+
command: "add",
83+
argv: { uri: env.dir("checkout") },
84+
expect: {
85+
global: { "cool-lib": { kind: "file" } },
86+
store: { "cool-lib": { symlink: env.dir("checkout") } },
87+
},
88+
})
89+
})
90+
91+
test("explicit name overrides git remote", async () => {
92+
await using env = await fixture({
93+
repos: { "checkout": { remote: "git@github.com:acme/cool-lib.git" } },
94+
})
95+
96+
await run({
97+
command: "add",
98+
argv: { uri: env.dir("checkout"), name: "override" },
99+
expect: {
100+
global: { "override": { kind: "file", uri: env.dir("checkout") } },
101+
store: { "override": { symlink: env.dir("checkout") } },
102+
},
103+
})
104+
})

test/cli/link.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { test } from "bun:test"
2+
import { fixture } from "../fixture"
3+
import { run } from "./runner"
4+
5+
const entry = (name: string) =>
6+
({ kind: "file" as const, name, uri: `/mock/${name}`, description: "" })
7+
8+
test("link by name updates local config and creates ref symlink", async () => {
9+
await using env = await fixture({
10+
repos: { "a": {} },
11+
global: [entry("a")],
12+
store: { "a": "a" },
13+
})
14+
15+
await run({
16+
command: "link",
17+
argv: { name: "a" },
18+
expect: {
19+
local: { "a": { name: "a" } },
20+
ref: ["a"],
21+
},
22+
})
23+
})
24+
25+
test("link --remove unlinks repo", async () => {
26+
await using env = await fixture({
27+
repos: { "a": {} },
28+
global: [entry("a")],
29+
store: { "a": "a" },
30+
local: ["a"],
31+
})
32+
33+
// first sync to create the ref symlink
34+
await run({
35+
command: "sync",
36+
argv: {},
37+
expect: {
38+
ref: ["a"],
39+
},
40+
})
41+
42+
await run({
43+
command: "link",
44+
argv: { name: "a", remove: true },
45+
expect: {
46+
localAbsent: ["a"],
47+
refAbsent: ["a"],
48+
},
49+
})
50+
})
51+
52+
test("link unknown repo exits with error", async () => {
53+
await using env = await fixture()
54+
55+
await run({
56+
command: "link",
57+
argv: { name: "ghost" },
58+
expect: {
59+
exit: 1,
60+
localAbsent: ["ghost"],
61+
refAbsent: ["ghost"],
62+
},
63+
})
64+
})

test/cli/runner.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import fs from "fs"
2+
import path from "path"
3+
import { expect } from "bun:test"
4+
import { add, link, sync, remove } from "@spader/dotllm/cli/commands/index"
5+
import { Config } from "@spader/dotllm/core"
6+
import type { Command } from "@spader/dotllm/cli/yargs"
7+
8+
const commands: Record<string, Command> = { add, link, sync, remove }
9+
10+
type ExpectedEntry = {
11+
name?: string
12+
uri?: string
13+
kind?: "file" | "url"
14+
description?: string
15+
}
16+
17+
type ExpectedStore = Record<string, { symlink: string } | true>
18+
19+
type Spec = {
20+
command: string
21+
argv: Record<string, unknown>
22+
expect: {
23+
exit?: number
24+
global?: Record<string, ExpectedEntry>
25+
store?: ExpectedStore
26+
local?: Record<string, ExpectedEntry>
27+
localAbsent?: string[]
28+
ref?: string[]
29+
refAbsent?: string[]
30+
}
31+
}
32+
33+
class ExitError extends Error {
34+
code: number
35+
constructor(code: number) {
36+
super(`process.exit(${code})`)
37+
this.code = code
38+
}
39+
}
40+
41+
export async function run(spec: Spec): Promise<void> {
42+
const cmd = commands[spec.command]
43+
if (!cmd?.handler) throw new Error(`unknown command: ${spec.command}`)
44+
45+
const original = process.exit
46+
let exitCode: number | undefined
47+
48+
process.exit = (code?: number) => {
49+
exitCode = code ?? 0
50+
throw new ExitError(exitCode)
51+
}
52+
53+
try {
54+
await cmd.handler(spec.argv)
55+
} catch (e) {
56+
if (!(e instanceof ExitError)) throw e
57+
} finally {
58+
process.exit = original
59+
}
60+
61+
if (spec.expect.exit !== undefined) {
62+
expect(exitCode).toBe(spec.expect.exit)
63+
}
64+
65+
if (spec.expect.global) {
66+
const global = Config.Global.read()
67+
for (const [name, expected] of Object.entries(spec.expect.global)) {
68+
const entry = Config.Global.find(global, name)
69+
expect(entry).toBeDefined()
70+
if (!entry) continue
71+
if (expected.name !== undefined) expect(entry.name).toBe(expected.name)
72+
if (expected.uri !== undefined) expect(entry.uri).toBe(expected.uri)
73+
if (expected.kind !== undefined) expect(entry.kind).toBe(expected.kind)
74+
if (expected.description !== undefined) expect(entry.description).toBe(expected.description)
75+
}
76+
}
77+
78+
if (spec.expect.store) {
79+
for (const [name, expected] of Object.entries(spec.expect.store)) {
80+
const target = path.join(Config.storeDir(), name)
81+
expect(fs.existsSync(target)).toBe(true)
82+
if (expected === true) continue
83+
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true)
84+
expect(fs.readlinkSync(target)).toBe(expected.symlink)
85+
}
86+
}
87+
88+
if (spec.expect.local) {
89+
const local = Config.Local.read()
90+
for (const [name, expected] of Object.entries(spec.expect.local)) {
91+
expect(Config.Local.has(local, name)).toBe(true)
92+
const entry = local.refs[name]
93+
if (!entry) continue
94+
if (expected.name !== undefined) expect(entry.name).toBe(expected.name)
95+
if (expected.uri !== undefined) expect(entry.uri).toBe(expected.uri)
96+
if (expected.kind !== undefined) expect(entry.kind).toBe(expected.kind)
97+
if (expected.description !== undefined) expect(entry.description).toBe(expected.description)
98+
}
99+
}
100+
101+
if (spec.expect.localAbsent) {
102+
const local = Config.Local.read()
103+
for (const name of spec.expect.localAbsent) {
104+
expect(Config.Local.has(local, name)).toBe(false)
105+
}
106+
}
107+
108+
if (spec.expect.ref) {
109+
for (const name of spec.expect.ref) {
110+
const target = path.join(Config.refDir(), name)
111+
expect(fs.existsSync(target)).toBe(true)
112+
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true)
113+
}
114+
}
115+
116+
if (spec.expect.refAbsent) {
117+
for (const name of spec.expect.refAbsent) {
118+
const target = path.join(Config.refDir(), name)
119+
expect(fs.existsSync(target)).toBe(false)
120+
}
121+
}
122+
}

0 commit comments

Comments
 (0)