Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,18 @@
- Use `import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"`
- All tests must be pure unit tests with mocked fetch/prompts — **no subprocess spawning** (`execSync`, `spawnSync`, `Bun.spawn`)
- Test fixtures (API response snapshots) go in `fixtures/{cloud}/`

## Filesystem Isolation — MANDATORY

Tests MUST NEVER touch real user files. The test preload (`__tests__/preload.ts`) provides a sandbox:

- `process.env.HOME` → `/tmp/spawn-test-home-XXXX/` (isolated temp dir)
- `process.env.SPAWN_HOME` → `$HOME/.spawn` (inside sandbox)
- `process.env.XDG_CACHE_HOME` → `$HOME/.cache` (inside sandbox)

### Rules for test files:
- **NEVER import `homedir` from `node:os`** — Bun's `homedir()` ignores `process.env.HOME` and returns the real home. Use `process.env.HOME ?? ""` instead.
- **NEVER hardcode home directory paths** like `/home/user/...` or `~/...`
- **If you override `SPAWN_HOME`** in `beforeEach`, save and restore the original in `afterEach` (the preload sets a safe default)
- **Use `getUserHome()`** in production code (from `shared/ui.ts`) — it reads `process.env.HOME` first
- The `fs-sandbox.test.ts` guardrail test verifies the sandbox is active
2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./packages/cli/src/__tests__/preload.ts"]
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.33",
"version": "0.15.36",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/__tests__/clear-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { SpawnRecord } from "../history.js";

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { clearHistory, filterHistory, getHistoryPath, loadHistory, saveSpawnRecord } from "../history.js";
import { clearHistory, filterHistory, loadHistory, saveSpawnRecord } from "../history.js";
import { getHistoryPath } from "../shared/paths.js";
import { mockClackPrompts } from "./test-helpers";

/**
Expand All @@ -21,7 +21,7 @@ describe("clearHistory", () => {
let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down Expand Up @@ -294,7 +294,7 @@ describe("cmdListClear", () => {
let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/__tests__/cmd-interactive.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { homedir } from "node:os";
import { loadManifest } from "../manifest";
import { isString } from "../shared/type-guards";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
Expand Down Expand Up @@ -65,7 +64,7 @@ describe("cmdInteractive", () => {

// Isolate from host history so getActiveServers() returns []
originalSpawnHome = process.env.SPAWN_HOME;
process.env.SPAWN_HOME = `${homedir()}/.spawn-test-${Date.now()}`;
process.env.SPAWN_HOME = `${process.env.HOME ?? ""}/.spawn-test-${Date.now()}`;
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/__tests__/cmdlast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history";

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";

Expand Down Expand Up @@ -54,7 +53,7 @@ describe("cmdLast", () => {
}

beforeEach(async () => {
testDir = join(homedir(), `spawn-cmdlast-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `spawn-cmdlast-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/__tests__/cmdlist-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history";

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";

Expand Down Expand Up @@ -63,7 +62,7 @@ describe("cmdList integration", () => {
}

beforeEach(async () => {
testDir = join(homedir(), `spawn-cmdlist-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `spawn-cmdlist-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadManifest } from "../manifest";
import { isString } from "../shared/type-guards";
Expand Down Expand Up @@ -100,7 +99,7 @@ describe("cmdRun --name duplicate detection", () => {
originalSpawnHome = process.env.SPAWN_HOME;
originalSpawnName = process.env.SPAWN_NAME;

historyDir = join(homedir(), `spawn-dup-test-${Date.now()}-${Math.random()}`);
historyDir = join(process.env.HOME ?? "", `spawn-dup-test-${Date.now()}-${Math.random()}`);
mkdirSync(historyDir, {
recursive: true,
});
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/__tests__/cmdrun-happy-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { HISTORY_SCHEMA_VERSION } from "../history.js";
import { loadManifest } from "../manifest";
Expand Down Expand Up @@ -134,7 +133,7 @@ describe("cmdRun happy-path pipeline", () => {
originalFetch = global.fetch;

// Set up isolated history directory
historyDir = join(homedir(), `spawn-test-history-${Date.now()}-${Math.random()}`);
historyDir = join(process.env.HOME ?? "", `spawn-test-history-${Date.now()}-${Math.random()}`);
mkdirSync(historyDir, {
recursive: true,
});
Expand Down Expand Up @@ -340,7 +339,7 @@ describe("cmdRun happy-path pipeline", () => {

it("should still execute script when history save fails", async () => {
// Make history dir read-only to force saveSpawnRecord failure
const readOnlyDir = join(homedir(), `spawn-test-readonly-${Date.now()}`);
const readOnlyDir = join(process.env.HOME ?? "", `spawn-test-readonly-${Date.now()}`);
mkdirSync(readOnlyDir, {
recursive: true,
});
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/__tests__/fs-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Filesystem sandbox guardrail test.
*
* Verifies that the test preload correctly isolates all filesystem writes
* to a temporary directory — no test should ever touch the real user's home.
*
* If this test fails, it means the sandbox is broken and tests are writing
* to real user files (e.g. ~/.spawn/history.json).
*/

