workspace-cleanup.test.ts

  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", { 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});