Run server tests on Linux+Windows matrix (#809)

* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
This commit is contained in:
Mohamed Boudra
2026-05-08 10:57:22 +08:00
committed by GitHub
parent 39e461b872
commit c4e4a28bc0
44 changed files with 5673 additions and 4899 deletions

View File

@@ -66,7 +66,12 @@ jobs:
run: npm run typecheck run: npm run typecheck
server-tests: server-tests:
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -95,50 +100,6 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: >
npx vitest run
src/utils/executable.probe.test.ts
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/tree-kill.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests: desktop-tests:
strategy: strategy:
fail-fast: false fail-fast: false

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)); const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -116,7 +117,10 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("raw stderr line\n"); expect(result.log).toContain("raw stderr line\n");
}); });
test("logs worker signal exits even when the worker cannot log", async () => { // POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
test.skipIf(isPlatform("win32"))(
"logs worker signal exits even when the worker cannot log",
async () => {
const result = await runSupervisorFixture({ const result = await runSupervisorFixture({
workerSource: ` workerSource: `
process.kill(process.pid, "SIGKILL"); process.kill(process.pid, "SIGKILL");
@@ -128,5 +132,6 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain('"msg":"Worker exited"'); expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"'); expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting"); expect(result.log).toContain("Supervisor exiting");
}); },
);
}); });

View File