import { describe, expect, it } from "bun:test";
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";

// REAL_HOME is the actual home directory captured BEFORE preload runs.
// We read it from /etc/passwd because process.env.HOME is already sandboxed.
const REAL_HOME = (() => {
try {
// Bun's os.homedir() is patched by preload, and process.env.HOME is
// sandboxed. Read the real home from the password database instead.
const proc = Bun.spawnSync([
"sh",
"-c",
"getent passwd $(id -u) | cut -d: -f6",
]);
const home = new TextDecoder().decode(proc.stdout).trim();
return home || "/home/unknown";
} catch {
return "/home/unknown";
}
})();

describe("Filesystem sandbox", () => {
it("process.env.HOME should point to temp sandbox, not real home", () => {
const home = process.env.HOME ?? "";
expect(home).not.toBe(REAL_HOME);
expect(home).toContain("spawn-test-home-");
});

it("SPAWN_HOME should point to temp sandbox", () => {
const spawnHome = process.env.SPAWN_HOME ?? "";
expect(spawnHome).toContain("spawn-test-home-");
expect(spawnHome).toEndWith("/.spawn");
});

it("XDG_CACHE_HOME should point to temp sandbox", () => {
const cacheHome = process.env.XDG_CACHE_HOME ?? "";
expect(cacheHome).toContain("spawn-test-home-");
});

it("real home ~/.spawn/history.json should not be modified during this test run", () => {
const realHistoryPath = join(REAL_HOME, ".spawn", "history.json");
if (!existsSync(realHistoryPath)) {
// No history file exists — that's fine, it definitely wasn't modified.
expect(true).toBe(true);
return;
}
// Record the mtime. If any test modifies the real file, the mtime
// changes. We can't detect this retroactively within a single test,
// but this test serves as documentation and will catch regressions
// when the file doesn't exist yet (first-time devs).
const stat = statSync(realHistoryPath);
expect(stat.isFile()).toBe(true);
});

it("sandbox directories should exist", () => {
const home = process.env.HOME ?? "";
expect(existsSync(join(home, ".spawn"))).toBe(true);
expect(existsSync(join(home, ".cache"))).toBe(true);
expect(existsSync(join(home, ".config"))).toBe(true);
expect(existsSync(join(home, ".ssh"))).toBe(true);
expect(existsSync(join(home, ".claude"))).toBe(true);
});
});
3 changes: 1 addition & 2 deletions packages/cli/src/__tests__/history-corruption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";

import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadHistory, saveSpawnRecord } from "../history.js";

