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