1import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2import { readdirSync, mkdtempSync } from "node:fs";
3import { rm } from "node:fs/promises";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import { execSync } from "node:child_process";
7import { ConfigError } from "../src/util/errors.js";
8
9/**
10 * Snapshot rumilo-* dirs in tmpdir so we can detect leaks.
11 */
12function rumiloTmpDirs(): Set<string> {
13 return new Set(readdirSync(tmpdir()).filter((n) => n.startsWith("rumilo-")));
14}
15
16function leakedDirs(before: Set<string>, after: Set<string>): string[] {
17 return [...after].filter((d) => !before.has(d));
18}
19
20async function cleanupLeaked(leaked: string[]): Promise<void> {
21 for (const d of leaked) {
22 await rm(join(tmpdir(), d), { recursive: true, force: true });
23 }
24}
25
26// βββ web command: workspace leaked on missing credentials βββββββββββ
27
28describe("web command β workspace cleanup on early failure", () => {
29 const origEnv = { ...process.env };
30
31 beforeEach(() => {
32 // Ensure credential env vars are absent so validation throws.
33 delete process.env["KAGI_SESSION_TOKEN"];
34 delete process.env["TABSTACK_API_KEY"];
35 });
36
37 afterEach(() => {
38 process.env = { ...origEnv };
39 });
40
41 test("workspace dir is removed when credential validation throws", async () => {
42 const before = rumiloTmpDirs();
43
44 const { runWebCommand } = await import("../src/cli/commands/web.js");
45
46 try {
47 await runWebCommand({
48 query: "test",
49 verbose: false,
50 cleanup: true,
51 });
52 } catch (e: any) {
53 expect(e).toBeInstanceOf(ConfigError);
54 }
55
56 const after = rumiloTmpDirs();
57 const leaked = leakedDirs(before, after);
58
59 // Safety: clean up any leaked dirs so the test doesn't pollute.
60 await cleanupLeaked(leaked);
61
62 // If this fails, the workspace was created but not cleaned up β a leak.
63 expect(leaked).toEqual([]);
64 });
65});
66
67// βββ repo command: workspace leaked on checkout failure βββββββββββββ
68
69describe("repo command β workspace cleanup on early failure", () => {
70 const origEnv = { ...process.env };
71 let localRepo: string;
72
73 beforeEach(() => {
74 // Create a small local bare git repo so clone succeeds without network.
75 localRepo = mkdtempSync(join(tmpdir(), "rumilo-test-bare-"));
76 execSync("git init --bare", { cwd: localRepo, stdio: "ignore" });
77 // Create a temporary work clone to add a commit (bare repos need content)
78 const workClone = mkdtempSync(join(tmpdir(), "rumilo-test-work-"));
79 execSync(`git clone ${localRepo} work`, { cwd: workClone, stdio: "ignore" });
80 const workDir = join(workClone, "work");
81 execSync("git config user.email test@test.com && git config user.name Test && git config commit.gpgsign false", { cwd: workDir, stdio: "ignore" });
82 execSync("echo hello > README.md && git add . && git commit -m init", { cwd: workDir, stdio: "ignore" });
83 execSync("git push", { cwd: workDir, stdio: "ignore" });
84 // Clean up work clone
85 execSync(`rm -rf ${workClone}`, { stdio: "ignore" });
86 });
87
88 afterEach(async () => {
89 process.env = { ...origEnv };
90 await rm(localRepo, { recursive: true, force: true });
91 });
92
93 test("workspace dir is removed when clone fails", async () => {
94 const before = rumiloTmpDirs();
95
96 const { runRepoCommand } = await import("../src/cli/commands/repo.js");
97
98 try {
99 await runRepoCommand({
100 query: "test",
101 uri: "file:///nonexistent-path/repo.git",
102 full: false,
103 verbose: false,
104 cleanup: true,
105 });
106 } catch {
107 // expected β clone will fail
108 }
109
110 const after = rumiloTmpDirs();
111 const leaked = leakedDirs(before, after);
112 await cleanupLeaked(leaked);
113 expect(leaked).toEqual([]);
114 });
115
116 test("workspace dir is removed when ref checkout fails after clone", async () => {
117 const before = rumiloTmpDirs();
118
119 const { runRepoCommand } = await import("../src/cli/commands/repo.js");
120
121 try {
122 await runRepoCommand({
123 query: "test",
124 uri: localRepo,
125 ref: "nonexistent-ref-abc123",
126 full: true, // full clone for local bare repo compatibility
127 verbose: false,
128 cleanup: true,
129 });
130 } catch {
131 // expected β checkout of bad ref will fail
132 }
133
134 const after = rumiloTmpDirs();
135 const leaked = leakedDirs(before, after);
136 await cleanupLeaked(leaked);
137 expect(leaked).toEqual([]);
138 });
139});