@lovenyberg/ove 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { runDiagnostics, type DiagnosticDeps } from "./setup";
6
+ import type { Config } from "./config";
7
+
8
+ function makeConfig(overrides: Partial<Config> = {}): Config {
9
+ return {
10
+ repos: {},
11
+ users: { "cli:local": { name: "local", repos: ["*"] } },
12
+ claude: { maxTurns: 25 },
13
+ reposDir: "./repos",
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function makeDeps(overrides: Partial<DiagnosticDeps> = {}): DiagnosticDeps {
19
+ return {
20
+ which: () => null,
21
+ fetch: (() => Promise.reject(new Error("no network"))) as any,
22
+ spawn: (() => ({
23
+ exited: Promise.resolve(1),
24
+ stdout: new ReadableStream({ start(c) { c.close(); } }),
25
+ stderr: new ReadableStream({ start(c) { c.close(); } }),
26
+ })) as any,
27
+ accessSync: () => {},
28
+ existsSync: () => false,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ const ENV_KEYS = [
34
+ "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN",
35
+ "GITHUB_POLL_REPOS",
36
+ ] as const;
37
+
38
+ describe("runDiagnostics", () => {
39
+ let dir: string;
40
+ const origEnv: Record<string, string | undefined> = {};
41
+
42
+ beforeEach(() => {
43
+ dir = mkdtempSync(join(tmpdir(), "ove-diag-test-"));
44
+ for (const key of ENV_KEYS) {
45
+ origEnv[key] = process.env[key];
46
+ delete process.env[key];
47
+ }
48
+ });
49
+
50
+ afterEach(() => {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ for (const key of ENV_KEYS) {
53
+ if (origEnv[key] !== undefined) process.env[key] = origEnv[key];
54
+ else delete process.env[key];
55
+ }
56
+ });
57
+
58
+ it("passes when git is found", async () => {
59
+ const deps = makeDeps({
60
+ which: (cmd: string) => cmd === "git" ? "/usr/bin/git" : null,
61
+ spawn: ((args: string[]) => {
62
+ if (args[0] === "git") {
63
+ return {
64
+ exited: Promise.resolve(0),
65
+ stdout: new ReadableStream({
66
+ start(c) { c.enqueue(new TextEncoder().encode("git version 2.43.0\n")); c.close(); },
67
+ }),
68
+ stderr: new ReadableStream({ start(c) { c.close(); } }),
69
+ };
70
+ }
71
+ return {
72
+ exited: Promise.resolve(1),
73
+ stdout: new ReadableStream({ start(c) { c.close(); } }),
74
+ stderr: new ReadableStream({ start(c) { c.close(); } }),
75
+ };
76
+ }) as any,
77
+ });
78
+
79
+ const results = await runDiagnostics(makeConfig(), deps);
80
+ const git = results.find(r => r.name === "git");
81
+ expect(git?.status).toBe("pass");
82
+ expect(git?.message).toContain("2.43.0");
83
+ });
84
+
85
+ it("fails when git is not found", async () => {
86
+ const deps = makeDeps({ which: () => null });
87
+ const results = await runDiagnostics(makeConfig(), deps);
88
+ const git = results.find(r => r.name === "git");
89
+ expect(git?.status).toBe("fail");
90
+ expect(git?.message).toContain("not found");
91
+ });
92
+
93
+ it("passes when claude CLI is found (default runner)", async () => {
94
+ const deps = makeDeps({
95
+ which: (cmd: string) => cmd === "claude" ? "/usr/local/bin/claude" : null,
96
+ });
97
+ const results = await runDiagnostics(makeConfig(), deps);
98
+ const claude = results.find(r => r.name === "claude");
99
+ expect(claude?.status).toBe("pass");
100
+ expect(claude?.message).toContain("claude CLI installed");
101
+ });
102
+
103
+ it("fails when claude CLI is not found", async () => {
104
+ const deps = makeDeps({ which: () => null });
105
+ const results = await runDiagnostics(makeConfig(), deps);
106
+ const claude = results.find(r => r.name === "claude");
107
+ expect(claude?.status).toBe("fail");
108
+ expect(claude?.message).toContain("claude CLI not found");
109
+ });
110
+
111
+ it("checks codex CLI when runner is codex", async () => {
112
+ const deps = makeDeps({
113
+ which: (cmd: string) => cmd === "codex" ? "/usr/local/bin/codex" : null,
114
+ });
115
+ const config = makeConfig({ runner: { name: "codex" } });
116
+ const results = await runDiagnostics(config, deps);
117
+ const codex = results.find(r => r.name === "codex");
118
+ expect(codex?.status).toBe("pass");
119
+ expect(codex?.message).toContain("codex CLI installed");
120
+ });
121
+
122
+ it("warns when gh CLI is missing but GitHub sync is configured", async () => {
123
+ const deps = makeDeps({ which: () => null });
124
+ const config = makeConfig({ github: { orgs: ["myorg"] } });
125
+ const results = await runDiagnostics(config, deps);
126
+ const gh = results.find(r => r.name === "gh");
127
+ expect(gh?.status).toBe("warn");
128
+ expect(gh?.message).toContain("gh CLI not found");
129
+ });
130
+
131
+ it("passes when gh CLI is found and GitHub sync is configured", async () => {
132
+ const deps = makeDeps({
133
+ which: (cmd: string) => cmd === "gh" ? "/usr/bin/gh" : null,
134
+ });
135
+ const config = makeConfig({ github: { orgs: ["myorg"] } });
136
+ const results = await runDiagnostics(config, deps);
137
+ const gh = results.find(r => r.name === "gh");
138
+ expect(gh?.status).toBe("pass");
139
+ });
140
+
141
+ it("skips gh check when no GitHub sync configured", async () => {
142
+ const deps = makeDeps({ which: () => null });
143
+ const results = await runDiagnostics(makeConfig(), deps);
144
+ const gh = results.find(r => r.name === "gh");
145
+ expect(gh).toBeUndefined();
146
+ });
147
+
148
+ it("passes when REPOS_DIR exists and is writable", async () => {
149
+ const reposDir = join(dir, "repos");
150
+ mkdirSync(reposDir);
151
+ const deps = makeDeps({
152
+ existsSync: (p: string) => p === reposDir,
153
+ accessSync: () => {},
154
+ });
155
+ const config = makeConfig({ reposDir });
156
+ const results = await runDiagnostics(config, deps);
157
+ const rd = results.find(r => r.name === "repos_dir");
158
+ expect(rd?.status).toBe("pass");
159
+ expect(rd?.message).toContain("writable");
160
+ });
161
+
162
+ it("fails when REPOS_DIR does not exist", async () => {
163
+ const deps = makeDeps({ existsSync: () => false });
164
+ const config = makeConfig({ reposDir: "./nonexistent" });
165
+ const results = await runDiagnostics(config, deps);
166
+ const rd = results.find(r => r.name === "repos_dir");
167
+ expect(rd?.status).toBe("fail");
168
+ expect(rd?.message).toContain("does not exist");
169
+ });
170
+
171
+ it("fails when REPOS_DIR exists but is not writable", async () => {
172
+ const deps = makeDeps({
173
+ existsSync: () => true,
174
+ accessSync: () => { throw new Error("EACCES"); },
175
+ });
176
+ const config = makeConfig({ reposDir: "/read-only" });
177
+ const results = await runDiagnostics(config, deps);
178
+ const rd = results.find(r => r.name === "repos_dir");
179
+ expect(rd?.status).toBe("fail");
180
+ expect(rd?.message).toContain("not writable");
181
+ });
182
+
183
+ it("passes SSH check when github returns exit 1 with success message", async () => {
184
+ const deps = makeDeps({
185
+ spawn: ((args: string[]) => {
186
+ if (args[0] === "ssh") {
187
+ return {
188
+ exited: Promise.resolve(1),
189
+ stdout: new ReadableStream({ start(c) { c.close(); } }),
190
+ stderr: new ReadableStream({
191
+ start(c) {
192
+ c.enqueue(new TextEncoder().encode("Hi user! You've successfully authenticated, but GitHub does not provide shell access.\n"));
193
+ c.close();
194
+ },
195
+ }),
196
+ };
197
+ }
198
+ return {
199
+ exited: Promise.resolve(1),
200
+ stdout: new ReadableStream({ start(c) { c.close(); } }),
201
+ stderr: new ReadableStream({ start(c) { c.close(); } }),
202
+ };
203
+ }) as any,
204
+ });
205
+ const results = await runDiagnostics(makeConfig(), deps);
206
+ const ssh = results.find(r => r.name === "ssh");
207
+ expect(ssh?.status).toBe("pass");
208
+ expect(ssh?.message).toContain("SSH access to github.com");
209
+ });
210
+
211
+ it("warns when SSH check fails", async () => {
212
+ const deps = makeDeps({
213
+ spawn: ((args: string[]) => ({
214
+ exited: Promise.resolve(255),
215
+ stdout: new ReadableStream({ start(c) { c.close(); } }),
216
+ stderr: new ReadableStream({
217
+ start(c) {
218
+ c.enqueue(new TextEncoder().encode("Permission denied (publickey).\n"));
219
+ c.close();
220
+ },
221
+ }),
222
+ })) as any,
223
+ });
224
+ const results = await runDiagnostics(makeConfig(), deps);
225
+ const ssh = results.find(r => r.name === "ssh");
226
+ expect(ssh?.status).toBe("warn");
227
+ });
228
+
229
+ it("passes Slack token check when API returns ok", async () => {
230
+ process.env.SLACK_BOT_TOKEN = "xoxb-valid-token";
231
+ const deps = makeDeps({
232
+ fetch: (async (url: string) => {
233
+ if (url === "https://slack.com/api/auth.test") {
234
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
235
+ }
236
+ throw new Error("unexpected fetch");
237
+ }) as any,
238
+ });
239
+ const results = await runDiagnostics(makeConfig(), deps);
240
+ const slack = results.find(r => r.name === "slack");
241
+ expect(slack?.status).toBe("pass");
242
+ expect(slack?.message).toContain("valid");
243
+ });
244
+
245
+ it("fails Slack token check when API returns not ok", async () => {
246
+ process.env.SLACK_BOT_TOKEN = "xoxb-invalid";
247
+ const deps = makeDeps({
248
+ fetch: (async (url: string) => {
249
+ if (url === "https://slack.com/api/auth.test") {
250
+ return new Response(JSON.stringify({ ok: false }), { status: 200 });
251
+ }
252
+ throw new Error("unexpected fetch");
253
+ }) as any,
254
+ });
255
+ const results = await runDiagnostics(makeConfig(), deps);
256
+ const slack = results.find(r => r.name === "slack");
257
+ expect(slack?.status).toBe("fail");
258
+ expect(slack?.message).toContain("invalid");
259
+ });
260
+
261
+ it("warns on Slack network error", async () => {
262
+ process.env.SLACK_BOT_TOKEN = "xoxb-valid-token";
263
+ const deps = makeDeps({
264
+ fetch: (() => Promise.reject(new Error("network error"))) as any,
265
+ });
266
+ const results = await runDiagnostics(makeConfig(), deps);
267
+ const slack = results.find(r => r.name === "slack");
268
+ expect(slack?.status).toBe("warn");
269
+ expect(slack?.message).toContain("network error");
270
+ });
271
+
272
+ it("skips Slack check when no token set", async () => {
273
+ const deps = makeDeps();
274
+ const results = await runDiagnostics(makeConfig(), deps);
275
+ const slack = results.find(r => r.name === "slack");
276
+ expect(slack).toBeUndefined();
277
+ });
278
+
279
+ it("passes Telegram token check when API returns ok", async () => {
280
+ process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-DEF";
281
+ const deps = makeDeps({
282
+ fetch: (async (url: string) => {
283
+ if (url.includes("api.telegram.org")) {
284
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
285
+ }
286
+ throw new Error("unexpected fetch");
287
+ }) as any,
288
+ });
289
+ const results = await runDiagnostics(makeConfig(), deps);
290
+ const tg = results.find(r => r.name === "telegram");
291
+ expect(tg?.status).toBe("pass");
292
+ });
293
+
294
+ it("fails Telegram token check when API returns not ok", async () => {
295
+ process.env.TELEGRAM_BOT_TOKEN = "bad-token";
296
+ const deps = makeDeps({
297
+ fetch: (async (url: string) => {
298
+ if (url.includes("api.telegram.org")) {
299
+ return new Response(JSON.stringify({ ok: false }), { status: 200 });
300
+ }
301
+ throw new Error("unexpected fetch");
302
+ }) as any,
303
+ });
304
+ const results = await runDiagnostics(makeConfig(), deps);
305
+ const tg = results.find(r => r.name === "telegram");
306
+ expect(tg?.status).toBe("fail");
307
+ });
308
+
309
+ it("passes Discord token check when API returns 200", async () => {
310
+ process.env.DISCORD_BOT_TOKEN = "valid-discord-token";
311
+ const deps = makeDeps({
312
+ fetch: (async (url: string) => {
313
+ if (url.includes("discord.com")) {
314
+ return new Response(JSON.stringify({ id: "123" }), { status: 200 });
315
+ }
316
+ throw new Error("unexpected fetch");
317
+ }) as any,
318
+ });
319
+ const results = await runDiagnostics(makeConfig(), deps);
320
+ const dc = results.find(r => r.name === "discord");
321
+ expect(dc?.status).toBe("pass");
322
+ });
323
+
324
+ it("fails Discord token check when API returns 401", async () => {
325
+ process.env.DISCORD_BOT_TOKEN = "invalid-discord-token";
326
+ const deps = makeDeps({
327
+ fetch: (async (url: string) => {
328
+ if (url.includes("discord.com")) {
329
+ return new Response(JSON.stringify({ message: "401: Unauthorized" }), { status: 401 });
330
+ }
331
+ throw new Error("unexpected fetch");
332
+ }) as any,
333
+ });
334
+ const results = await runDiagnostics(makeConfig(), deps);
335
+ const dc = results.find(r => r.name === "discord");
336
+ expect(dc?.status).toBe("fail");
337
+ });
338
+
339
+ it("warns on Discord network error", async () => {
340
+ process.env.DISCORD_BOT_TOKEN = "valid-discord-token";
341
+ const deps = makeDeps({
342
+ fetch: (() => Promise.reject(new Error("timeout"))) as any,
343
+ });
344
+ const results = await runDiagnostics(makeConfig(), deps);
345
+ const dc = results.find(r => r.name === "discord");
346
+ expect(dc?.status).toBe("warn");
347
+ expect(dc?.message).toContain("network error");
348
+ });
349
+
350
+ it("warns when gh CLI missing with GITHUB_POLL_REPOS env var", async () => {
351
+ process.env.GITHUB_POLL_REPOS = "owner/repo";
352
+ const deps = makeDeps({ which: () => null });
353
+ const results = await runDiagnostics(makeConfig(), deps);
354
+ const gh = results.find(r => r.name === "gh");
355
+ expect(gh?.status).toBe("warn");
356
+ });
357
+
358
+ it("always checks git, runner, ssh, and repos_dir", async () => {
359
+ const deps = makeDeps();
360
+ const results = await runDiagnostics(makeConfig(), deps);
361
+ const names = results.map(r => r.name);
362
+ expect(names).toContain("git");
363
+ expect(names).toContain("claude");
364
+ expect(names).toContain("ssh");
365
+ expect(names).toContain("repos_dir");
366
+ });
367
+
368
+ it("skips placeholder Slack tokens", async () => {
369
+ process.env.SLACK_BOT_TOKEN = "xoxb-...";
370
+ const deps = makeDeps();
371
+ const results = await runDiagnostics(makeConfig(), deps);
372
+ const slack = results.find(r => r.name === "slack");
373
+ expect(slack).toBeUndefined();
374
+ });
375
+ });