mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -66,7 +66,12 @@ jobs:
|
||||
run: npm run typecheck
|
||||
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -95,50 +100,6 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
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:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
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 supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||
@@ -116,17 +117,21 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.log).toContain("raw stderr line\n");
|
||||
});
|
||||
|
||||
test("logs worker signal exits even when the worker cannot log", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
// 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({
|
||||
workerSource: `
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
});
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync } from "node:fs";
|
||||
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 { z } from "zod";
|
||||
|
||||
@@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
|
||||
interface LooseSafeParseResult {
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
@@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-123",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-456",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-789",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => {
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-with-worktree",
|
||||
@@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
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 initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
||||
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(initialBranch).not.toBe("");
|
||||
@@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "-b", "existing-feature"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
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);
|
||||
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");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
@@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => {
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/pr-123",
|
||||
projectId: "/tmp/repo",
|
||||
projectId: REPO_CWD,
|
||||
cwd: "/tmp/worktrees/pr-123",
|
||||
kind: "worktree" as const,
|
||||
displayName: "pr-123",
|
||||
@@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => {
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
...(options?.setupContinuation?.kind === "agent"
|
||||
? {
|
||||
@@ -949,7 +979,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
title: "PR agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this PR branch from prompt",
|
||||
@@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => {
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
const workspaceGitService = {
|
||||
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 () => {
|
||||
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 paseoHome = join(tempDir, ".paseo");
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
@@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
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",
|
||||
});
|
||||
expect(response.structuredContent.worktrees).toEqual([
|
||||
@@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-injected-123",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
createManagedAgent({
|
||||
id: "agent-compact",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
||||
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
||||
labels: { role: "researcher" },
|
||||
@@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
thinkingOptionId: "high",
|
||||
effectiveThinkingOptionId: "high",
|
||||
status: "idle",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
lastUserMessageAt: null,
|
||||
@@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => {
|
||||
spies.agentManager.listAgents.mockReturnValue([
|
||||
createManagedAgent({
|
||||
id: "running-target",
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
lifecycle: "running",
|
||||
updatedAt: new Date(recent),
|
||||
}),
|
||||
createManagedAgent({
|
||||
id: "idle-target",
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
lifecycle: "idle",
|
||||
updatedAt: new Date(recent),
|
||||
}),
|
||||
createManagedAgent({
|
||||
id: "old-running-target",
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
lifecycle: "running",
|
||||
createdAt: new Date(old),
|
||||
updatedAt: new Date(old),
|
||||
}),
|
||||
]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
|
||||
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
|
||||
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
||||
createStoredRecord({
|
||||
id: "recent-other-cwd",
|
||||
cwd: resolvePath("/tmp/other"),
|
||||
archivedAt: recent,
|
||||
}),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1950,7 +1998,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({
|
||||
cwd: "/tmp/target",
|
||||
cwd: TARGET_CWD,
|
||||
includeArchived: true,
|
||||
sinceHours: 48,
|
||||
statuses: ["running", "closed"],
|
||||
@@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-archived-compact",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
archivedAt: now,
|
||||
@@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
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];
|
||||
|
||||
expect(item).toEqual({
|
||||
@@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
status: "closed",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
createdAt: "2026-04-11T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: null,
|
||||
|
||||
@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
|
||||
expect(event.item.text).not.toContain("data:image");
|
||||
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
||||
const source = markdownImageSource(event.item.text);
|
||||
expect(source).toMatch(/paseo-attachments\/.+\.png$/);
|
||||
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
|
||||
expect(existsSync(source)).toBe(true);
|
||||
rmSync(source, { force: true });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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(() => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
@@ -77,24 +64,6 @@ describe("default provider availability", () => {
|
||||
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 () => {
|
||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||
isolatePathTo(binDir);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
@@ -152,52 +153,56 @@ describe("paseo daemon bootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("generates a relay pairing offer for unix socket listeners", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const logger = pino({ level: "silent" });
|
||||
// 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 paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: socketPath,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "127.0.0.1:9",
|
||||
relayPublicEndpoint: "127.0.0.1:9",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: socketPath,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "127.0.0.1:9",
|
||||
relayPublicEndpoint: "127.0.0.1:9",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
includeQr: false,
|
||||
});
|
||||
expect(pairing.relayEnabled).toBe(true);
|
||||
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await daemon.agentManager.flush().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome,
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "127.0.0.1:9",
|
||||
relayPublicEndpoint: "127.0.0.1:9",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
includeQr: false,
|
||||
});
|
||||
expect(pairing.relayEnabled).toBe(true);
|
||||
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await daemon.agentManager.flush().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
|
||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||
import { withTimeout } from "../../utils/promise-timeout.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 { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
@@ -220,54 +221,59 @@ test("returns error for non-git directory", async () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
|
||||
test("returns repo info for git repo with branch and dirty state", async () => {
|
||||
const cwd = tmpCwd();
|
||||
// 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();
|
||||
|
||||
// Initialize git repo
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||
// Initialize git repo
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||
|
||||
// Create and commit a file
|
||||
const testFile = path.join(cwd, "test.txt");
|
||||
writeFileSync(testFile, "original content\n");
|
||||
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
// Create and commit a file
|
||||
const testFile = path.join(cwd, "test.txt");
|
||||
writeFileSync(testFile, "original content\n");
|
||||
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Modify the file (makes repo dirty)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
// Modify the file (makes repo dirty)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
|
||||
// Create agent in the git repo
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Repo Info Test",
|
||||
});
|
||||
// Create agent in the git repo
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Repo Info Test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Get checkout status
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
// Get checkout status
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
|
||||
// Verify repo info returned without error
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isGit).toBe(true);
|
||||
// macOS symlinks /var to /private/var, so we check containment
|
||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||
expect(result.currentBranch).toBeTruthy();
|
||||
expect(result.isDirty).toBe(true);
|
||||
// Verify repo info returned without error
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isGit).toBe(true);
|
||||
// macOS symlinks /var to /private/var, so we check containment
|
||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||
expect(result.currentBranch).toBeTruthy();
|
||||
expect(result.isDirty).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000,
|
||||
); // 1 minute timeout
|
||||
|
||||
test("returns clean state when no uncommitted changes", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
||||
import { readExplorerFile } from "./service.js";
|
||||
|
||||
async function createHomeTempDir(prefix: string): Promise<string> {
|
||||
return mkdtemp(path.join(os.homedir(), prefix));
|
||||
@@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise<string> {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const root = await createTempDir("paseo-file-explorer-");
|
||||
|
||||
@@ -135,25 +112,4 @@ describe("file explorer service", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
|
||||
},
|
||||
file: {
|
||||
level: "debug",
|
||||
path: path.join(paseoHome, "logs", "programmatic.log"),
|
||||
path: path.resolve(paseoHome, "logs", "programmatic.log"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import os from "node:os";
|
||||
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 { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||
import type {
|
||||
@@ -24,6 +32,7 @@ import type {
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -220,60 +229,71 @@ describe("LoopService", () => {
|
||||
let storage: AgentStorage;
|
||||
|
||||
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");
|
||||
workspaceDir = path.join(tmpDir, "workspace");
|
||||
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
workspaceDir = realpathSync.native(workspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("runs fresh worker agents until verify-check passes", async () => {
|
||||
const state = { workerRuns: 0 };
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
claude: new ScriptedAgentClient("claude", {
|
||||
async onRun({ config }) {
|
||||
state.workerRuns += 1;
|
||||
if (config.title?.includes("worker") && state.workerRuns >= 2) {
|
||||
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
|
||||
}
|
||||
if (config.title?.includes("worker")) {
|
||||
return `worker run ${state.workerRuns}`;
|
||||
}
|
||||
return '{"passed":true,"reason":"not used"}';
|
||||
},
|
||||
}),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const service = new LoopService({ paseoHome, agentManager: manager, logger });
|
||||
await service.initialize();
|
||||
// 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 verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
|
||||
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
claude: new ScriptedAgentClient("claude", {
|
||||
async onRun({ config }) {
|
||||
state.workerRuns += 1;
|
||||
if (config.title?.includes("worker") && state.workerRuns >= 2) {
|
||||
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
|
||||
}
|
||||
if (config.title?.includes("worker")) {
|
||||
return `worker run ${state.workerRuns}`;
|
||||
}
|
||||
return '{"passed":true,"reason":"not used"}';
|
||||
},
|
||||
}),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const service = new LoopService({ paseoHome, agentManager: manager, logger });
|
||||
await service.initialize();
|
||||
|
||||
const loop = await service.runLoop({
|
||||
prompt: "Create done.txt when the task is actually fixed.",
|
||||
cwd: workspaceDir,
|
||||
verifyChecks: ["test -f done.txt"],
|
||||
sleepMs: 1,
|
||||
maxIterations: 3,
|
||||
});
|
||||
const loop = await service.runLoop({
|
||||
prompt: "Create done.txt when the task is actually fixed.",
|
||||
cwd: workspaceDir,
|
||||
verifyChecks: [
|
||||
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
|
||||
],
|
||||
sleepMs: 1,
|
||||
maxIterations: 3,
|
||||
});
|
||||
|
||||
await waitForLoopCompletion(service, loop.id);
|
||||
await waitForLoopCompletion(service, loop.id);
|
||||
|
||||
const finalLoop = await service.inspectLoop(loop.id);
|
||||
expect(finalLoop.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations).toHaveLength(2);
|
||||
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
|
||||
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
||||
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
||||
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
||||
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
||||
});
|
||||
const finalLoop = await service.inspectLoop(loop.id);
|
||||
expect(finalLoop.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations).toHaveLength(2);
|
||||
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(
|
||||
finalLoop.iterations[1]?.workerAgentId,
|
||||
);
|
||||
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
||||
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
||||
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
||||
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
||||
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
||||
},
|
||||
);
|
||||
|
||||
test("uses worker and verifier provider-model settings when provided", async () => {
|
||||
const workerConfigs: AgentSessionConfig[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -65,41 +66,45 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
]);
|
||||
});
|
||||
|
||||
test("reuses an existing worktree and still upserts the workspace", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const firstDeps = createDeps();
|
||||
const first = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
firstDeps,
|
||||
);
|
||||
const events: string[] = [];
|
||||
const deps = createDeps({
|
||||
events,
|
||||
projects: firstDeps.projects,
|
||||
workspaces: firstDeps.workspaces,
|
||||
});
|
||||
// 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();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const firstDeps = createDeps();
|
||||
const first = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
firstDeps,
|
||||
);
|
||||
const events: string[] = [];
|
||||
const deps = createDeps({
|
||||
events,
|
||||
projects: firstDeps.projects,
|
||||
workspaces: firstDeps.workspaces,
|
||||
});
|
||||
|
||||
const second = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const second = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||
});
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||
},
|
||||
);
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
@@ -131,7 +136,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
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,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -157,7 +162,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
firstAgentContext: { prompt: "Try another name" },
|
||||
generateBranchNameFromContext: async () => "second-agent-name",
|
||||
});
|
||||
const branchAfterSecond = execSync("git branch --show-current", {
|
||||
const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -196,7 +201,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -253,7 +258,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
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 });
|
||||
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
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 () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
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 });
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
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 });
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -524,17 +529,24 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
}
|
||||
|
||||
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()
|
||||
.trim();
|
||||
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
const mainRepoRoot = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
{
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
},
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.replace(/\/\.git$/, "");
|
||||
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
|
||||
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
@@ -566,34 +578,39 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("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 commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
|
||||
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" });
|
||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.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");
|
||||
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
|
||||
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",
|
||||
});
|
||||
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -22,16 +22,25 @@ function createWorkspaceRepo(options?: {
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "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" });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", options?.branchName ?? "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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -12,16 +12,25 @@ function createWorkspaceRepo(options?: {
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "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" });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", options?.branchName ?? "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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import {
|
||||
buildWorkspaceScriptPayloads,
|
||||
@@ -21,16 +21,25 @@ function createWorkspaceRepo(options?: {
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "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" });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", options?.branchName ?? "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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
asDaemonConfigStore,
|
||||
createProviderSnapshotManagerStub,
|
||||
} from "./test-utils/session-stubs.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
interface SessionHandlerInternals {
|
||||
startVoiceTurnController(): Promise<void>;
|
||||
@@ -705,39 +706,46 @@ describe("project config RPC authorization", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("read_project_config_request accepts a symlink to an active project root", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const linkRoot = join(makeRoot(), "link");
|
||||
symlinkSync(repoRoot, linkRoot, "dir");
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||
});
|
||||
// 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();
|
||||
writeFileSync(
|
||||
join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: "npm ci" } }),
|
||||
);
|
||||
const linkRoot = join(makeRoot(), "link");
|
||||
symlinkSync(repoRoot, linkRoot, "dir");
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot: linkRoot,
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot: linkRoot,
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "read-symlink-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: expect.objectContaining({
|
||||
mtimeMs: expect.any(Number),
|
||||
size: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
||||
const archivedRoot = makeRoot();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
@@ -36,6 +37,9 @@ type WorkspaceUpdatePayload = Extract<
|
||||
{ type: "workspace_update" }
|
||||
>["payload"];
|
||||
|
||||
const REPO_CWD = path.resolve("/tmp/repo");
|
||||
const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`;
|
||||
|
||||
function createWorkspaceRuntimeSnapshot(
|
||||
cwd: string,
|
||||
overrides?: {
|
||||
@@ -243,7 +247,7 @@ function seedGitWorkspace(input: {
|
||||
input.projectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId: input.projectId,
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: input.cwd,
|
||||
displayName: "repo",
|
||||
kind: "git",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -274,7 +278,7 @@ describe("workspace git watch targets", () => {
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
@@ -289,22 +293,22 @@ describe("workspace git watch targets", () => {
|
||||
id: "ws-10",
|
||||
projectId: "proj-1",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
workspaceDirectory: REPO_CWD,
|
||||
};
|
||||
|
||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
|
||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||
{ cwd: "/tmp/repo" },
|
||||
{ cwd: REPO_CWD },
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -314,7 +318,7 @@ describe("workspace git watch targets", () => {
|
||||
};
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "renamed-branch",
|
||||
},
|
||||
@@ -349,7 +353,7 @@ describe("workspace git watch targets", () => {
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
@@ -360,11 +364,11 @@ describe("workspace git watch targets", () => {
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
emitted.length = 0;
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature/server-push",
|
||||
isDirty: true,
|
||||
@@ -380,9 +384,9 @@ describe("workspace git watch targets", () => {
|
||||
) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>;
|
||||
expect(statusUpdates).toHaveLength(1);
|
||||
expect(statusUpdates[0]?.payload).toMatchObject({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
currentBranch: "feature/server-push",
|
||||
isDirty: true,
|
||||
baseRef: "main",
|
||||
@@ -393,10 +397,10 @@ describe("workspace git watch targets", () => {
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId: "subscription:/tmp/repo",
|
||||
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
|
||||
});
|
||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||
{ cwd: "/tmp/repo" },
|
||||
{ cwd: REPO_CWD },
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -412,7 +416,7 @@ describe("workspace git watch targets", () => {
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
name: "main",
|
||||
});
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
@@ -423,11 +427,11 @@ describe("workspace git watch targets", () => {
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
emitted.length = 0;
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
github: {
|
||||
featuresEnabled: true,
|
||||
pullRequest: {
|
||||
@@ -457,7 +461,7 @@ describe("workspace git watch targets", () => {
|
||||
| { payload: CheckoutStatusUpdatePayload }
|
||||
| undefined;
|
||||
expect(statusUpdate?.payload.prStatus).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: {
|
||||
number: 456,
|
||||
url: "https://github.com/acme/repo/pull/456",
|
||||
@@ -481,7 +485,7 @@ describe("workspace git watch targets", () => {
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "subscription:/tmp/repo",
|
||||
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
|
||||
});
|
||||
|
||||
await session.cleanup();
|
||||
@@ -491,7 +495,7 @@ describe("workspace git watch targets", () => {
|
||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||
|
||||
workspaceGitService.getSnapshot.mockResolvedValue(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
github: {
|
||||
featuresEnabled: true,
|
||||
pullRequest: {
|
||||
@@ -508,15 +512,15 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_pr_status_request",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
requestId: "req-pr-status",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(
|
||||
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
|
||||
).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: {
|
||||
number: undefined,
|
||||
url: "https://github.com/acme/repo/pull/456",
|
||||
@@ -543,12 +547,12 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_pr_status_request",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
requestId: "req-pr-cached",
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,18 @@ function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) {
|
||||
}
|
||||
|
||||
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) {
|
||||
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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S1: open fresh git repo creates workspace at canonical root", async () => {
|
||||
const h = createHarness({ gitRoots: ["/foo"] });
|
||||
await openProject(h.session, "/foo");
|
||||
const h = createHarness({ gitRoots: [FOO] });
|
||||
await openProject(h.session, FOO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/foo");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
|
||||
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 () => {
|
||||
const h = createHarness({});
|
||||
await openProject(h.session, "/bar");
|
||||
await openProject(h.session, BAR);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/bar");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
|
||||
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 () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/foo")],
|
||||
projects: [gitProject("/foo")],
|
||||
gitRoots: ["/foo"],
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
gitRoots: [FOO],
|
||||
});
|
||||
await openProject(h.session, "/foo");
|
||||
await openProject(h.session, FOO);
|
||||
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.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 () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/foo")],
|
||||
projects: [gitProject("/foo")],
|
||||
gitRoots: ["/foo"],
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
gitRoots: [FOO],
|
||||
});
|
||||
await openProject(h.session, "/foo/sub");
|
||||
await openProject(h.session, FOO_SUB);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/bar")],
|
||||
projects: [dirProject("/bar")],
|
||||
workspaces: [dirWorkspace(BAR)],
|
||||
projects: [dirProject(BAR)],
|
||||
});
|
||||
await openProject(h.session, "/bar/baz");
|
||||
await openProject(h.session, BAR_BAZ);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz");
|
||||
expect(h.workspaces.has("/bar")).toBe(true);
|
||||
expect(h.workspaces.has("/bar/baz")).toBe(true);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
|
||||
expect(h.workspaces.has(BAR)).toBe(true);
|
||||
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
|
||||
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 () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox");
|
||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
|
||||
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
|
||||
await openProject(h.session, TOOLBOX);
|
||||
expect(h.workspaces.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 () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/foo")],
|
||||
projects: [gitProject("/foo")],
|
||||
gitRoots: ["/foo", "/foo/sub"],
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
gitRoots: [FOO, FOO_SUB],
|
||||
});
|
||||
await openProject(h.session, "/foo/sub");
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub");
|
||||
expect(h.workspaces.has("/foo")).toBe(true);
|
||||
expect(h.workspaces.has("/foo/sub")).toBe(true);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||
expect(h.workspaces.has(FOO)).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 () => {
|
||||
const archivedAt = "2026-04-04T17:15:22.423Z";
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)],
|
||||
projects: [dirProject("/Users/me/Developer", archivedAt)],
|
||||
workspaces: [dirWorkspace(USERS_DEVELOPER, archivedAt)],
|
||||
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
|
||||
});
|
||||
await openProject(h.session, "/Users/me/Developer/projects/foo");
|
||||
expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt);
|
||||
expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true);
|
||||
await openProject(h.session, USERS_PROJECT);
|
||||
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
|
||||
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 () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox/flomo-cli");
|
||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt);
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
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 () => {
|
||||
const archivedAt = "2026-04-04T17:15:22.423Z";
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/projects", archivedAt)],
|
||||
projects: [dirProject("/projects", archivedAt)],
|
||||
gitRoots: ["/projects/some-git-repo"],
|
||||
workspaces: [dirWorkspace(PROJECTS, archivedAt)],
|
||||
projects: [dirProject(PROJECTS, archivedAt)],
|
||||
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");
|
||||
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(h.workspaces.has("/projects/some-git-repo")).toBe(true);
|
||||
expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt);
|
||||
expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt);
|
||||
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
|
||||
expect(h.workspaces.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 () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox");
|
||||
await openProject(h.session, TOOLBOX);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.id).toBe("/toolbox");
|
||||
expect(resp?.workspace?.projectId).toBe("/toolbox");
|
||||
expect(resp?.workspace?.id).toBe(TOOLBOX);
|
||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.projects.size).toBe(1);
|
||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
|
||||
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
|
||||
expect(h.workspaces.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 () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [dirWorkspace("/parent", archivedAt)],
|
||||
projects: [dirProject("/parent", archivedAt)],
|
||||
workspaces: [dirWorkspace(PARENT, archivedAt)],
|
||||
projects: [dirProject(PARENT, archivedAt)],
|
||||
});
|
||||
const found = await asInternals<{
|
||||
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
||||
}>(h.session).findWorkspaceByDirectory("/parent/child");
|
||||
}>(h.session).findWorkspaceByDirectory(PARENT_CHILD);
|
||||
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 () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
||||
projects: [gitProject("/toolbox", archivedAt)],
|
||||
gitRoots: ["/toolbox"],
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, "/toolbox/flomo-cli");
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
|
||||
@@ -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 { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -48,6 +48,9 @@ import {
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
const REPO_CWD = path.resolve("/tmp/repo");
|
||||
const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
|
||||
|
||||
interface SessionTestAccess {
|
||||
projectRegistry: {
|
||||
list(...args: unknown[]): Promise<unknown[]>;
|
||||
@@ -604,7 +607,7 @@ test("unsupported persisted agents are excluded from active lists but preserved
|
||||
const storedRecord = {
|
||||
id: "agent-unsupported",
|
||||
provider: "gemini",
|
||||
cwd: "/tmp/history",
|
||||
cwd: path.resolve("/tmp/history"),
|
||||
createdAt: "2026-04-13T10:13:11.457Z",
|
||||
updatedAt: "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({
|
||||
projectId: "proj-1",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -736,7 +739,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -755,7 +758,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
||||
await session.forwardAgentUpdate(
|
||||
makeManagedAgent({
|
||||
id: "agent-1",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "running",
|
||||
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(
|
||||
makeManagedAgent({
|
||||
id: "agent-1",
|
||||
cwd: "/tmp/unregistered",
|
||||
cwd: UNREGISTERED_CWD,
|
||||
lifecycle: "running",
|
||||
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({
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectKey: "/tmp/unregistered",
|
||||
projectKey: UNREGISTERED_CWD,
|
||||
projectName: "unregistered",
|
||||
checkout: {
|
||||
cwd: "/tmp/unregistered",
|
||||
cwd: UNREGISTERED_CWD,
|
||||
isGit: false,
|
||||
},
|
||||
},
|
||||
@@ -826,7 +829,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
const archivedRecord = {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "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,
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
},
|
||||
persistence: null,
|
||||
title: "Archive me",
|
||||
@@ -893,7 +896,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
projectRegistry: (() => {
|
||||
const proj = createPersistedProjectRecord({
|
||||
projectId: "proj-1",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
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({
|
||||
workspaceId: "ws-1",
|
||||
projectId: "proj-1",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -934,7 +937,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
loopService: asLoopService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
initial: { cwd: REPO_CWD, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
@@ -996,7 +999,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
||||
const archivedRecord = {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
@@ -1055,7 +1058,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
||||
projectRegistry: (() => {
|
||||
const proj = createPersistedProjectRecord({
|
||||
projectId: "proj-close",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
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({
|
||||
workspaceId: "ws-close",
|
||||
projectId: "proj-close",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
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 = {
|
||||
...makeAgent({
|
||||
id: "agent-live",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: "idle",
|
||||
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 = {
|
||||
...makeAgent({
|
||||
id: storedAgentId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:05:00.000Z",
|
||||
}),
|
||||
@@ -1244,7 +1247,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
||||
projectRegistry: (() => {
|
||||
const proj = createPersistedProjectRecord({
|
||||
projectId: "proj-stored",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
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({
|
||||
workspaceId: "ws-stored",
|
||||
projectId: "proj-stored",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -1350,7 +1353,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
const goodRecord = {
|
||||
...makeAgent({
|
||||
id: "agent-good",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
@@ -1393,7 +1396,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
projectRegistry: (() => {
|
||||
const proj = createPersistedProjectRecord({
|
||||
projectId: "proj-err",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -1413,7 +1416,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
const ws = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-err",
|
||||
projectId: "proj-err",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
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 () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const legacyRoot = path.resolve("/tmp/legacy");
|
||||
const activeCwd = path.join(legacyRoot, "active");
|
||||
const archivedCwd = path.join(legacyRoot, "archived");
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-legacy-global",
|
||||
rootPath: "/tmp/legacy",
|
||||
rootPath: legacyRoot,
|
||||
kind: "non_git",
|
||||
displayName: "legacy",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -1725,7 +1731,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
||||
const activeWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-legacy-active",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/legacy/active",
|
||||
cwd: activeCwd,
|
||||
kind: "directory",
|
||||
displayName: "active",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -1734,7 +1740,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
||||
const archivedWorkspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-legacy-archived",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/legacy/archived",
|
||||
cwd: archivedCwd,
|
||||
kind: "directory",
|
||||
displayName: "archived",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -1747,13 +1753,13 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: "legacy-active",
|
||||
cwd: "/tmp/legacy/active",
|
||||
cwd: activeCwd,
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:01:00.000Z",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "legacy-archived-workspace",
|
||||
cwd: "/tmp/legacy/archived",
|
||||
cwd: archivedCwd,
|
||||
status: "idle",
|
||||
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 () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const historyCwd = path.resolve("/tmp/history");
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-history",
|
||||
rootPath: "/tmp/history",
|
||||
rootPath: historyCwd,
|
||||
kind: "non_git",
|
||||
displayName: "history",
|
||||
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({
|
||||
workspaceId: "ws-history",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/history",
|
||||
cwd: historyCwd,
|
||||
kind: "directory",
|
||||
displayName: "history",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -1801,7 +1808,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as
|
||||
{
|
||||
...makeAgent({
|
||||
id: "history-archived",
|
||||
cwd: "/tmp/history",
|
||||
cwd: historyCwd,
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
@@ -1842,7 +1849,7 @@ test("fetch_agent_request still resolves archived historical agents", async () =
|
||||
const agent = {
|
||||
...makeAgent({
|
||||
id: "archived-history-agent",
|
||||
cwd: "/tmp/history-detail",
|
||||
cwd: path.resolve("/tmp/history-detail"),
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
@@ -1941,7 +1948,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-status",
|
||||
projectId: "proj-repo-status",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "repo",
|
||||
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 () => [
|
||||
makeAgent({
|
||||
id: "a1",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: "running",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "a2",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: "error",
|
||||
updatedAt: "2026-03-01T12:01:00.000Z",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "a3",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:02:00.000Z",
|
||||
pendingPermissions: 1,
|
||||
@@ -1985,7 +1992,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-subdir",
|
||||
projectId: "proj-repo-subdir",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
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 () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
REPO_CWD,
|
||||
{
|
||||
id: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
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 () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
REPO_CWD,
|
||||
{
|
||||
id: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
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");
|
||||
expect(workspaceUpdates).toHaveLength(2);
|
||||
@@ -2145,7 +2152,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
id: "ws-repo-running",
|
||||
projectId: "proj-repo-running",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
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 repoDir = path.join(tempDir, "repo");
|
||||
const paseoHome = path.join(tempDir, "paseo-home");
|
||||
execSync(`mkdir -p ${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" });
|
||||
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, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
|
||||
if (cwd === repoDir) {
|
||||
@@ -2263,7 +2276,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-main",
|
||||
projectId: "proj-repo-main",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
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",
|
||||
projectId: "proj-repo-main",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
@@ -2310,7 +2323,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
|
||||
id: "ws-repo-feature",
|
||||
projectId: "proj-repo-main",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feature",
|
||||
@@ -2376,14 +2389,14 @@ test("open_project_request registers a workspace before any agent exists", async
|
||||
|
||||
await session.handleMessage({
|
||||
type: "open_project_request",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
requestId: "req-open",
|
||||
});
|
||||
|
||||
expect(workspaces.get("/tmp/repo")).toBeTruthy();
|
||||
expect(workspaces.get(REPO_CWD)).toBeTruthy();
|
||||
const response = findByType(emitted, "open_project_response");
|
||||
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 () => {
|
||||
@@ -2391,7 +2404,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
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) => {
|
||||
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 projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
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);
|
||||
|
||||
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 projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const home = "/Users/moboudra";
|
||||
const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea";
|
||||
const home = path.resolve("/Users/moboudra");
|
||||
const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
|
||||
|
||||
projects.set(
|
||||
home,
|
||||
@@ -2609,8 +2622,8 @@ test("open_project_request does not unarchive an archived parent workspace for a
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const home = "/Users/moboudra";
|
||||
const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea";
|
||||
const home = path.resolve("/Users/moboudra");
|
||||
const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
|
||||
const archivedAt = "2026-04-24T08:00:00.000Z";
|
||||
|
||||
projects.set(
|
||||
@@ -2677,8 +2690,14 @@ test("open_project_request reclassifies an archived directory workspace when git
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings";
|
||||
const repoRoot = "/Users/moboudra/dev/paseo";
|
||||
const repoRoot = path.resolve("/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 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 projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings";
|
||||
const repoRoot = "/Users/moboudra/dev/paseo";
|
||||
const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
|
||||
const cwd = path.join(
|
||||
path.resolve("/Users/moboudra"),
|
||||
".paseo",
|
||||
"worktrees",
|
||||
"orchestrate",
|
||||
"desktop-daemon-settings",
|
||||
);
|
||||
|
||||
projects.set(
|
||||
cwd,
|
||||
@@ -2879,8 +2904,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings";
|
||||
const repoRoot = "/Users/moboudra/dev/paseo";
|
||||
const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
|
||||
const cwd = path.join(
|
||||
path.resolve("/Users/moboudra"),
|
||||
".paseo",
|
||||
"worktrees",
|
||||
"orchestrate",
|
||||
"desktop-daemon-settings",
|
||||
);
|
||||
|
||||
projects.set(
|
||||
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 workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
|
||||
const cwd = "/tmp/repo";
|
||||
const cwd = REPO_CWD;
|
||||
projects.set(
|
||||
cwd,
|
||||
createPersistedProjectRecord({
|
||||
@@ -3030,7 +3061,7 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const repoRoot = "/tmp/repo";
|
||||
const repoRoot = REPO_CWD;
|
||||
const subdir = "/tmp/repo/packages/app";
|
||||
|
||||
session.emit = (message) => {
|
||||
@@ -3213,10 +3244,10 @@ test("open_in_editor_request launches the selected target", async () => {
|
||||
type: "open_in_editor_request",
|
||||
requestId: "req-open-editor",
|
||||
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
|
||||
| { payload: Record<string, unknown> }
|
||||
| undefined;
|
||||
@@ -3229,7 +3260,7 @@ test("archive_workspace_request hides non-destructive workspace records", async
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-archive",
|
||||
projectId: "proj-repo-archive",
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
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 remoteProjectId = "remote:github.com/zimakki/inkwell";
|
||||
|
||||
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`);
|
||||
mkdirSync(worktreeWorkspaceId, { recursive: true });
|
||||
|
||||
projects.set(
|
||||
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 newProjectId = "remote:github.com/new-owner/inkwell";
|
||||
|
||||
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`);
|
||||
mkdirSync(worktreeWorkspaceId, { recursive: true });
|
||||
|
||||
projects.set(
|
||||
oldProjectId,
|
||||
@@ -3476,7 +3507,7 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
|
||||
const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
|
||||
const projectId = "remote:github.com/acme/repo";
|
||||
|
||||
execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`);
|
||||
mkdirSync(subdirWorkspaceId, { recursive: true });
|
||||
|
||||
projects.set(
|
||||
projectId,
|
||||
@@ -3566,7 +3597,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-baseline",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -3575,7 +3606,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-baseline",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
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 project = createPersistedProjectRecord({
|
||||
projectId: "proj-archiving-map",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
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 project = createPersistedProjectRecord({
|
||||
projectId: "proj-archiving-emit",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
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 () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "runtime-branch",
|
||||
isDirty: true,
|
||||
@@ -3758,7 +3789,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-runtime-fetch",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
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({
|
||||
workspaceId: "ws-runtime-fetch",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
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> }
|
||||
| undefined;
|
||||
|
||||
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(response?.payload.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "ws-runtime-fetch",
|
||||
@@ -3856,7 +3887,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-fetch-boundary",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
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({
|
||||
workspaceId: "ws-fetch-boundary",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
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 () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
isDirty: true,
|
||||
@@ -3922,7 +3953,7 @@ test("workspace_update includes updated runtime fields", async () => {
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-runtime-update",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -3931,7 +3962,7 @@ test("workspace_update includes updated runtime fields", async () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-runtime-update",
|
||||
projectId: project.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
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,
|
||||
});
|
||||
|
||||
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
@@ -3998,7 +4029,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const gitProject = createPersistedProjectRecord({
|
||||
projectId: "proj-git-subscribe",
|
||||
rootPath: "/tmp/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
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({
|
||||
workspaceId: "ws-git-subscribe",
|
||||
projectId: gitProject.projectId,
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
|
||||
@@ -86,6 +86,15 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
total: 0,
|
||||
byLifecycle: {},
|
||||
withActiveForegroundTurn: 0,
|
||||
timelineStats: {
|
||||
totalItems: 0,
|
||||
maxItemsPerAgent: 0,
|
||||
},
|
||||
})),
|
||||
...agentManagerOverrides,
|
||||
};
|
||||
const daemonConfigStore = {
|
||||
|
||||
@@ -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 { readdir } from "node:fs/promises";
|
||||
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 {
|
||||
createGitHubService,
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
WorkspaceGitServiceImpl,
|
||||
type WorkspaceGitRuntimeSnapshot,
|
||||
} from "./workspace-git-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
|
||||
function createLogger() {
|
||||
const logger = {
|
||||
@@ -230,11 +233,11 @@ function buildDefaultServiceDeps() {
|
||||
listBranchSuggestions: vi.fn(async () => []),
|
||||
listPaseoWorktrees: vi.fn(async () => []),
|
||||
github: createGitHubServiceStub(),
|
||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
||||
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||
hasOriginRemote: vi.fn(async () => false),
|
||||
runGitFetch: vi.fn(async () => {}),
|
||||
runGitCommand: vi.fn(async () => ({
|
||||
stdout: "/tmp/repo\n",
|
||||
stdout: `${REPO_CWD}\n`,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
@@ -273,9 +276,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
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;
|
||||
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);
|
||||
|
||||
@@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
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(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
||||
@@ -307,22 +310,22 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const service = createService({ getCheckoutStatus });
|
||||
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(getCheckoutStatus).not.toHaveBeenCalled();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
||||
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
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"));
|
||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -336,9 +339,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
nowMs = 1;
|
||||
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
||||
await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -348,15 +351,15 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
test("forced getSnapshot emits even when the fingerprint matches", async () => {
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService({ getCheckoutStatus });
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
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).toHaveBeenCalledWith(createSnapshot("/tmp/repo"));
|
||||
expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD));
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -371,13 +374,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
const listener = vi.fn();
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
|
||||
nowMs = 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
@@ -390,17 +393,17 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||
const service = createService({ getCheckoutStatus });
|
||||
|
||||
const first = service.getSnapshot("/tmp/repo");
|
||||
const second = service.getSnapshot("/tmp/repo/.");
|
||||
const first = service.getSnapshot(REPO_CWD);
|
||||
const second = service.getSnapshot(join(REPO_CWD, "."));
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
createSnapshot("/tmp/repo"),
|
||||
createSnapshot("/tmp/repo"),
|
||||
createSnapshot(REPO_CWD),
|
||||
createSnapshot(REPO_CWD),
|
||||
]);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -424,24 +427,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
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, {
|
||||
git: { diffStat: { additions: 4, deletions: 2 } },
|
||||
}),
|
||||
);
|
||||
|
||||
nowMs += 16_000;
|
||||
const first = service.getSnapshot("/tmp/repo");
|
||||
const first = service.getSnapshot(REPO_CWD);
|
||||
await flushPromises();
|
||||
const second = service.getSnapshot("/tmp/repo");
|
||||
const second = service.getSnapshot(REPO_CWD);
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
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: {
|
||||
currentBranch: "feature",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
@@ -461,11 +464,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
nowMs = 3_000;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
service.scheduleRefreshForCwd("/tmp/repo");
|
||||
service.scheduleRefreshForCwd(REPO_CWD);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await flushPromises();
|
||||
@@ -480,20 +483,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const secondRefresh = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
||||
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||
.mockImplementationOnce(async () => secondRefresh.promise)
|
||||
.mockImplementation(async () => createCheckoutStatus("/tmp/repo"));
|
||||
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
nowMs = 3_000;
|
||||
const refreshPromise = service.refresh("/tmp/repo");
|
||||
const refreshPromise = service.refresh(REPO_CWD);
|
||||
await flushPromises();
|
||||
const forcedPromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
||||
const duplicateForcedPromise = service.getSnapshot("/tmp/repo", {
|
||||
const forcedPromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||
const duplicateForcedPromise = service.getSnapshot(REPO_CWD, {
|
||||
force: true,
|
||||
reason: "test",
|
||||
});
|
||||
@@ -501,7 +504,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
secondRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
secondRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||
await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
|
||||
@@ -513,19 +516,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
||||
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||
.mockImplementationOnce(async () => forcedRefresh.promise);
|
||||
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();
|
||||
const second = service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
||||
const second = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
forcedRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
@@ -537,18 +540,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
||||
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||
.mockImplementationOnce(async () => forcedRefresh.promise);
|
||||
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();
|
||||
service.scheduleRefreshForCwd("/tmp/repo");
|
||||
service.scheduleRefreshForCwd(REPO_CWD);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await flushPromises();
|
||||
|
||||
forcedRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||
await forcePromise;
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
@@ -563,12 +566,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
nowMs = 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
nowMs = 3_001;
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -582,10 +585,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
nowMs = 16_000;
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -601,7 +604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getPullRequestStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
nowMs = 60_000;
|
||||
@@ -609,7 +612,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenLastCalledWith("/tmp/repo", expect.anything(), {
|
||||
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
|
||||
force: false,
|
||||
reason: "self-heal-git",
|
||||
});
|
||||
@@ -623,7 +626,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const resolveAbsoluteGitDir = vi
|
||||
.fn<() => Promise<string | null>>()
|
||||
.mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
|
||||
.mockResolvedValue("/tmp/repo/.git");
|
||||
.mockResolvedValue(join(REPO_CWD, ".git"));
|
||||
const watch = vi.fn(() => createWatcher() as never);
|
||||
const service = createService({
|
||||
resolveAbsoluteGitDir,
|
||||
@@ -631,7 +634,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||
@@ -642,7 +645,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo");
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith(REPO_CWD);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -659,11 +662,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
||||
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||
watch,
|
||||
});
|
||||
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -697,7 +700,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
github,
|
||||
});
|
||||
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -744,7 +747,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
github,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
nowMs = 20_000;
|
||||
@@ -777,7 +780,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
github,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
|
||||
@@ -801,7 +804,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
github,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -819,8 +822,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
|
||||
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
nowMs = 60_000;
|
||||
@@ -841,7 +844,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
subscription.unsubscribe();
|
||||
nowMs = 60_000;
|
||||
@@ -860,7 +863,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
service.dispose();
|
||||
nowMs = 60_000;
|
||||
@@ -875,24 +878,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
||||
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||
.mockImplementationOnce(async () => selfHealRefresh.promise);
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
nowMs = 60_000;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flushPromises();
|
||||
const directRead = service.getSnapshot("/tmp/repo");
|
||||
const directRead = service.getSnapshot(REPO_CWD);
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
selfHealRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||
await directRead;
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
@@ -923,8 +926,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.validateBranchRef("/tmp/repo", "feature");
|
||||
const second = service.validateBranchRef("/tmp/repo/.", "feature");
|
||||
const first = service.validateBranchRef(REPO_CWD, "feature");
|
||||
const second = service.validateBranchRef(join(REPO_CWD, "."), "feature");
|
||||
await flushPromises();
|
||||
|
||||
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
||||
@@ -935,10 +938,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
]);
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.validateBranchRef("/tmp/repo", "feature");
|
||||
await service.validateBranchRef(REPO_CWD, "feature");
|
||||
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);
|
||||
|
||||
service.dispose();
|
||||
@@ -968,15 +971,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.hasLocalBranch("/tmp/repo", "feature");
|
||||
const second = service.hasLocalBranch("/tmp/repo/.", "feature");
|
||||
const first = service.hasLocalBranch(REPO_CWD, "feature");
|
||||
const second = service.hasLocalBranch(join(REPO_CWD, "."), "feature");
|
||||
await flushPromises();
|
||||
|
||||
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runGitCommand).toHaveBeenCalledWith(
|
||||
["rev-parse", "--verify", "--quiet", "refs/heads/feature"],
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/repo",
|
||||
cwd: REPO_CWD,
|
||||
acceptExitCodes: [0, 1],
|
||||
}),
|
||||
);
|
||||
@@ -990,11 +993,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||
|
||||
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);
|
||||
|
||||
await expect(
|
||||
service.hasLocalBranch("/tmp/repo", "feature", { force: true, reason: "test" }),
|
||||
service.hasLocalBranch(REPO_CWD, "feature", { force: true, reason: "test" }),
|
||||
).resolves.toBe(false);
|
||||
expect(runGitCommand).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -1013,18 +1016,18 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({
|
||||
await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({
|
||||
kind: "local",
|
||||
name: "feature-old",
|
||||
});
|
||||
|
||||
nowMs = 16_000;
|
||||
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);
|
||||
|
||||
nowMs = 16_500;
|
||||
await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({
|
||||
await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({
|
||||
kind: "local",
|
||||
name: "feature-old",
|
||||
});
|
||||
@@ -1046,8 +1049,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 });
|
||||
const second = service.suggestBranchesForCwd("/tmp/repo/.", {
|
||||
const first = service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 });
|
||||
const second = service.suggestBranchesForCwd(join(REPO_CWD, "."), {
|
||||
query: "feat",
|
||||
limit: 5,
|
||||
});
|
||||
@@ -1058,11 +1061,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]);
|
||||
|
||||
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);
|
||||
|
||||
await service.suggestBranchesForCwd(
|
||||
"/tmp/repo",
|
||||
REPO_CWD,
|
||||
{ query: "feat", limit: 5 },
|
||||
{ force: true, reason: "test" },
|
||||
);
|
||||
@@ -1096,8 +1099,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.listStashes("/tmp/repo", { paseoOnly: true });
|
||||
const second = service.listStashes("/tmp/repo/.", { paseoOnly: true });
|
||||
const first = service.listStashes(REPO_CWD, { paseoOnly: true });
|
||||
const second = service.listStashes(join(REPO_CWD, "."), { paseoOnly: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
||||
@@ -1114,10 +1117,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
]);
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.listStashes("/tmp/repo", { paseoOnly: true });
|
||||
await service.listStashes(REPO_CWD, { paseoOnly: true });
|
||||
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);
|
||||
|
||||
service.dispose();
|
||||
@@ -1138,16 +1141,16 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.listWorktrees("/tmp/repo");
|
||||
const second = service.listWorktrees("/tmp/repo/.");
|
||||
const first = service.listWorktrees(REPO_CWD);
|
||||
const second = service.listWorktrees(join(REPO_CWD, "."));
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]);
|
||||
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.listWorktrees("/tmp/repo");
|
||||
await service.listWorktrees(REPO_CWD);
|
||||
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);
|
||||
|
||||
service.dispose();
|
||||
@@ -1158,7 +1161,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const nestedWorkspaceDir = join(repoDir, "packages", "app");
|
||||
mkdirSync(nestedWorkspaceDir, { recursive: true });
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const worktrees = [
|
||||
{
|
||||
@@ -1181,7 +1184,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
|
||||
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
||||
expect(listPaseoWorktrees).toHaveBeenCalledWith({
|
||||
cwd: repoDir,
|
||||
cwd: realpathSync.native(repoDir).replace(/\\/g, "/"),
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
} finally {
|
||||
@@ -1202,8 +1205,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.resolveDefaultBranch("/tmp/repo");
|
||||
const second = service.resolveDefaultBranch("/tmp/repo/.");
|
||||
const first = service.resolveDefaultBranch(REPO_CWD);
|
||||
const second = service.resolveDefaultBranch(join(REPO_CWD, "."));
|
||||
await flushPromises();
|
||||
|
||||
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
|
||||
@@ -1211,10 +1214,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]);
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.resolveDefaultBranch("/tmp/repo");
|
||||
await service.resolveDefaultBranch(REPO_CWD);
|
||||
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);
|
||||
|
||||
service.dispose();
|
||||
@@ -1226,25 +1229,25 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
const getCheckoutStatus = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => checkoutDeferred.promise)
|
||||
.mockResolvedValue(createCheckoutStatus("/tmp/repo"));
|
||||
.mockResolvedValue(createCheckoutStatus(REPO_CWD));
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.resolveRepoRoot("/tmp/repo");
|
||||
const second = service.resolveRepoRoot("/tmp/repo/.");
|
||||
const first = service.resolveRepoRoot(REPO_CWD);
|
||||
const second = service.resolveRepoRoot(join(REPO_CWD, "."));
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
checkoutDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(["/tmp/repo", "/tmp/repo"]);
|
||||
checkoutDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([REPO_CWD, REPO_CWD]);
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.resolveRepoRoot("/tmp/repo");
|
||||
await service.resolveRepoRoot(REPO_CWD);
|
||||
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);
|
||||
|
||||
service.dispose();
|
||||
@@ -1262,11 +1265,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -1281,7 +1284,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
createCheckoutStatus(cwd, {
|
||||
currentBranch: "feature/service-metadata",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
}),
|
||||
);
|
||||
const service = createService({
|
||||
@@ -1290,7 +1293,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceGitMetadata("/tmp/repo", { directoryName: "Local Repo" }),
|
||||
service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }),
|
||||
).resolves.toEqual({
|
||||
projectKind: "git",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
@@ -1298,13 +1301,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
gitRemote: "https://github.com/getpaseo/paseo.git",
|
||||
isWorktree: false,
|
||||
projectSlug: "paseo",
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
currentBranch: "feature/service-metadata",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
});
|
||||
|
||||
nowMs = 1_000;
|
||||
await service.getWorkspaceGitMetadata("/tmp/repo/.", { directoryName: "Local Repo" });
|
||||
await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" });
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.dispose();
|
||||
@@ -1314,18 +1317,21 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
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" });
|
||||
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, "tracked.txt"), "before\n");
|
||||
execSync("git add tracked.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||
execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\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({
|
||||
getCheckoutDiff: getCheckoutDiffUncached as never,
|
||||
@@ -1357,8 +1363,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
.mockResolvedValue({ diff: "second" });
|
||||
const service = createService({ getCheckoutDiff, now: () => new Date(0) });
|
||||
|
||||
const first = service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" });
|
||||
const second = service.getCheckoutDiff("/tmp/repo/.", { mode: "uncommitted" });
|
||||
const first = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
|
||||
const second = service.getCheckoutDiff(join(REPO_CWD, "."), { mode: "uncommitted" });
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
||||
@@ -1383,16 +1389,12 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
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",
|
||||
});
|
||||
nowMs = 1;
|
||||
await expect(
|
||||
service.getCheckoutDiff(
|
||||
"/tmp/repo",
|
||||
{ mode: "uncommitted" },
|
||||
{ force: true, reason: "test" },
|
||||
),
|
||||
service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }, { force: true, reason: "test" }),
|
||||
).resolves.toEqual({ diff: "forced" });
|
||||
|
||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
|
||||
@@ -1412,15 +1414,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
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",
|
||||
});
|
||||
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",
|
||||
);
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -1441,13 +1443,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "main" }),
|
||||
service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }),
|
||||
).resolves.toEqual({ diff: "main" });
|
||||
await expect(
|
||||
service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "release" }),
|
||||
service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "release" }),
|
||||
).resolves.toEqual({ diff: "release" });
|
||||
await expect(
|
||||
service.getCheckoutDiff("/tmp/repo", {
|
||||
service.getCheckoutDiff(REPO_CWD, {
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: true,
|
||||
@@ -1459,47 +1461,51 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("Linux working tree walker excludes gitignored directories", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
||||
// 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;
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
||||
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
|
||||
mkdirSync(join(repoDir, "kept"), { recursive: true });
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
|
||||
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
|
||||
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
|
||||
writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n");
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
|
||||
mkdirSync(join(repoDir, "kept"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
|
||||
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
|
||||
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
|
||||
writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n");
|
||||
|
||||
const watchedPaths: string[] = [];
|
||||
const watchSpy = (watchPath: string) => {
|
||||
watchedPaths.push(watchPath);
|
||||
return { close: vi.fn(), on: vi.fn().mockReturnThis() };
|
||||
};
|
||||
const watchedPaths: string[] = [];
|
||||
const watchSpy = (watchPath: string) => {
|
||||
watchedPaths.push(watchPath);
|
||||
return { close: vi.fn(), on: vi.fn().mockReturnThis() };
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
watch: watchSpy as never,
|
||||
readdir: readdir as never,
|
||||
runGitCommand: runGitCommandReal as never,
|
||||
getCheckoutStatus: getCheckoutStatusUncached as never,
|
||||
resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never,
|
||||
});
|
||||
const service = createService({
|
||||
watch: watchSpy as never,
|
||||
readdir: readdir as never,
|
||||
runGitCommand: runGitCommandReal as never,
|
||||
getCheckoutStatus: getCheckoutStatusUncached as never,
|
||||
resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never,
|
||||
});
|
||||
|
||||
try {
|
||||
const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn());
|
||||
try {
|
||||
const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn());
|
||||
|
||||
const ignoredRoot = join(repoDir, "ignored");
|
||||
expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]);
|
||||
expect(watchedPaths).toContain(repoDir);
|
||||
expect(watchedPaths).toContain(join(repoDir, "kept"));
|
||||
const ignoredRoot = join(repoDir, "ignored");
|
||||
expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]);
|
||||
expect(watchedPaths).toContain(repoDir);
|
||||
expect(watchedPaths).toContain(join(repoDir, "kept"));
|
||||
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
service.dispose();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
service.dispose();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 pino from "pino";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
@@ -8,6 +9,9 @@ import {
|
||||
WorkspaceGitServiceImpl,
|
||||
type WorkspaceGitRuntimeSnapshot,
|
||||
} from "./workspace-git-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const REPO_CWD = path.resolve("/tmp/repo");
|
||||
|
||||
interface ServiceInternals {
|
||||
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
|
||||
@@ -202,11 +206,11 @@ function buildDefaultTestServiceDeps() {
|
||||
})),
|
||||
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()),
|
||||
github: createGitHubServiceStub(),
|
||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
||||
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||
hasOriginRemote: vi.fn(async () => false),
|
||||
runGitFetch: vi.fn(async () => {}),
|
||||
runGitCommand: vi.fn(async () => ({
|
||||
stdout: "/tmp/repo\n",
|
||||
stdout: `${REPO_CWD}\n`,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
@@ -237,12 +241,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const service = createService();
|
||||
|
||||
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("initial" in subscription).toBe(false);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
||||
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -267,8 +271,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
now: () => new Date("2026-04-12T02:03:04.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(
|
||||
createSnapshot("/tmp/repo", {
|
||||
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
|
||||
createSnapshot(REPO_CWD, {
|
||||
github: {
|
||||
pullRequest: {
|
||||
url: "https://github.com/acme/repo/pull/999",
|
||||
@@ -304,10 +308,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
getCheckoutShortstat,
|
||||
});
|
||||
|
||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(
|
||||
createSnapshot("/tmp/repo", {
|
||||
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
|
||||
createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
repoRoot: "/tmp/repo",
|
||||
repoRoot: REPO_CWD,
|
||||
currentBranch: "feature/worktree",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
@@ -325,11 +329,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const listener = vi.fn();
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
|
||||
nowMs += 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
@@ -342,7 +346,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||
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({
|
||||
getCheckoutStatus,
|
||||
@@ -350,27 +354,27 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
resolveAbsoluteGitDir,
|
||||
});
|
||||
|
||||
const firstSnapshotPromise = service.getSnapshot("/tmp/repo");
|
||||
const secondSnapshotPromise = service.getSnapshot("/tmp/repo/.");
|
||||
const firstSnapshotPromise = service.getSnapshot(REPO_CWD);
|
||||
const secondSnapshotPromise = service.getSnapshot(join(REPO_CWD, "."));
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||
|
||||
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
|
||||
createSnapshot("/tmp/repo"),
|
||||
createSnapshot("/tmp/repo"),
|
||||
createSnapshot(REPO_CWD),
|
||||
createSnapshot(REPO_CWD),
|
||||
]);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
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(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -379,7 +383,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
||||
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");
|
||||
const service = createService({
|
||||
@@ -388,8 +392,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
@@ -402,7 +406,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
test("equivalent cwd strings share one workspace target across service entry points", async () => {
|
||||
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");
|
||||
const service = createService({
|
||||
@@ -411,14 +415,18 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
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"));
|
||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||
await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
|
||||
createSnapshot(REPO_CWD),
|
||||
);
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||
|
||||
nowMs += 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
||||
await service.refresh(REPO_CWD);
|
||||
await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
|
||||
createSnapshot(REPO_CWD),
|
||||
);
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||
@@ -430,7 +438,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
||||
const runGitFetch = vi.fn(async () => {});
|
||||
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({
|
||||
resolveAbsoluteGitDir,
|
||||
@@ -438,8 +446,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
runGitFetch,
|
||||
});
|
||||
|
||||
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn());
|
||||
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
const second = service.registerWorkspace(
|
||||
{ cwd: join(REPO_CWD, "packages", "server") },
|
||||
vi.fn(),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
||||
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -493,18 +504,18 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||
const initialSnapshot = await service.getSnapshot(REPO_CWD);
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
|
||||
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
||||
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
await flushPromises();
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
createSnapshot("/tmp/repo", {
|
||||
createSnapshot(REPO_CWD, {
|
||||
github: {
|
||||
pullRequest: {
|
||||
url: "https://github.com/acme/repo/pull/123",
|
||||
@@ -525,9 +536,9 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
test("unchanged runtime snapshots do not emit duplicate updates", async () => {
|
||||
const getCheckoutStatus = vi
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null }))
|
||||
.mockResolvedValueOnce(createCheckoutStatus(REPO_CWD, { remoteUrl: null }))
|
||||
.mockResolvedValueOnce(
|
||||
createCheckoutStatus("/tmp/repo", {
|
||||
createCheckoutStatus(REPO_CWD, {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
remoteUrl: null,
|
||||
aheadBehind: { ahead: 2, behind: 0 },
|
||||
@@ -535,7 +546,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createCheckoutStatus("/tmp/repo", {
|
||||
createCheckoutStatus(REPO_CWD, {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
remoteUrl: null,
|
||||
aheadBehind: { ahead: 2, behind: 0 },
|
||||
@@ -563,22 +574,22 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||
const initialSnapshot = await service.getSnapshot(REPO_CWD);
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
|
||||
expect(initialSnapshot.git.currentBranch).toBe("main");
|
||||
|
||||
nowMs += 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
await flushPromises();
|
||||
|
||||
nowMs += 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await service.refresh(REPO_CWD);
|
||||
await flushPromises();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
createSnapshot("/tmp/repo", {
|
||||
createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
remoteUrl: null,
|
||||
@@ -597,7 +608,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
});
|
||||
|
||||
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());
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const service = createService({
|
||||
@@ -607,23 +618,24 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
await service.getSnapshot("/tmp/repo");
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
|
||||
await service.getSnapshot("/tmp/repo", {
|
||||
await service.getSnapshot(REPO_CWD, {
|
||||
force: true,
|
||||
reason: "test-force-emit",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo"));
|
||||
expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD));
|
||||
|
||||
subscription.unsubscribe();
|
||||
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;
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
@@ -637,20 +649,20 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
return watcher;
|
||||
});
|
||||
const readdir = vi.fn(async (directory: string) => {
|
||||
if (directory === "/tmp/repo") {
|
||||
if (directory === REPO_CWD) {
|
||||
return [
|
||||
createDirent("packages", true),
|
||||
createDirent(".git", true),
|
||||
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)];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
||||
if (directory === path.join(REPO_CWD, "packages", "server")) {
|
||||
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 [];
|
||||
@@ -658,19 +670,19 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
const service = createService({ watch, readdir });
|
||||
const subscription = await service.requestWorkingTreeWatch(
|
||||
path.join("/tmp/repo", "packages", "server"),
|
||||
path.join(REPO_CWD, "packages", "server"),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(subscription.repoRoot).toBe("/tmp/repo");
|
||||
expect(subscription.repoRoot).toBe(REPO_CWD);
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/.git",
|
||||
"/tmp/repo/packages",
|
||||
"/tmp/repo/packages/app",
|
||||
"/tmp/repo/packages/server",
|
||||
"/tmp/repo/packages/server/src",
|
||||
"/tmp/repo/packages/server/src/server",
|
||||
REPO_CWD,
|
||||
join(REPO_CWD, ".git"),
|
||||
join(REPO_CWD, "packages"),
|
||||
join(REPO_CWD, "packages", "app"),
|
||||
join(REPO_CWD, "packages", "server"),
|
||||
join(REPO_CWD, "packages", "server", "src"),
|
||||
join(REPO_CWD, "packages", "server", "src", "server"),
|
||||
]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -688,11 +700,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener);
|
||||
const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener);
|
||||
const first = await service.requestWorkingTreeWatch(REPO_CWD, firstListener);
|
||||
const second = await service.requestWorkingTreeWatch(join(REPO_CWD, "."), secondListener);
|
||||
|
||||
expect(first.repoRoot).toBe("/tmp/repo");
|
||||
expect(second.repoRoot).toBe("/tmp/repo");
|
||||
expect(first.repoRoot).toBe(REPO_CWD);
|
||||
expect(second.repoRoot).toBe(REPO_CWD);
|
||||
expect(watch).toHaveBeenCalledTimes(2);
|
||||
|
||||
first.unsubscribe();
|
||||
@@ -726,10 +738,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
.mockImplementationOnce(() => createWatcher());
|
||||
|
||||
const service = createService({ watch });
|
||||
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(
|
||||
"/tmp/repo",
|
||||
);
|
||||
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
|
||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD);
|
||||
|
||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||
|
||||
@@ -749,19 +759,18 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
resolveAbsoluteGitDir,
|
||||
});
|
||||
|
||||
const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn());
|
||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(
|
||||
"/tmp/plain",
|
||||
);
|
||||
const plainCwd = path.join(os.tmpdir(), "plain");
|
||||
const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn());
|
||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd);
|
||||
|
||||
expect(subscription.repoRoot).toBeNull();
|
||||
const expectedRecursive = process.platform !== "linux";
|
||||
expect(watch).toHaveBeenCalledWith(
|
||||
"/tmp/plain",
|
||||
plainCwd,
|
||||
{ recursive: expectedRecursive },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(target?.repoWatchPath).toBe("/tmp/plain");
|
||||
expect(target?.repoWatchPath).toBe(plainCwd);
|
||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -780,13 +789,13 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
|
||||
const listener = vi.fn();
|
||||
|
||||
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener);
|
||||
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener);
|
||||
expect(watchCallbacks).toHaveLength(2);
|
||||
|
||||
watchCallbacks[0]?.();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo", {
|
||||
expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, {
|
||||
force: true,
|
||||
reason: "working-tree-watch",
|
||||
});
|
||||
@@ -810,15 +819,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const service = createService({ getCheckoutShortstat, watch });
|
||||
const workspaceListener = vi.fn();
|
||||
|
||||
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
||||
const workspaceSubscription = service.registerWorkspace(
|
||||
{ cwd: "/tmp/repo" },
|
||||
workspaceListener,
|
||||
);
|
||||
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
||||
const initialSnapshot = await service.getSnapshot(REPO_CWD);
|
||||
const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, workspaceListener);
|
||||
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
|
||||
|
||||
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();
|
||||
|
||||
repoRootWatch?.callback();
|
||||
@@ -826,12 +832,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
|
||||
"/tmp/repo",
|
||||
REPO_CWD,
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
{ force: true },
|
||||
);
|
||||
expect(workspaceListener).toHaveBeenCalledWith(
|
||||
createSnapshot("/tmp/repo", {
|
||||
createSnapshot(REPO_CWD, {
|
||||
git: { diffStat: { additions: 8, deletions: 3 } },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -117,13 +117,13 @@ function createWorkspaceGitServiceStub(
|
||||
function createTempGitRepo(prefix: string): string {
|
||||
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
const dir = realpathSync(raw);
|
||||
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir, stdio: "ignore" });
|
||||
writeFileSync(path.join(dir, "README.md"), "# Test\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" });
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -253,12 +253,18 @@ describe("WorkspaceReconciliationService", () => {
|
||||
);
|
||||
|
||||
// Initialize as git repo
|
||||
execSync("git init -b main", { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" });
|
||||
execSync("git add .", { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: resolved, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||
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({
|
||||
projectRegistry,
|
||||
@@ -311,7 +317,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
);
|
||||
|
||||
// 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,
|
||||
stdio: "ignore",
|
||||
});
|
||||
@@ -341,7 +347,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
const dir = createTempGitRepo("reconcile-branch-");
|
||||
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();
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.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", () => {
|
||||
let tmpDir: string;
|
||||
let paseoHome: string;
|
||||
@@ -44,7 +47,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
await agentStorage.upsert({
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/non-git-project",
|
||||
cwd: NON_GIT_PROJECT,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||
@@ -61,7 +64,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
await agentStorage.upsert({
|
||||
id: "agent-2",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/non-git-project",
|
||||
cwd: NON_GIT_PROJECT,
|
||||
createdAt: "2026-03-01T01:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-03T00:00:00.000Z",
|
||||
@@ -78,7 +81,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
await agentStorage.upsert({
|
||||
id: "agent-archived",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/archived-project",
|
||||
cwd: ARCHIVED_PROJECT,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-01T00:00:00.000Z",
|
||||
@@ -104,13 +107,13 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
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]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
|
||||
const projects = await projectRegistry.list();
|
||||
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]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
506
packages/server/src/server/worktree-bootstrap.posix.test.ts
Normal file
506
packages/server/src/server/worktree-bootstrap.posix.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
type WorktreeConfig,
|
||||
} 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";
|
||||
|
||||
interface CreateAgentWorktreeTestOptions {
|
||||
@@ -69,30 +69,22 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
realTerminalManagers = [];
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||
repoDir = join(tempDir, "repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
execSync(`mkdir -p ${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" });
|
||||
execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
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 () => {
|
||||
@@ -100,114 +92,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
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 () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -217,8 +101,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
@@ -268,8 +152,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", {
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], {
|
||||
cwd: repoDir,
|
||||
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 () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -383,11 +214,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add terminal bootstrap config"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
@@ -467,114 +302,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
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 {
|
||||
cwd: 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: {
|
||||
createTerminalCalls: CreateTerminalCall[];
|
||||
terminalRecords: StubTerminalRecord[];
|
||||
@@ -759,8 +471,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
message = "add script config",
|
||||
): void {
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts }));
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync(`git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, {
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
@@ -1117,11 +829,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add respawn service script config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add respawn service script config"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
@@ -1216,11 +932,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add renamed service script config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add renamed service script config"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
@@ -1278,11 +998,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add colliding service config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add colliding service config"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
@@ -1350,97 +1074,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
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 () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -1453,11 +1086,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add remote service script config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add remote service script config"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
|
||||
611
packages/server/src/server/worktree-core.posix.test.ts
Normal file
611
packages/server/src/server/worktree-core.posix.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
interface LegacyCreateWorktreeTestOptions {
|
||||
branchName: string;
|
||||
@@ -484,49 +486,49 @@ function createWorkspaceArchivingDeps() {
|
||||
}
|
||||
|
||||
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");
|
||||
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" });
|
||||
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");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function createGitHubPrRemoteRepo() {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
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");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'review branch'", {
|
||||
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 = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
|
||||
const featureSha = execFileSync("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" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
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",
|
||||
});
|
||||
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" });
|
||||
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 };
|
||||
}
|
||||
@@ -645,8 +647,8 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'broken config'", {
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "broken config"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
@@ -713,118 +715,126 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("emits running setup snapshots before completed for real setup commands", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\""],
|
||||
// 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({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\""],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createLegacyWorktreeForTest({
|
||||
branchName: "feature-running-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-running-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createLegacyWorktreeForTest({
|
||||
branchName: "feature-running-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-running-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: "43",
|
||||
worktree: {
|
||||
branchName: "feature-running-setup",
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
||||
snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: "43",
|
||||
worktree: {
|
||||
branchName: "feature-running-setup",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
slug: "feature-running-setup",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
slug: "feature-running-setup",
|
||||
worktreePath,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages.length).toBeGreaterThan(1);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: "43",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
|
||||
const progressMessages = emitted.filter(
|
||||
(
|
||||
message,
|
||||
): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages.length).toBeGreaterThan(1);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: "43",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
|
||||
|
||||
const runningMessages = progressMessages.filter(
|
||||
(message) => message.payload.status === "running",
|
||||
);
|
||||
expect(runningMessages.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
progressMessages.findIndex((message) => message.payload.status === "running"),
|
||||
).toBeLessThan(progressMessages.findIndex((message) => message.payload.status === "completed"));
|
||||
const runningMessages = progressMessages.filter(
|
||||
(message) => message.payload.status === "running",
|
||||
);
|
||||
expect(runningMessages.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
progressMessages.findIndex((message) => message.payload.status === "running"),
|
||||
).toBeLessThan(
|
||||
progressMessages.findIndex((message) => message.payload.status === "completed"),
|
||||
);
|
||||
|
||||
const setupOutputMessage = runningMessages.find((message) =>
|
||||
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
||||
);
|
||||
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
|
||||
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
|
||||
log: expect.stringContaining("phase-one"),
|
||||
status: "running",
|
||||
});
|
||||
const setupOutputMessage = runningMessages.find((message) =>
|
||||
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
||||
);
|
||||
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
|
||||
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
|
||||
log: expect.stringContaining("phase-one"),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
expect(progressMessages.at(-1)?.payload).toMatchObject({
|
||||
workspaceId: "43",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
|
||||
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
|
||||
log: expect.stringContaining("phase-two"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(snapshots.get("43")).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload).toMatchObject({
|
||||
workspaceId: "43",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
|
||||
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
|
||||
log: expect.stringContaining("phase-two"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(snapshots.get("43")).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
@@ -1199,7 +1209,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = execSync("git branch --show-current", { cwd: worktreePath, stdio: "pipe" })
|
||||
const branch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branch).toBe("feature/review-pr");
|
||||
@@ -1248,7 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
|
||||
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,
|
||||
stdio: "pipe",
|
||||
})
|
||||
@@ -1445,7 +1458,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
baseBranch: "main",
|
||||
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,
|
||||
}),
|
||||
);
|
||||
expect(backgroundWork).toHaveBeenCalledWith(
|
||||
const backgroundInput = backgroundWork.mock.calls[0]?.[0];
|
||||
expect(backgroundInput).toEqual(
|
||||
expect.objectContaining({
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "response-after-create",
|
||||
worktreePath: registeredWorktreePath,
|
||||
@@ -1588,6 +1603,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
shouldBootstrap: true,
|
||||
}),
|
||||
);
|
||||
expect(realpathSync.native(backgroundInput?.repoRoot ?? "")).toBe(
|
||||
realpathSync.native(repoDir),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { it, expect, afterEach } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.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 { tmpdir } from "node:os";
|
||||
|
||||
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
|
||||
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
timeoutMs: number,
|
||||
@@ -19,47 +24,52 @@ async function waitForCondition(
|
||||
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;
|
||||
const temporaryDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
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();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
while (temporaryDirs.length > 0) {
|
||||
const dir = temporaryDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
try {
|
||||
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 () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const terminals = await manager.getTerminals(cwd);
|
||||
|
||||
expect(terminals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns existing terminals on subsequent calls", async () => {
|
||||
manager = createTerminalManager();
|
||||
const created = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const first = await manager.getTerminals("/tmp");
|
||||
const second = await manager.getTerminals("/tmp");
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const created = await manager.createTerminal({ cwd });
|
||||
const first = await manager.getTerminals(cwd);
|
||||
const second = await manager.getTerminals(cwd);
|
||||
|
||||
expect(first.length).toBe(1);
|
||||
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 () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
||||
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
|
||||
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-"));
|
||||
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(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 () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const cwd = realpathSync(tmpdir());
|
||||
await manager.createTerminal({ cwd });
|
||||
const second = await manager.createTerminal({ cwd });
|
||||
|
||||
expect(second.name).toBe("Terminal 2");
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const terminals = await manager.getTerminals(cwd);
|
||||
expect(terminals.length).toBe(2);
|
||||
});
|
||||
|
||||
it("uses custom name when provided", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("creates first terminal if none exist", async () => {
|
||||
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");
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const terminals = await manager.getTerminals(cwd);
|
||||
expect(terminals.length).toBe(1);
|
||||
expect(terminals[0].id).toBe(session.id);
|
||||
});
|
||||
@@ -123,71 +138,67 @@ it("throws for relative paths", async () => {
|
||||
|
||||
it("does not reject Windows absolute paths as relative", async () => {
|
||||
manager = createTerminalManager();
|
||||
// Should pass path validation (not throw "cwd must be absolute path").
|
||||
// The terminal may or may not spawn successfully on non-Windows hosts,
|
||||
// so we only assert the validation error is absent.
|
||||
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-absolute-path-"));
|
||||
temporaryDirs.push(cwd);
|
||||
|
||||
try {
|
||||
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
|
||||
await manager.createTerminal({ cwd });
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toBe("cwd must be absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
it("inherits registered env for the worktree root cwd", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const markerPath = join(cwd, "root-port.txt");
|
||||
manager = createTerminalManager();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const markerPath = join(cwd, "root-port.txt");
|
||||
|
||||
manager.registerCwdEnv({
|
||||
cwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45678" },
|
||||
});
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
||||
session.send({
|
||||
type: "input",
|
||||
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45678");
|
||||
manager.registerCwdEnv({
|
||||
cwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45678" },
|
||||
});
|
||||
await manager.createTerminal({
|
||||
cwd,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
|
||||
],
|
||||
});
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45678");
|
||||
});
|
||||
|
||||
it("inherits registered env for subdirectories within the worktree", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
|
||||
const subdirCwd = join(rootCwd, "packages", "app");
|
||||
mkdirSync(subdirCwd, { recursive: true });
|
||||
temporaryDirs.push(rootCwd);
|
||||
const markerPath = join(subdirCwd, "subdir-port.txt");
|
||||
manager = createTerminalManager();
|
||||
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
|
||||
const subdirCwd = join(rootCwd, "packages", "app");
|
||||
mkdirSync(subdirCwd, { recursive: true });
|
||||
temporaryDirs.push(rootCwd);
|
||||
const markerPath = join(subdirCwd, "subdir-port.txt");
|
||||
|
||||
manager.registerCwdEnv({
|
||||
cwd: rootCwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45679" },
|
||||
});
|
||||
const session = await manager.createTerminal({ cwd: subdirCwd });
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
||||
session.send({
|
||||
type: "input",
|
||||
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45679");
|
||||
manager.registerCwdEnv({
|
||||
cwd: rootCwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45679" },
|
||||
});
|
||||
await manager.createTerminal({
|
||||
cwd: subdirCwd,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
|
||||
],
|
||||
});
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45679");
|
||||
});
|
||||
|
||||
it("returns terminal by id", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const found = manager.getTerminal(session.id);
|
||||
|
||||
expect(found).toBe(session);
|
||||
@@ -202,7 +213,7 @@ it("returns undefined for unknown id", () => {
|
||||
|
||||
it("removes terminal from manager", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
const id = session.id;
|
||||
|
||||
manager.killTerminal(id);
|
||||
@@ -212,24 +223,26 @@ it("removes terminal from manager", async () => {
|
||||
|
||||
it("removes cwd entry when last terminal is killed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const created = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const created = await manager.createTerminal({ cwd });
|
||||
manager.killTerminal(created.id);
|
||||
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
const remaining = await manager.getTerminals(cwd);
|
||||
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 () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const cwd = realpathSync(tmpdir());
|
||||
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);
|
||||
|
||||
expect(manager.listDirectories()).toContain("/tmp");
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
expect(manager.listDirectories()).toContain(cwd);
|
||||
const remaining = await manager.getTerminals(cwd);
|
||||
expect(remaining.length).toBe(1);
|
||||
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 () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
const exitedId = session.id;
|
||||
session.kill();
|
||||
|
||||
@@ -249,7 +263,7 @@ it("auto-removes terminal when shell exits", async () => {
|
||||
|
||||
expect(manager.getTerminal(exitedId)).toBeUndefined();
|
||||
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
const remaining = await manager.getTerminals(cwd);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -260,19 +274,25 @@ it("returns empty array initially", () => {
|
||||
|
||||
it("returns all cwds with active terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
await manager.createTerminal({ cwd: "/home" });
|
||||
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-"));
|
||||
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();
|
||||
expect(dirs).toContain("/tmp");
|
||||
expect(dirs).toContain("/home");
|
||||
expect(dirs).toContain(firstCwd);
|
||||
expect(dirs).toContain(secondCwd);
|
||||
expect(dirs.length).toBe(2);
|
||||
});
|
||||
|
||||
it("kills all terminals and clears state", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const homeSession = await manager.createTerminal({ cwd: "/home" });
|
||||
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-"));
|
||||
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 homeId = homeSession.id;
|
||||
|
||||
@@ -285,6 +305,7 @@ it("kills all terminals and clears state", async () => {
|
||||
|
||||
it("emits cwd snapshots when terminals are created", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push({
|
||||
@@ -296,15 +317,15 @@ it("emits cwd snapshots when terminals are created", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
||||
await manager.createTerminal({ cwd });
|
||||
await manager.createTerminal({ cwd, name: "Dev Server" });
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
cwd,
|
||||
terminals: [{ name: "Terminal 1" }],
|
||||
});
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
cwd,
|
||||
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 () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: TerminalTitleEntry[][] = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push(input.terminals.map(toTitleEntry));
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
|
||||
|
||||
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
|
||||
|
||||
unsubscribe();
|
||||
manager = createTerminalManager();
|
||||
const snapshots: TerminalTitleEntry[][] = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push(input.terminals.map(toTitleEntry));
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({
|
||||
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);
|
||||
|
||||
unsubscribe();
|
||||
}, 10000);
|
||||
|
||||
it("emits empty snapshot when last terminal is removed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = realpathSync(tmpdir());
|
||||
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
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);
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
cwd,
|
||||
terminalCount: 0,
|
||||
});
|
||||
|
||||
|
||||
1171
packages/server/src/terminal/terminal.posix.test.ts
Normal file
1171
packages/server/src/terminal/terminal.posix.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3
packages/server/src/test-utils/platform.ts
Normal file
3
packages/server/src/test-utils/platform.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isPlatform(...platforms: NodeJS.Platform[]): boolean {
|
||||
return platforms.includes(process.platform);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 repoDir = join(tempDir, "repo");
|
||||
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
execSync("git init -b main", { cwd: repoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
|
||||
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,10 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSyn
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const isWindows = isPlatform("win32");
|
||||
|
||||
describe("searchHomeDirectories", () => {
|
||||
let tempRoot: string;
|
||||
@@ -18,6 +19,8 @@ describe("searchHomeDirectories", () => {
|
||||
|
||||
mkdirSync(homeDir, { 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", "playground"), { recursive: true });
|
||||
@@ -26,7 +29,9 @@ describe("searchHomeDirectories", () => {
|
||||
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
||||
|
||||
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
||||
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
||||
if (!isWindows) {
|
||||
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,8 +55,9 @@ describe("searchHomeDirectories", () => {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(results).toContain(path.join(homeDir, "projects"));
|
||||
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
||||
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||
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"));
|
||||
});
|
||||
|
||||
@@ -62,7 +68,9 @@ describe("searchHomeDirectories", () => {
|
||||
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 () => {
|
||||
@@ -77,8 +85,9 @@ describe("searchHomeDirectories", () => {
|
||||
limit: 30,
|
||||
});
|
||||
|
||||
const exactIndex = results.indexOf(exactSegmentPath);
|
||||
const prefixIndex = results.indexOf(prefixSegmentPath);
|
||||
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||
const exactIndex = resolvedResults.indexOf(realpathSync.native(exactSegmentPath));
|
||||
const prefixIndex = resolvedResults.indexOf(realpathSync.native(prefixSegmentPath));
|
||||
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(exactIndex).toBeLessThan(prefixIndex);
|
||||
@@ -96,8 +105,9 @@ describe("searchHomeDirectories", () => {
|
||||
limit: 30,
|
||||
});
|
||||
|
||||
const earlierIndex = results.indexOf(earlierPath);
|
||||
const laterIndex = results.indexOf(laterPath);
|
||||
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||
const earlierIndex = resolvedResults.indexOf(realpathSync.native(earlierPath));
|
||||
const laterIndex = resolvedResults.indexOf(realpathSync.native(laterPath));
|
||||
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(earlierIndex).toBeLessThan(laterIndex);
|
||||
@@ -125,7 +135,8 @@ describe("searchHomeDirectories", () => {
|
||||
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({
|
||||
homeDir,
|
||||
query: "outside",
|
||||
@@ -170,7 +181,9 @@ describe("searchWorkspaceEntries", () => {
|
||||
);
|
||||
writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n");
|
||||
|
||||
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
|
||||
if (!isWindows) {
|
||||
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -214,28 +227,32 @@ describe("searchWorkspaceEntries", () => {
|
||||
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("supports path-style queries and does not escape cwd through symlinks", async () => {
|
||||
const pathResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "src/co",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
expect(pathResults).toContainEqual({
|
||||
path: "src/components",
|
||||
kind: "directory",
|
||||
});
|
||||
// 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({
|
||||
cwd: workspaceDir,
|
||||
query: "src/co",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
expect(pathResults).toContainEqual({
|
||||
path: "src/components",
|
||||
kind: "directory",
|
||||
});
|
||||
|
||||
const escapedResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "escaped",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
|
||||
});
|
||||
const escapedResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "escaped",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
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 () => {
|
||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
||||
|
||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { probeExecutable } from "./executable.js";
|
||||
|
||||
const timeoutMs = 1000;
|
||||
@@ -142,18 +143,40 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("probeExecutable", () => {
|
||||
test.each(fixtures)("$name", async ({ create, expected }) => {
|
||||
const { executablePath, pidFile } = create(makeTempDir());
|
||||
const startedAt = performance.now();
|
||||
// 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 startedAt = performance.now();
|
||||
|
||||
const result = await probeExecutable(executablePath, timeoutMs);
|
||||
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" }));
|
||||
}
|
||||
});
|
||||
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" }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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" }));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
} from "./executable.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
const originalEnv = {
|
||||
PATH: process.env.PATH,
|
||||
@@ -28,24 +29,16 @@ function prependPath(...dirs: string[]): void {
|
||||
|
||||
function writeExecutable(filePath: string, content: string): string {
|
||||
writeFileSync(filePath, content);
|
||||
if (process.platform !== "win32") {
|
||||
if (!isPlatform("win32")) {
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
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 {
|
||||
const filePath =
|
||||
process.platform === "win32" ? path.join(dir, "broken.exe") : path.join(dir, "broken");
|
||||
const filePath = isPlatform("win32") ? path.join(dir, "broken.exe") : path.join(dir, "broken");
|
||||
writeFileSync(filePath, "not executable");
|
||||
if (process.platform !== "win32") {
|
||||
if (!isPlatform("win32")) {
|
||||
chmodSync(filePath, 0o644);
|
||||
}
|
||||
return filePath;
|
||||
@@ -64,7 +57,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const executableDir = 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 () => {
|
||||
const dir = makeTempDir();
|
||||
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
|
||||
@@ -110,10 +103,7 @@ describe("findExecutable", () => {
|
||||
});
|
||||
|
||||
test("returns an invokable absolute path", async () => {
|
||||
const dir = makeTempDir();
|
||||
const fixture = writeInvokableFixture(dir, "absolute-ok");
|
||||
|
||||
await expect(findExecutable(fixture)).resolves.toBe(fixture);
|
||||
await expect(findExecutable(process.execPath)).resolves.toBe(process.execPath);
|
||||
});
|
||||
|
||||
test("returns null for an absolute path that cannot spawn", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
@@ -97,26 +98,30 @@ describe("paseo config file substrate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stale writes when the current revision changed before rename", () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
|
||||
const currentRevision = statPaseoConfigPath(tempDir);
|
||||
// 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" } }));
|
||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
|
||||
const currentRevision = statPaseoConfigPath(tempDir);
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup: "from editor" } },
|
||||
expectedRevision,
|
||||
});
|
||||
const result = writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup: "from editor" } },
|
||||
expectedRevision,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "stale_project_config", currentRevision },
|
||||
});
|
||||
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
||||
JSON.stringify({ worktree: { setup: "new" } }),
|
||||
);
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "stale_project_config", currentRevision },
|
||||
});
|
||||
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
||||
JSON.stringify({ worktree: { setup: "new" } }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
|
||||
const config = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { spawnProcess } from "./spawn.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
|
||||
interface SpawnResult {
|
||||
code: number | null;
|
||||
@@ -136,10 +137,9 @@ async function runFixture(params: {
|
||||
}
|
||||
|
||||
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
|
||||
const pathKey =
|
||||
process.platform === "win32"
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
|
||||
: "PATH";
|
||||
const pathKey = isPlatform("win32")
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
|
||||
: "PATH";
|
||||
const previousPath = process.env[pathKey];
|
||||
const previousPathExt = process.env.PATHEXT;
|
||||
|
||||
@@ -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 () => {
|
||||
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 () => {
|
||||
const fixture = makeFixture();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("execCommand", () => {
|
||||
|
||||
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("");
|
||||
});
|
||||
|
||||
|
||||
1125
packages/server/src/utils/worktree.posix.test.ts
Normal file
1125
packages/server/src/utils/worktree.posix.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user