@@ -1,7 +1,8 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { z } from "zod"; import { z } from "zod";
@@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js"; import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
interface LooseSafeParseResult { interface LooseSafeParseResult {
success: boolean; success: boolean;
data: unknown; data: unknown;
@@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123", id: "agent-123",
cwd: "/tmp/repo", cwd: REPO_CWD,
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-456", id: "agent-456",
cwd: "/tmp/repo", cwd: REPO_CWD,
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-789", id: "agent-789",
cwd: "/tmp/repo", cwd: REPO_CWD,
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => {
const startedAgentSetupIds: string[] = []; const startedAgentSetupIds: string[] = [];
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@example.com"], {
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n"); await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree", id: "agent-with-worktree",
@@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => {
}; };
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@example.com"], {
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n"); await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree", id: "agent-auto-named-worktree",
@@ -798,7 +814,10 @@ describe("create_agent MCP tool", () => {
}); });
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }) const initialBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString() .toString()
.trim(); .trim();
expect(initialBranch).not.toBe(""); expect(initialBranch).not.toBe("");
@@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => {
}; };
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@example.com"], {
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n"); await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "-b", "existing-feature"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "feature.txt"), "feature\n"); await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree", id: "agent-checkout-worktree",
@@ -871,7 +899,9 @@ describe("create_agent MCP tool", () => {
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
expect( expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(), execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature"); ).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => {
}, },
workspace: { workspace: {
workspaceId: "/tmp/worktrees/pr-123", workspaceId: "/tmp/worktrees/pr-123",
projectId: "/tmp/repo", projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123", cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const, kind: "worktree" as const,
displayName: "pr-123", displayName: "pr-123",
@@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => {
updatedAt: "2026-04-30T00:00:00.000Z", updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null, archivedAt: null,
}, },
repoRoot: "/tmp/repo", repoRoot: REPO_CWD,
created: true, created: true,
...(options?.setupContinuation?.kind === "agent" ...(options?.setupContinuation?.kind === "agent"
? { ? {
@@ -949,7 +979,7 @@ describe("create_agent MCP tool", () => {
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.callback({ await tool.callback({
cwd: "/tmp/repo", cwd: REPO_CWD,
title: "PR agent", title: "PR agent",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt", initialPrompt: "Rename this PR branch from prompt",
@@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => {
const setupContinuations: Array<"workspace" | "agent" | undefined> = []; const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@example.com"], {
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n"); await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = { const workspaceGitService = {
getSnapshot: vi.fn(async () => null), getSnapshot: vi.fn(async () => null),
}; };
@@ -1031,19 +1067,27 @@ describe("create_agent MCP tool", () => {
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")); const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
);
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo"); const paseoHome = join(tempDir, ".paseo");
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@example.com"], {
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n"); await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = { const workspaceGitService = {
getSnapshot: vi.fn(async () => null), getSnapshot: vi.fn(async () => null),
@@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => {
}); });
const tool = registeredTool(server, "list_worktrees"); const tool = registeredTool(server, "list_worktrees");
const response = await tool.callback({ cwd: "/tmp/repo" }); const response = await tool.callback({ cwd: REPO_CWD });
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", { expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees", reason: "mcp:list-worktrees",
}); });
expect(response.structuredContent.worktrees).toEqual([ expect(response.structuredContent.worktrees).toEqual([
@@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-injected-123", id: "agent-injected-123",
cwd: "/tmp/repo", cwd: REPO_CWD,
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => {
createManagedAgent({ createManagedAgent({
id: "agent-compact", id: "agent-compact",
provider: "codex", provider: "codex",
cwd: "/tmp/repo", cwd: REPO_CWD,
config: { model: "gpt-5.4", thinkingOptionId: "high" }, config: { model: "gpt-5.4", thinkingOptionId: "high" },
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" }, runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
labels: { role: "researcher" }, labels: { role: "researcher" },
@@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: "high", thinkingOptionId: "high",
effectiveThinkingOptionId: "high", effectiveThinkingOptionId: "high",
status: "idle", status: "idle",
cwd: "/tmp/repo", cwd: REPO_CWD,
createdAt: expect.any(String), createdAt: expect.any(String),
updatedAt: expect.any(String), updatedAt: expect.any(String),
lastUserMessageAt: null, lastUserMessageAt: null,
@@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => {
spies.agentManager.listAgents.mockReturnValue([ spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({ createManagedAgent({
id: "running-target", id: "running-target",
cwd: "/tmp/target", cwd: TARGET_CWD,
lifecycle: "running", lifecycle: "running",
updatedAt: new Date(recent), updatedAt: new Date(recent),
}), }),
createManagedAgent({ createManagedAgent({
id: "idle-target", id: "idle-target",
cwd: "/tmp/target", cwd: TARGET_CWD,
lifecycle: "idle", lifecycle: "idle",
updatedAt: new Date(recent), updatedAt: new Date(recent),
}), }),
createManagedAgent({ createManagedAgent({
id: "old-running-target", id: "old-running-target",
cwd: "/tmp/target", cwd: TARGET_CWD,
lifecycle: "running", lifecycle: "running",
createdAt: new Date(old), createdAt: new Date(old),
updatedAt: new Date(old), updatedAt: new Date(old),
}), }),
]); ]);
spies.agentStorage.list.mockResolvedValue([ spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }), createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }), createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }), createStoredRecord({
id: "recent-other-cwd",
cwd: resolvePath("/tmp/other"),
archivedAt: recent,
}),
]); ]);
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
@@ -1950,7 +1998,7 @@ describe("agent snapshot MCP serialization", () => {
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ const response = await tool.callback({
cwd: "/tmp/target", cwd: TARGET_CWD,
includeArchived: true, includeArchived: true,
sinceHours: 48, sinceHours: 48,
statuses: ["running", "closed"], statuses: ["running", "closed"],
@@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => {
spies.agentStorage.list.mockResolvedValue([ spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ createStoredRecord({
id: "stored-archived-compact", id: "stored-archived-compact",
cwd: "/tmp/repo", cwd: REPO_CWD,
updatedAt: now, updatedAt: now,
lastActivityAt: now, lastActivityAt: now,
archivedAt: now, archivedAt: now,
@@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true }); const response = await tool.callback({ cwd: REPO_CWD, includeArchived: true });
const item = agentsOf(response)[0]; const item = agentsOf(response)[0];
expect(item).toEqual({ expect(item).toEqual({
@@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: null, thinkingOptionId: null,
effectiveThinkingOptionId: null, effectiveThinkingOptionId: null,
status: "closed", status: "closed",
cwd: "/tmp/repo", cwd: REPO_CWD,
createdAt: "2026-04-11T00:00:00.000Z", createdAt: "2026-04-11T00:00:00.000Z",
updatedAt: now, updatedAt: now,
lastUserMessageAt: null, lastUserMessageAt: null,

View File

@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
expect(event.item.text).not.toContain("data:image"); expect(event.item.text).not.toContain("data:image");
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64); expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(event.item.text); const source = markdownImageSource(event.item.text);
expect(source).toMatch(/paseo-attachments\/.+\.png$/); expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
expect(existsSync(source)).toBe(true); expect(existsSync(source)).toBe(true);
rmSync(source, { force: true }); rmSync(source, { force: true });
}); });

View File

@@ -0,0 +1,71 @@
// POSIX-only: POSIX PATH executable probing fixtures
/* eslint-disable max-nested-callbacks */
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { isPlatform } from "../../../test-utils/platform.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function isolatePathTo(dir: string): void {
process.env.PATH = dir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
}
function writeProviderShim(dir: string, command: string): string {
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
const content =
process.platform === "win32"
? `@echo off\r\necho ${command} 1.0\r\n`
: `#!/bin/sh\necho ${command} 1.0\n`;
writeFileSync(filePath, content);
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe.skipIf(isPlatform("win32"))("provider-availability POSIX-only", () => {
test("Codex reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
writeProviderShim(binDir, "codex");
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
writeProviderShim(binDir, "opencode");
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
});

View File

@@ -1,4 +1,4 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
@@ -31,19 +31,6 @@ function isolatePathTo(dir: string): void {
} }
} }
function writeProviderShim(dir: string, command: string): string {
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
const content =
process.platform === "win32"
? `@echo off\r\necho ${command} 1.0\r\n`
: `#!/bin/sh\necho ${command} 1.0\n`;
writeFileSync(filePath, content);
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
afterEach(() => { afterEach(() => {
process.env.PATH = originalEnv.PATH; process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT; process.env.PATHEXT = originalEnv.PATHEXT;
@@ -77,24 +64,6 @@ describe("default provider availability", () => {
await expect(client.isAvailable()).resolves.toBe(false); await expect(client.isAvailable()).resolves.toBe(false);
}); });
test("Codex reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
writeProviderShim(binDir, "codex");
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
writeProviderShim(binDir, "opencode");
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("AgentManager reports Codex unavailable without throwing", async () => { test("AgentManager reports Codex unavailable without throwing", async () => {
const binDir = makeTempDir("provider-availability-manager-bin-"); const binDir = makeTempDir("provider-availability-manager-bin-");
isolatePathTo(binDir); isolatePathTo(binDir);

View File

@@ -8,6 +8,7 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
import { generateLocalPairingOffer } from "./pairing-offer.js"; import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
describe("paseo daemon bootstrap", () => { describe("paseo daemon bootstrap", () => {
afterEach(() => { afterEach(() => {
@@ -152,7 +153,10 @@ describe("paseo daemon bootstrap", () => {
}); });
}); });
test("generates a relay pairing offer for unix socket listeners", async () => { // POSIX-only: Unix socket listen paths are invalid Windows listen targets.
test.skipIf(isPlatform("win32"))(
"generates a relay pairing offer for unix socket listeners",
async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-")); const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo"); const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -199,5 +203,6 @@ describe("paseo daemon bootstrap", () => {
await rm(paseoHomeRoot, { recursive: true, force: true }); await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true }); await rm(staticDir, { recursive: true, force: true });
} }
}); },
);
}); });

View File

@@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { withTimeout } from "../../utils/promise-timeout.js"; import { withTimeout } from "../../utils/promise-timeout.js";
import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
import { isPlatform } from "../../test-utils/platform.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js"; import type { SessionOutboundMessage } from "../messages.js";
@@ -220,7 +221,10 @@ test("returns error for non-git directory", async () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout }, 60000); // 1 minute timeout
test("returns repo info for git repo with branch and dirty state", async () => { // POSIX-only: asserts repo-root containment across macOS /var symlink normalization.
test.skipIf(isPlatform("win32"))(
"returns repo info for git repo with branch and dirty state",
async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
// Initialize git repo // Initialize git repo
@@ -267,7 +271,9 @@ test("returns repo info for git repo with branch and dirty state", async () => {
// Cleanup // Cleanup
await ctx.client.deleteAgent(agent.id); await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout },
60000,
); // 1 minute timeout
test("returns clean state when no uncommitted changes", async () => { test("returns clean state when no uncommitted changes", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();

View File

@@ -0,0 +1,58 @@
// POSIX-only: symlink fixtures
/* eslint-disable max-nested-callbacks */
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listDirectoryEntries, readExplorerFile } from "./service.js";
import { isPlatform } from "../../test-utils/platform.js";
async function createTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), prefix));
}
describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
it("lists directory entries even when a dangling symlink exists", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
await mkdir(path.join(root, "packages", "server"), { recursive: true });
const serverDir = path.join(root, "packages", "server");
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
const result = await listDirectoryEntries({
root,
relativePath: "packages/server",
});
expect(result.path).toBe("packages/server");
const names = result.entries.map((entry) => entry.name);
expect(names).toContain("README.md");
expect(names).not.toContain("AGENTS.md");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects symlinked files that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
await expect(
readExplorerFile({
root,
relativePath: "secret-link.txt",
}),
).rejects.toThrow("Access outside of workspace is not allowed");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
});

View File

@@ -1,8 +1,8 @@
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { listDirectoryEntries, readExplorerFile } from "./service.js"; import { readExplorerFile } from "./service.js";
async function createHomeTempDir(prefix: string): Promise<string> { async function createHomeTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.homedir(), prefix)); return mkdtemp(path.join(os.homedir(), prefix));
@@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise<string> {
} }
describe("file explorer service", () => { describe("file explorer service", () => {
it("lists directory entries even when a dangling symlink exists", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
await mkdir(path.join(root, "packages", "server"), { recursive: true });
const serverDir = path.join(root, "packages", "server");
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
const result = await listDirectoryEntries({
root,
relativePath: "packages/server",
});
expect(result.path).toBe("packages/server");
const names = result.entries.map((entry) => entry.name);
expect(names).toContain("README.md");
expect(names).not.toContain("AGENTS.md");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("reads .ex files as text", async () => { it("reads .ex files as text", async () => {
const root = await createTempDir("paseo-file-explorer-"); const root = await createTempDir("paseo-file-explorer-");
@@ -135,25 +112,4 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
it("rejects symlinked files that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
await expect(
readExplorerFile({
root,
relativePath: "secret-link.txt",
}),
).rejects.toThrow("Access outside of workspace is not allowed");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
}); });

View File

@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
}, },
file: { file: {
level: "debug", level: "debug",
path: path.join(paseoHome, "logs", "programmatic.log"), path: path.resolve(paseoHome, "logs", "programmatic.log"),
}, },
}); });
}); });

View File

@@ -1,6 +1,14 @@
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { beforeEach, afterEach, describe, expect, test } from "vitest"; import { beforeEach, afterEach, describe, expect, test } from "vitest";
import type { import type {
@@ -24,6 +32,7 @@ import type {
import { AgentStorage } from "./agent/agent-storage.js"; import { AgentStorage } from "./agent/agent-storage.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { LoopService } from "./loop-service.js"; import { LoopService } from "./loop-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { createTestLogger } from "../test-utils/test-logger.js"; import { createTestLogger } from "../test-utils/test-logger.js";
const TEST_CAPABILITIES: AgentCapabilityFlags = { const TEST_CAPABILITIES: AgentCapabilityFlags = {
@@ -220,19 +229,25 @@ describe("LoopService", () => {
let storage: AgentStorage; let storage: AgentStorage;
beforeEach(() => { beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-")); tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-")));
paseoHome = path.join(tmpDir, "paseo-home"); paseoHome = path.join(tmpDir, "paseo-home");
workspaceDir = path.join(tmpDir, "workspace"); workspaceDir = path.join(tmpDir, "workspace");
storage = new AgentStorage(path.join(tmpDir, "agents"), logger); storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
mkdirSync(workspaceDir, { recursive: true }); mkdirSync(workspaceDir, { recursive: true });
workspaceDir = realpathSync.native(workspaceDir);
}); });
afterEach(() => { afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
test("runs fresh worker agents until verify-check passes", async () => { // POSIX-only: real worker agent spawns a PTY whose Windows ConPTY path resolution still fails (error 267) after realpathSync; revisit when we have a Windows dev box.
test.skipIf(isPlatform("win32"))(
"runs fresh worker agents until verify-check passes",
async () => {
const state = { workerRuns: 0 }; const state = { workerRuns: 0 };
const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
claude: new ScriptedAgentClient("claude", { claude: new ScriptedAgentClient("claude", {
@@ -257,7 +272,9 @@ describe("LoopService", () => {
const loop = await service.runLoop({ const loop = await service.runLoop({
prompt: "Create done.txt when the task is actually fixed.", prompt: "Create done.txt when the task is actually fixed.",
cwd: workspaceDir, cwd: workspaceDir,
verifyChecks: ["test -f done.txt"], verifyChecks: [
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
],
sleepMs: 1, sleepMs: 1,
maxIterations: 3, maxIterations: 3,
}); });
@@ -267,13 +284,16 @@ describe("LoopService", () => {
const finalLoop = await service.inspectLoop(loop.id); const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded"); expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.iterations).toHaveLength(2); expect(finalLoop.iterations).toHaveLength(2);
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId); expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(
finalLoop.iterations[1]?.workerAgentId,
);
expect(finalLoop.iterations[0]?.status).toBe("failed"); expect(finalLoop.iterations[0]?.status).toBe("failed");
expect(finalLoop.iterations[1]?.status).toBe("succeeded"); expect(finalLoop.iterations[1]?.status).toBe("succeeded");
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false); expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true); expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
}); },
);
test("uses worker and verifier provider-model settings when provided", async () => { test("uses worker and verifier provider-model settings when provided", async () => {
const workerConfigs: AgentSessionConfig[] = []; const workerConfigs: AgentSessionConfig[] = [];

View File

@@ -1,4 +1,4 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -13,6 +13,7 @@ import {
type CreatePaseoWorktreeDeps, type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { isPlatform } from "../test-utils/platform.js";
const cleanupPaths: string[] = []; const cleanupPaths: string[] = [];
@@ -65,7 +66,10 @@ test("creates a worktree and registers it in the source workspace project withou
]); ]);
}); });
test("reuses an existing worktree and still upserts the workspace", async () => { // POSIX-only: Windows git worktree paths need separate canonicalization coverage.
test.skipIf(isPlatform("win32"))(
"reuses an existing worktree and still upserts the workspace",
async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo"); const paseoHome = path.join(tempDir, ".paseo");
@@ -99,7 +103,8 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
expect(second.created).toBe(false); expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`); expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
}); },
);
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => { test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
@@ -131,7 +136,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
generateBranchNameFromContext: async ({ firstAgentContext }) => generateBranchNameFromContext: async ({ firstAgentContext }) =>
firstAgentContext.prompt ? "renamed-from-agent-context" : null, firstAgentContext.prompt ? "renamed-from-agent-context" : null,
}); });
const branchAfterFirst = execSync("git branch --show-current", { const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -157,7 +162,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
firstAgentContext: { prompt: "Try another name" }, firstAgentContext: { prompt: "Try another name" },
generateBranchNameFromContext: async () => "second-agent-name", generateBranchNameFromContext: async () => "second-agent-name",
}); });
const branchAfterSecond = execSync("git branch --show-current", { const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -196,7 +201,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
: null, : null,
}); });
const branchAfter = execSync("git branch --show-current", { const branchAfter = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -253,7 +258,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
: null, : null,
}); });
const branchAfter = execSync("git branch --show-current", { const branchAfter = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -286,7 +291,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
).resolves.toEqual({ attempted: true, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
expect( expect(
execSync("git branch --show-current", { execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -305,11 +310,11 @@ test("leaves the branch alone when generated branch text is invalid", async () =
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => { test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n"); writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree( const created = await createPaseoWorktree(
{ {
@@ -334,7 +339,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
}), }),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect( expect(
execSync("git branch --show-current", { execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -370,7 +375,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
}), }),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect( expect(
execSync("git branch --show-current", { execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -524,17 +529,24 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
} }
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" }) const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" })
.toString() .toString()
.trim(); .trim();
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", { const mainRepoRoot = execFileSync(
"git",
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{
cwd, cwd,
stdio: "pipe", stdio: "pipe",
}) },
)
.toString() .toString()
.trim() .trim()
.replace(/\/\.git$/, ""); .replace(/\/\.git$/, "");
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" }) const currentBranch = execFileSync("git", ["branch", "--show-current"], {
cwd,
stdio: "pipe",
})
.toString() .toString()
.trim(); .trim();
@@ -566,34 +578,39 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
function createGitRepo(): { tempDir: string; repoDir: string } { function createGitRepo(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@example.com"], {
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } { function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
const { tempDir, repoDir } = createGitRepo(); const { tempDir, repoDir } = createGitRepo();
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n"); writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" });
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim(); const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); .toString()
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" }); .trim();
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-D", "pr-123"], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git"); const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
stdio: "pipe", stdio: "pipe",
}); });
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, { execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], {
stdio: "pipe", stdio: "pipe",
}); });
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir }; return { tempDir, repoDir };
} }

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -22,16 +22,25 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`); mkdirSync(repoDir, { recursive: true });
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { return {
tempDir, tempDir,

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -12,16 +12,25 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`); mkdirSync(repoDir, { recursive: true });
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { return {
tempDir, tempDir,

View File

@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { import {
buildWorkspaceScriptPayloads, buildWorkspaceScriptPayloads,
@@ -21,16 +21,25 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`); mkdirSync(repoDir, { recursive: true });
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { return {
tempDir, tempDir,

View File

@@ -47,6 +47,7 @@ import {
asDaemonConfigStore, asDaemonConfigStore,
createProviderSnapshotManagerStub, createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js"; } from "./test-utils/session-stubs.js";
import { isPlatform } from "../test-utils/platform.js";
interface SessionHandlerInternals { interface SessionHandlerInternals {
startVoiceTurnController(): Promise<void>; startVoiceTurnController(): Promise<void>;
@@ -705,9 +706,15 @@ describe("project config RPC authorization", () => {
]); ]);
}); });
test("read_project_config_request accepts a symlink to an active project root", async () => { // POSIX-only: creates a directory symlink without Windows privileges.
test.skipIf(isPlatform("win32"))(
"read_project_config_request accepts a symlink to an active project root",
async () => {
const repoRoot = makeRoot(); const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); writeFileSync(
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm ci" } }),
);
const linkRoot = join(makeRoot(), "link"); const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir"); symlinkSync(repoRoot, linkRoot, "dir");
const messages: unknown[] = []; const messages: unknown[] = [];
@@ -737,7 +744,8 @@ describe("project config RPC authorization", () => {
}, },
}, },
]); ]);
}); },
);
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => { test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
const archivedRoot = makeRoot(); const archivedRoot = makeRoot();

View File

@@ -1,4 +1,5 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import path from "node:path";
import type pino from "pino"; import type pino from "pino";
import { Session, type SessionOptions } from "./session.js"; import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js";
@@ -36,6 +37,9 @@ type WorkspaceUpdatePayload = Extract<
{ type: "workspace_update" } { type: "workspace_update" }
>["payload"]; >["payload"];
const REPO_CWD = path.resolve("/tmp/repo");
const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`;
function createWorkspaceRuntimeSnapshot( function createWorkspaceRuntimeSnapshot(
cwd: string, cwd: string,
overrides?: { overrides?: {
@@ -243,7 +247,7 @@ function seedGitWorkspace(input: {
input.projectId, input.projectId,
createPersistedProjectRecord({ createPersistedProjectRecord({
projectId: input.projectId, projectId: input.projectId,
rootPath: "/tmp/repo", rootPath: input.cwd,
displayName: "repo", displayName: "repo",
kind: "git", kind: "git",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -274,7 +278,7 @@ describe("workspace git watch targets", () => {
workspaces, workspaces,
projectId: "proj-1", projectId: "proj-1",
workspaceId: "ws-10", workspaceId: "ws-10",
cwd: "/tmp/repo", cwd: REPO_CWD,
name: "main", name: "main",
}); });
sessionAny.workspaceUpdatesSubscription = { sessionAny.workspaceUpdatesSubscription = {
@@ -289,22 +293,22 @@ describe("workspace git watch targets", () => {
id: "ws-10", id: "ws-10",
projectId: "proj-1", projectId: "proj-1",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: REPO_CWD,
projectKind: "git", projectKind: "git",
workspaceKind: "local_checkout", workspaceKind: "local_checkout",
name: "main", name: "main",
status: "done", status: "done",
activityAt: null, activityAt: null,
diffStat: { additions: 1, deletions: 0 }, diffStat: { additions: 1, deletions: 0 },
workspaceDirectory: "/tmp/repo", workspaceDirectory: REPO_CWD,
}; };
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true }); sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" }, { cwd: REPO_CWD },
expect.any(Function), expect.any(Function),
); );
@@ -314,7 +318,7 @@ describe("workspace git watch targets", () => {
}; };
subscriptions[0]?.listener( subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", { createWorkspaceRuntimeSnapshot(REPO_CWD, {
git: { git: {
currentBranch: "renamed-branch", currentBranch: "renamed-branch",
}, },
@@ -349,7 +353,7 @@ describe("workspace git watch targets", () => {
workspaces, workspaces,
projectId: "proj-1", projectId: "proj-1",
workspaceId: "ws-10", workspaceId: "ws-10",
cwd: "/tmp/repo", cwd: REPO_CWD,
name: "main", name: "main",
}); });
sessionAny.workspaceUpdatesSubscription = { sessionAny.workspaceUpdatesSubscription = {
@@ -360,11 +364,11 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(),
}; };
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true }); sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
emitted.length = 0; emitted.length = 0;
subscriptions[0]?.listener( subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", { createWorkspaceRuntimeSnapshot(REPO_CWD, {
git: { git: {
currentBranch: "feature/server-push", currentBranch: "feature/server-push",
isDirty: true, isDirty: true,
@@ -380,9 +384,9 @@ describe("workspace git watch targets", () => {
) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>; ) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>;
expect(statusUpdates).toHaveLength(1); expect(statusUpdates).toHaveLength(1);
expect(statusUpdates[0]?.payload).toMatchObject({ expect(statusUpdates[0]?.payload).toMatchObject({
cwd: "/tmp/repo", cwd: REPO_CWD,
isGit: true, isGit: true,
repoRoot: "/tmp/repo", repoRoot: REPO_CWD,
currentBranch: "feature/server-push", currentBranch: "feature/server-push",
isDirty: true, isDirty: true,
baseRef: "main", baseRef: "main",
@@ -393,10 +397,10 @@ describe("workspace git watch targets", () => {
remoteUrl: "https://github.com/acme/repo.git", remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
error: null, error: null,
requestId: "subscription:/tmp/repo", requestId: REPO_SUBSCRIPTION_REQUEST_ID,
}); });
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" }, { cwd: REPO_CWD },
expect.any(Function), expect.any(Function),
); );
@@ -412,7 +416,7 @@ describe("workspace git watch targets", () => {
workspaces, workspaces,
projectId: "proj-1", projectId: "proj-1",
workspaceId: "ws-10", workspaceId: "ws-10",
cwd: "/tmp/repo", cwd: REPO_CWD,
name: "main", name: "main",
}); });
sessionAny.workspaceUpdatesSubscription = { sessionAny.workspaceUpdatesSubscription = {
@@ -423,11 +427,11 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(),
}; };
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true }); sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
emitted.length = 0; emitted.length = 0;
subscriptions[0]?.listener( subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", { createWorkspaceRuntimeSnapshot(REPO_CWD, {
github: { github: {
featuresEnabled: true, featuresEnabled: true,
pullRequest: { pullRequest: {
@@ -457,7 +461,7 @@ describe("workspace git watch targets", () => {
| { payload: CheckoutStatusUpdatePayload } | { payload: CheckoutStatusUpdatePayload }
| undefined; | undefined;
expect(statusUpdate?.payload.prStatus).toEqual({ expect(statusUpdate?.payload.prStatus).toEqual({
cwd: "/tmp/repo", cwd: REPO_CWD,
status: { status: {
number: 456, number: 456,
url: "https://github.com/acme/repo/pull/456", url: "https://github.com/acme/repo/pull/456",
@@ -481,7 +485,7 @@ describe("workspace git watch targets", () => {
}, },
githubFeaturesEnabled: true, githubFeaturesEnabled: true,
error: null, error: null,
requestId: "subscription:/tmp/repo", requestId: REPO_SUBSCRIPTION_REQUEST_ID,
}); });
await session.cleanup(); await session.cleanup();
@@ -491,7 +495,7 @@ describe("workspace git watch targets", () => {
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
workspaceGitService.getSnapshot.mockResolvedValue( workspaceGitService.getSnapshot.mockResolvedValue(
createWorkspaceRuntimeSnapshot("/tmp/repo", { createWorkspaceRuntimeSnapshot(REPO_CWD, {
github: { github: {
featuresEnabled: true, featuresEnabled: true,
pullRequest: { pullRequest: {
@@ -508,15 +512,15 @@ describe("workspace git watch targets", () => {
await session.handleMessage({ await session.handleMessage({
type: "checkout_pr_status_request", type: "checkout_pr_status_request",
cwd: "/tmp/repo", cwd: REPO_CWD,
requestId: "req-pr-status", requestId: "req-pr-status",
}); });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
expect( expect(
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
).toEqual({ ).toEqual({
cwd: "/tmp/repo", cwd: REPO_CWD,
status: { status: {
number: undefined, number: undefined,
url: "https://github.com/acme/repo/pull/456", url: "https://github.com/acme/repo/pull/456",
@@ -543,12 +547,12 @@ describe("workspace git watch targets", () => {
await session.handleMessage({ await session.handleMessage({
type: "checkout_pr_status_request", type: "checkout_pr_status_request",
cwd: "/tmp/repo", cwd: REPO_CWD,
requestId: "req-pr-cached", requestId: "req-pr-cached",
}); });
expect(workspaceGitService.refresh).not.toHaveBeenCalled(); expect(workspaceGitService.refresh).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined(); expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
}); });
}); });

View File

@@ -185,6 +185,18 @@ function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) {
} }
const T0 = "2026-01-01T00:00:00.000Z"; const T0 = "2026-01-01T00:00:00.000Z";
const FOO = path.resolve("/foo");
const FOO_SUB = path.join(FOO, "sub");
const BAR = path.resolve("/bar");
const BAR_BAZ = path.join(BAR, "baz");
const TOOLBOX = path.resolve("/toolbox");
const TOOLBOX_FLOMO = path.join(TOOLBOX, "flomo-cli");
const USERS_DEVELOPER = path.resolve("/Users/me/Developer");
const USERS_PROJECT = path.join(USERS_DEVELOPER, "projects", "foo");
const PROJECTS = path.resolve("/projects");
const SOME_GIT_REPO = path.join(PROJECTS, "some-git-repo");
const PARENT = path.resolve("/parent");
const PARENT_CHILD = path.join(PARENT, "child");
function gitWorkspace(rootPath: string, archivedAt: string | null = null) { function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({ return createPersistedWorkspaceRecord({
@@ -240,13 +252,13 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
// S1. Open a fresh git repo: creates a workspace at the canonical root. // S1. Open a fresh git repo: creates a workspace at the canonical root.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
test("S1: open fresh git repo creates workspace at canonical root", async () => { test("S1: open fresh git repo creates workspace at canonical root", async () => {
const h = createHarness({ gitRoots: ["/foo"] }); const h = createHarness({ gitRoots: [FOO] });
await openProject(h.session, "/foo"); await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull(); expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/foo"); expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has("/foo")).toBe(true); expect(h.workspaces.has(FOO)).toBe(true);
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -255,12 +267,12 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => { test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => {
const h = createHarness({}); const h = createHarness({});
await openProject(h.session, "/bar"); await openProject(h.session, BAR);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull(); expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/bar"); expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
expect(resp?.workspace?.workspaceKind).toBe("directory"); expect(resp?.workspace?.workspaceKind).toBe("directory");
expect(h.workspaces.has("/bar")).toBe(true); expect(h.workspaces.has(BAR)).toBe(true);
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -269,15 +281,15 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
test("S3: re-open active workspace by exact path returns the same record", async () => { test("S3: re-open active workspace by exact path returns the same record", async () => {
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/foo")], workspaces: [gitWorkspace(FOO)],
projects: [gitProject("/foo")], projects: [gitProject(FOO)],
gitRoots: ["/foo"], gitRoots: [FOO],
}); });
await openProject(h.session, "/foo"); await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe("/foo"); expect(resp?.workspace?.id).toBe(FOO);
expect(h.workspaces.size).toBe(1); expect(h.workspaces.size).toBe(1);
expect(h.workspaces.get("/foo")?.archivedAt).toBeNull(); expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -286,13 +298,13 @@ test("S3: re-open active workspace by exact path returns the same record", async
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => { test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/foo")], workspaces: [gitWorkspace(FOO)],
projects: [gitProject("/foo")], projects: [gitProject(FOO)],
gitRoots: ["/foo"], gitRoots: [FOO],
}); });
await openProject(h.session, "/foo/sub"); await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe("/foo"); expect(resp?.workspace?.id).toBe(FOO);
expect(h.workspaces.size).toBe(1); expect(h.workspaces.size).toBe(1);
}); });
@@ -302,14 +314,14 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => { test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => {
const h = createHarness({ const h = createHarness({
workspaces: [dirWorkspace("/bar")], workspaces: [dirWorkspace(BAR)],
projects: [dirProject("/bar")], projects: [dirProject(BAR)],
}); });
await openProject(h.session, "/bar/baz"); await openProject(h.session, BAR_BAZ);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz"); expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
expect(h.workspaces.has("/bar")).toBe(true); expect(h.workspaces.has(BAR)).toBe(true);
expect(h.workspaces.has("/bar/baz")).toBe(true); expect(h.workspaces.has(BAR_BAZ)).toBe(true);
expect(h.workspaces.size).toBe(2); expect(h.workspaces.size).toBe(2);
}); });
@@ -320,13 +332,13 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => { test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z"; const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)], workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject("/toolbox", archivedAt)], projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: ["/toolbox"], gitRoots: [TOOLBOX],
}); });
await openProject(h.session, "/toolbox"); await openProject(h.session, TOOLBOX);
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull(); expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull(); expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -334,15 +346,15 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => { test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => {
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/foo")], workspaces: [gitWorkspace(FOO)],
projects: [gitProject("/foo")], projects: [gitProject(FOO)],
gitRoots: ["/foo", "/foo/sub"], gitRoots: [FOO, FOO_SUB],
}); });
await openProject(h.session, "/foo/sub"); await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub"); expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
expect(h.workspaces.has("/foo")).toBe(true); expect(h.workspaces.has(FOO)).toBe(true);
expect(h.workspaces.has("/foo/sub")).toBe(true); expect(h.workspaces.has(FOO_SUB)).toBe(true);
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -353,12 +365,12 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => { test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => {
const archivedAt = "2026-04-04T17:15:22.423Z"; const archivedAt = "2026-04-04T17:15:22.423Z";
const h = createHarness({ const h = createHarness({
workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)], workspaces: [dirWorkspace(USERS_DEVELOPER, archivedAt)],
projects: [dirProject("/Users/me/Developer", archivedAt)], projects: [dirProject(USERS_DEVELOPER, archivedAt)],
}); });
await openProject(h.session, "/Users/me/Developer/projects/foo"); await openProject(h.session, USERS_PROJECT);
expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt); expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true); expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -369,12 +381,12 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => { test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z"; const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)], workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject("/toolbox", archivedAt)], projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: ["/toolbox"], gitRoots: [TOOLBOX],
}); });
await openProject(h.session, "/toolbox/flomo-cli"); await openProject(h.session, TOOLBOX_FLOMO);
expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt); expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -389,18 +401,18 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => { test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => {
const archivedAt = "2026-04-04T17:15:22.423Z"; const archivedAt = "2026-04-04T17:15:22.423Z";
const h = createHarness({ const h = createHarness({
workspaces: [dirWorkspace("/projects", archivedAt)], workspaces: [dirWorkspace(PROJECTS, archivedAt)],
projects: [dirProject("/projects", archivedAt)], projects: [dirProject(PROJECTS, archivedAt)],
gitRoots: ["/projects/some-git-repo"], gitRoots: [SOME_GIT_REPO],
}); });
await openProject(h.session, "/projects/some-git-repo"); await openProject(h.session, SOME_GIT_REPO);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull(); expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/projects/some-git-repo"); expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has("/projects/some-git-repo")).toBe(true); expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt); expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt); expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -411,19 +423,19 @@ test("S10: opening a git repo nested inside an archived non-git directory create
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => { test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z"; const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)], workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject("/toolbox", archivedAt)], projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: ["/toolbox"], gitRoots: [TOOLBOX],
}); });
await openProject(h.session, "/toolbox"); await openProject(h.session, TOOLBOX);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull(); expect(resp?.error).toBeNull();
expect(resp?.workspace?.id).toBe("/toolbox"); expect(resp?.workspace?.id).toBe(TOOLBOX);
expect(resp?.workspace?.projectId).toBe("/toolbox"); expect(resp?.workspace?.projectId).toBe(TOOLBOX);
expect(h.workspaces.size).toBe(1); expect(h.workspaces.size).toBe(1);
expect(h.projects.size).toBe(1); expect(h.projects.size).toBe(1);
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull(); expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull(); expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -434,12 +446,12 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => { test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z"; const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({ const h = createHarness({
workspaces: [dirWorkspace("/parent", archivedAt)], workspaces: [dirWorkspace(PARENT, archivedAt)],
projects: [dirProject("/parent", archivedAt)], projects: [dirProject(PARENT, archivedAt)],
}); });
const found = await asInternals<{ const found = await asInternals<{
findWorkspaceByDirectory(cwd: string): Promise<unknown>; findWorkspaceByDirectory(cwd: string): Promise<unknown>;
}>(h.session).findWorkspaceByDirectory("/parent/child"); }>(h.session).findWorkspaceByDirectory(PARENT_CHILD);
expect(found).toBeNull(); expect(found).toBeNull();
}); });
@@ -456,11 +468,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => { test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z"; const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({ const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)], workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject("/toolbox", archivedAt)], projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: ["/toolbox"], gitRoots: [TOOLBOX],
}); });
await openProject(h.session, "/toolbox/flomo-cli"); await openProject(h.session, TOOLBOX_FLOMO);
const resp = getOpenResponse(h.emitted, "req-1"); const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull(); expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceKind).toBe("directory"); expect(resp?.workspace?.workspaceKind).toBe("directory");

View File

@@ -1,4 +1,4 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -48,6 +48,9 @@ import {
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
} from "./workspace-registry.js"; } from "./workspace-registry.js";
const REPO_CWD = path.resolve("/tmp/repo");
const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
interface SessionTestAccess { interface SessionTestAccess {
projectRegistry: { projectRegistry: {
list(...args: unknown[]): Promise<unknown[]>; list(...args: unknown[]): Promise<unknown[]>;
@@ -604,7 +607,7 @@ test("unsupported persisted agents are excluded from active lists but preserved
const storedRecord = { const storedRecord = {
id: "agent-unsupported", id: "agent-unsupported",
provider: "gemini", provider: "gemini",
cwd: "/tmp/history", cwd: path.resolve("/tmp/history"),
createdAt: "2026-04-13T10:13:11.457Z", createdAt: "2026-04-13T10:13:11.457Z",
updatedAt: "2026-04-13T10:16:06.556Z", updatedAt: "2026-04-13T10:16:06.556Z",
lastActivityAt: "2026-04-13T10:16:06.556Z", lastActivityAt: "2026-04-13T10:16:06.556Z",
@@ -727,7 +730,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
); );
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-1", projectId: "proj-1",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -736,7 +739,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-1", workspaceId: "ws-1",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -755,7 +758,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
await session.forwardAgentUpdate( await session.forwardAgentUpdate(
makeManagedAgent({ makeManagedAgent({
id: "agent-1", id: "agent-1",
cwd: "/tmp/repo", cwd: REPO_CWD,
lifecycle: "running", lifecycle: "running",
updatedAt: "2026-03-30T15:00:00.000Z", updatedAt: "2026-03-30T15:00:00.000Z",
}), }),
@@ -801,7 +804,7 @@ test("agent_update emits fallback placement when no workspace is registered", as
await session.forwardAgentUpdate( await session.forwardAgentUpdate(
makeManagedAgent({ makeManagedAgent({
id: "agent-1", id: "agent-1",
cwd: "/tmp/unregistered", cwd: UNREGISTERED_CWD,
lifecycle: "running", lifecycle: "running",
updatedAt: "2026-03-30T15:00:00.000Z", updatedAt: "2026-03-30T15:00:00.000Z",
}), }),
@@ -811,10 +814,10 @@ test("agent_update emits fallback placement when no workspace is registered", as
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
kind: "upsert", kind: "upsert",
project: { project: {
projectKey: "/tmp/unregistered", projectKey: UNREGISTERED_CWD,
projectName: "unregistered", projectName: "unregistered",
checkout: { checkout: {
cwd: "/tmp/unregistered", cwd: UNREGISTERED_CWD,
isGit: false, isGit: false,
}, },
}, },
@@ -826,7 +829,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
const archivedRecord = { const archivedRecord = {
id: "agent-1", id: "agent-1",
provider: "codex", provider: "codex",
cwd: "/tmp/repo", cwd: REPO_CWD,
createdAt: "2026-03-30T15:00:00.000Z", createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z", updatedAt: "2026-03-30T15:00:00.000Z",
lastActivityAt: "2026-03-30T15:00:00.000Z", lastActivityAt: "2026-03-30T15:00:00.000Z",
@@ -836,7 +839,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
runtimeInfo: null, runtimeInfo: null,
config: { config: {
provider: "codex", provider: "codex",
cwd: "/tmp/repo", cwd: REPO_CWD,
}, },
persistence: null, persistence: null,
title: "Archive me", title: "Archive me",
@@ -893,7 +896,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
projectRegistry: (() => { projectRegistry: (() => {
const proj = createPersistedProjectRecord({ const proj = createPersistedProjectRecord({
projectId: "proj-1", projectId: "proj-1",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "non_git", kind: "non_git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -913,7 +916,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
const ws = createPersistedWorkspaceRecord({ const ws = createPersistedWorkspaceRecord({
workspaceId: "ws-1", workspaceId: "ws-1",
projectId: "proj-1", projectId: "proj-1",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "directory", kind: "directory",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -934,7 +937,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
loopService: asLoopService(), loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({ checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({ subscribe: async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null }, initial: { cwd: REPO_CWD, files: [], error: null },
unsubscribe: () => {}, unsubscribe: () => {},
}), }),
scheduleRefreshForCwd: () => {}, scheduleRefreshForCwd: () => {},
@@ -996,7 +999,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
const archivedRecord = { const archivedRecord = {
id: "agent-1", id: "agent-1",
provider: "codex", provider: "codex",
cwd: "/tmp/repo", cwd: REPO_CWD,
model: null, model: null,
thinkingOptionId: null, thinkingOptionId: null,
effectiveThinkingOptionId: null, effectiveThinkingOptionId: null,
@@ -1055,7 +1058,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
projectRegistry: (() => { projectRegistry: (() => {
const proj = createPersistedProjectRecord({ const proj = createPersistedProjectRecord({
projectId: "proj-close", projectId: "proj-close",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "non_git", kind: "non_git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1075,7 +1078,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
const ws = createPersistedWorkspaceRecord({ const ws = createPersistedWorkspaceRecord({
workspaceId: "ws-close", workspaceId: "ws-close",
projectId: "proj-close", projectId: "proj-close",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "directory", kind: "directory",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1170,7 +1173,7 @@ test("close_items_request archives stored agents that are not currently loaded",
const liveRecord = { const liveRecord = {
...makeAgent({ ...makeAgent({
id: "agent-live", id: "agent-live",
cwd: "/tmp/repo", cwd: REPO_CWD,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}), }),
@@ -1179,7 +1182,7 @@ test("close_items_request archives stored agents that are not currently loaded",
const storedRecord = { const storedRecord = {
...makeAgent({ ...makeAgent({
id: storedAgentId, id: storedAgentId,
cwd: "/tmp/repo", cwd: REPO_CWD,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:05:00.000Z", updatedAt: "2026-03-01T12:05:00.000Z",
}), }),
@@ -1244,7 +1247,7 @@ test("close_items_request archives stored agents that are not currently loaded",
projectRegistry: (() => { projectRegistry: (() => {
const proj = createPersistedProjectRecord({ const proj = createPersistedProjectRecord({
projectId: "proj-stored", projectId: "proj-stored",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "non_git", kind: "non_git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1264,7 +1267,7 @@ test("close_items_request archives stored agents that are not currently loaded",
const ws = createPersistedWorkspaceRecord({ const ws = createPersistedWorkspaceRecord({
workspaceId: "ws-stored", workspaceId: "ws-stored",
projectId: "proj-stored", projectId: "proj-stored",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "directory", kind: "directory",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1350,7 +1353,7 @@ test("close_items_request continues after an archive failure", async () => {
const goodRecord = { const goodRecord = {
...makeAgent({ ...makeAgent({
id: "agent-good", id: "agent-good",
cwd: "/tmp/repo", cwd: REPO_CWD,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}), }),
@@ -1393,7 +1396,7 @@ test("close_items_request continues after an archive failure", async () => {
projectRegistry: (() => { projectRegistry: (() => {
const proj = createPersistedProjectRecord({ const proj = createPersistedProjectRecord({
projectId: "proj-err", projectId: "proj-err",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "non_git", kind: "non_git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1413,7 +1416,7 @@ test("close_items_request continues after an archive failure", async () => {
const ws = createPersistedWorkspaceRecord({ const ws = createPersistedWorkspaceRecord({
workspaceId: "ws-err", workspaceId: "ws-err",
projectId: "proj-err", projectId: "proj-err",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "directory", kind: "directory",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1714,9 +1717,12 @@ test("active-scoped fetch_agents pages within active scope instead of global his
test("legacy unscoped fetch_agents keeps global workspace behavior", async () => { test("legacy unscoped fetch_agents keeps global workspace behavior", async () => {
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const legacyRoot = path.resolve("/tmp/legacy");
const activeCwd = path.join(legacyRoot, "active");
const archivedCwd = path.join(legacyRoot, "archived");
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-legacy-global", projectId: "proj-legacy-global",
rootPath: "/tmp/legacy", rootPath: legacyRoot,
kind: "non_git", kind: "non_git",
displayName: "legacy", displayName: "legacy",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1725,7 +1731,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
const activeWorkspace = createPersistedWorkspaceRecord({ const activeWorkspace = createPersistedWorkspaceRecord({
workspaceId: "ws-legacy-active", workspaceId: "ws-legacy-active",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/legacy/active", cwd: activeCwd,
kind: "directory", kind: "directory",
displayName: "active", displayName: "active",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1734,7 +1740,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
const archivedWorkspace = createPersistedWorkspaceRecord({ const archivedWorkspace = createPersistedWorkspaceRecord({
workspaceId: "ws-legacy-archived", workspaceId: "ws-legacy-archived",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/legacy/archived", cwd: archivedCwd,
kind: "directory", kind: "directory",
displayName: "archived", displayName: "archived",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1747,13 +1753,13 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
session.listAgentPayloads = async () => [ session.listAgentPayloads = async () => [
makeAgent({ makeAgent({
id: "legacy-active", id: "legacy-active",
cwd: "/tmp/legacy/active", cwd: activeCwd,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:01:00.000Z", updatedAt: "2026-03-01T12:01:00.000Z",
}), }),
makeAgent({ makeAgent({
id: "legacy-archived-workspace", id: "legacy-archived-workspace",
cwd: "/tmp/legacy/archived", cwd: archivedCwd,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}), }),
@@ -1773,9 +1779,10 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
test("fetch_agent_history_request pages archived historical rows separately", async () => { test("fetch_agent_history_request pages archived historical rows separately", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const historyCwd = path.resolve("/tmp/history");
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-history", projectId: "proj-history",
rootPath: "/tmp/history", rootPath: historyCwd,
kind: "non_git", kind: "non_git",
displayName: "history", displayName: "history",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1784,7 +1791,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-history", workspaceId: "ws-history",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/history", cwd: historyCwd,
kind: "directory", kind: "directory",
displayName: "history", displayName: "history",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1801,7 +1808,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as
{ {
...makeAgent({ ...makeAgent({
id: "history-archived", id: "history-archived",
cwd: "/tmp/history", cwd: historyCwd,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}), }),
@@ -1842,7 +1849,7 @@ test("fetch_agent_request still resolves archived historical agents", async () =
const agent = { const agent = {
...makeAgent({ ...makeAgent({
id: "archived-history-agent", id: "archived-history-agent",
cwd: "/tmp/history-detail", cwd: path.resolve("/tmp/history-detail"),
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}), }),
@@ -1941,7 +1948,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "ws-repo-status", workspaceId: "ws-repo-status",
projectId: "proj-repo-status", projectId: "proj-repo-status",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -1951,19 +1958,19 @@ test("branch/detached policies and dominant status bucket are deterministic", as
session.listAgentPayloads = async () => [ session.listAgentPayloads = async () => [
makeAgent({ makeAgent({
id: "a1", id: "a1",
cwd: "/tmp/repo", cwd: REPO_CWD,
status: "running", status: "running",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
}), }),
makeAgent({ makeAgent({
id: "a2", id: "a2",
cwd: "/tmp/repo", cwd: REPO_CWD,
status: "error", status: "error",
updatedAt: "2026-03-01T12:01:00.000Z", updatedAt: "2026-03-01T12:01:00.000Z",
}), }),
makeAgent({ makeAgent({
id: "a3", id: "a3",
cwd: "/tmp/repo", cwd: REPO_CWD,
status: "idle", status: "idle",
updatedAt: "2026-03-01T12:02:00.000Z", updatedAt: "2026-03-01T12:02:00.000Z",
pendingPermissions: 1, pendingPermissions: 1,
@@ -1985,7 +1992,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "ws-repo-subdir", workspaceId: "ws-repo-subdir",
projectId: "proj-repo-subdir", projectId: "proj-repo-subdir",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -2101,12 +2108,12 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () => session.buildWorkspaceDescriptorMap = async () =>
new Map([ new Map([
[ [
"/tmp/repo", REPO_CWD,
{ {
id: "ws-repo-running", id: "ws-repo-running",
projectId: "proj-repo-running", projectId: "proj-repo-running",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: REPO_CWD,
projectKind: "non_git", projectKind: "non_git",
workspaceKind: "directory", workspaceKind: "directory",
name: "repo", name: "repo",
@@ -2115,17 +2122,17 @@ test("workspace update stream keeps persisted workspace visible after agents sto
}, },
], ],
]); ]);
await session.emitWorkspaceUpdateForCwd("/tmp/repo"); await session.emitWorkspaceUpdateForCwd(REPO_CWD);
session.buildWorkspaceDescriptorMap = async () => session.buildWorkspaceDescriptorMap = async () =>
new Map([ new Map([
[ [
"/tmp/repo", REPO_CWD,
{ {
id: "ws-repo-running", id: "ws-repo-running",
projectId: "proj-repo-running", projectId: "proj-repo-running",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: REPO_CWD,
projectKind: "non_git", projectKind: "non_git",
workspaceKind: "directory", workspaceKind: "directory",
name: "repo", name: "repo",
@@ -2134,7 +2141,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
}, },
], ],
]); ]);
await session.emitWorkspaceUpdateForCwd("/tmp/repo"); await session.emitWorkspaceUpdateForCwd(REPO_CWD);
const workspaceUpdates = filterByType(emitted, "workspace_update"); const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates).toHaveLength(2); expect(workspaceUpdates).toHaveLength(2);
@@ -2145,7 +2152,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
id: "ws-repo-running", id: "ws-repo-running",
projectId: "proj-repo-running", projectId: "proj-repo-running",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: REPO_CWD,
projectKind: "non_git", projectKind: "non_git",
workspaceKind: "directory", workspaceKind: "directory",
name: "repo", name: "repo",
@@ -2160,13 +2167,19 @@ test("create paseo worktree request returns a registered workspace descriptor",
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, "paseo-home"); const paseoHome = path.join(tempDir, "paseo-home");
execSync(`mkdir -p ${repoDir}`); mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@test.com"], {
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
const workspaceGitService = createNoopWorkspaceGitService(); const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
if (cwd === repoDir) { if (cwd === repoDir) {
@@ -2263,7 +2276,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
createPersistedWorkspaceRecord({ createPersistedWorkspaceRecord({
workspaceId: "ws-repo-main", workspaceId: "ws-repo-main",
projectId: "proj-repo-main", projectId: "proj-repo-main",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -2296,7 +2309,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
id: "ws-repo-main", id: "ws-repo-main",
projectId: "proj-repo-main", projectId: "proj-repo-main",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: REPO_CWD,
projectKind: "git", projectKind: "git",
workspaceKind: "local_checkout", workspaceKind: "local_checkout",
name: "main", name: "main",
@@ -2310,7 +2323,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
id: "ws-repo-feature", id: "ws-repo-feature",
projectId: "proj-repo-main", projectId: "proj-repo-main",
projectDisplayName: "repo", projectDisplayName: "repo",
projectRootPath: "/tmp/repo", projectRootPath: REPO_CWD,
projectKind: "git", projectKind: "git",
workspaceKind: "worktree", workspaceKind: "worktree",
name: "feature", name: "feature",
@@ -2376,14 +2389,14 @@ test("open_project_request registers a workspace before any agent exists", async
await session.handleMessage({ await session.handleMessage({
type: "open_project_request", type: "open_project_request",
cwd: "/tmp/repo", cwd: REPO_CWD,
requestId: "req-open", requestId: "req-open",
}); });
expect(workspaces.get("/tmp/repo")).toBeTruthy(); expect(workspaces.get(REPO_CWD)).toBeTruthy();
const response = findByType(emitted, "open_project_response"); const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull(); expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe("/tmp/repo"); expect(response?.payload.workspace?.id).toBe(REPO_CWD);
}); });
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => { test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
@@ -2391,7 +2404,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/tmp/slow-github-repo"; const cwd = path.resolve("/tmp/slow-github-repo");
session.emit = (message) => { session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message); if (isSessionOutboundMessage(message)) emitted.push(message);
@@ -2453,7 +2466,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/tmp/github-runtime-repo"; const cwd = path.resolve("/tmp/github-runtime-repo");
const snapshot = createWorkspaceRuntimeSnapshot(cwd); const snapshot = createWorkspaceRuntimeSnapshot(cwd);
let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
@@ -2545,8 +2558,8 @@ test("open_project_request does not match a new child directory to an existing p
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const home = "/Users/moboudra"; const home = path.resolve("/Users/moboudra");
const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea"; const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
projects.set( projects.set(
home, home,
@@ -2609,8 +2622,8 @@ test("open_project_request does not unarchive an archived parent workspace for a
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const home = "/Users/moboudra"; const home = path.resolve("/Users/moboudra");
const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea"; const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
const archivedAt = "2026-04-24T08:00:00.000Z"; const archivedAt = "2026-04-24T08:00:00.000Z";
projects.set( projects.set(
@@ -2677,8 +2690,14 @@ test("open_project_request reclassifies an archived directory workspace when git
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
const repoRoot = "/Users/moboudra/dev/paseo"; const cwd = path.join(
path.resolve("/Users/moboudra"),
".paseo",
"worktrees",
"orchestrate",
"desktop-daemon-settings",
);
const remoteProjectId = "remote:github.com/getpaseo/paseo"; const remoteProjectId = "remote:github.com/getpaseo/paseo";
const archivedAt = "2026-04-24T09:48:36.168Z"; const archivedAt = "2026-04-24T09:48:36.168Z";
@@ -2769,8 +2788,14 @@ test("open_project_request reclassifies an active directory workspace when git m
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
const repoRoot = "/Users/moboudra/dev/paseo"; const cwd = path.join(
path.resolve("/Users/moboudra"),
".paseo",
"worktrees",
"orchestrate",
"desktop-daemon-settings",
);
projects.set( projects.set(
cwd, cwd,
@@ -2879,8 +2904,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
const repoRoot = "/Users/moboudra/dev/paseo"; const cwd = path.join(
path.resolve("/Users/moboudra"),
".paseo",
"worktrees",
"orchestrate",
"desktop-daemon-settings",
);
projects.set( projects.set(
repoRoot, repoRoot,
@@ -2966,7 +2997,7 @@ test("open_project_request unarchives an existing archived workspace and project
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/tmp/repo"; const cwd = REPO_CWD;
projects.set( projects.set(
cwd, cwd,
createPersistedProjectRecord({ createPersistedProjectRecord({
@@ -3030,7 +3061,7 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const repoRoot = "/tmp/repo"; const repoRoot = REPO_CWD;
const subdir = "/tmp/repo/packages/app"; const subdir = "/tmp/repo/packages/app";
session.emit = (message) => { session.emit = (message) => {
@@ -3213,10 +3244,10 @@ test("open_in_editor_request launches the selected target", async () => {
type: "open_in_editor_request", type: "open_in_editor_request",
requestId: "req-open-editor", requestId: "req-open-editor",
editorId: "vscode", editorId: "vscode",
path: "/tmp/repo", path: REPO_CWD,
}); });
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]); expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]);
const response = emitted.find((message) => message.type === "open_in_editor_response") as const response = emitted.find((message) => message.type === "open_in_editor_response") as
| { payload: Record<string, unknown> } | { payload: Record<string, unknown> }
| undefined; | undefined;
@@ -3229,7 +3260,7 @@ test("archive_workspace_request hides non-destructive workspace records", async
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-repo-archive", workspaceId: "ws-repo-archive",
projectId: "proj-repo-archive", projectId: "proj-repo-archive",
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "directory", kind: "directory",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3271,7 +3302,7 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem
const localProjectId = mainWorkspaceId; const localProjectId = mainWorkspaceId;
const remoteProjectId = "remote:github.com/zimakki/inkwell"; const remoteProjectId = "remote:github.com/zimakki/inkwell";
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); mkdirSync(worktreeWorkspaceId, { recursive: true });
projects.set( projects.set(
localProjectId, localProjectId,
@@ -3381,7 +3412,7 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w
const oldProjectId = "remote:github.com/old-owner/inkwell"; const oldProjectId = "remote:github.com/old-owner/inkwell";
const newProjectId = "remote:github.com/new-owner/inkwell"; const newProjectId = "remote:github.com/new-owner/inkwell";
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); mkdirSync(worktreeWorkspaceId, { recursive: true });
projects.set( projects.set(
oldProjectId, oldProjectId,
@@ -3476,7 +3507,7 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
const subdirWorkspaceId = path.join(repoRoot, "packages", "app"); const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
const projectId = "remote:github.com/acme/repo"; const projectId = "remote:github.com/acme/repo";
execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`); mkdirSync(subdirWorkspaceId, { recursive: true });
projects.set( projects.set(
projectId, projectId,
@@ -3566,7 +3597,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-baseline", projectId: "proj-baseline",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3575,7 +3606,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-baseline", workspaceId: "ws-baseline",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3627,7 +3658,7 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () =>
const archivingAt = "2026-04-30T20:45:00.000Z"; const archivingAt = "2026-04-30T20:45:00.000Z";
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-archiving-map", projectId: "proj-archiving-map",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3669,7 +3700,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
const archivingAt = "2026-04-30T20:45:00.000Z"; const archivingAt = "2026-04-30T20:45:00.000Z";
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-archiving-emit", projectId: "proj-archiving-emit",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3725,7 +3756,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
git: { git: {
currentBranch: "runtime-branch", currentBranch: "runtime-branch",
isDirty: true, isDirty: true,
@@ -3758,7 +3789,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
); );
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-runtime-fetch", projectId: "proj-runtime-fetch",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3767,7 +3798,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-runtime-fetch", workspaceId: "ws-runtime-fetch",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3803,7 +3834,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
| { type: "fetch_workspaces_response"; payload: Record<string, unknown> } | { type: "fetch_workspaces_response"; payload: Record<string, unknown> }
| undefined; | undefined;
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith("/tmp/repo"); expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD);
expect(response?.payload.entries).toEqual([ expect(response?.payload.entries).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "ws-runtime-fetch", id: "ws-runtime-fetch",
@@ -3856,7 +3887,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
); );
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-fetch-boundary", projectId: "proj-fetch-boundary",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3865,7 +3896,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-fetch-boundary", workspaceId: "ws-fetch-boundary",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3895,7 +3926,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
test("workspace_update includes updated runtime fields", async () => { test("workspace_update includes updated runtime fields", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
git: { git: {
currentBranch: "feature/runtime-payloads", currentBranch: "feature/runtime-payloads",
isDirty: true, isDirty: true,
@@ -3922,7 +3953,7 @@ test("workspace_update includes updated runtime fields", async () => {
); );
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-runtime-update", projectId: "proj-runtime-update",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3931,7 +3962,7 @@ test("workspace_update includes updated runtime fields", async () => {
const workspace = createPersistedWorkspaceRecord({ const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-runtime-update", workspaceId: "ws-runtime-update",
projectId: project.projectId, projectId: project.projectId,
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -3966,11 +3997,11 @@ test("workspace_update includes updated runtime fields", async () => {
}, },
}); });
await session.emitWorkspaceUpdateForCwd("/tmp/repo", { await session.emitWorkspaceUpdateForCwd(REPO_CWD, {
skipReconcile: true, skipReconcile: true,
}); });
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith("/tmp/repo"); expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD);
expect(emitted).toContainEqual({ expect(emitted).toContainEqual({
type: "workspace_update", type: "workspace_update",
payload: { payload: {
@@ -3998,7 +4029,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const gitProject = createPersistedProjectRecord({ const gitProject = createPersistedProjectRecord({
projectId: "proj-git-subscribe", projectId: "proj-git-subscribe",
rootPath: "/tmp/repo", rootPath: REPO_CWD,
kind: "git", kind: "git",
displayName: "repo", displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
@@ -4015,7 +4046,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
const gitWorkspace = createPersistedWorkspaceRecord({ const gitWorkspace = createPersistedWorkspaceRecord({
workspaceId: "ws-git-subscribe", workspaceId: "ws-git-subscribe",
projectId: gitProject.projectId, projectId: gitProject.projectId,
cwd: "/tmp/repo", cwd: REPO_CWD,
kind: "local_checkout", kind: "local_checkout",
displayName: "main", displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",

View File

@@ -86,6 +86,15 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
setAgentAttentionCallback: vi.fn(), setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null), getAgent: vi.fn(() => null),
getLastAssistantMessage: vi.fn(async () => null), getLastAssistantMessage: vi.fn(async () => null),
getMetricsSnapshot: vi.fn(() => ({
total: 0,
byLifecycle: {},
withActiveForegroundTurn: 0,
timelineStats: {
totalItems: 0,
maxItemsPerAgent: 0,
},
})),
...agentManagerOverrides, ...agentManagerOverrides,
}; };
const daemonConfigStore = { const daemonConfigStore = {

View File

@@ -1,8 +1,8 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join, resolve as resolvePath } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
createGitHubService, createGitHubService,
@@ -23,6 +23,9 @@ import {
WorkspaceGitServiceImpl, WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot, type WorkspaceGitRuntimeSnapshot,
} from "./workspace-git-service.js"; } from "./workspace-git-service.js";
import { isPlatform } from "../test-utils/platform.js";
const REPO_CWD = resolvePath("/tmp/repo");
function createLogger() { function createLogger() {
const logger = { const logger = {
@@ -230,11 +233,11 @@ function buildDefaultServiceDeps() {
listBranchSuggestions: vi.fn(async () => []), listBranchSuggestions: vi.fn(async () => []),
listPaseoWorktrees: vi.fn(async () => []), listPaseoWorktrees: vi.fn(async () => []),
github: createGitHubServiceStub(), github: createGitHubServiceStub(),
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => false), hasOriginRemote: vi.fn(async () => false),
runGitFetch: vi.fn(async () => {}), runGitFetch: vi.fn(async () => {}),
runGitCommand: vi.fn(async () => ({ runGitCommand: vi.fn(async () => ({
stdout: "/tmp/repo\n", stdout: `${REPO_CWD}\n`,
stderr: "", stderr: "",
truncated: false, truncated: false,
exitCode: 0, exitCode: 0,
@@ -273,9 +276,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
nowMs += 1_000; nowMs += 1_000;
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
@@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getPullRequestStatus, getPullRequestStatus,
}); });
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
@@ -307,22 +310,22 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const service = createService({ getCheckoutStatus }); const service = createService({ getCheckoutStatus });
const listener = vi.fn(); const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
expect(getCheckoutStatus).not.toHaveBeenCalled(); expect(getCheckoutStatus).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
expect(service.peekSnapshot("/tmp/repo")).toBeNull(); expect(service.peekSnapshot(REPO_CWD)).toBeNull();
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(service.peekSnapshot("/tmp/repo")).toBeNull(); expect(service.peekSnapshot(REPO_CWD)).toBeNull();
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -336,9 +339,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
nowMs = 1; nowMs = 1;
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -348,15 +351,15 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
test("forced getSnapshot emits even when the fingerprint matches", async () => { test("forced getSnapshot emits even when the fingerprint matches", async () => {
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({ getCheckoutStatus }); const service = createService({ getCheckoutStatus });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
const listener = vi.fn(); const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo")); expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD));
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -371,13 +374,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
const listener = vi.fn(); const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
nowMs = 3_000; nowMs = 3_000;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
@@ -390,17 +393,17 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const service = createService({ getCheckoutStatus }); const service = createService({ getCheckoutStatus });
const first = service.getSnapshot("/tmp/repo"); const first = service.getSnapshot(REPO_CWD);
const second = service.getSnapshot("/tmp/repo/."); const second = service.getSnapshot(join(REPO_CWD, "."));
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
await expect(Promise.all([first, second])).resolves.toEqual([ await expect(Promise.all([first, second])).resolves.toEqual([
createSnapshot("/tmp/repo"), createSnapshot(REPO_CWD),
createSnapshot("/tmp/repo"), createSnapshot(REPO_CWD),
]); ]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
@@ -424,24 +427,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
createSnapshot("/tmp/repo", { createSnapshot(REPO_CWD, {
git: { diffStat: { additions: 4, deletions: 2 } }, git: { diffStat: { additions: 4, deletions: 2 } },
}), }),
); );
nowMs += 16_000; nowMs += 16_000;
const first = service.getSnapshot("/tmp/repo"); const first = service.getSnapshot(REPO_CWD);
await flushPromises(); await flushPromises();
const second = service.getSnapshot("/tmp/repo"); const second = service.getSnapshot(REPO_CWD);
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
refreshStatus.resolve(createCheckoutStatus("/tmp/repo", { currentBranch: "feature" })); refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
const freshSnapshot = createSnapshot("/tmp/repo", { const freshSnapshot = createSnapshot(REPO_CWD, {
git: { git: {
currentBranch: "feature", currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 }, diffStat: { additions: 4, deletions: 2 },
@@ -461,11 +464,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
nowMs = 3_000; nowMs = 3_000;
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
service.scheduleRefreshForCwd("/tmp/repo"); service.scheduleRefreshForCwd(REPO_CWD);
} }
await vi.advanceTimersByTimeAsync(500); await vi.advanceTimersByTimeAsync(500);
await flushPromises(); await flushPromises();
@@ -480,20 +483,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const secondRefresh = createDeferred<CheckoutStatusGit>(); const secondRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>() .fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(async () => secondRefresh.promise) .mockImplementationOnce(async () => secondRefresh.promise)
.mockImplementation(async () => createCheckoutStatus("/tmp/repo")); .mockImplementation(async () => createCheckoutStatus(REPO_CWD));
const service = createService({ const service = createService({
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
nowMs = 3_000; nowMs = 3_000;
const refreshPromise = service.refresh("/tmp/repo"); const refreshPromise = service.refresh(REPO_CWD);
await flushPromises(); await flushPromises();
const forcedPromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); const forcedPromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
const duplicateForcedPromise = service.getSnapshot("/tmp/repo", { const duplicateForcedPromise = service.getSnapshot(REPO_CWD, {
force: true, force: true,
reason: "test", reason: "test",
}); });
@@ -501,7 +504,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
secondRefresh.resolve(createCheckoutStatus("/tmp/repo")); secondRefresh.resolve(createCheckoutStatus(REPO_CWD));
await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]); await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(3); expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
@@ -513,19 +516,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const forcedRefresh = createDeferred<CheckoutStatusGit>(); const forcedRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>() .fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(async () => forcedRefresh.promise); .mockImplementationOnce(async () => forcedRefresh.promise);
const service = createService({ getCheckoutStatus }); const service = createService({ getCheckoutStatus });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
const first = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); const first = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
await flushPromises(); await flushPromises();
const second = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); const second = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
forcedRefresh.resolve(createCheckoutStatus("/tmp/repo")); forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
await Promise.all([first, second]); await Promise.all([first, second]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -537,18 +540,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const forcedRefresh = createDeferred<CheckoutStatusGit>(); const forcedRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>() .fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(async () => forcedRefresh.promise); .mockImplementationOnce(async () => forcedRefresh.promise);
const service = createService({ getCheckoutStatus }); const service = createService({ getCheckoutStatus });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
const forcePromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
await flushPromises(); await flushPromises();
service.scheduleRefreshForCwd("/tmp/repo"); service.scheduleRefreshForCwd(REPO_CWD);
await vi.advanceTimersByTimeAsync(500); await vi.advanceTimersByTimeAsync(500);
await flushPromises(); await flushPromises();
forcedRefresh.resolve(createCheckoutStatus("/tmp/repo")); forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
await forcePromise; await forcePromise;
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -563,12 +566,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
nowMs = 3_000; nowMs = 3_000;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
nowMs = 3_001; nowMs = 3_001;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -582,10 +585,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
nowMs = 16_000; nowMs = 16_000;
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -601,7 +604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getPullRequestStatus, getPullRequestStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
nowMs = 60_000; nowMs = 60_000;
@@ -609,7 +612,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenLastCalledWith("/tmp/repo", expect.anything(), { expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
force: false, force: false,
reason: "self-heal-git", reason: "self-heal-git",
}); });
@@ -623,7 +626,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const resolveAbsoluteGitDir = vi const resolveAbsoluteGitDir = vi
.fn<() => Promise<string | null>>() .fn<() => Promise<string | null>>()
.mockRejectedValueOnce(new Error("git dir temporarily unavailable")) .mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
.mockResolvedValue("/tmp/repo/.git"); .mockResolvedValue(join(REPO_CWD, ".git"));
const watch = vi.fn(() => createWatcher() as never); const watch = vi.fn(() => createWatcher() as never);
const service = createService({ const service = createService({
resolveAbsoluteGitDir, resolveAbsoluteGitDir,
@@ -631,7 +634,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
@@ -642,7 +645,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises(); await flushPromises();
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo"); expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith(REPO_CWD);
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -659,11 +662,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({ const service = createService({
getCheckoutStatus, getCheckoutStatus,
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
watch, watch,
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -697,7 +700,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github, github,
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -744,7 +747,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github, github,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
nowMs = 20_000; nowMs = 20_000;
@@ -777,7 +780,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
github, github,
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled(); expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
@@ -801,7 +804,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
github, github,
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -819,8 +822,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn()); const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn());
await flushPromises(); await flushPromises();
nowMs = 60_000; nowMs = 60_000;
@@ -841,7 +844,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
subscription.unsubscribe(); subscription.unsubscribe();
nowMs = 60_000; nowMs = 60_000;
@@ -860,7 +863,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
service.dispose(); service.dispose();
nowMs = 60_000; nowMs = 60_000;
@@ -875,24 +878,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const selfHealRefresh = createDeferred<CheckoutStatusGit>(); const selfHealRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>() .fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(async () => selfHealRefresh.promise); .mockImplementationOnce(async () => selfHealRefresh.promise);
const service = createService({ const service = createService({
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
nowMs = 60_000; nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
await flushPromises(); await flushPromises();
const directRead = service.getSnapshot("/tmp/repo"); const directRead = service.getSnapshot(REPO_CWD);
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
selfHealRefresh.resolve(createCheckoutStatus("/tmp/repo")); selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
await directRead; await directRead;
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -923,8 +926,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.validateBranchRef("/tmp/repo", "feature"); const first = service.validateBranchRef(REPO_CWD, "feature");
const second = service.validateBranchRef("/tmp/repo/.", "feature"); const second = service.validateBranchRef(join(REPO_CWD, "."), "feature");
await flushPromises(); await flushPromises();
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
@@ -935,10 +938,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
]); ]);
nowMs = 1_000; nowMs = 1_000;
await service.validateBranchRef("/tmp/repo", "feature"); await service.validateBranchRef(REPO_CWD, "feature");
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
await service.validateBranchRef("/tmp/repo", "feature", { force: true, reason: "test" }); await service.validateBranchRef(REPO_CWD, "feature", { force: true, reason: "test" });
expect(resolveBranchCheckout).toHaveBeenCalledTimes(2); expect(resolveBranchCheckout).toHaveBeenCalledTimes(2);
service.dispose(); service.dispose();
@@ -968,15 +971,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.hasLocalBranch("/tmp/repo", "feature"); const first = service.hasLocalBranch(REPO_CWD, "feature");
const second = service.hasLocalBranch("/tmp/repo/.", "feature"); const second = service.hasLocalBranch(join(REPO_CWD, "."), "feature");
await flushPromises(); await flushPromises();
expect(runGitCommand).toHaveBeenCalledTimes(1); expect(runGitCommand).toHaveBeenCalledTimes(1);
expect(runGitCommand).toHaveBeenCalledWith( expect(runGitCommand).toHaveBeenCalledWith(
["rev-parse", "--verify", "--quiet", "refs/heads/feature"], ["rev-parse", "--verify", "--quiet", "refs/heads/feature"],
expect.objectContaining({ expect.objectContaining({
cwd: "/tmp/repo", cwd: REPO_CWD,
acceptExitCodes: [0, 1], acceptExitCodes: [0, 1],
}), }),
); );
@@ -990,11 +993,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
await expect(Promise.all([first, second])).resolves.toEqual([true, true]); await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
nowMs = 1_000; nowMs = 1_000;
await expect(service.hasLocalBranch("/tmp/repo", "feature")).resolves.toBe(true); await expect(service.hasLocalBranch(REPO_CWD, "feature")).resolves.toBe(true);
expect(runGitCommand).toHaveBeenCalledTimes(1); expect(runGitCommand).toHaveBeenCalledTimes(1);
await expect( await expect(
service.hasLocalBranch("/tmp/repo", "feature", { force: true, reason: "test" }), service.hasLocalBranch(REPO_CWD, "feature", { force: true, reason: "test" }),
).resolves.toBe(false); ).resolves.toBe(false);
expect(runGitCommand).toHaveBeenCalledTimes(2); expect(runGitCommand).toHaveBeenCalledTimes(2);
@@ -1013,18 +1016,18 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({ await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({
kind: "local", kind: "local",
name: "feature-old", name: "feature-old",
}); });
nowMs = 16_000; nowMs = 16_000;
resolveBranchCheckout.mockClear(); resolveBranchCheckout.mockClear();
await expect(service.validateBranchRef("/tmp/repo", "feature")).rejects.toThrow("git is busy"); await expect(service.validateBranchRef(REPO_CWD, "feature")).rejects.toThrow("git is busy");
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
nowMs = 16_500; nowMs = 16_500;
await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({ await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({
kind: "local", kind: "local",
name: "feature-old", name: "feature-old",
}); });
@@ -1046,8 +1049,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 }); const first = service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 });
const second = service.suggestBranchesForCwd("/tmp/repo/.", { const second = service.suggestBranchesForCwd(join(REPO_CWD, "."), {
query: "feat", query: "feat",
limit: 5, limit: 5,
}); });
@@ -1058,11 +1061,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]); await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]);
nowMs = 1_000; nowMs = 1_000;
await service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 }); await service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 });
expect(listBranchSuggestions).toHaveBeenCalledTimes(1); expect(listBranchSuggestions).toHaveBeenCalledTimes(1);
await service.suggestBranchesForCwd( await service.suggestBranchesForCwd(
"/tmp/repo", REPO_CWD,
{ query: "feat", limit: 5 }, { query: "feat", limit: 5 },
{ force: true, reason: "test" }, { force: true, reason: "test" },
); );
@@ -1096,8 +1099,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.listStashes("/tmp/repo", { paseoOnly: true }); const first = service.listStashes(REPO_CWD, { paseoOnly: true });
const second = service.listStashes("/tmp/repo/.", { paseoOnly: true }); const second = service.listStashes(join(REPO_CWD, "."), { paseoOnly: true });
await flushPromises(); await flushPromises();
expect(runGitCommand).toHaveBeenCalledTimes(1); expect(runGitCommand).toHaveBeenCalledTimes(1);
@@ -1114,10 +1117,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
]); ]);
nowMs = 1_000; nowMs = 1_000;
await service.listStashes("/tmp/repo", { paseoOnly: true }); await service.listStashes(REPO_CWD, { paseoOnly: true });
expect(runGitCommand).toHaveBeenCalledTimes(1); expect(runGitCommand).toHaveBeenCalledTimes(1);
await service.listStashes("/tmp/repo", { paseoOnly: true }, { force: true, reason: "test" }); await service.listStashes(REPO_CWD, { paseoOnly: true }, { force: true, reason: "test" });
expect(runGitCommand).toHaveBeenCalledTimes(2); expect(runGitCommand).toHaveBeenCalledTimes(2);
service.dispose(); service.dispose();
@@ -1138,16 +1141,16 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.listWorktrees("/tmp/repo"); const first = service.listWorktrees(REPO_CWD);
const second = service.listWorktrees("/tmp/repo/."); const second = service.listWorktrees(join(REPO_CWD, "."));
await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]); await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]);
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
nowMs = 1_000; nowMs = 1_000;
await service.listWorktrees("/tmp/repo"); await service.listWorktrees(REPO_CWD);
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
await service.listWorktrees("/tmp/repo", { force: true, reason: "test" }); await service.listWorktrees(REPO_CWD, { force: true, reason: "test" });
expect(listPaseoWorktrees).toHaveBeenCalledTimes(2); expect(listPaseoWorktrees).toHaveBeenCalledTimes(2);
service.dispose(); service.dispose();
@@ -1158,7 +1161,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
const nestedWorkspaceDir = join(repoDir, "packages", "app"); const nestedWorkspaceDir = join(repoDir, "packages", "app");
mkdirSync(nestedWorkspaceDir, { recursive: true }); mkdirSync(nestedWorkspaceDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
const worktrees = [ const worktrees = [
{ {
@@ -1181,7 +1184,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
expect(listPaseoWorktrees).toHaveBeenCalledWith({ expect(listPaseoWorktrees).toHaveBeenCalledWith({
cwd: repoDir, cwd: realpathSync.native(repoDir).replace(/\\/g, "/"),
paseoHome: "/tmp/paseo-test", paseoHome: "/tmp/paseo-test",
}); });
} finally { } finally {
@@ -1202,8 +1205,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.resolveDefaultBranch("/tmp/repo"); const first = service.resolveDefaultBranch(REPO_CWD);
const second = service.resolveDefaultBranch("/tmp/repo/."); const second = service.resolveDefaultBranch(join(REPO_CWD, "."));
await flushPromises(); await flushPromises();
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1); expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
@@ -1211,10 +1214,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]); await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]);
nowMs = 1_000; nowMs = 1_000;
await service.resolveDefaultBranch("/tmp/repo"); await service.resolveDefaultBranch(REPO_CWD);
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1); expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
await service.resolveDefaultBranch("/tmp/repo", { force: true, reason: "test" }); await service.resolveDefaultBranch(REPO_CWD, { force: true, reason: "test" });
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(2); expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(2);
service.dispose(); service.dispose();
@@ -1226,25 +1229,25 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
const getCheckoutStatus = vi const getCheckoutStatus = vi
.fn() .fn()
.mockImplementationOnce(async () => checkoutDeferred.promise) .mockImplementationOnce(async () => checkoutDeferred.promise)
.mockResolvedValue(createCheckoutStatus("/tmp/repo")); .mockResolvedValue(createCheckoutStatus(REPO_CWD));
const service = createService({ const service = createService({
getCheckoutStatus, getCheckoutStatus,
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.resolveRepoRoot("/tmp/repo"); const first = service.resolveRepoRoot(REPO_CWD);
const second = service.resolveRepoRoot("/tmp/repo/."); const second = service.resolveRepoRoot(join(REPO_CWD, "."));
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
checkoutDeferred.resolve(createCheckoutStatus("/tmp/repo")); checkoutDeferred.resolve(createCheckoutStatus(REPO_CWD));
await expect(Promise.all([first, second])).resolves.toEqual(["/tmp/repo", "/tmp/repo"]); await expect(Promise.all([first, second])).resolves.toEqual([REPO_CWD, REPO_CWD]);
nowMs = 1_000; nowMs = 1_000;
await service.resolveRepoRoot("/tmp/repo"); await service.resolveRepoRoot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
await service.resolveRepoRoot("/tmp/repo", { force: true, reason: "test" }); await service.resolveRepoRoot(REPO_CWD, { force: true, reason: "test" });
expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
service.dispose(); service.dispose();
@@ -1262,11 +1265,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await expect(service.resolveRepoRemoteUrl("/tmp/repo")).resolves.toBe( await expect(service.resolveRepoRemoteUrl(REPO_CWD)).resolves.toBe(
"https://github.com/getpaseo/paseo.git", "https://github.com/getpaseo/paseo.git",
); );
nowMs = 1_000; nowMs = 1_000;
await expect(service.resolveRepoRemoteUrl("/tmp/repo/.")).resolves.toBe( await expect(service.resolveRepoRemoteUrl(join(REPO_CWD, "."))).resolves.toBe(
"https://github.com/getpaseo/paseo.git", "https://github.com/getpaseo/paseo.git",
); );
@@ -1281,7 +1284,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
createCheckoutStatus(cwd, { createCheckoutStatus(cwd, {
currentBranch: "feature/service-metadata", currentBranch: "feature/service-metadata",
remoteUrl: "https://github.com/getpaseo/paseo.git", remoteUrl: "https://github.com/getpaseo/paseo.git",
repoRoot: "/tmp/repo", repoRoot: REPO_CWD,
}), }),
); );
const service = createService({ const service = createService({
@@ -1290,7 +1293,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
}); });
await expect( await expect(
service.getWorkspaceGitMetadata("/tmp/repo", { directoryName: "Local Repo" }), service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }),
).resolves.toEqual({ ).resolves.toEqual({
projectKind: "git", projectKind: "git",
projectDisplayName: "getpaseo/paseo", projectDisplayName: "getpaseo/paseo",
@@ -1298,13 +1301,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
gitRemote: "https://github.com/getpaseo/paseo.git", gitRemote: "https://github.com/getpaseo/paseo.git",
isWorktree: false, isWorktree: false,
projectSlug: "paseo", projectSlug: "paseo",
repoRoot: "/tmp/repo", repoRoot: REPO_CWD,
currentBranch: "feature/service-metadata", currentBranch: "feature/service-metadata",
remoteUrl: "https://github.com/getpaseo/paseo.git", remoteUrl: "https://github.com/getpaseo/paseo.git",
}); });
nowMs = 1_000; nowMs = 1_000;
await service.getWorkspaceGitMetadata("/tmp/repo/.", { directoryName: "Local Repo" }); await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" });
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
service.dispose(); service.dispose();
@@ -1314,18 +1317,21 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-"))); const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-")));
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true }); mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@test.com"], {
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(join(repoDir, "tracked.txt"), "before\n"); writeFileSync(join(repoDir, "tracked.txt"), "before\n");
execSync("git add tracked.txt", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\n"); writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\n");
writeFileSync(join(repoDir, "staged.txt"), "staged\n"); writeFileSync(join(repoDir, "staged.txt"), "staged\n");
execSync("git add staged.txt", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "staged.txt"], { cwd: repoDir, stdio: "pipe" });
const service = createService({ const service = createService({
getCheckoutDiff: getCheckoutDiffUncached as never, getCheckoutDiff: getCheckoutDiffUncached as never,
@@ -1357,8 +1363,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
.mockResolvedValue({ diff: "second" }); .mockResolvedValue({ diff: "second" });
const service = createService({ getCheckoutDiff, now: () => new Date(0) }); const service = createService({ getCheckoutDiff, now: () => new Date(0) });
const first = service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" }); const first = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
const second = service.getCheckoutDiff("/tmp/repo/.", { mode: "uncommitted" }); const second = service.getCheckoutDiff(join(REPO_CWD, "."), { mode: "uncommitted" });
await flushPromises(); await flushPromises();
expect(getCheckoutDiff).toHaveBeenCalledTimes(1); expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
@@ -1383,16 +1389,12 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({
diff: "first", diff: "first",
}); });
nowMs = 1; nowMs = 1;
await expect( await expect(
service.getCheckoutDiff( service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }, { force: true, reason: "test" }),
"/tmp/repo",
{ mode: "uncommitted" },
{ force: true, reason: "test" },
),
).resolves.toEqual({ diff: "forced" }); ).resolves.toEqual({ diff: "forced" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(2); expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
@@ -1412,15 +1414,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({
diff: "first", diff: "first",
}); });
nowMs = 16_000; nowMs = 16_000;
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).rejects.toThrow( await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).rejects.toThrow(
"git is busy", "git is busy",
); );
nowMs = 16_500; nowMs = 16_500;
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({
diff: "first", diff: "first",
}); });
@@ -1441,13 +1443,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
}); });
await expect( await expect(
service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "main" }), service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }),
).resolves.toEqual({ diff: "main" }); ).resolves.toEqual({ diff: "main" });
await expect( await expect(
service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "release" }), service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "release" }),
).resolves.toEqual({ diff: "release" }); ).resolves.toEqual({ diff: "release" });
await expect( await expect(
service.getCheckoutDiff("/tmp/repo", { service.getCheckoutDiff(REPO_CWD, {
mode: "base", mode: "base",
baseRef: "main", baseRef: "main",
ignoreWhitespace: true, ignoreWhitespace: true,
@@ -1459,7 +1461,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
service.dispose(); service.dispose();
}); });
test("Linux working tree walker excludes gitignored directories", async () => { // POSIX-only: this asserts Linux working-tree walker behavior around ignored directories.
test.skipIf(isPlatform("win32"))(
"Linux working tree walker excludes gitignored directories",
async () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
@@ -1467,7 +1472,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true }); mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
mkdirSync(join(repoDir, "kept"), { recursive: true }); mkdirSync(join(repoDir, "kept"), { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n"); writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n"); writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n"); writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
@@ -1501,5 +1506,6 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
} }
}); },
);
}); });

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import path from "node:path"; import os from "node:os";
import path, { join } from "node:path";
import type { FSWatcher } from "node:fs"; import type { FSWatcher } from "node:fs";
import type pino from "pino"; import type pino from "pino";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
@@ -8,6 +9,9 @@ import {
WorkspaceGitServiceImpl, WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot, type WorkspaceGitRuntimeSnapshot,
} from "./workspace-git-service.js"; } from "./workspace-git-service.js";
import { isPlatform } from "../test-utils/platform.js";
const REPO_CWD = path.resolve("/tmp/repo");
interface ServiceInternals { interface ServiceInternals {
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>; workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
@@ -202,11 +206,11 @@ function buildDefaultTestServiceDeps() {
})), })),
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()), getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()),
github: createGitHubServiceStub(), github: createGitHubServiceStub(),
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => false), hasOriginRemote: vi.fn(async () => false),
runGitFetch: vi.fn(async () => {}), runGitFetch: vi.fn(async () => {}),
runGitCommand: vi.fn(async () => ({ runGitCommand: vi.fn(async () => ({
stdout: "/tmp/repo\n", stdout: `${REPO_CWD}\n`,
stderr: "", stderr: "",
truncated: false, truncated: false,
exitCode: 0, exitCode: 0,
@@ -237,12 +241,12 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService(); const service = createService();
const listener = vi.fn(); const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
expect("initial" in subscription).toBe(false); expect("initial" in subscription).toBe(false);
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
expect(service.peekSnapshot("/tmp/repo")).toBeNull(); expect(service.peekSnapshot(REPO_CWD)).toBeNull();
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
@@ -267,8 +271,8 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date("2026-04-12T02:03:04.000Z"), now: () => new Date("2026-04-12T02:03:04.000Z"),
}); });
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
createSnapshot("/tmp/repo", { createSnapshot(REPO_CWD, {
github: { github: {
pullRequest: { pullRequest: {
url: "https://github.com/acme/repo/pull/999", url: "https://github.com/acme/repo/pull/999",
@@ -304,10 +308,10 @@ describe("WorkspaceGitServiceImpl", () => {
getCheckoutShortstat, getCheckoutShortstat,
}); });
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
createSnapshot("/tmp/repo", { createSnapshot(REPO_CWD, {
git: { git: {
repoRoot: "/tmp/repo", repoRoot: REPO_CWD,
currentBranch: "feature/worktree", currentBranch: "feature/worktree",
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: "/tmp/main-repo", mainRepoRoot: "/tmp/main-repo",
@@ -325,11 +329,11 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const listener = vi.fn(); const listener = vi.fn();
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
nowMs += 3_000; nowMs += 3_000;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
@@ -342,7 +346,7 @@ describe("WorkspaceGitServiceImpl", () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>(); const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
const service = createService({ const service = createService({
getCheckoutStatus, getCheckoutStatus,
@@ -350,27 +354,27 @@ describe("WorkspaceGitServiceImpl", () => {
resolveAbsoluteGitDir, resolveAbsoluteGitDir,
}); });
const firstSnapshotPromise = service.getSnapshot("/tmp/repo"); const firstSnapshotPromise = service.getSnapshot(REPO_CWD);
const secondSnapshotPromise = service.getSnapshot("/tmp/repo/."); const secondSnapshotPromise = service.getSnapshot(join(REPO_CWD, "."));
await flushPromises(); await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([ await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
createSnapshot("/tmp/repo"), createSnapshot(REPO_CWD),
createSnapshot("/tmp/repo"), createSnapshot(REPO_CWD),
]); ]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
@@ -379,7 +383,7 @@ describe("WorkspaceGitServiceImpl", () => {
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const service = createService({ const service = createService({
@@ -388,8 +392,8 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises(); await flushPromises();
expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
@@ -402,7 +406,7 @@ describe("WorkspaceGitServiceImpl", () => {
test("equivalent cwd strings share one workspace target across service entry points", async () => { test("equivalent cwd strings share one workspace target across service entry points", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const service = createService({ const service = createService({
@@ -411,14 +415,18 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs), now: () => new Date(nowMs),
}); });
const subscription = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn()); const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn());
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); createSnapshot(REPO_CWD),
);
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
nowMs += 3_000; nowMs += 3_000;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
createSnapshot(REPO_CWD),
);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
@@ -430,7 +438,7 @@ describe("WorkspaceGitServiceImpl", () => {
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
const runGitFetch = vi.fn(async () => {}); const runGitFetch = vi.fn(async () => {});
const hasOriginRemote = vi.fn(async () => true); const hasOriginRemote = vi.fn(async () => true);
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
const service = createService({ const service = createService({
resolveAbsoluteGitDir, resolveAbsoluteGitDir,
@@ -438,8 +446,11 @@ describe("WorkspaceGitServiceImpl", () => {
runGitFetch, runGitFetch,
}); });
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn()); const second = service.registerWorkspace(
{ cwd: join(REPO_CWD, "packages", "server") },
vi.fn(),
);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
expect(runGitFetch).toHaveBeenCalledTimes(1); expect(runGitFetch).toHaveBeenCalledTimes(1);
@@ -493,18 +504,18 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
const listener = vi.fn(); const listener = vi.fn();
const initialSnapshot = await service.getSnapshot("/tmp/repo"); const initialSnapshot = await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh"); expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
await flushPromises(); await flushPromises();
expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith( expect(listener).toHaveBeenCalledWith(
createSnapshot("/tmp/repo", { createSnapshot(REPO_CWD, {
github: { github: {
pullRequest: { pullRequest: {
url: "https://github.com/acme/repo/pull/123", url: "https://github.com/acme/repo/pull/123",
@@ -525,9 +536,9 @@ describe("WorkspaceGitServiceImpl", () => {
test("unchanged runtime snapshots do not emit duplicate updates", async () => { test("unchanged runtime snapshots do not emit duplicate updates", async () => {
const getCheckoutStatus = vi const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>() .fn<() => Promise<CheckoutStatusGit>>()
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null })) .mockResolvedValueOnce(createCheckoutStatus(REPO_CWD, { remoteUrl: null }))
.mockResolvedValueOnce( .mockResolvedValueOnce(
createCheckoutStatus("/tmp/repo", { createCheckoutStatus(REPO_CWD, {
currentBranch: "feature/runtime-payloads", currentBranch: "feature/runtime-payloads",
remoteUrl: null, remoteUrl: null,
aheadBehind: { ahead: 2, behind: 0 }, aheadBehind: { ahead: 2, behind: 0 },
@@ -535,7 +546,7 @@ describe("WorkspaceGitServiceImpl", () => {
}), }),
) )
.mockResolvedValueOnce( .mockResolvedValueOnce(
createCheckoutStatus("/tmp/repo", { createCheckoutStatus(REPO_CWD, {
currentBranch: "feature/runtime-payloads", currentBranch: "feature/runtime-payloads",
remoteUrl: null, remoteUrl: null,
aheadBehind: { ahead: 2, behind: 0 }, aheadBehind: { ahead: 2, behind: 0 },
@@ -563,22 +574,22 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
const listener = vi.fn(); const listener = vi.fn();
const initialSnapshot = await service.getSnapshot("/tmp/repo"); const initialSnapshot = await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
expect(initialSnapshot.git.currentBranch).toBe("main"); expect(initialSnapshot.git.currentBranch).toBe("main");
nowMs += 3_000; nowMs += 3_000;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
await flushPromises(); await flushPromises();
nowMs += 3_000; nowMs += 3_000;
await service.refresh("/tmp/repo"); await service.refresh(REPO_CWD);
await flushPromises(); await flushPromises();
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith( expect(listener).toHaveBeenCalledWith(
createSnapshot("/tmp/repo", { createSnapshot(REPO_CWD, {
git: { git: {
currentBranch: "feature/runtime-payloads", currentBranch: "feature/runtime-payloads",
remoteUrl: null, remoteUrl: null,
@@ -597,7 +608,7 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
test("forced snapshot refresh emits even when the fingerprint matches", async () => { test("forced snapshot refresh emits even when the fingerprint matches", async () => {
const getCheckoutStatus = vi.fn(async () => createCheckoutStatus("/tmp/repo")); const getCheckoutStatus = vi.fn(async () => createCheckoutStatus(REPO_CWD));
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const service = createService({ const service = createService({
@@ -607,23 +618,24 @@ describe("WorkspaceGitServiceImpl", () => {
}); });
const listener = vi.fn(); const listener = vi.fn();
await service.getSnapshot("/tmp/repo"); await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await service.getSnapshot("/tmp/repo", { await service.getSnapshot(REPO_CWD, {
force: true, force: true,
reason: "test-force-emit", reason: "test-force-emit",
}); });
await flushPromises(); await flushPromises();
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo")); expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD));
subscription.unsubscribe(); subscription.unsubscribe();
service.dispose(); service.dispose();
}); });
test("watches nested repository directories on Linux", async () => { // POSIX-only: this asserts Linux recursive-watch fallback behavior.
test.skipIf(isPlatform("win32"))("watches nested repository directories on Linux", async () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { Object.defineProperty(process, "platform", {
configurable: true, configurable: true,
@@ -637,20 +649,20 @@ describe("WorkspaceGitServiceImpl", () => {
return watcher; return watcher;
}); });
const readdir = vi.fn(async (directory: string) => { const readdir = vi.fn(async (directory: string) => {
if (directory === "/tmp/repo") { if (directory === REPO_CWD) {
return [ return [
createDirent("packages", true), createDirent("packages", true),
createDirent(".git", true), createDirent(".git", true),
createDirent("README.md", false), createDirent("README.md", false),
]; ];
} }
if (directory === path.join("/tmp/repo", "packages")) { if (directory === path.join(REPO_CWD, "packages")) {
return [createDirent("server", true), createDirent("app", true)]; return [createDirent("server", true), createDirent("app", true)];
} }
if (directory === path.join("/tmp/repo", "packages", "server")) { if (directory === path.join(REPO_CWD, "packages", "server")) {
return [createDirent("src", true)]; return [createDirent("src", true)];
} }
if (directory === path.join("/tmp/repo", "packages", "server", "src")) { if (directory === path.join(REPO_CWD, "packages", "server", "src")) {
return [createDirent("server", true)]; return [createDirent("server", true)];
} }
return []; return [];
@@ -658,19 +670,19 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService({ watch, readdir }); const service = createService({ watch, readdir });
const subscription = await service.requestWorkingTreeWatch( const subscription = await service.requestWorkingTreeWatch(
path.join("/tmp/repo", "packages", "server"), path.join(REPO_CWD, "packages", "server"),
vi.fn(), vi.fn(),
); );
expect(subscription.repoRoot).toBe("/tmp/repo"); expect(subscription.repoRoot).toBe(REPO_CWD);
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
"/tmp/repo", REPO_CWD,
"/tmp/repo/.git", join(REPO_CWD, ".git"),
"/tmp/repo/packages", join(REPO_CWD, "packages"),
"/tmp/repo/packages/app", join(REPO_CWD, "packages", "app"),
"/tmp/repo/packages/server", join(REPO_CWD, "packages", "server"),
"/tmp/repo/packages/server/src", join(REPO_CWD, "packages", "server", "src"),
"/tmp/repo/packages/server/src/server", join(REPO_CWD, "packages", "server", "src", "server"),
]); ]);
subscription.unsubscribe(); subscription.unsubscribe();
@@ -688,11 +700,11 @@ describe("WorkspaceGitServiceImpl", () => {
const firstListener = vi.fn(); const firstListener = vi.fn();
const secondListener = vi.fn(); const secondListener = vi.fn();
const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener); const first = await service.requestWorkingTreeWatch(REPO_CWD, firstListener);
const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener); const second = await service.requestWorkingTreeWatch(join(REPO_CWD, "."), secondListener);
expect(first.repoRoot).toBe("/tmp/repo"); expect(first.repoRoot).toBe(REPO_CWD);
expect(second.repoRoot).toBe("/tmp/repo"); expect(second.repoRoot).toBe(REPO_CWD);
expect(watch).toHaveBeenCalledTimes(2); expect(watch).toHaveBeenCalledTimes(2);
first.unsubscribe(); first.unsubscribe();
@@ -726,10 +738,8 @@ describe("WorkspaceGitServiceImpl", () => {
.mockImplementationOnce(() => createWatcher()); .mockImplementationOnce(() => createWatcher());
const service = createService({ watch }); const service = createService({ watch });
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get( const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD);
"/tmp/repo",
);
expect(target?.fallbackRefreshInterval).not.toBeNull(); expect(target?.fallbackRefreshInterval).not.toBeNull();
@@ -749,19 +759,18 @@ describe("WorkspaceGitServiceImpl", () => {
resolveAbsoluteGitDir, resolveAbsoluteGitDir,
}); });
const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn()); const plainCwd = path.join(os.tmpdir(), "plain");
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get( const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn());
"/tmp/plain", const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd);
);
expect(subscription.repoRoot).toBeNull(); expect(subscription.repoRoot).toBeNull();
const expectedRecursive = process.platform !== "linux"; const expectedRecursive = process.platform !== "linux";
expect(watch).toHaveBeenCalledWith( expect(watch).toHaveBeenCalledWith(
"/tmp/plain", plainCwd,
{ recursive: expectedRecursive }, { recursive: expectedRecursive },
expect.any(Function), expect.any(Function),
); );
expect(target?.repoWatchPath).toBe("/tmp/plain"); expect(target?.repoWatchPath).toBe(plainCwd);
expect(target?.fallbackRefreshInterval).not.toBeNull(); expect(target?.fallbackRefreshInterval).not.toBeNull();
subscription.unsubscribe(); subscription.unsubscribe();
@@ -780,13 +789,13 @@ describe("WorkspaceGitServiceImpl", () => {
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh"); const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
const listener = vi.fn(); const listener = vi.fn();
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener); const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener);
expect(watchCallbacks).toHaveLength(2); expect(watchCallbacks).toHaveLength(2);
watchCallbacks[0]?.(); watchCallbacks[0]?.();
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo", { expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, {
force: true, force: true,
reason: "working-tree-watch", reason: "working-tree-watch",
}); });
@@ -810,15 +819,12 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService({ getCheckoutShortstat, watch }); const service = createService({ getCheckoutShortstat, watch });
const workspaceListener = vi.fn(); const workspaceListener = vi.fn();
const initialSnapshot = await service.getSnapshot("/tmp/repo"); const initialSnapshot = await service.getSnapshot(REPO_CWD);
const workspaceSubscription = service.registerWorkspace( const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, workspaceListener);
{ cwd: "/tmp/repo" }, const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
workspaceListener,
);
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 }); expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 });
const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo"); const repoRootWatch = watchCallbacks.find((entry) => entry.path === REPO_CWD);
expect(repoRootWatch).toBeDefined(); expect(repoRootWatch).toBeDefined();
repoRootWatch?.callback(); repoRootWatch?.callback();
@@ -826,12 +832,12 @@ describe("WorkspaceGitServiceImpl", () => {
await flushPromises(); await flushPromises();
expect(getCheckoutShortstat).toHaveBeenLastCalledWith( expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
"/tmp/repo", REPO_CWD,
{ paseoHome: "/tmp/paseo-test" }, { paseoHome: "/tmp/paseo-test" },
{ force: true }, { force: true },
); );
expect(workspaceListener).toHaveBeenCalledWith( expect(workspaceListener).toHaveBeenCalledWith(
createSnapshot("/tmp/repo", { createSnapshot(REPO_CWD, {
git: { diffStat: { additions: 8, deletions: 3 } }, git: { diffStat: { additions: 8, deletions: 3 } },
}), }),
); );

View File

@@ -1,4 +1,4 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -117,13 +117,13 @@ function createWorkspaceGitServiceStub(
function createTempGitRepo(prefix: string): string { function createTempGitRepo(prefix: string): string {
const raw = mkdtempSync(path.join(tmpdir(), prefix)); const raw = mkdtempSync(path.join(tmpdir(), prefix));
const dir = realpathSync(raw); const dir = realpathSync(raw);
execSync("git init -b main", { cwd: dir, stdio: "ignore" }); execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" });
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" }); execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" }); execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir, stdio: "ignore" });
writeFileSync(path.join(dir, "README.md"), "# Test\n"); writeFileSync(path.join(dir, "README.md"), "# Test\n");
execSync("git add .", { cwd: dir, stdio: "ignore" }); execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" });
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" }); execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" });
return dir; return dir;
} }
@@ -253,12 +253,18 @@ describe("WorkspaceReconciliationService", () => {
); );
// Initialize as git repo // Initialize as git repo
execSync("git init -b main", { cwd: resolved, stdio: "ignore" }); execFileSync("git", ["init", "-b", "main"], { cwd: resolved, stdio: "ignore" });
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@test.com"], {
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" }); cwd: resolved,
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" }); stdio: "ignore",
execSync("git add .", { cwd: resolved, stdio: "ignore" }); });
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" }); execFileSync("git", ["config", "user.name", "Test"], { cwd: resolved, stdio: "ignore" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: resolved,
stdio: "ignore",
});
execFileSync("git", ["add", "."], { cwd: resolved, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "init"], { cwd: resolved, stdio: "ignore" });
const service = new WorkspaceReconciliationService({ const service = new WorkspaceReconciliationService({
projectRegistry, projectRegistry,
@@ -311,7 +317,7 @@ describe("WorkspaceReconciliationService", () => {
); );
// Change the remote // Change the remote
execSync("git remote add origin git@github.com:new-owner/new-repo.git", { execFileSync("git", ["remote", "add", "origin", "git@github.com:new-owner/new-repo.git"], {
cwd: dir, cwd: dir,
stdio: "ignore", stdio: "ignore",
}); });
@@ -341,7 +347,7 @@ describe("WorkspaceReconciliationService", () => {
const dir = createTempGitRepo("reconcile-branch-"); const dir = createTempGitRepo("reconcile-branch-");
tempDirs.push(dir); tempDirs.push(dir);
execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" }); execFileSync("git", ["checkout", "-b", "feature-branch"], { cwd: dir, stdio: "ignore" });
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();

View File

@@ -11,6 +11,9 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
describe("bootstrapWorkspaceRegistries", () => { describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string; let tmpDir: string;
let paseoHome: string; let paseoHome: string;
@@ -44,7 +47,7 @@ describe("bootstrapWorkspaceRegistries", () => {
await agentStorage.upsert({ await agentStorage.upsert({
id: "agent-1", id: "agent-1",
provider: "codex", provider: "codex",
cwd: "/tmp/non-git-project", cwd: NON_GIT_PROJECT,
createdAt: "2026-03-01T00:00:00.000Z", createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z", updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z", lastActivityAt: "2026-03-02T00:00:00.000Z",
@@ -61,7 +64,7 @@ describe("bootstrapWorkspaceRegistries", () => {
await agentStorage.upsert({ await agentStorage.upsert({
id: "agent-2", id: "agent-2",
provider: "codex", provider: "codex",
cwd: "/tmp/non-git-project", cwd: NON_GIT_PROJECT,
createdAt: "2026-03-01T01:00:00.000Z", createdAt: "2026-03-01T01:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:00.000Z",
lastActivityAt: "2026-03-03T00:00:00.000Z", lastActivityAt: "2026-03-03T00:00:00.000Z",
@@ -78,7 +81,7 @@ describe("bootstrapWorkspaceRegistries", () => {
await agentStorage.upsert({ await agentStorage.upsert({
id: "agent-archived", id: "agent-archived",
provider: "codex", provider: "codex",
cwd: "/tmp/archived-project", cwd: ARCHIVED_PROJECT,
createdAt: "2026-03-01T00:00:00.000Z", createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z", lastActivityAt: "2026-03-01T00:00:00.000Z",
@@ -104,13 +107,13 @@ describe("bootstrapWorkspaceRegistries", () => {
const workspaces = await workspaceRegistry.list(); const workspaces = await workspaceRegistry.list();
expect(workspaces).toHaveLength(1); expect(workspaces).toHaveLength(1);
expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project"); expect(workspaces[0]?.workspaceId).toBe(NON_GIT_PROJECT);
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
const projects = await projectRegistry.list(); const projects = await projectRegistry.list();
expect(projects).toHaveLength(1); expect(projects).toHaveLength(1);
expect(projects[0]?.projectId).toBe("/tmp/non-git-project"); expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT);
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
}); });

View File

@@ -0,0 +1,506 @@
// POSIX-only: worktree setup shell and terminal service fixtures
/* eslint-disable max-nested-callbacks */
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
mkdirSync,
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { isPlatform } from "../test-utils/platform.js";
import {
createWorktree as createWorktreePrimitive,
type WorktreeConfig,
} from "../utils/worktree.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
interface CreateAgentWorktreeTestOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
interface CreateAgentWorktreeTestResult {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
async function cleanupTerminalManager(terminalManager: TerminalManager): Promise<void> {
const terminalsByCwd = await Promise.all(
terminalManager.listDirectories().map((cwd) => terminalManager.getTerminals(cwd)),
);
const terminals = terminalsByCwd.flat();
await Promise.all(terminals.map((terminal) => killTerminal(terminalManager, terminal)));
terminalManager.killAll();
}
function killTerminal(terminalManager: TerminalManager, terminal: TerminalSession): Promise<void> {
return terminalManager.killTerminalAndWait(terminal.id, {
gracefulTimeoutMs: 100,
forceTimeoutMs: 100,
});
}
async function createBootstrapWorktreeForTest(
options: CreateAgentWorktreeTestOptions,
): Promise<CreateAgentWorktreeTestResult> {
const worktree = await createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: false,
paseoHome: options.paseoHome,
});
return { worktree, shouldBootstrap: true };
}
describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string;
let repoDir: string;
let paseoHome: string;
let realTerminalManagers: TerminalManager[];
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (existsSync(targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Timed out waiting for path: ${targetPath}`);
}
function readEnvFile(path: string): Record<string, string> {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Expected env file to contain a JSON object: ${path}`);
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") {
env[key] = value;
}
}
return env;
}
beforeEach(() => {
realTerminalManagers = [];
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
repoDir = join(tempDir, "repo");
paseoHome = join(tempDir, "paseo-home");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(join(repoDir, "file.txt"), "hello\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
});
afterEach(async () => {
await Promise.all(realTerminalManagers.map(cleanupTerminalManager));
rmSync(tempDir, { recursive: true, force: true });
});
it("streams running setup updates live and persists only a final setup timeline row", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
worktreeSlug: "feature-streaming-setup",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
const live: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-test",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
live.push(item);
return true;
},
});
const liveSetupItems = live.filter(
(item) =>
item.type === "tool_call" &&
item.name === "paseo_worktree_setup" &&
item.status === "running",
);
expect(liveSetupItems.length).toBeGreaterThan(0);
const persistedSetupItems = persisted.filter(
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItems).toHaveLength(1);
expect(persistedSetupItems[0]?.type).toBe("tool_call");
if (persistedSetupItems[0]?.type === "tool_call") {
expect(persistedSetupItems[0].status).toBe("completed");
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
if (persistedSetupItems[0].detail.type === "worktree_setup") {
expect(persistedSetupItems[0].detail.log).toContain(
'==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2',
);
expect(persistedSetupItems[0].detail.log).toContain("line-one");
expect(persistedSetupItems[0].detail.log).toContain("line-two");
expect(persistedSetupItems[0].detail.log).toContain(
'==> [2/2] Running: echo "line-three"',
);
expect(persistedSetupItems[0].detail.log).toContain("line-three");
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
index: 1,
command: 'echo "line-one"; echo "line-two" 1>&2',
log: expect.stringContaining("line-one"),
status: "completed",
exitCode: 0,
});
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
index: 2,
command: 'echo "line-three"',
log: "line-three\n",
status: "completed",
exitCode: 0,
});
expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe(
true,
);
expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe(
true,
);
}
}
const liveCallIds = new Set(
liveSetupItems
.filter(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call",
)
.map((item) => item.callId),
);
expect(liveCallIds.size).toBe(1);
if (persistedSetupItems[0]?.type === "tool_call") {
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
}
});
it("keeps only the final carriage-return-updated content in command logs", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add carriage return setup"],
{
cwd: repoDir,
stdio: "pipe",
},
);
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-carriage-return",
baseBranch: "main",
worktreeSlug: "feature-carriage-return",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-carriage-return",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const persistedSetupItem = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
throw new Error("Expected worktree_setup tool detail");
}
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
});
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
terminals: [
{
name: "Port Terminal",
command: "true",
},
],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add port setup and terminals"],
{
cwd: repoDir,
stdio: "pipe",
},
);
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
worktreeSlug: "feature-shared-runtime-port",
paseoHome,
});
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
const createTerminalEnvs: Record<string, string>[] = [];
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-shared-runtime-port",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
},
async createTerminal(options) {
createTerminalEnvs.push(options.env ?? {});
return {
id: "term-1",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
killAndWait: async () => {},
};
},
registerCwdEnv(options) {
registeredEnvs.push({ cwd: options.cwd, env: options.env });
},
getTerminal() {
return undefined;
},
killTerminal() {},
async killTerminalAndWait() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
await waitForPathExists(setupPortPath);
const setupPort = readFileSync(setupPortPath, "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
expect(registeredEnvs).toHaveLength(1);
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalEnvs.length).toBeGreaterThan(0);
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
const terminalToolCall = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" &&
item.name === "paseo_worktree_terminals" &&
item.status === "completed",
);
expect(terminalToolCall?.status).toBe("completed");
});
it("injects real peer service env into terminal-backed services", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
api: {
type: "service",
command:
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
},
web: {
type: "service",
command:
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
},
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add real peer env services"],
{
cwd: repoDir,
stdio: "pipe",
},
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const terminalManager = createTerminalManager();
realTerminalManagers.push(terminalManager);
await Promise.all(
["api", "web"].map((scriptName) =>
spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-peer-env",
scriptName,
daemonPort: 6767,
routeStore,
runtimeStore,
terminalManager,
}),
),
);
const apiEnvPath = join(repoDir, "api-env.json");
const webEnvPath = join(repoDir, "web-env.json");
await waitForPathExists(apiEnvPath);
await waitForPathExists(webEnvPath);
const apiEnv = readEnvFile(apiEnvPath);
const webEnv = readEnvFile(webEnvPath);
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(apiEnv).not.toHaveProperty("PORT");
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv).not.toHaveProperty("PORT");
const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT);
const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(Number.isInteger(apiPort)).toBe(true);
expect(Number.isInteger(webPort)).toBe(true);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.feature-peer-env.repo.localhost",
port: apiPort,
workspaceId: repoDir,
projectSlug: "repo",
scriptName: "api",
},
{
hostname: "web.feature-peer-env.repo.localhost",
port: webPort,
workspaceId: repoDir,
projectSlug: "repo",
scriptName: "web",
},
]);
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process"; import { execFileSync } from "child_process";
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs";
import { join } from "path"; import { join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
@@ -14,7 +14,7 @@ import {
createWorktree as createWorktreePrimitive, createWorktree as createWorktreePrimitive,
type WorktreeConfig, type WorktreeConfig,
} from "../utils/worktree.js"; } from "../utils/worktree.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js"; import type { TerminalSession } from "../terminal/terminal.js";
interface CreateAgentWorktreeTestOptions { interface CreateAgentWorktreeTestOptions {
@@ -69,30 +69,22 @@ describe("runAsyncWorktreeBootstrap", () => {
let paseoHome: string; let paseoHome: string;
let realTerminalManagers: TerminalManager[]; let realTerminalManagers: TerminalManager[];
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (existsSync(targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Timed out waiting for path: ${targetPath}`);
}
beforeEach(() => { beforeEach(() => {
realTerminalManagers = []; realTerminalManagers = [];
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
repoDir = join(tempDir, "repo"); repoDir = join(tempDir, "repo");
paseoHome = join(tempDir, "paseo-home"); paseoHome = join(tempDir, "paseo-home");
execSync(`mkdir -p ${repoDir}`); mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" }); writeFileSync(join(repoDir, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
}); });
afterEach(async () => { afterEach(async () => {
@@ -100,114 +92,6 @@ describe("runAsyncWorktreeBootstrap", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}); });
it("streams running setup updates live and persists only a final setup timeline row", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
worktreeSlug: "feature-streaming-setup",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
const live: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-test",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
live.push(item);
return true;
},
});
const liveSetupItems = live.filter(
(item) =>
item.type === "tool_call" &&
item.name === "paseo_worktree_setup" &&
item.status === "running",
);
expect(liveSetupItems.length).toBeGreaterThan(0);
const persistedSetupItems = persisted.filter(
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItems).toHaveLength(1);
expect(persistedSetupItems[0]?.type).toBe("tool_call");
if (persistedSetupItems[0]?.type === "tool_call") {
expect(persistedSetupItems[0].status).toBe("completed");
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
if (persistedSetupItems[0].detail.type === "worktree_setup") {
expect(persistedSetupItems[0].detail.log).toContain(
'==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2',
);
expect(persistedSetupItems[0].detail.log).toContain("line-one");
expect(persistedSetupItems[0].detail.log).toContain("line-two");
expect(persistedSetupItems[0].detail.log).toContain('==> [2/2] Running: echo "line-three"');
expect(persistedSetupItems[0].detail.log).toContain("line-three");
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
index: 1,
command: 'echo "line-one"; echo "line-two" 1>&2',
log: expect.stringContaining("line-one"),
status: "completed",
exitCode: 0,
});
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
index: 2,
command: 'echo "line-three"',
log: "line-three\n",
status: "completed",
exitCode: 0,
});
expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe(
true,
);
expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe(
true,
);
}
}
const liveCallIds = new Set(
liveSetupItems
.filter(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call",
)
.map((item) => item.callId),
);
expect(liveCallIds.size).toBe(1);
if (persistedSetupItems[0]?.type === "tool_call") {
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
}
});
it("does not fail setup when live timeline emission throws", async () => { it("does not fail setup when live timeline emission throws", async () => {
writeFileSync( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
@@ -217,8 +101,8 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup'", { execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
@@ -268,8 +152,8 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", { execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
@@ -316,59 +200,6 @@ describe("runAsyncWorktreeBootstrap", () => {
); );
}); });
it("keeps only the final carriage-return-updated content in command logs", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
],
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-carriage-return",
baseBranch: "main",
worktreeSlug: "feature-carriage-return",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-carriage-return",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const persistedSetupItem = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
throw new Error("Expected worktree_setup tool detail");
}
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
});
it("waits for terminal output before sending bootstrap commands", async () => { it("waits for terminal output before sending bootstrap commands", async () => {
writeFileSync( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
@@ -383,11 +214,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", { execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add terminal bootstrap config"],
{
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); },
);
const worktreeBootstrap = await createBootstrapWorktreeForTest({ const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir, cwd: repoDir,
@@ -467,114 +302,6 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(sendAt).toBeGreaterThanOrEqual(readyAt); expect(sendAt).toBeGreaterThanOrEqual(readyAt);
}); });
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
terminals: [
{
name: "Port Terminal",
command: "true",
},
],
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
worktreeSlug: "feature-shared-runtime-port",
paseoHome,
});
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
const createTerminalEnvs: Record<string, string>[] = [];
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-shared-runtime-port",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
},
async createTerminal(options) {
createTerminalEnvs.push(options.env ?? {});
return {
id: "term-1",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
killAndWait: async () => {},
};
},
registerCwdEnv(options) {
registeredEnvs.push({ cwd: options.cwd, env: options.env });
},
getTerminal() {
return undefined;
},
killTerminal() {},
async killTerminalAndWait() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
await waitForPathExists(setupPortPath);
const setupPort = readFileSync(setupPortPath, "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
expect(registeredEnvs).toHaveLength(1);
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalEnvs.length).toBeGreaterThan(0);
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
const terminalToolCall = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" &&
item.name === "paseo_worktree_terminals" &&
item.status === "completed",
);
expect(terminalToolCall?.status).toBe("completed");
});
interface CreateTerminalCall { interface CreateTerminalCall {
cwd: string; cwd: string;
name?: string; name?: string;
@@ -679,21 +406,6 @@ describe("runAsyncWorktreeBootstrap", () => {
}; };
} }
function readEnvFile(path: string): Record<string, string> {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Expected env file to contain a JSON object: ${path}`);
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") {
env[key] = value;
}
}
return env;
}
function assertServiceTerminalCallSelfEnv(params: { function assertServiceTerminalCallSelfEnv(params: {
createTerminalCalls: CreateTerminalCall[]; createTerminalCalls: CreateTerminalCall[];
terminalRecords: StubTerminalRecord[]; terminalRecords: StubTerminalRecord[];
@@ -759,8 +471,8 @@ describe("runAsyncWorktreeBootstrap", () => {
message = "add script config", message = "add script config",
): void { ): void {
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts })); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts }));
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync(`git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, { execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
@@ -1117,11 +829,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add respawn service script config'", { execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add respawn service script config"],
{
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); },
);
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -1216,11 +932,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add renamed service script config'", { execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add renamed service script config"],
{
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); },
);
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -1278,11 +998,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add colliding service config'", { execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add colliding service config"],
{
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); },
);
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -1350,97 +1074,6 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT"); expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT");
}); });
it("injects real peer service env into terminal-backed services", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
api: {
type: "service",
command:
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
},
web: {
type: "service",
command:
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
},
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add real peer env services'", {
cwd: repoDir,
stdio: "pipe",
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const terminalManager = createTerminalManager();
realTerminalManagers.push(terminalManager);
await Promise.all(
["api", "web"].map((scriptName) =>
spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-peer-env",
scriptName,
daemonPort: 6767,
routeStore,
runtimeStore,
terminalManager,
}),
),
);
const apiEnvPath = join(repoDir, "api-env.json");
const webEnvPath = join(repoDir, "web-env.json");
await waitForPathExists(apiEnvPath);
await waitForPathExists(webEnvPath);
const apiEnv = readEnvFile(apiEnvPath);
const webEnv = readEnvFile(webEnvPath);
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(apiEnv).not.toHaveProperty("PORT");
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv).not.toHaveProperty("PORT");
const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT);
const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(Number.isInteger(apiPort)).toBe(true);
expect(Number.isInteger(webPort)).toBe(true);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.feature-peer-env.repo.localhost",
port: apiPort,
workspaceId: repoDir,
projectSlug: "repo",
scriptName: "api",
},
{
hostname: "web.feature-peer-env.repo.localhost",
port: webPort,
workspaceId: repoDir,
projectSlug: "repo",
scriptName: "web",
},
]);
});
it("binds services to the network when the daemon listens on a non-loopback host", async () => { it("binds services to the network when the daemon listens on a non-loopback host", async () => {
writeFileSync( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
@@ -1453,11 +1086,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}), }),
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add remote service script config'", { execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add remote service script config"],
{
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); },
);
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();

View File

@@ -0,0 +1,611 @@
// POSIX-only: git worktree reuse fixtures
/* eslint-disable max-nested-callbacks */
import { execFileSync, spawnSync } from "node:child_process";
import {
mkdirSync,
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, afterEach } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import { UnknownBranchError } from "../utils/worktree.js";
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
import { isPlatform } from "../test-utils/platform.js";
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createCoreDeps(options?: { github?: GitHubService }) {
return {
github: options?.github ?? createGitHubServiceStub(),
workspaceGitService: {
resolveRepoRoot: async (cwd: string) => cwd,
},
resolveDefaultBranch: async () => "main",
};
}
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, ".paseo");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { tempDir, repoDir, paseoHome };
}
function createGitRepoWithDevBranch(): { tempDir: string; repoDir: string; paseoHome: string } {
const { tempDir, repoDir, paseoHome } = createGitRepo();
execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "dev branch"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const featureBranch = "feature/review-pr";
execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "review branch"], {
cwd: repoDir,
stdio: "pipe",
});
const featureSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
stdio: "pipe",
});
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", featureSha], {
stdio: "pipe",
});
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createForkGitHubPrRemoteRepo(): {
tempDir: string;
repoDir: string;
headRemoteDir: string;
paseoHome: string;
} {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const baseRemoteDir = path.join(tempDir, "base.git");
const headRemoteDir = path.join(tempDir, "therainisme.git");
const headCloneDir = path.join(tempDir, "therainisme-clone");
execFileSync("git", ["clone", "--bare", repoDir, baseRemoteDir], {
stdio: "pipe",
});
execFileSync("git", ["clone", "--bare", repoDir, headRemoteDir], {
stdio: "pipe",
});
execFileSync("git", ["remote", "add", "origin", baseRemoteDir], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["clone", headRemoteDir, headCloneDir], {
stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: headCloneDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: headCloneDir, stdio: "pipe" });
writeFileSync(path.join(headCloneDir, "README.md"), "fork pr main branch\n");
execFileSync("git", ["add", "README.md"], { cwd: headCloneDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr main branch"], {
cwd: headCloneDir,
stdio: "pipe",
});
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: headCloneDir, stdio: "pipe" })
.toString()
.trim();
execFileSync("git", ["push", "origin", "main"], { cwd: headCloneDir, stdio: "pipe" });
execFileSync("git", [`--git-dir=${baseRemoteDir}`, "fetch", headRemoteDir, "main"], {
stdio: "pipe",
});
execFileSync("git", [`--git-dir=${baseRemoteDir}`, "update-ref", "refs/pull/526/head", prHead], {
stdio: "pipe",
});
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, headRemoteDir, paseoHome };
}
describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
describe("createWorktreeCore", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("creates the legacy RPC branch-off worktree from the repo default branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "legacy-rpc",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
branchName: "legacy-rpc",
});
expect(result.created).toBe(true);
expect(result.worktree.branchName).toBe("legacy-rpc");
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent.kind).toBe("branch-off");
expect(result.created).toBe(true);
expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath));
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "review-pr-123",
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "feature/review-pr",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("uses the PR head ref as the default slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(path.basename(result.worktree.worktreePath)).toBe("feature-review-pr");
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("creates the MCP standalone worktree input shape", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "mcp-standalone",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
branchName: "mcp-standalone",
});
expect(result.worktree.branchName).toBe("mcp-standalone");
});
test("branches off an explicit refName base", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch();
cleanupPaths.push(tempDir);
const devTip = execFileSync("git", ["rev-parse", "dev"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "from-dev",
action: "branch-off",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
const mergeBase = execFileSync("git", ["merge-base", "HEAD", devTip], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "dev",
branchName: "from-dev",
});
expect(mergeBase).toBe(devTip);
});
test("checks out an explicit existing branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
const branch = execFileSync("git", ["branch", "--show-current"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "checkout-branch",
branchName: "dev",
});
expect(branch).toBe("dev");
});
test("checks out an explicit GitHub PR target", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "pr-123",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("pr-123");
});
test("checks out a fork PR whose head branch collides with local main", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: headRemoteDir,
headRepositoryUrl: headRemoteDir,
isCrossRepository: true,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const sourceBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: repoDir,
stdio: "pipe",
})
.toString()
.trim();
const worktreeBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8");
writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n");
execFileSync("git", ["add", "FOLLOWUP.md"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
});
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "maintainer edit"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
});
const pushDryRunResult = spawnSync("git", ["push", "--dry-run"], {
cwd: result.worktree.worktreePath,
encoding: "utf8",
});
const pushDryRun = `${pushDryRunResult.stdout}${pushDryRunResult.stderr}`;
expect(sourceBranch).toBe("main");
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 526,
headRef: "main",
baseRefName: "main",
localBranchName: "therainisme/main",
pushRemoteUrl: headRemoteDir,
});
expect(result.worktree.branchName).toBe("therainisme/main");
expect(path.basename(result.worktree.worktreePath)).toBe("therainisme-main");
expect(worktreeBranch).toBe("therainisme/main");
expect(readme.replace(/\r\n/g, "\n")).toBe("fork pr main branch\n");
expect(pushDryRun).toContain("HEAD -> main");
});
test("uses a unique local branch when the same fork PR branch already exists", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: headRemoteDir,
headRepositoryUrl: headRemoteDir,
isCrossRepository: true,
}),
};
const first = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "first-pr-worktree",
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const second = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "second-pr-worktree",
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
expect(first.worktree.branchName).toBe("therainisme/main");
expect(second.worktree.branchName).toBe("therainisme/main-1");
expect(
execFileSync("git", ["config", "--get", "remote.paseo-pr-526.push"], {
cwd: second.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("HEAD:refs/heads/main");
});
test("throws a typed error for an unknown checkout branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
await expect(
createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "missing-branch",
paseoHome,
runSetup: false,
},
createCoreDeps(),
),
).rejects.toBeInstanceOf(UnknownBranchError);
});
test("creates the agent-create worktree input shape", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "agent-worktree",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
branchName: "agent-worktree",
});
expect(result.worktree.branchName).toBe("agent-worktree");
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
test("reuses an existing branch-off worktree for the same slug", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const first = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
deps,
);
const second = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
deps,
);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.worktree).toEqual(first.worktree);
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
test("reuses an existing GitHub PR worktree for the resolved slug", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const input = {
cwd: repoDir,
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
};
const first = await createCoreWorktree(input, deps);
const second = await createCoreWorktree(input, deps);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.worktree).toEqual(first.worktree);
});
test("uses an injectable GitHubService dependency for missing PR head refs", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const headRefLookups: Array<{ cwd: string; number: number }> = [];
const github: GitHubService = {
...createGitHubServiceStub(),
getPullRequestHeadRef: async ({ cwd, number }) => {
headRefLookups.push({ cwd, number });
return "feature/from-service";
},
};
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "stubbed-github",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
expect(headRefLookups).toEqual([{ cwd: repoDir, number: 123 }]);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "feature/from-service",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("feature/from-service");
});
});
});

View File

@@ -1,615 +0,0 @@
import { execSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, afterEach, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import { UnknownBranchError } from "../utils/worktree.js";
import {
createWorktreeCore as createCoreWorktree,
resolveWorktreeRepoRoot,
} from "./worktree-core.js";
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createCoreDeps(options?: { github?: GitHubService }) {
return {
github: options?.github ?? createGitHubServiceStub(),
workspaceGitService: {
resolveRepoRoot: async (cwd: string) => cwd,
},
resolveDefaultBranch: async () => "main",
};
}
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, ".paseo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitRepoWithDevBranch(): { tempDir: string; repoDir: string; paseoHome: string } {
const { tempDir, repoDir, paseoHome } = createGitRepo();
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'dev branch'", {
cwd: repoDir,
stdio: "pipe",
});
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const featureBranch = "feature/review-pr";
execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'review branch'", {
cwd: repoDir,
stdio: "pipe",
});
const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
stdio: "pipe",
});
execSync(
`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`,
{ stdio: "pipe" },
);
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createForkGitHubPrRemoteRepo(): {
tempDir: string;
repoDir: string;
headRemoteDir: string;
paseoHome: string;
} {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const baseRemoteDir = path.join(tempDir, "base.git");
const headRemoteDir = path.join(tempDir, "therainisme.git");
const headCloneDir = path.join(tempDir, "therainisme-clone");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(baseRemoteDir)}`, {
stdio: "pipe",
});
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(headRemoteDir)}`, {
stdio: "pipe",
});
execSync(`git remote add origin ${JSON.stringify(baseRemoteDir)}`, {
cwd: repoDir,
stdio: "pipe",
});
execSync(`git clone ${JSON.stringify(headRemoteDir)} ${JSON.stringify(headCloneDir)}`, {
stdio: "pipe",
});
execSync("git config user.email 'test@test.com'", { cwd: headCloneDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: headCloneDir, stdio: "pipe" });
writeFileSync(path.join(headCloneDir, "README.md"), "fork pr main branch\n");
execSync("git add README.md", { cwd: headCloneDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'fork pr main branch'", {
cwd: headCloneDir,
stdio: "pipe",
});
const prHead = execSync("git rev-parse HEAD", { cwd: headCloneDir, stdio: "pipe" })
.toString()
.trim();
execSync("git push origin main", { cwd: headCloneDir, stdio: "pipe" });
execSync(
`git --git-dir=${JSON.stringify(baseRemoteDir)} fetch ${JSON.stringify(headRemoteDir)} main`,
{ stdio: "pipe" },
);
execSync(
`git --git-dir=${JSON.stringify(baseRemoteDir)} update-ref refs/pull/526/head ${prHead}`,
{ stdio: "pipe" },
);
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, headRemoteDir, paseoHome };
}
describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("creates the legacy RPC branch-off worktree from the repo default branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "legacy-rpc",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
branchName: "legacy-rpc",
});
expect(result.created).toBe(true);
expect(result.worktree.branchName).toBe("legacy-rpc");
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent.kind).toBe("branch-off");
expect(result.created).toBe(true);
expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath));
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "review-pr-123",
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "feature/review-pr",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("uses the PR head ref as the default slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(path.basename(result.worktree.worktreePath)).toBe("feature-review-pr");
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("creates the MCP standalone worktree input shape", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "mcp-standalone",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
branchName: "mcp-standalone",
});
expect(result.worktree.branchName).toBe("mcp-standalone");
});
test("branches off an explicit refName base", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch();
cleanupPaths.push(tempDir);
const devTip = execSync("git rev-parse dev", { cwd: repoDir, stdio: "pipe" }).toString().trim();
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "from-dev",
action: "branch-off",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
const mergeBase = execSync(`git merge-base HEAD ${JSON.stringify(devTip)}`, {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "dev",
branchName: "from-dev",
});
expect(mergeBase).toBe(devTip);
});
test("checks out an explicit existing branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
const branch = execSync("git branch --show-current", {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "checkout-branch",
branchName: "dev",
});
expect(branch).toBe("dev");
});
test("checks out an explicit GitHub PR target", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "pr-123",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("pr-123");
});
test("checks out a fork PR whose head branch collides with local main", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: headRemoteDir,
headRepositoryUrl: headRemoteDir,
isCrossRepository: true,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const sourceBranch = execSync("git branch --show-current", { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
const worktreeBranch = execSync("git branch --show-current", {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8");
writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n");
execSync("git add FOLLOWUP.md", { cwd: result.worktree.worktreePath, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'maintainer edit'", {
cwd: result.worktree.worktreePath,
stdio: "pipe",
});
const pushDryRun = execSync("git push --dry-run 2>&1", {
cwd: result.worktree.worktreePath,
stdio: "pipe",
}).toString();
expect(sourceBranch).toBe("main");
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 526,
headRef: "main",
baseRefName: "main",
localBranchName: "therainisme/main",
pushRemoteUrl: headRemoteDir,
});
expect(result.worktree.branchName).toBe("therainisme/main");
expect(path.basename(result.worktree.worktreePath)).toBe("therainisme-main");
expect(worktreeBranch).toBe("therainisme/main");
expect(readme).toBe("fork pr main branch\n");
expect(pushDryRun).toContain("HEAD -> main");
});
test("uses a unique local branch when the same fork PR branch already exists", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: headRemoteDir,
headRepositoryUrl: headRemoteDir,
isCrossRepository: true,
}),
};
const first = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "first-pr-worktree",
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const second = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "second-pr-worktree",
action: "checkout",
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
expect(first.worktree.branchName).toBe("therainisme/main");
expect(second.worktree.branchName).toBe("therainisme/main-1");
expect(
execSync("git config --get remote.paseo-pr-526.push", {
cwd: second.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("HEAD:refs/heads/main");
});
test("throws a typed error for an unknown checkout branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
await expect(
createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "missing-branch",
paseoHome,
runSetup: false,
},
createCoreDeps(),
),
).rejects.toBeInstanceOf(UnknownBranchError);
});
test("creates the agent-create worktree input shape", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "agent-worktree",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
branchName: "agent-worktree",
});
expect(result.worktree.branchName).toBe("agent-worktree");
});
test("reuses an existing branch-off worktree for the same slug", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const first = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
deps,
);
const second = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
deps,
);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.worktree).toEqual(first.worktree);
});
test("reuses an existing GitHub PR worktree for the resolved slug", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const input = {
cwd: repoDir,
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
};
const first = await createCoreWorktree(input, deps);
const second = await createCoreWorktree(input, deps);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.worktree).toEqual(first.worktree);
});
test("uses an injectable GitHubService dependency for missing PR head refs", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const headRefLookups: Array<{ cwd: string; number: number }> = [];
const github: GitHubService = {
...createGitHubServiceStub(),
getPullRequestHeadRef: async ({ cwd, number }) => {
headRefLookups.push({ cwd, number });
return "feature/from-service";
},
};
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "stubbed-github",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
expect(headRefLookups).toEqual([{ cwd: repoDir, number: 123 }]);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "feature/from-service",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("feature/from-service");
});
});
describe("resolveWorktreeRepoRoot", () => {
test("resolves repository roots through the workspace git service", async () => {
const workspaceGitService = {
resolveRepoRoot: vi.fn().mockResolvedValue("/tmp/main-repo"),
};
await expect(
resolveWorktreeRepoRoot(
{ cwd: "/tmp/main-repo/worktrees/feature", paseoHome: "/tmp/paseo-home" },
workspaceGitService,
),
).resolves.toBe("/tmp/main-repo");
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledTimes(1);
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(
"/tmp/main-repo/worktrees/feature",
);
});
});

View File

@@ -1,5 +1,6 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { import {
mkdirSync,
existsSync, existsSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
@@ -41,6 +42,7 @@ import {
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { isPlatform } from "../test-utils/platform.js";
interface LegacyCreateWorktreeTestOptions { interface LegacyCreateWorktreeTestOptions {
branchName: string; branchName: string;
@@ -484,49 +486,49 @@ function createWorkspaceArchivingDeps() {
} }
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) { function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-"))); const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`); mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
function createGitHubPrRemoteRepo() { function createGitHubPrRemoteRepo() {
const { tempDir, repoDir } = createGitRepo(); const { tempDir, repoDir } = createGitRepo();
const featureBranch = "feature/review-pr"; const featureBranch = "feature/review-pr";
execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "review branch\n"); writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'review branch'", { execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "review branch"], {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }) const featureSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
.toString() .toString()
.trim(); .trim();
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git"); const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
stdio: "pipe", stdio: "pipe",
}); });
execSync( execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", featureSha], {
`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`,
{
stdio: "pipe", stdio: "pipe",
}, });
); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
@@ -645,8 +647,8 @@ describe("runWorktreeSetupInBackground", () => {
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n"); writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'broken config'", { execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "broken config"], {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
@@ -713,7 +715,10 @@ describe("runWorktreeSetupInBackground", () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
}); });
test("emits running setup snapshots before completed for real setup commands", async () => { // POSIX-only: setup command is hardcoded to sh, printf, and sleep.
test.skipIf(isPlatform("win32"))(
"emits running setup snapshots before completed for real setup commands",
async () => {
const { tempDir, repoDir } = createGitRepo({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
worktree: { worktree: {
@@ -765,7 +770,9 @@ describe("runWorktreeSetupInBackground", () => {
); );
const progressMessages = emitted.filter( const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> => (
message,
): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress", message.type === "workspace_setup_progress",
); );
expect(progressMessages.length).toBeGreaterThan(1); expect(progressMessages.length).toBeGreaterThan(1);
@@ -789,7 +796,9 @@ describe("runWorktreeSetupInBackground", () => {
expect(runningMessages.length).toBeGreaterThan(0); expect(runningMessages.length).toBeGreaterThan(0);
expect( expect(
progressMessages.findIndex((message) => message.payload.status === "running"), progressMessages.findIndex((message) => message.payload.status === "running"),
).toBeLessThan(progressMessages.findIndex((message) => message.payload.status === "completed")); ).toBeLessThan(
progressMessages.findIndex((message) => message.payload.status === "completed"),
);
const setupOutputMessage = runningMessages.find((message) => const setupOutputMessage = runningMessages.find((message) =>
message.payload.detail.commands[0]?.log.includes("phase-one"), message.payload.detail.commands[0]?.log.includes("phase-one"),
@@ -824,7 +833,8 @@ describe("runWorktreeSetupInBackground", () => {
status: "completed", status: "completed",
error: null, error: null,
}); });
}); },
);
test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => { test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => {
const { tempDir, repoDir } = createGitRepo({ const { tempDir, repoDir } = createGitRepo({
@@ -1199,7 +1209,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
return; return;
} }
const branch = execSync("git branch --show-current", { cwd: worktreePath, stdio: "pipe" }) const branch = execFileSync("git", ["branch", "--show-current"], {
cwd: worktreePath,
stdio: "pipe",
})
.toString() .toString()
.trim(); .trim();
expect(branch).toBe("feature/review-pr"); expect(branch).toBe("feature/review-pr");
@@ -1248,7 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123"); expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true); expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
const branch = execSync("git branch --show-current", { const branch = execFileSync("git", ["branch", "--show-current"], {
cwd: result.sessionConfig.cwd, cwd: result.sessionConfig.cwd,
stdio: "pipe", stdio: "pipe",
}) })
@@ -1445,7 +1458,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
baseBranch: "main", baseBranch: "main",
branchName: "resolver-feature", branchName: "resolver-feature",
}); });
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir); const resolvedCwd = resolveDefaultBranch.mock.calls[0]?.[0];
expect(resolvedCwd).toBeDefined();
expect(realpathSync.native(resolvedCwd ?? "")).toBe(realpathSync.native(repoDir));
}); });
}); });
@@ -1577,10 +1592,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
cwd: registeredWorktreePath, cwd: registeredWorktreePath,
}), }),
); );
expect(backgroundWork).toHaveBeenCalledWith( const backgroundInput = backgroundWork.mock.calls[0]?.[0];
expect(backgroundInput).toEqual(
expect.objectContaining({ expect.objectContaining({
requestCwd: repoDir, requestCwd: repoDir,
repoRoot: repoDir,
worktree: { worktree: {
branchName: "response-after-create", branchName: "response-after-create",
worktreePath: registeredWorktreePath, worktreePath: registeredWorktreePath,
@@ -1588,6 +1603,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
shouldBootstrap: true, shouldBootstrap: true,
}), }),
); );
expect(realpathSync.native(backgroundInput?.repoRoot ?? "")).toBe(
realpathSync.native(repoDir),
);
} finally { } finally {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
} }

View File

@@ -1,9 +1,14 @@
import { it, expect, afterEach } from "vitest"; import { it, expect, afterEach } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
}
async function waitForCondition( async function waitForCondition(
predicate: () => boolean, predicate: () => boolean,
timeoutMs: number, timeoutMs: number,
@@ -19,47 +24,52 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
} }
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
const originalShell = process.env.SHELL;
process.env.SHELL = shell;
try {
return await run();
} finally {
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
}
}
let manager: TerminalManager; let manager: TerminalManager;
const temporaryDirs: string[] = []; const temporaryDirs: string[] = [];
afterEach(() => { afterEach(async () => {
if (manager) { if (manager) {
const terminalsByCwd = await Promise.all(
manager.listDirectories().map((cwd) => manager.getTerminals(cwd)),
);
await Promise.all(
terminalsByCwd
.flat()
.map((terminal) =>
manager.killTerminalAndWait(terminal.id, { gracefulTimeoutMs: 50, forceTimeoutMs: 50 }),
),
);
manager.killAll(); manager.killAll();
} }
await new Promise((resolve) => setTimeout(resolve, 50));
while (temporaryDirs.length > 0) { while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop(); const dir = temporaryDirs.pop();
if (dir) { if (dir) {
try {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EBUSY") {
throw error;
}
}
} }
} }
}); });
it("returns empty list for new cwd", async () => { it("returns empty list for new cwd", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp"); const cwd = realpathSync(tmpdir());
const terminals = await manager.getTerminals(cwd);
expect(terminals).toHaveLength(0); expect(terminals).toHaveLength(0);
}); });
it("returns existing terminals on subsequent calls", async () => { it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" }); const cwd = realpathSync(tmpdir());
const first = await manager.getTerminals("/tmp"); const created = await manager.createTerminal({ cwd });
const second = await manager.getTerminals("/tmp"); const first = await manager.getTerminals(cwd);
const second = await manager.getTerminals(cwd);
expect(first.length).toBe(1); expect(first.length).toBe(1);
expect(first[0].id).toBe(created.id); expect(first[0].id).toBe(created.id);
@@ -79,8 +89,11 @@ it("accepts Windows absolute paths", async () => {
it("creates separate terminals for different cwds", async () => { it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })]; const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-"));
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })]; const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-"));
temporaryDirs.push(firstCwd, secondCwd);
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })];
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })];
expect(tmpTerminals.length).toBe(1); expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1); expect(homeTerminals.length).toBe(1);
@@ -89,29 +102,31 @@ it("creates separate terminals for different cwds", async () => {
it("creates additional terminal with auto-incrementing name", async () => { it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" }); const cwd = realpathSync(tmpdir());
const second = await manager.createTerminal({ cwd: "/tmp" }); await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
expect(second.name).toBe("Terminal 2"); expect(second.name).toBe("Terminal 2");
const terminals = await manager.getTerminals("/tmp"); const terminals = await manager.getTerminals(cwd);
expect(terminals.length).toBe(2); expect(terminals.length).toBe(2);
}); });
it("uses custom name when provided", async () => { it("uses custom name when provided", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" });
expect(session.name).toBe("Dev Server"); expect(session.name).toBe("Dev Server");
}); });
it("creates first terminal if none exist", async () => { it("creates first terminal if none exist", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" }); const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
expect(session.name).toBe("Terminal 1"); expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals("/tmp"); const terminals = await manager.getTerminals(cwd);
expect(terminals.length).toBe(1); expect(terminals.length).toBe(1);
expect(terminals[0].id).toBe(session.id); expect(terminals[0].id).toBe(session.id);
}); });
@@ -123,18 +138,17 @@ it("throws for relative paths", async () => {
it("does not reject Windows absolute paths as relative", async () => { it("does not reject Windows absolute paths as relative", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
// Should pass path validation (not throw "cwd must be absolute path"). const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-absolute-path-"));
// The terminal may or may not spawn successfully on non-Windows hosts, temporaryDirs.push(cwd);
// so we only assert the validation error is absent.
try { try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" }); await manager.createTerminal({ cwd });
} catch (error) { } catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path"); expect((error as Error).message).not.toBe("cwd must be absolute path");
} }
}); });
it("inherits registered env for the worktree root cwd", async () => { it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
temporaryDirs.push(cwd); temporaryDirs.push(cwd);
@@ -144,22 +158,20 @@ it("inherits registered env for the worktree root cwd", async () => {
cwd, cwd,
env: { PASEO_WORKTREE_PORT: "45678" }, env: { PASEO_WORKTREE_PORT: "45678" },
}); });
const session = await manager.createTerminal({ cwd }); await manager.createTerminal({
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { cwd,
session.send({ command: process.execPath,
type: "input", args: [
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, "-e",
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
],
}); });
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000); await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45678"); expect(readFileSync(markerPath, "utf8")).toBe("45678");
}); });
});
it("inherits registered env for subdirectories within the worktree", async () => { it("inherits registered env for subdirectories within the worktree", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
const subdirCwd = join(rootCwd, "packages", "app"); const subdirCwd = join(rootCwd, "packages", "app");
@@ -171,23 +183,22 @@ it("inherits registered env for subdirectories within the worktree", async () =>
cwd: rootCwd, cwd: rootCwd,
env: { PASEO_WORKTREE_PORT: "45679" }, env: { PASEO_WORKTREE_PORT: "45679" },
}); });
const session = await manager.createTerminal({ cwd: subdirCwd }); await manager.createTerminal({
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { cwd: subdirCwd,
session.send({ command: process.execPath,
type: "input", args: [
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, "-e",
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
],
}); });
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000); await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45679"); expect(readFileSync(markerPath, "utf8")).toBe("45679");
}); });
});
it("returns terminal by id", async () => { it("returns terminal by id", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" }); const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const found = manager.getTerminal(session.id); const found = manager.getTerminal(session.id);
expect(found).toBe(session); expect(found).toBe(session);
@@ -202,7 +213,7 @@ it("returns undefined for unknown id", () => {
it("removes terminal from manager", async () => { it("removes terminal from manager", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" }); const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const id = session.id; const id = session.id;
manager.killTerminal(id); manager.killTerminal(id);
@@ -212,24 +223,26 @@ it("removes terminal from manager", async () => {
it("removes cwd entry when last terminal is killed", async () => { it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" }); const cwd = realpathSync(tmpdir());
const created = await manager.createTerminal({ cwd });
manager.killTerminal(created.id); manager.killTerminal(created.id);
const remaining = await manager.getTerminals("/tmp"); const remaining = await manager.getTerminals(cwd);
expect(remaining).toHaveLength(0); expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain("/tmp"); expect(manager.listDirectories()).not.toContain(cwd);
}); });
it("keeps cwd entry when other terminals remain", async () => { it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" }); const cwd = realpathSync(tmpdir());
const second = await manager.createTerminal({ cwd: "/tmp" }); await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
const terminals = await manager.getTerminals("/tmp"); const terminals = await manager.getTerminals(cwd);
manager.killTerminal(terminals[0].id); manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp"); expect(manager.listDirectories()).toContain(cwd);
const remaining = await manager.getTerminals("/tmp"); const remaining = await manager.getTerminals(cwd);
expect(remaining.length).toBe(1); expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id); expect(remaining[0].id).toBe(second.id);
}); });
@@ -241,7 +254,8 @@ it("is no-op for unknown id", () => {
it("auto-removes terminal when shell exits", async () => { it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" }); const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
const exitedId = session.id; const exitedId = session.id;
session.kill(); session.kill();
@@ -249,7 +263,7 @@ it("auto-removes terminal when shell exits", async () => {
expect(manager.getTerminal(exitedId)).toBeUndefined(); expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp"); const remaining = await manager.getTerminals(cwd);
expect(remaining).toHaveLength(0); expect(remaining).toHaveLength(0);
}); });
@@ -260,19 +274,25 @@ it("returns empty array initially", () => {
it("returns all cwds with active terminals", async () => { it("returns all cwds with active terminals", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" }); const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-"));
await manager.createTerminal({ cwd: "/home" }); const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-"));
temporaryDirs.push(firstCwd, secondCwd);
await manager.createTerminal({ cwd: firstCwd });
await manager.createTerminal({ cwd: secondCwd });
const dirs = manager.listDirectories(); const dirs = manager.listDirectories();
expect(dirs).toContain("/tmp"); expect(dirs).toContain(firstCwd);
expect(dirs).toContain("/home"); expect(dirs).toContain(secondCwd);
expect(dirs.length).toBe(2); expect(dirs.length).toBe(2);
}); });
it("kills all terminals and clears state", async () => { it("kills all terminals and clears state", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const tmpSession = await manager.createTerminal({ cwd: "/tmp" }); const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-"));
const homeSession = await manager.createTerminal({ cwd: "/home" }); const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-"));
temporaryDirs.push(firstCwd, secondCwd);
const tmpSession = await manager.createTerminal({ cwd: firstCwd });
const homeSession = await manager.createTerminal({ cwd: secondCwd });
const tmpId = tmpSession.id; const tmpId = tmpSession.id;
const homeId = homeSession.id; const homeId = homeSession.id;
@@ -285,6 +305,7 @@ it("kills all terminals and clears state", async () => {
it("emits cwd snapshots when terminals are created", async () => { it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = []; const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => { const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({ snapshots.push({
@@ -296,15 +317,15 @@ it("emits cwd snapshots when terminals are created", async () => {
}); });
}); });
await manager.createTerminal({ cwd: "/tmp" }); await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); await manager.createTerminal({ cwd, name: "Dev Server" });
expect(snapshots).toContainEqual({ expect(snapshots).toContainEqual({
cwd: "/tmp", cwd,
terminals: [{ name: "Terminal 1" }], terminals: [{ name: "Terminal 1" }],
}); });
expect(snapshots).toContainEqual({ expect(snapshots).toContainEqual({
cwd: "/tmp", cwd,
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }], terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
}); });
@@ -330,24 +351,26 @@ function hasLogsTitle(sessionId: string) {
} }
it("emits updated terminal titles after debounced title changes", async () => { it("emits updated terminal titles after debounced title changes", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const snapshots: TerminalTitleEntry[][] = []; const snapshots: TerminalTitleEntry[][] = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => { const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(input.terminals.map(toTitleEntry)); snapshots.push(input.terminals.map(toTitleEntry));
}); });
const session = await manager.createTerminal({ cwd: "/tmp" }); const session = await manager.createTerminal({
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); cwd: realpathSync(tmpdir()),
command: process.execPath,
args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"],
});
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000); await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
unsubscribe(); unsubscribe();
});
}, 10000); }, 10000);
it("emits empty snapshot when last terminal is removed", async () => { it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const snapshots: Array<{ cwd: string; terminalCount: number }> = []; const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => { const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({ snapshots.push({
@@ -356,11 +379,11 @@ it("emits empty snapshot when last terminal is removed", async () => {
}); });
}); });
const session = await manager.createTerminal({ cwd: "/tmp" }); const session = await manager.createTerminal({ cwd });
manager.killTerminal(session.id); manager.killTerminal(session.id);
expect(snapshots).toContainEqual({ expect(snapshots).toContainEqual({
cwd: "/tmp", cwd,
terminalCount: 0, terminalCount: 0,
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export function isPlatform(...platforms: NodeJS.Platform[]): boolean {
return platforms.includes(process.platform);
}

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
@@ -38,16 +38,18 @@ function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoD
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-"))); const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
execSync(`mkdir -p ${repoDir}`); mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) { for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`); writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
} }
execSync("git add .", { cwd: repoDir }); execFileSync("git", ["add", "."], { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
});
for (let i = 0; i < fileCount; i += 1) { for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`); writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSyn
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
const isWindows = process.platform === "win32"; const isWindows = isPlatform("win32");
describe("searchHomeDirectories", () => { describe("searchHomeDirectories", () => {
let tempRoot: string; let tempRoot: string;
@@ -18,6 +19,8 @@ describe("searchHomeDirectories", () => {
mkdirSync(homeDir, { recursive: true }); mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true }); mkdirSync(outsideDir, { recursive: true });
homeDir = realpathSync(homeDir);
outsideDir = realpathSync(outsideDir);
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
@@ -26,7 +29,9 @@ describe("searchHomeDirectories", () => {
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
if (!isWindows) {
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
}
}); });
afterEach(() => { afterEach(() => {
@@ -50,8 +55,9 @@ describe("searchHomeDirectories", () => {
limit: 10, limit: 10,
}); });
expect(results).toContain(path.join(homeDir, "projects")); const resolvedResults = results.map((result) => realpathSync.native(result));
expect(results).toContain(path.join(homeDir, "projects", "paseo")); expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects")));
expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects", "paseo")));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
}); });
@@ -62,7 +68,9 @@ describe("searchHomeDirectories", () => {
limit: 10, limit: 10,
}); });
expect(results).toEqual([path.join(homeDir, "projects", "paseo")]); expect(results.map((result) => realpathSync.native(result))).toEqual([
realpathSync.native(path.join(homeDir, "projects", "paseo")),
]);
}); });
it("prioritizes exact segment matches before segment-prefix matches", async () => { it("prioritizes exact segment matches before segment-prefix matches", async () => {
@@ -77,8 +85,9 @@ describe("searchHomeDirectories", () => {
limit: 30, limit: 30,
}); });
const exactIndex = results.indexOf(exactSegmentPath); const resolvedResults = results.map((result) => realpathSync.native(result));
const prefixIndex = results.indexOf(prefixSegmentPath); const exactIndex = resolvedResults.indexOf(realpathSync.native(exactSegmentPath));
const prefixIndex = resolvedResults.indexOf(realpathSync.native(prefixSegmentPath));
expect(exactIndex).toBeGreaterThanOrEqual(0); expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0); expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex); expect(exactIndex).toBeLessThan(prefixIndex);
@@ -96,8 +105,9 @@ describe("searchHomeDirectories", () => {
limit: 30, limit: 30,
}); });
const earlierIndex = results.indexOf(earlierPath); const resolvedResults = results.map((result) => realpathSync.native(result));
const laterIndex = results.indexOf(laterPath); const earlierIndex = resolvedResults.indexOf(realpathSync.native(earlierPath));
const laterIndex = resolvedResults.indexOf(realpathSync.native(laterPath));
expect(earlierIndex).toBeGreaterThanOrEqual(0); expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0); expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex); expect(earlierIndex).toBeLessThan(laterIndex);
@@ -125,7 +135,8 @@ describe("searchHomeDirectories", () => {
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
}); });
it("does not return paths that escape home through symlinks", async () => { // POSIX-only: creates and follows a symlink escape fixture.
it.skipIf(isWindows)("does not return paths that escape home through symlinks", async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "outside", query: "outside",
@@ -170,7 +181,9 @@ describe("searchWorkspaceEntries", () => {
); );
writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n"); writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n");
if (!isWindows) {
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link")); symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
}
}); });
afterEach(() => { afterEach(() => {
@@ -214,7 +227,10 @@ describe("searchWorkspaceEntries", () => {
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]); expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
}); });
it("supports path-style queries and does not escape cwd through symlinks", async () => { // POSIX-only: creates and follows a symlink escape fixture.
it.skipIf(isWindows)(
"supports path-style queries and does not escape cwd through symlinks",
async () => {
const pathResults = await searchWorkspaceEntries({ const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir, cwd: workspaceDir,
query: "src/co", query: "src/co",
@@ -235,7 +251,8 @@ describe("searchWorkspaceEntries", () => {
includeDirectories: true, includeDirectories: true,
}); });
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false); expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
}); },
);
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => { it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true }); mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });

View File

@@ -12,6 +12,7 @@ import path from "node:path";
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { probeExecutable } from "./executable.js"; import { probeExecutable } from "./executable.js";
const timeoutMs = 1000; const timeoutMs = 1000;
@@ -142,7 +143,10 @@ afterEach(() => {
}); });
describe("probeExecutable", () => { describe("probeExecutable", () => {
test.each(fixtures)("$name", async ({ create, expected }) => { // POSIX-only: positive fixtures rely on direct script probing; Windows command-script probing has separate coverage.
test.skipIf(isPlatform("win32")).each(fixtures.filter((fixture) => fixture.expected))(
"$name",
async ({ create, expected }) => {
const { executablePath, pidFile } = create(makeTempDir()); const { executablePath, pidFile } = create(makeTempDir());
const startedAt = performance.now(); const startedAt = performance.now();
@@ -155,5 +159,24 @@ describe("probeExecutable", () => {
const pid = Number(readFileSync(pidFile, "utf8")); const pid = Number(readFileSync(pidFile, "utf8"));
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" })); expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
} }
}); },
);
test.each(fixtures.filter((fixture) => !fixture.expected))(
"$name",
async ({ create, expected }) => {
const { executablePath, pidFile } = create(makeTempDir());
const startedAt = performance.now();
const result = await probeExecutable(executablePath, timeoutMs);
expect(result).toBe(expected);
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
if (pidFile) {
await waitForFile(pidFile);
const pid = Number(readFileSync(pidFile, "utf8"));
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
}
},
);
}); });