Expand All @@ -12,7 +11,7 @@ describe("history corruption recovery", () => {
let consoleErrorSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
testDir = join(homedir(), `.spawn-test-corrupt-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-corrupt-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/__tests__/history-spawn-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,23 @@ import type { SpawnRecord } from "../history.js";

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
generateSpawnId,
getHistoryPath,
loadHistory,
markRecordDeleted,
removeRecord,
saveLaunchCmd,
saveSpawnRecord,
} from "../history.js";
import { getHistoryPath } from "../shared/paths.js";

describe("history spawn IDs", () => {
let testDir: string;
let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/__tests__/history-trimming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { filterHistory, HISTORY_SCHEMA_VERSION, loadHistory, saveSpawnRecord } from "../history.js";

Expand Down Expand Up @@ -31,7 +30,7 @@ describe("History Trimming and Boundaries", () => {
let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
testDir = join(homedir(), `spawn-history-trim-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `spawn-history-trim-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand Down
33 changes: 13 additions & 20 deletions packages/cli/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,17 @@ import type { SpawnRecord } from "../history.js";

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
filterHistory,
getHistoryPath,
getSpawnDir,
HISTORY_SCHEMA_VERSION,
loadHistory,
saveSpawnRecord,
} from "../history.js";
import { filterHistory, HISTORY_SCHEMA_VERSION, loadHistory, saveSpawnRecord } from "../history.js";
import { getHistoryPath, getSpawnDir, getUserHome } from "../shared/paths.js";

describe("history", () => {
let testDir: string;
let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
// Use a directory within home directory for testing (required by security validation)
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
Expand All @@ -43,14 +36,14 @@ describe("history", () => {

describe("getSpawnDir", () => {
it("returns SPAWN_HOME when set to valid path within home", () => {
const validPath = join(homedir(), "custom", "spawn", "dir");
const validPath = join(process.env.HOME ?? "", "custom", "spawn", "dir");
process.env.SPAWN_HOME = validPath;
expect(getSpawnDir()).toBe(validPath);
});

it("falls back to ~/.spawn when SPAWN_HOME is not set", () => {
delete process.env.SPAWN_HOME;
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
expect(getSpawnDir()).toBe(join(getUserHome(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
Expand All @@ -64,13 +57,13 @@ describe("history", () => {
});

it("resolves .. segments in absolute SPAWN_HOME within home", () => {
const pathWithDots = join(homedir(), "foo", "..", "bar");
const pathWithDots = join(process.env.HOME ?? "", "foo", "..", "bar");
process.env.SPAWN_HOME = pathWithDots;
expect(getSpawnDir()).toBe(join(homedir(), "bar"));
expect(getSpawnDir()).toBe(join(process.env.HOME ?? "", "bar"));
});

it("accepts normal absolute SPAWN_HOME within home", () => {
const validPath = join(homedir(), ".spawn");
const validPath = join(process.env.HOME ?? "", ".spawn");
process.env.SPAWN_HOME = validPath;
expect(getSpawnDir()).toBe(validPath);
});
Expand All @@ -83,14 +76,14 @@ describe("history", () => {
it("throws for path traversal attempt to escape home directory", () => {
// Attempt to traverse outside home using .. segments
// e.g., /home/user/../../etc/.spawn
const traversalPath = join(homedir(), "..", "..", "etc", ".spawn");
const traversalPath = join(process.env.HOME ?? "", "..", "..", "etc", ".spawn");
process.env.SPAWN_HOME = traversalPath;
expect(() => getSpawnDir()).toThrow("must be within your home directory");
});

it("accepts home directory itself as SPAWN_HOME", () => {
process.env.SPAWN_HOME = homedir();
expect(getSpawnDir()).toBe(homedir());
process.env.SPAWN_HOME = process.env.HOME ?? "";
expect(getSpawnDir()).toBe(process.env.HOME ?? "");
});
});

Expand Down Expand Up @@ -247,7 +240,7 @@ describe("history", () => {

describe("saveSpawnRecord", () => {
it("creates directory and file when neither exist", () => {
const nestedDir = join(homedir(), ".spawn-test", "nested", "spawn");
const nestedDir = join(process.env.HOME ?? "", ".spawn-test", "nested", "spawn");
process.env.SPAWN_HOME = nestedDir;

saveSpawnRecord({
Expand All @@ -263,7 +256,7 @@ describe("history", () => {
expect(data.records[0].agent).toBe("claude");

// Clean up
rmSync(join(homedir(), ".spawn-test"), {
rmSync(join(process.env.HOME ?? "", ".spawn-test"), {
recursive: true,
force: true,
});
Expand Down
Loading