workspace-cleanup.test.ts

  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});