View File

@@ -9,6 +9,7 @@ import {
quoteWindowsArgument, quoteWindowsArgument,
quoteWindowsCommand, quoteWindowsCommand,
} from "./executable.js"; } from "./executable.js";
import { isPlatform } from "../test-utils/platform.js";
const originalEnv = { const originalEnv = {
PATH: process.env.PATH, PATH: process.env.PATH,
@@ -28,24 +29,16 @@ function prependPath(...dirs: string[]): void {
function writeExecutable(filePath: string, content: string): string { function writeExecutable(filePath: string, content: string): string {
writeFileSync(filePath, content); writeFileSync(filePath, content);
if (process.platform !== "win32") { if (!isPlatform("win32")) {
chmodSync(filePath, 0o755); chmodSync(filePath, 0o755);
} }
return filePath; return filePath;
} }
function writeInvokableFixture(dir: string, name: string): string {
if (process.platform === "win32") {
return writeExecutable(path.join(dir, `${name}.cmd`), "@echo off\r\necho 0.1\r\n");
}
return writeExecutable(path.join(dir, name), "#!/bin/sh\necho 0.1\n");
}
function writeBrokenAbsoluteFixture(dir: string): string { function writeBrokenAbsoluteFixture(dir: string): string {
const filePath = const filePath = isPlatform("win32") ? path.join(dir, "broken.exe") : path.join(dir, "broken");
process.platform === "win32" ? path.join(dir, "broken.exe") : path.join(dir, "broken");
writeFileSync(filePath, "not executable"); writeFileSync(filePath, "not executable");
if (process.platform !== "win32") { if (!isPlatform("win32")) {
chmodSync(filePath, 0o644); chmodSync(filePath, 0o644);
} }
return filePath; return filePath;
@@ -64,7 +57,7 @@ afterEach(() => {
}); });
describe("findExecutable", () => { describe("findExecutable", () => {
describe.skipIf(process.platform === "win32")("POSIX", () => { describe.skipIf(isPlatform("win32"))("POSIX", () => {
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => { test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
const executableDir = makeTempDir(); const executableDir = makeTempDir();
const nonExecutableDir = makeTempDir(); const nonExecutableDir = makeTempDir();
@@ -78,7 +71,7 @@ describe("findExecutable", () => {
}); });
}); });
describe.runIf(process.platform === "win32")("Windows", () => { describe.runIf(isPlatform("win32"))("Windows", () => {
test("returns a working .cmd when an invalid .exe candidate appears first", async () => { test("returns a working .cmd when an invalid .exe candidate appears first", async () => {
const dir = makeTempDir(); const dir = makeTempDir();
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter); process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
@@ -110,10 +103,7 @@ describe("findExecutable", () => {
}); });
test("returns an invokable absolute path", async () => { test("returns an invokable absolute path", async () => {
const dir = makeTempDir(); await expect(findExecutable(process.execPath)).resolves.toBe(process.execPath);
const fixture = writeInvokableFixture(dir, "absolute-ok");
await expect(findExecutable(fixture)).resolves.toBe(fixture);
}); });
test("returns null for an absolute path that cannot spawn", async () => { test("returns null for an absolute path that cannot spawn", async () => {

View File

@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js"; import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
import { import {
readPaseoConfigForEdit, readPaseoConfigForEdit,
@@ -97,7 +98,10 @@ describe("paseo config file substrate", () => {
); );
}); });
it("rejects stale writes when the current revision changed before rename", () => { // POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture.
it.skipIf(isPlatform("win32"))(
"rejects stale writes when the current revision changed before rename",
() => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir); const expectedRevision = statPaseoConfigPath(tempDir);
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } })); writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
@@ -116,7 +120,8 @@ describe("paseo config file substrate", () => {
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe( expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
JSON.stringify({ worktree: { setup: "new" } }), JSON.stringify({ worktree: { setup: "new" } }),
); );
}); },
);
it("round-trips unknown top-level, worktree, and script-entry fields", () => { it("round-trips unknown top-level, worktree, and script-entry fields", () => {
const config = { const config = {

View File

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest";
import { findExecutable } from "./executable.js"; import { findExecutable } from "./executable.js";
import { spawnProcess } from "./spawn.js"; import { spawnProcess } from "./spawn.js";
import { isPlatform } from "../test-utils/platform.js";
interface SpawnResult { interface SpawnResult {
code: number | null; code: number | null;
@@ -136,8 +137,7 @@ async function runFixture(params: {
} }
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> { function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
const pathKey = const pathKey = isPlatform("win32")
process.platform === "win32"
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
: "PATH"; : "PATH";
const previousPath = process.env[pathKey]; const previousPath = process.env[pathKey];
@@ -169,7 +169,7 @@ afterEach(() => {
} }
}); });
describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => { describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => { test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => {
const fixture = makeFixture(); const fixture = makeFixture();
@@ -227,7 +227,7 @@ describe.runIf(process.platform === "win32")("Windows spawn launch regression",
}); });
}); });
describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => { describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
test("direct launch with a space-containing executable works on this platform", async () => { test("direct launch with a space-containing executable works on this platform", async () => {
const fixture = makeFixture(); const fixture = makeFixture();

View File

@@ -67,7 +67,7 @@ describe("execCommand", () => {
const result = await execCommand(command.command, command.args, { cwd }); const result = await execCommand(command.command, command.args, { cwd });
expect(result.stdout.trim()).toBe(cwd); expect(realpathSync(result.stdout.trim())).toBe(cwd);
expect(result.stderr).toBe(""); expect(result.stderr).toBe("");
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff