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
|
run: npm run typecheck
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
name: server-tests (${{ matrix.os }})
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -95,50 +100,6 @@ jobs:
|
|||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
server-tests-windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run Windows-critical server tests
|
|
||||||
working-directory: packages/server
|
|
||||||
run: >
|
|
||||||
npx vitest run
|
|
||||||
src/utils/executable.probe.test.ts
|
|
||||||
src/utils/executable.test.ts
|
|
||||||
src/utils/spawn.launch-regression.test.ts
|
|
||||||
src/utils/spawn.percent-escape.test.ts
|
|
||||||
src/utils/spawn.test.ts
|
|
||||||
src/utils/tree-kill.test.ts
|
|
||||||
src/utils/run-git-command.test.ts
|
|
||||||
src/utils/checkout-git-rev-parse.test.ts
|
|
||||||
src/terminal/worker-terminal-manager.test.ts
|
|
||||||
src/server/agent/provider-registry.test.ts
|
|
||||||
src/server/agent/provider-launch-config.test.ts
|
|
||||||
src/server/agent/provider-snapshot-manager.test.ts
|
|
||||||
src/server/agent/providers/claude-agent.spawn.test.ts
|
|
||||||
src/server/agent/providers/provider-windows-launch.test.ts
|
|
||||||
src/server/agent/providers/provider-availability.test.ts
|
|
||||||
src/server/workspace-registry-model.test.ts
|
|
||||||
src/server/persisted-config.test.ts
|
|
||||||
src/server/bootstrap-provider-availability.test.ts
|
|
||||||
|
|
||||||
desktop-tests:
|
desktop-tests:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { isPlatform } from "../src/test-utils/platform.js";
|
||||||
|
|
||||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||||
@@ -116,7 +117,10 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.log).toContain("raw stderr line\n");
|
expect(result.log).toContain("raw stderr line\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logs worker signal exits even when the worker cannot log", async () => {
|
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"logs worker signal exits even when the worker cannot log",
|
||||||
|
async () => {
|
||||||
const result = await runSupervisorFixture({
|
const result = await runSupervisorFixture({
|
||||||
workerSource: `
|
workerSource: `
|
||||||
process.kill(process.pid, "SIGKILL");
|
process.kill(process.pid, "SIGKILL");
|
||||||
@@ -128,5 +132,6 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.log).toContain('"msg":"Worker exited"');
|
expect(result.log).toContain('"msg":"Worker exited"');
|
||||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||||
expect(result.log).toContain("Supervisor exiting");
|
expect(result.log).toContain("Supervisor exiting");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join, resolve as resolvePath } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
|||||||
import type { GitHubService } from "../../services/github-service.js";
|
import type { GitHubService } from "../../services/github-service.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
|
|
||||||
|
const REPO_CWD = resolvePath("/tmp/repo");
|
||||||
|
const TARGET_CWD = resolvePath("/tmp/target");
|
||||||
|
|
||||||
interface LooseSafeParseResult {
|
interface LooseSafeParseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
@@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-123",
|
id: "agent-123",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-456",
|
id: "agent-456",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-789",
|
id: "agent-789",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => {
|
|||||||
const startedAgentSetupIds: string[] = [];
|
const startedAgentSetupIds: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||||
id: "agent-with-worktree",
|
id: "agent-with-worktree",
|
||||||
@@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||||
id: "agent-auto-named-worktree",
|
id: "agent-auto-named-worktree",
|
||||||
@@ -798,7 +814,10 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||||
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
cwd: agentCwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
expect(initialBranch).not.toBe("");
|
expect(initialBranch).not.toBe("");
|
||||||
@@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "-b", "existing-feature"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||||
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||||
id: "agent-checkout-worktree",
|
id: "agent-checkout-worktree",
|
||||||
@@ -871,7 +899,9 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||||
expect(
|
expect(
|
||||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
|
||||||
|
.toString()
|
||||||
|
.trim(),
|
||||||
).toBe("existing-feature");
|
).toBe("existing-feature");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
@@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
workspaceId: "/tmp/worktrees/pr-123",
|
workspaceId: "/tmp/worktrees/pr-123",
|
||||||
projectId: "/tmp/repo",
|
projectId: REPO_CWD,
|
||||||
cwd: "/tmp/worktrees/pr-123",
|
cwd: "/tmp/worktrees/pr-123",
|
||||||
kind: "worktree" as const,
|
kind: "worktree" as const,
|
||||||
displayName: "pr-123",
|
displayName: "pr-123",
|
||||||
@@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
},
|
},
|
||||||
repoRoot: "/tmp/repo",
|
repoRoot: REPO_CWD,
|
||||||
created: true,
|
created: true,
|
||||||
...(options?.setupContinuation?.kind === "agent"
|
...(options?.setupContinuation?.kind === "agent"
|
||||||
? {
|
? {
|
||||||
@@ -949,7 +979,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
await tool.callback({
|
await tool.callback({
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
title: "PR agent",
|
title: "PR agent",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "Rename this PR branch from prompt",
|
initialPrompt: "Rename this PR branch from prompt",
|
||||||
@@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => {
|
|||||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
const workspaceGitService = {
|
const workspaceGitService = {
|
||||||
getSnapshot: vi.fn(async () => null),
|
getSnapshot: vi.fn(async () => null),
|
||||||
};
|
};
|
||||||
@@ -1031,19 +1067,27 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
const tempDir = realpathSync.native(
|
||||||
|
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
||||||
|
);
|
||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
const paseoHome = join(tempDir, ".paseo");
|
const paseoHome = join(tempDir, ".paseo");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const workspaceGitService = {
|
const workspaceGitService = {
|
||||||
getSnapshot: vi.fn(async () => null),
|
getSnapshot: vi.fn(async () => null),
|
||||||
@@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_worktrees");
|
const tool = registeredTool(server, "list_worktrees");
|
||||||
|
|
||||||
const response = await tool.callback({ cwd: "/tmp/repo" });
|
const response = await tool.callback({ cwd: REPO_CWD });
|
||||||
|
|
||||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
|
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
|
||||||
reason: "mcp:list-worktrees",
|
reason: "mcp:list-worktrees",
|
||||||
});
|
});
|
||||||
expect(response.structuredContent.worktrees).toEqual([
|
expect(response.structuredContent.worktrees).toEqual([
|
||||||
@@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "agent-injected-123",
|
id: "agent-injected-123",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "agent-compact",
|
id: "agent-compact",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
||||||
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
||||||
labels: { role: "researcher" },
|
labels: { role: "researcher" },
|
||||||
@@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
@@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
spies.agentManager.listAgents.mockReturnValue([
|
spies.agentManager.listAgents.mockReturnValue([
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "running-target",
|
id: "running-target",
|
||||||
cwd: "/tmp/target",
|
cwd: TARGET_CWD,
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
updatedAt: new Date(recent),
|
updatedAt: new Date(recent),
|
||||||
}),
|
}),
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "idle-target",
|
id: "idle-target",
|
||||||
cwd: "/tmp/target",
|
cwd: TARGET_CWD,
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
updatedAt: new Date(recent),
|
updatedAt: new Date(recent),
|
||||||
}),
|
}),
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "old-running-target",
|
id: "old-running-target",
|
||||||
cwd: "/tmp/target",
|
cwd: TARGET_CWD,
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
createdAt: new Date(old),
|
createdAt: new Date(old),
|
||||||
updatedAt: new Date(old),
|
updatedAt: new Date(old),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
spies.agentStorage.list.mockResolvedValue([
|
spies.agentStorage.list.mockResolvedValue([
|
||||||
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
|
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
||||||
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
|
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
||||||
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
|
createStoredRecord({
|
||||||
|
id: "recent-other-cwd",
|
||||||
|
cwd: resolvePath("/tmp/other"),
|
||||||
|
archivedAt: recent,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -1950,7 +1998,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.callback({
|
const response = await tool.callback({
|
||||||
cwd: "/tmp/target",
|
cwd: TARGET_CWD,
|
||||||
includeArchived: true,
|
includeArchived: true,
|
||||||
sinceHours: 48,
|
sinceHours: 48,
|
||||||
statuses: ["running", "closed"],
|
statuses: ["running", "closed"],
|
||||||
@@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
spies.agentStorage.list.mockResolvedValue([
|
spies.agentStorage.list.mockResolvedValue([
|
||||||
createStoredRecord({
|
createStoredRecord({
|
||||||
id: "stored-archived-compact",
|
id: "stored-archived-compact",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
archivedAt: now,
|
archivedAt: now,
|
||||||
@@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_agents");
|
const tool = registeredTool(server, "list_agents");
|
||||||
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
|
const response = await tool.callback({ cwd: REPO_CWD, includeArchived: true });
|
||||||
const item = agentsOf(response)[0];
|
const item = agentsOf(response)[0];
|
||||||
|
|
||||||
expect(item).toEqual({
|
expect(item).toEqual({
|
||||||
@@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
effectiveThinkingOptionId: null,
|
effectiveThinkingOptionId: null,
|
||||||
status: "closed",
|
status: "closed",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
createdAt: "2026-04-11T00:00:00.000Z",
|
createdAt: "2026-04-11T00:00:00.000Z",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
|
|||||||
@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(event.item.text).not.toContain("data:image");
|
expect(event.item.text).not.toContain("data:image");
|
||||||
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
||||||
const source = markdownImageSource(event.item.text);
|
const source = markdownImageSource(event.item.text);
|
||||||
expect(source).toMatch(/paseo-attachments\/.+\.png$/);
|
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
|
||||||
expect(existsSync(source)).toBe(true);
|
expect(existsSync(source)).toBe(true);
|
||||||
rmSync(source, { force: true });
|
rmSync(source, { force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
@@ -31,19 +31,6 @@ function isolatePathTo(dir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeProviderShim(dir: string, command: string): string {
|
|
||||||
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
|
|
||||||
const content =
|
|
||||||
process.platform === "win32"
|
|
||||||
? `@echo off\r\necho ${command} 1.0\r\n`
|
|
||||||
: `#!/bin/sh\necho ${command} 1.0\n`;
|
|
||||||
writeFileSync(filePath, content);
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
chmodSync(filePath, 0o755);
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.PATH = originalEnv.PATH;
|
process.env.PATH = originalEnv.PATH;
|
||||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
@@ -77,24 +64,6 @@ describe("default provider availability", () => {
|
|||||||
await expect(client.isAvailable()).resolves.toBe(false);
|
await expect(client.isAvailable()).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Codex reports available when the default command resolves from PATH", async () => {
|
|
||||||
const binDir = makeTempDir("provider-availability-codex-");
|
|
||||||
isolatePathTo(binDir);
|
|
||||||
writeProviderShim(binDir, "codex");
|
|
||||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
|
||||||
|
|
||||||
await expect(client.isAvailable()).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("OpenCode reports available when the default command resolves from PATH", async () => {
|
|
||||||
const binDir = makeTempDir("provider-availability-opencode-");
|
|
||||||
isolatePathTo(binDir);
|
|
||||||
writeProviderShim(binDir, "opencode");
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
|
||||||
|
|
||||||
await expect(client.isAvailable()).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AgentManager reports Codex unavailable without throwing", async () => {
|
test("AgentManager reports Codex unavailable without throwing", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||||
isolatePathTo(binDir);
|
isolatePathTo(binDir);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
|
|||||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
describe("paseo daemon bootstrap", () => {
|
describe("paseo daemon bootstrap", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -152,7 +153,10 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates a relay pairing offer for unix socket listeners", async () => {
|
// POSIX-only: Unix socket listen paths are invalid Windows listen targets.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"generates a relay pairing offer for unix socket listeners",
|
||||||
|
async () => {
|
||||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
@@ -199,5 +203,6 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||||
await rm(staticDir, { recursive: true, force: true });
|
await rm(staticDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
|
|||||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
||||||
|
import { isPlatform } from "../../test-utils/platform.js";
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
import type { SessionOutboundMessage } from "../messages.js";
|
import type { SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
@@ -220,7 +221,10 @@ test("returns error for non-git directory", async () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60000); // 1 minute timeout
|
}, 60000); // 1 minute timeout
|
||||||
|
|
||||||
test("returns repo info for git repo with branch and dirty state", async () => {
|
// POSIX-only: asserts repo-root containment across macOS /var symlink normalization.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"returns repo info for git repo with branch and dirty state",
|
||||||
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// Initialize git repo
|
// Initialize git repo
|
||||||
@@ -267,7 +271,9 @@ test("returns repo info for git repo with branch and dirty state", async () => {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60000); // 1 minute timeout
|
},
|
||||||
|
60000,
|
||||||
|
); // 1 minute timeout
|
||||||
|
|
||||||
test("returns clean state when no uncommitted changes", async () => {
|
test("returns clean state when no uncommitted changes", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|||||||
@@ -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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
import { readExplorerFile } from "./service.js";
|
||||||
|
|
||||||
async function createHomeTempDir(prefix: string): Promise<string> {
|
async function createHomeTempDir(prefix: string): Promise<string> {
|
||||||
return mkdtemp(path.join(os.homedir(), prefix));
|
return mkdtemp(path.join(os.homedir(), prefix));
|
||||||
@@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("file explorer service", () => {
|
describe("file explorer service", () => {
|
||||||
it("lists directory entries even when a dangling symlink exists", async () => {
|
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mkdir(path.join(root, "packages", "server"), { recursive: true });
|
|
||||||
const serverDir = path.join(root, "packages", "server");
|
|
||||||
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
|
|
||||||
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
|
|
||||||
|
|
||||||
const result = await listDirectoryEntries({
|
|
||||||
root,
|
|
||||||
relativePath: "packages/server",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.path).toBe("packages/server");
|
|
||||||
const names = result.entries.map((entry) => entry.name);
|
|
||||||
expect(names).toContain("README.md");
|
|
||||||
expect(names).not.toContain("AGENTS.md");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads .ex files as text", async () => {
|
it("reads .ex files as text", async () => {
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
@@ -135,25 +112,4 @@ describe("file explorer service", () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects symlinked files that resolve outside the workspace", async () => {
|
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
|
||||||
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const externalFile = path.join(outsideRoot, "secret.txt");
|
|
||||||
await writeFile(externalFile, "top secret\n", "utf-8");
|
|
||||||
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
readExplorerFile({
|
|
||||||
root,
|
|
||||||
relativePath: "secret-link.txt",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Access outside of workspace is not allowed");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await rm(outsideRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
|
|||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
level: "debug",
|
level: "debug",
|
||||||
path: path.join(paseoHome, "logs", "programmatic.log"),
|
path: path.resolve(paseoHome, "logs", "programmatic.log"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
@@ -24,6 +32,7 @@ import type {
|
|||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
|
|
||||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -220,19 +229,25 @@ describe("LoopService", () => {
|
|||||||
let storage: AgentStorage;
|
let storage: AgentStorage;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-"));
|
tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-")));
|
||||||
paseoHome = path.join(tmpDir, "paseo-home");
|
paseoHome = path.join(tmpDir, "paseo-home");
|
||||||
workspaceDir = path.join(tmpDir, "workspace");
|
workspaceDir = path.join(tmpDir, "workspace");
|
||||||
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
|
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
|
||||||
mkdirSync(workspaceDir, { recursive: true });
|
mkdirSync(workspaceDir, { recursive: true });
|
||||||
|
workspaceDir = realpathSync.native(workspaceDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runs fresh worker agents until verify-check passes", async () => {
|
// POSIX-only: real worker agent spawns a PTY whose Windows ConPTY path resolution still fails (error 267) after realpathSync; revisit when we have a Windows dev box.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"runs fresh worker agents until verify-check passes",
|
||||||
|
async () => {
|
||||||
const state = { workerRuns: 0 };
|
const state = { workerRuns: 0 };
|
||||||
|
const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
|
||||||
|
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
claude: new ScriptedAgentClient("claude", {
|
claude: new ScriptedAgentClient("claude", {
|
||||||
@@ -257,7 +272,9 @@ describe("LoopService", () => {
|
|||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
prompt: "Create done.txt when the task is actually fixed.",
|
prompt: "Create done.txt when the task is actually fixed.",
|
||||||
cwd: workspaceDir,
|
cwd: workspaceDir,
|
||||||
verifyChecks: ["test -f done.txt"],
|
verifyChecks: [
|
||||||
|
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
|
||||||
|
],
|
||||||
sleepMs: 1,
|
sleepMs: 1,
|
||||||
maxIterations: 3,
|
maxIterations: 3,
|
||||||
});
|
});
|
||||||
@@ -267,13 +284,16 @@ describe("LoopService", () => {
|
|||||||
const finalLoop = await service.inspectLoop(loop.id);
|
const finalLoop = await service.inspectLoop(loop.id);
|
||||||
expect(finalLoop.status).toBe("succeeded");
|
expect(finalLoop.status).toBe("succeeded");
|
||||||
expect(finalLoop.iterations).toHaveLength(2);
|
expect(finalLoop.iterations).toHaveLength(2);
|
||||||
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
|
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(
|
||||||
|
finalLoop.iterations[1]?.workerAgentId,
|
||||||
|
);
|
||||||
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
expect(finalLoop.iterations[0]?.status).toBe("failed");
|
||||||
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
|
||||||
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
|
||||||
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
|
||||||
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("uses worker and verifier provider-model settings when provided", async () => {
|
test("uses worker and verifier provider-model settings when provided", async () => {
|
||||||
const workerConfigs: AgentSessionConfig[] = [];
|
const workerConfigs: AgentSessionConfig[] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type CreatePaseoWorktreeDeps,
|
type CreatePaseoWorktreeDeps,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
@@ -65,7 +66,10 @@ test("creates a worktree and registers it in the source workspace project withou
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reuses an existing worktree and still upserts the workspace", async () => {
|
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"reuses an existing worktree and still upserts the workspace",
|
||||||
|
async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const paseoHome = path.join(tempDir, ".paseo");
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
@@ -99,7 +103,8 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
|
|||||||
expect(second.created).toBe(false);
|
expect(second.created).toBe(false);
|
||||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
@@ -131,7 +136,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
|||||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||||
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
||||||
});
|
});
|
||||||
const branchAfterFirst = execSync("git branch --show-current", {
|
const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -157,7 +162,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
|||||||
firstAgentContext: { prompt: "Try another name" },
|
firstAgentContext: { prompt: "Try another name" },
|
||||||
generateBranchNameFromContext: async () => "second-agent-name",
|
generateBranchNameFromContext: async () => "second-agent-name",
|
||||||
});
|
});
|
||||||
const branchAfterSecond = execSync("git branch --show-current", {
|
const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -196,7 +201,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branchAfter = execSync("git branch --show-current", {
|
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -253,7 +258,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branchAfter = execSync("git branch --show-current", {
|
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -286,7 +291,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
|||||||
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
execSync("git branch --show-current", {
|
execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -305,11 +310,11 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
|||||||
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
|
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const created = await createPaseoWorktree(
|
const created = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
@@ -334,7 +339,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||||
expect(
|
expect(
|
||||||
execSync("git branch --show-current", {
|
execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -370,7 +375,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||||
expect(
|
expect(
|
||||||
execSync("git branch --show-current", {
|
execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -524,17 +529,24 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" })
|
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" })
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
|
const mainRepoRoot = execFileSync(
|
||||||
|
"git",
|
||||||
|
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||||
|
{
|
||||||
cwd,
|
cwd,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.toString()
|
.toString()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\/\.git$/, "");
|
.replace(/\/\.git$/, "");
|
||||||
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
|
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
@@ -566,34 +578,39 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
function createGitRepo(): { tempDir: string; repoDir: string } {
|
function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||||
const { tempDir, repoDir } = createGitRepo();
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" });
|
||||||
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
|
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
|
||||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
.toString()
|
||||||
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
|
.trim();
|
||||||
|
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["branch", "-D", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const remoteDir = path.join(tempDir, "remote.git");
|
const remoteDir = path.join(tempDir, "remote.git");
|
||||||
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
|
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
|
||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -22,16 +22,25 @@ function createWorkspaceRepo(options?: {
|
|||||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tempDir,
|
tempDir,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -12,16 +12,25 @@ function createWorkspaceRepo(options?: {
|
|||||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tempDir,
|
tempDir,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { ScriptRouteStore } from "./script-proxy.js";
|
import { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceScriptPayloads,
|
buildWorkspaceScriptPayloads,
|
||||||
@@ -21,16 +21,25 @@ function createWorkspaceRepo(options?: {
|
|||||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tempDir,
|
tempDir,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
asDaemonConfigStore,
|
asDaemonConfigStore,
|
||||||
createProviderSnapshotManagerStub,
|
createProviderSnapshotManagerStub,
|
||||||
} from "./test-utils/session-stubs.js";
|
} from "./test-utils/session-stubs.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
interface SessionHandlerInternals {
|
interface SessionHandlerInternals {
|
||||||
startVoiceTurnController(): Promise<void>;
|
startVoiceTurnController(): Promise<void>;
|
||||||
@@ -705,9 +706,15 @@ describe("project config RPC authorization", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("read_project_config_request accepts a symlink to an active project root", async () => {
|
// POSIX-only: creates a directory symlink without Windows privileges.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"read_project_config_request accepts a symlink to an active project root",
|
||||||
|
async () => {
|
||||||
const repoRoot = makeRoot();
|
const repoRoot = makeRoot();
|
||||||
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
writeFileSync(
|
||||||
|
join(repoRoot, "paseo.json"),
|
||||||
|
JSON.stringify({ worktree: { setup: "npm ci" } }),
|
||||||
|
);
|
||||||
const linkRoot = join(makeRoot(), "link");
|
const linkRoot = join(makeRoot(), "link");
|
||||||
symlinkSync(repoRoot, linkRoot, "dir");
|
symlinkSync(repoRoot, linkRoot, "dir");
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
@@ -737,7 +744,8 @@ describe("project config RPC authorization", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
||||||
const archivedRoot = makeRoot();
|
const archivedRoot = makeRoot();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { Session, type SessionOptions } from "./session.js";
|
import { Session, type SessionOptions } from "./session.js";
|
||||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||||
@@ -36,6 +37,9 @@ type WorkspaceUpdatePayload = Extract<
|
|||||||
{ type: "workspace_update" }
|
{ type: "workspace_update" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
|
||||||
|
const REPO_CWD = path.resolve("/tmp/repo");
|
||||||
|
const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`;
|
||||||
|
|
||||||
function createWorkspaceRuntimeSnapshot(
|
function createWorkspaceRuntimeSnapshot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
overrides?: {
|
overrides?: {
|
||||||
@@ -243,7 +247,7 @@ function seedGitWorkspace(input: {
|
|||||||
input.projectId,
|
input.projectId,
|
||||||
createPersistedProjectRecord({
|
createPersistedProjectRecord({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
rootPath: "/tmp/repo",
|
rootPath: input.cwd,
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
kind: "git",
|
kind: "git",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -274,7 +278,7 @@ describe("workspace git watch targets", () => {
|
|||||||
workspaces,
|
workspaces,
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
workspaceId: "ws-10",
|
workspaceId: "ws-10",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
name: "main",
|
name: "main",
|
||||||
});
|
});
|
||||||
sessionAny.workspaceUpdatesSubscription = {
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
@@ -289,22 +293,22 @@ describe("workspace git watch targets", () => {
|
|||||||
id: "ws-10",
|
id: "ws-10",
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: REPO_CWD,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "local_checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: "main",
|
name: "main",
|
||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
workspaceDirectory: "/tmp/repo",
|
workspaceDirectory: REPO_CWD,
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
||||||
|
|
||||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||||
|
|
||||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||||
{ cwd: "/tmp/repo" },
|
{ cwd: REPO_CWD },
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -314,7 +318,7 @@ describe("workspace git watch targets", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "renamed-branch",
|
currentBranch: "renamed-branch",
|
||||||
},
|
},
|
||||||
@@ -349,7 +353,7 @@ describe("workspace git watch targets", () => {
|
|||||||
workspaces,
|
workspaces,
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
workspaceId: "ws-10",
|
workspaceId: "ws-10",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
name: "main",
|
name: "main",
|
||||||
});
|
});
|
||||||
sessionAny.workspaceUpdatesSubscription = {
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
@@ -360,11 +364,11 @@ describe("workspace git watch targets", () => {
|
|||||||
lastEmittedByWorkspaceId: new Map(),
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||||
emitted.length = 0;
|
emitted.length = 0;
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "feature/server-push",
|
currentBranch: "feature/server-push",
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
@@ -380,9 +384,9 @@ describe("workspace git watch targets", () => {
|
|||||||
) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>;
|
) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>;
|
||||||
expect(statusUpdates).toHaveLength(1);
|
expect(statusUpdates).toHaveLength(1);
|
||||||
expect(statusUpdates[0]?.payload).toMatchObject({
|
expect(statusUpdates[0]?.payload).toMatchObject({
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
isGit: true,
|
isGit: true,
|
||||||
repoRoot: "/tmp/repo",
|
repoRoot: REPO_CWD,
|
||||||
currentBranch: "feature/server-push",
|
currentBranch: "feature/server-push",
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -393,10 +397,10 @@ describe("workspace git watch targets", () => {
|
|||||||
remoteUrl: "https://github.com/acme/repo.git",
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "subscription:/tmp/repo",
|
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
|
||||||
});
|
});
|
||||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||||
{ cwd: "/tmp/repo" },
|
{ cwd: REPO_CWD },
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -412,7 +416,7 @@ describe("workspace git watch targets", () => {
|
|||||||
workspaces,
|
workspaces,
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
workspaceId: "ws-10",
|
workspaceId: "ws-10",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
name: "main",
|
name: "main",
|
||||||
});
|
});
|
||||||
sessionAny.workspaceUpdatesSubscription = {
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
@@ -423,11 +427,11 @@ describe("workspace git watch targets", () => {
|
|||||||
lastEmittedByWorkspaceId: new Map(),
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
|
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||||
emitted.length = 0;
|
emitted.length = 0;
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
github: {
|
github: {
|
||||||
featuresEnabled: true,
|
featuresEnabled: true,
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
@@ -457,7 +461,7 @@ describe("workspace git watch targets", () => {
|
|||||||
| { payload: CheckoutStatusUpdatePayload }
|
| { payload: CheckoutStatusUpdatePayload }
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(statusUpdate?.payload.prStatus).toEqual({
|
expect(statusUpdate?.payload.prStatus).toEqual({
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: {
|
status: {
|
||||||
number: 456,
|
number: 456,
|
||||||
url: "https://github.com/acme/repo/pull/456",
|
url: "https://github.com/acme/repo/pull/456",
|
||||||
@@ -481,7 +485,7 @@ describe("workspace git watch targets", () => {
|
|||||||
},
|
},
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "subscription:/tmp/repo",
|
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
@@ -491,7 +495,7 @@ describe("workspace git watch targets", () => {
|
|||||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||||
|
|
||||||
workspaceGitService.getSnapshot.mockResolvedValue(
|
workspaceGitService.getSnapshot.mockResolvedValue(
|
||||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
github: {
|
github: {
|
||||||
featuresEnabled: true,
|
featuresEnabled: true,
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
@@ -508,15 +512,15 @@ describe("workspace git watch targets", () => {
|
|||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_status_request",
|
type: "checkout_pr_status_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
requestId: "req-pr-status",
|
requestId: "req-pr-status",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
|
||||||
expect(
|
expect(
|
||||||
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
|
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
|
||||||
).toEqual({
|
).toEqual({
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: {
|
status: {
|
||||||
number: undefined,
|
number: undefined,
|
||||||
url: "https://github.com/acme/repo/pull/456",
|
url: "https://github.com/acme/repo/pull/456",
|
||||||
@@ -543,12 +547,12 @@ describe("workspace git watch targets", () => {
|
|||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_status_request",
|
type: "checkout_pr_status_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
requestId: "req-pr-cached",
|
requestId: "req-pr-cached",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
|
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
|
||||||
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
|
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,6 +185,18 @@ function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const T0 = "2026-01-01T00:00:00.000Z";
|
const T0 = "2026-01-01T00:00:00.000Z";
|
||||||
|
const FOO = path.resolve("/foo");
|
||||||
|
const FOO_SUB = path.join(FOO, "sub");
|
||||||
|
const BAR = path.resolve("/bar");
|
||||||
|
const BAR_BAZ = path.join(BAR, "baz");
|
||||||
|
const TOOLBOX = path.resolve("/toolbox");
|
||||||
|
const TOOLBOX_FLOMO = path.join(TOOLBOX, "flomo-cli");
|
||||||
|
const USERS_DEVELOPER = path.resolve("/Users/me/Developer");
|
||||||
|
const USERS_PROJECT = path.join(USERS_DEVELOPER, "projects", "foo");
|
||||||
|
const PROJECTS = path.resolve("/projects");
|
||||||
|
const SOME_GIT_REPO = path.join(PROJECTS, "some-git-repo");
|
||||||
|
const PARENT = path.resolve("/parent");
|
||||||
|
const PARENT_CHILD = path.join(PARENT, "child");
|
||||||
|
|
||||||
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
|
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
|
||||||
return createPersistedWorkspaceRecord({
|
return createPersistedWorkspaceRecord({
|
||||||
@@ -240,13 +252,13 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
|
|||||||
// S1. Open a fresh git repo: creates a workspace at the canonical root.
|
// S1. Open a fresh git repo: creates a workspace at the canonical root.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
test("S1: open fresh git repo creates workspace at canonical root", async () => {
|
test("S1: open fresh git repo creates workspace at canonical root", async () => {
|
||||||
const h = createHarness({ gitRoots: ["/foo"] });
|
const h = createHarness({ gitRoots: [FOO] });
|
||||||
await openProject(h.session, "/foo");
|
await openProject(h.session, FOO);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.workspaceDirectory).toBe("/foo");
|
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
|
||||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||||
expect(h.workspaces.has("/foo")).toBe(true);
|
expect(h.workspaces.has(FOO)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -255,12 +267,12 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => {
|
test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => {
|
||||||
const h = createHarness({});
|
const h = createHarness({});
|
||||||
await openProject(h.session, "/bar");
|
await openProject(h.session, BAR);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.workspaceDirectory).toBe("/bar");
|
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
|
||||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||||
expect(h.workspaces.has("/bar")).toBe(true);
|
expect(h.workspaces.has(BAR)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -269,15 +281,15 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
test("S3: re-open active workspace by exact path returns the same record", async () => {
|
test("S3: re-open active workspace by exact path returns the same record", async () => {
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/foo")],
|
workspaces: [gitWorkspace(FOO)],
|
||||||
projects: [gitProject("/foo")],
|
projects: [gitProject(FOO)],
|
||||||
gitRoots: ["/foo"],
|
gitRoots: [FOO],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/foo");
|
await openProject(h.session, FOO);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.workspace?.id).toBe("/foo");
|
expect(resp?.workspace?.id).toBe(FOO);
|
||||||
expect(h.workspaces.size).toBe(1);
|
expect(h.workspaces.size).toBe(1);
|
||||||
expect(h.workspaces.get("/foo")?.archivedAt).toBeNull();
|
expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -286,13 +298,13 @@ test("S3: re-open active workspace by exact path returns the same record", async
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
|
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/foo")],
|
workspaces: [gitWorkspace(FOO)],
|
||||||
projects: [gitProject("/foo")],
|
projects: [gitProject(FOO)],
|
||||||
gitRoots: ["/foo"],
|
gitRoots: [FOO],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/foo/sub");
|
await openProject(h.session, FOO_SUB);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.workspace?.id).toBe("/foo");
|
expect(resp?.workspace?.id).toBe(FOO);
|
||||||
expect(h.workspaces.size).toBe(1);
|
expect(h.workspaces.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,14 +314,14 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => {
|
test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => {
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [dirWorkspace("/bar")],
|
workspaces: [dirWorkspace(BAR)],
|
||||||
projects: [dirProject("/bar")],
|
projects: [dirProject(BAR)],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/bar/baz");
|
await openProject(h.session, BAR_BAZ);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz");
|
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
|
||||||
expect(h.workspaces.has("/bar")).toBe(true);
|
expect(h.workspaces.has(BAR)).toBe(true);
|
||||||
expect(h.workspaces.has("/bar/baz")).toBe(true);
|
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
|
||||||
expect(h.workspaces.size).toBe(2);
|
expect(h.workspaces.size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,13 +332,13 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
|
|||||||
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
|
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
|
||||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||||
projects: [gitProject("/toolbox", archivedAt)],
|
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||||
gitRoots: ["/toolbox"],
|
gitRoots: [TOOLBOX],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/toolbox");
|
await openProject(h.session, TOOLBOX);
|
||||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
|
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||||
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
|
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -334,15 +346,15 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => {
|
test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => {
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/foo")],
|
workspaces: [gitWorkspace(FOO)],
|
||||||
projects: [gitProject("/foo")],
|
projects: [gitProject(FOO)],
|
||||||
gitRoots: ["/foo", "/foo/sub"],
|
gitRoots: [FOO, FOO_SUB],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/foo/sub");
|
await openProject(h.session, FOO_SUB);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub");
|
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||||
expect(h.workspaces.has("/foo")).toBe(true);
|
expect(h.workspaces.has(FOO)).toBe(true);
|
||||||
expect(h.workspaces.has("/foo/sub")).toBe(true);
|
expect(h.workspaces.has(FOO_SUB)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -353,12 +365,12 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
|
|||||||
test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => {
|
test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => {
|
||||||
const archivedAt = "2026-04-04T17:15:22.423Z";
|
const archivedAt = "2026-04-04T17:15:22.423Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)],
|
workspaces: [dirWorkspace(USERS_DEVELOPER, archivedAt)],
|
||||||
projects: [dirProject("/Users/me/Developer", archivedAt)],
|
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/Users/me/Developer/projects/foo");
|
await openProject(h.session, USERS_PROJECT);
|
||||||
expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt);
|
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
|
||||||
expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true);
|
expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -369,12 +381,12 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
|
|||||||
test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => {
|
test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => {
|
||||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||||
projects: [gitProject("/toolbox", archivedAt)],
|
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||||
gitRoots: ["/toolbox"],
|
gitRoots: [TOOLBOX],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/toolbox/flomo-cli");
|
await openProject(h.session, TOOLBOX_FLOMO);
|
||||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt);
|
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -389,18 +401,18 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
|
|||||||
test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => {
|
test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => {
|
||||||
const archivedAt = "2026-04-04T17:15:22.423Z";
|
const archivedAt = "2026-04-04T17:15:22.423Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [dirWorkspace("/projects", archivedAt)],
|
workspaces: [dirWorkspace(PROJECTS, archivedAt)],
|
||||||
projects: [dirProject("/projects", archivedAt)],
|
projects: [dirProject(PROJECTS, archivedAt)],
|
||||||
gitRoots: ["/projects/some-git-repo"],
|
gitRoots: [SOME_GIT_REPO],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/projects/some-git-repo");
|
await openProject(h.session, SOME_GIT_REPO);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.workspaceDirectory).toBe("/projects/some-git-repo");
|
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
|
||||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||||
expect(h.workspaces.has("/projects/some-git-repo")).toBe(true);
|
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
|
||||||
expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt);
|
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||||
expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt);
|
expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -411,19 +423,19 @@ test("S10: opening a git repo nested inside an archived non-git directory create
|
|||||||
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
|
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
|
||||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||||
projects: [gitProject("/toolbox", archivedAt)],
|
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||||
gitRoots: ["/toolbox"],
|
gitRoots: [TOOLBOX],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/toolbox");
|
await openProject(h.session, TOOLBOX);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.id).toBe("/toolbox");
|
expect(resp?.workspace?.id).toBe(TOOLBOX);
|
||||||
expect(resp?.workspace?.projectId).toBe("/toolbox");
|
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||||
expect(h.workspaces.size).toBe(1);
|
expect(h.workspaces.size).toBe(1);
|
||||||
expect(h.projects.size).toBe(1);
|
expect(h.projects.size).toBe(1);
|
||||||
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
|
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||||
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
|
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -434,12 +446,12 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
|
|||||||
test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => {
|
test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => {
|
||||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [dirWorkspace("/parent", archivedAt)],
|
workspaces: [dirWorkspace(PARENT, archivedAt)],
|
||||||
projects: [dirProject("/parent", archivedAt)],
|
projects: [dirProject(PARENT, archivedAt)],
|
||||||
});
|
});
|
||||||
const found = await asInternals<{
|
const found = await asInternals<{
|
||||||
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
||||||
}>(h.session).findWorkspaceByDirectory("/parent/child");
|
}>(h.session).findWorkspaceByDirectory(PARENT_CHILD);
|
||||||
expect(found).toBeNull();
|
expect(found).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,11 +468,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
|
|||||||
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
|
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
|
||||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||||
const h = createHarness({
|
const h = createHarness({
|
||||||
workspaces: [gitWorkspace("/toolbox", archivedAt)],
|
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||||
projects: [gitProject("/toolbox", archivedAt)],
|
projects: [gitProject(TOOLBOX, archivedAt)],
|
||||||
gitRoots: ["/toolbox"],
|
gitRoots: [TOOLBOX],
|
||||||
});
|
});
|
||||||
await openProject(h.session, "/toolbox/flomo-cli");
|
await openProject(h.session, TOOLBOX_FLOMO);
|
||||||
const resp = getOpenResponse(h.emitted, "req-1");
|
const resp = getOpenResponse(h.emitted, "req-1");
|
||||||
expect(resp?.error).toBeNull();
|
expect(resp?.error).toBeNull();
|
||||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -48,6 +48,9 @@ import {
|
|||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
|
|
||||||
|
const REPO_CWD = path.resolve("/tmp/repo");
|
||||||
|
const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
|
||||||
|
|
||||||
interface SessionTestAccess {
|
interface SessionTestAccess {
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
list(...args: unknown[]): Promise<unknown[]>;
|
list(...args: unknown[]): Promise<unknown[]>;
|
||||||
@@ -604,7 +607,7 @@ test("unsupported persisted agents are excluded from active lists but preserved
|
|||||||
const storedRecord = {
|
const storedRecord = {
|
||||||
id: "agent-unsupported",
|
id: "agent-unsupported",
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
cwd: "/tmp/history",
|
cwd: path.resolve("/tmp/history"),
|
||||||
createdAt: "2026-04-13T10:13:11.457Z",
|
createdAt: "2026-04-13T10:13:11.457Z",
|
||||||
updatedAt: "2026-04-13T10:16:06.556Z",
|
updatedAt: "2026-04-13T10:16:06.556Z",
|
||||||
lastActivityAt: "2026-04-13T10:16:06.556Z",
|
lastActivityAt: "2026-04-13T10:16:06.556Z",
|
||||||
@@ -727,7 +730,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
|||||||
);
|
);
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -736,7 +739,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-1",
|
workspaceId: "ws-1",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -755,7 +758,7 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
|||||||
await session.forwardAgentUpdate(
|
await session.forwardAgentUpdate(
|
||||||
makeManagedAgent({
|
makeManagedAgent({
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -801,7 +804,7 @@ test("agent_update emits fallback placement when no workspace is registered", as
|
|||||||
await session.forwardAgentUpdate(
|
await session.forwardAgentUpdate(
|
||||||
makeManagedAgent({
|
makeManagedAgent({
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
cwd: "/tmp/unregistered",
|
cwd: UNREGISTERED_CWD,
|
||||||
lifecycle: "running",
|
lifecycle: "running",
|
||||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -811,10 +814,10 @@ test("agent_update emits fallback placement when no workspace is registered", as
|
|||||||
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
||||||
kind: "upsert",
|
kind: "upsert",
|
||||||
project: {
|
project: {
|
||||||
projectKey: "/tmp/unregistered",
|
projectKey: UNREGISTERED_CWD,
|
||||||
projectName: "unregistered",
|
projectName: "unregistered",
|
||||||
checkout: {
|
checkout: {
|
||||||
cwd: "/tmp/unregistered",
|
cwd: UNREGISTERED_CWD,
|
||||||
isGit: false,
|
isGit: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -826,7 +829,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
|||||||
const archivedRecord = {
|
const archivedRecord = {
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
createdAt: "2026-03-30T15:00:00.000Z",
|
createdAt: "2026-03-30T15:00:00.000Z",
|
||||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||||
lastActivityAt: "2026-03-30T15:00:00.000Z",
|
lastActivityAt: "2026-03-30T15:00:00.000Z",
|
||||||
@@ -836,7 +839,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
|||||||
runtimeInfo: null,
|
runtimeInfo: null,
|
||||||
config: {
|
config: {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
},
|
},
|
||||||
persistence: null,
|
persistence: null,
|
||||||
title: "Archive me",
|
title: "Archive me",
|
||||||
@@ -893,7 +896,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
|||||||
projectRegistry: (() => {
|
projectRegistry: (() => {
|
||||||
const proj = createPersistedProjectRecord({
|
const proj = createPersistedProjectRecord({
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -913,7 +916,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
|||||||
const ws = createPersistedWorkspaceRecord({
|
const ws = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-1",
|
workspaceId: "ws-1",
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -934,7 +937,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
|||||||
loopService: asLoopService(),
|
loopService: asLoopService(),
|
||||||
checkoutDiffManager: asCheckoutDiffManager({
|
checkoutDiffManager: asCheckoutDiffManager({
|
||||||
subscribe: async () => ({
|
subscribe: async () => ({
|
||||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
initial: { cwd: REPO_CWD, files: [], error: null },
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
@@ -996,7 +999,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
|||||||
const archivedRecord = {
|
const archivedRecord = {
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
model: null,
|
model: null,
|
||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
effectiveThinkingOptionId: null,
|
effectiveThinkingOptionId: null,
|
||||||
@@ -1055,7 +1058,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
|||||||
projectRegistry: (() => {
|
projectRegistry: (() => {
|
||||||
const proj = createPersistedProjectRecord({
|
const proj = createPersistedProjectRecord({
|
||||||
projectId: "proj-close",
|
projectId: "proj-close",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1075,7 +1078,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
|||||||
const ws = createPersistedWorkspaceRecord({
|
const ws = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-close",
|
workspaceId: "ws-close",
|
||||||
projectId: "proj-close",
|
projectId: "proj-close",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1170,7 +1173,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
|||||||
const liveRecord = {
|
const liveRecord = {
|
||||||
...makeAgent({
|
...makeAgent({
|
||||||
id: "agent-live",
|
id: "agent-live",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -1179,7 +1182,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
|||||||
const storedRecord = {
|
const storedRecord = {
|
||||||
...makeAgent({
|
...makeAgent({
|
||||||
id: storedAgentId,
|
id: storedAgentId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:05:00.000Z",
|
updatedAt: "2026-03-01T12:05:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -1244,7 +1247,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
|||||||
projectRegistry: (() => {
|
projectRegistry: (() => {
|
||||||
const proj = createPersistedProjectRecord({
|
const proj = createPersistedProjectRecord({
|
||||||
projectId: "proj-stored",
|
projectId: "proj-stored",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1264,7 +1267,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
|||||||
const ws = createPersistedWorkspaceRecord({
|
const ws = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-stored",
|
workspaceId: "ws-stored",
|
||||||
projectId: "proj-stored",
|
projectId: "proj-stored",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1350,7 +1353,7 @@ test("close_items_request continues after an archive failure", async () => {
|
|||||||
const goodRecord = {
|
const goodRecord = {
|
||||||
...makeAgent({
|
...makeAgent({
|
||||||
id: "agent-good",
|
id: "agent-good",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -1393,7 +1396,7 @@ test("close_items_request continues after an archive failure", async () => {
|
|||||||
projectRegistry: (() => {
|
projectRegistry: (() => {
|
||||||
const proj = createPersistedProjectRecord({
|
const proj = createPersistedProjectRecord({
|
||||||
projectId: "proj-err",
|
projectId: "proj-err",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1413,7 +1416,7 @@ test("close_items_request continues after an archive failure", async () => {
|
|||||||
const ws = createPersistedWorkspaceRecord({
|
const ws = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-err",
|
workspaceId: "ws-err",
|
||||||
projectId: "proj-err",
|
projectId: "proj-err",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1714,9 +1717,12 @@ test("active-scoped fetch_agents pages within active scope instead of global his
|
|||||||
|
|
||||||
test("legacy unscoped fetch_agents keeps global workspace behavior", async () => {
|
test("legacy unscoped fetch_agents keeps global workspace behavior", async () => {
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
|
const legacyRoot = path.resolve("/tmp/legacy");
|
||||||
|
const activeCwd = path.join(legacyRoot, "active");
|
||||||
|
const archivedCwd = path.join(legacyRoot, "archived");
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-legacy-global",
|
projectId: "proj-legacy-global",
|
||||||
rootPath: "/tmp/legacy",
|
rootPath: legacyRoot,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "legacy",
|
displayName: "legacy",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1725,7 +1731,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
|||||||
const activeWorkspace = createPersistedWorkspaceRecord({
|
const activeWorkspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-legacy-active",
|
workspaceId: "ws-legacy-active",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/legacy/active",
|
cwd: activeCwd,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "active",
|
displayName: "active",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1734,7 +1740,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
|||||||
const archivedWorkspace = createPersistedWorkspaceRecord({
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-legacy-archived",
|
workspaceId: "ws-legacy-archived",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/legacy/archived",
|
cwd: archivedCwd,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "archived",
|
displayName: "archived",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1747,13 +1753,13 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
|||||||
session.listAgentPayloads = async () => [
|
session.listAgentPayloads = async () => [
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "legacy-active",
|
id: "legacy-active",
|
||||||
cwd: "/tmp/legacy/active",
|
cwd: activeCwd,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:01:00.000Z",
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
||||||
}),
|
}),
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "legacy-archived-workspace",
|
id: "legacy-archived-workspace",
|
||||||
cwd: "/tmp/legacy/archived",
|
cwd: archivedCwd,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -1773,9 +1779,10 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () =>
|
|||||||
test("fetch_agent_history_request pages archived historical rows separately", async () => {
|
test("fetch_agent_history_request pages archived historical rows separately", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
|
const historyCwd = path.resolve("/tmp/history");
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-history",
|
projectId: "proj-history",
|
||||||
rootPath: "/tmp/history",
|
rootPath: historyCwd,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "history",
|
displayName: "history",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1784,7 +1791,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-history",
|
workspaceId: "ws-history",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/history",
|
cwd: historyCwd,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "history",
|
displayName: "history",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1801,7 +1808,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as
|
|||||||
{
|
{
|
||||||
...makeAgent({
|
...makeAgent({
|
||||||
id: "history-archived",
|
id: "history-archived",
|
||||||
cwd: "/tmp/history",
|
cwd: historyCwd,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -1842,7 +1849,7 @@ test("fetch_agent_request still resolves archived historical agents", async () =
|
|||||||
const agent = {
|
const agent = {
|
||||||
...makeAgent({
|
...makeAgent({
|
||||||
id: "archived-history-agent",
|
id: "archived-history-agent",
|
||||||
cwd: "/tmp/history-detail",
|
cwd: path.resolve("/tmp/history-detail"),
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
@@ -1941,7 +1948,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as
|
|||||||
createPersistedWorkspaceRecord({
|
createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-repo-status",
|
workspaceId: "ws-repo-status",
|
||||||
projectId: "proj-repo-status",
|
projectId: "proj-repo-status",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -1951,19 +1958,19 @@ test("branch/detached policies and dominant status bucket are deterministic", as
|
|||||||
session.listAgentPayloads = async () => [
|
session.listAgentPayloads = async () => [
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "a1",
|
id: "a1",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: "running",
|
status: "running",
|
||||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "a2",
|
id: "a2",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: "error",
|
status: "error",
|
||||||
updatedAt: "2026-03-01T12:01:00.000Z",
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
||||||
}),
|
}),
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "a3",
|
id: "a3",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
updatedAt: "2026-03-01T12:02:00.000Z",
|
updatedAt: "2026-03-01T12:02:00.000Z",
|
||||||
pendingPermissions: 1,
|
pendingPermissions: 1,
|
||||||
@@ -1985,7 +1992,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
|
|||||||
createPersistedWorkspaceRecord({
|
createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-repo-subdir",
|
workspaceId: "ws-repo-subdir",
|
||||||
projectId: "proj-repo-subdir",
|
projectId: "proj-repo-subdir",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -2101,12 +2108,12 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
|||||||
session.buildWorkspaceDescriptorMap = async () =>
|
session.buildWorkspaceDescriptorMap = async () =>
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
"/tmp/repo",
|
REPO_CWD,
|
||||||
{
|
{
|
||||||
id: "ws-repo-running",
|
id: "ws-repo-running",
|
||||||
projectId: "proj-repo-running",
|
projectId: "proj-repo-running",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: REPO_CWD,
|
||||||
projectKind: "non_git",
|
projectKind: "non_git",
|
||||||
workspaceKind: "directory",
|
workspaceKind: "directory",
|
||||||
name: "repo",
|
name: "repo",
|
||||||
@@ -2115,17 +2122,17 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo");
|
await session.emitWorkspaceUpdateForCwd(REPO_CWD);
|
||||||
|
|
||||||
session.buildWorkspaceDescriptorMap = async () =>
|
session.buildWorkspaceDescriptorMap = async () =>
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
"/tmp/repo",
|
REPO_CWD,
|
||||||
{
|
{
|
||||||
id: "ws-repo-running",
|
id: "ws-repo-running",
|
||||||
projectId: "proj-repo-running",
|
projectId: "proj-repo-running",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: REPO_CWD,
|
||||||
projectKind: "non_git",
|
projectKind: "non_git",
|
||||||
workspaceKind: "directory",
|
workspaceKind: "directory",
|
||||||
name: "repo",
|
name: "repo",
|
||||||
@@ -2134,7 +2141,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo");
|
await session.emitWorkspaceUpdateForCwd(REPO_CWD);
|
||||||
|
|
||||||
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
||||||
expect(workspaceUpdates).toHaveLength(2);
|
expect(workspaceUpdates).toHaveLength(2);
|
||||||
@@ -2145,7 +2152,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
|||||||
id: "ws-repo-running",
|
id: "ws-repo-running",
|
||||||
projectId: "proj-repo-running",
|
projectId: "proj-repo-running",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: REPO_CWD,
|
||||||
projectKind: "non_git",
|
projectKind: "non_git",
|
||||||
workspaceKind: "directory",
|
workspaceKind: "directory",
|
||||||
name: "repo",
|
name: "repo",
|
||||||
@@ -2160,13 +2167,19 @@ test("create paseo worktree request returns a registered workspace descriptor",
|
|||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
const paseoHome = path.join(tempDir, "paseo-home");
|
const paseoHome = path.join(tempDir, "paseo-home");
|
||||||
execSync(`mkdir -p ${repoDir}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
const workspaceGitService = createNoopWorkspaceGitService();
|
const workspaceGitService = createNoopWorkspaceGitService();
|
||||||
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
|
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
|
||||||
if (cwd === repoDir) {
|
if (cwd === repoDir) {
|
||||||
@@ -2263,7 +2276,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
|
|||||||
createPersistedWorkspaceRecord({
|
createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-repo-main",
|
workspaceId: "ws-repo-main",
|
||||||
projectId: "proj-repo-main",
|
projectId: "proj-repo-main",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -2296,7 +2309,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
|
|||||||
id: "ws-repo-main",
|
id: "ws-repo-main",
|
||||||
projectId: "proj-repo-main",
|
projectId: "proj-repo-main",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: REPO_CWD,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "local_checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: "main",
|
name: "main",
|
||||||
@@ -2310,7 +2323,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async ()
|
|||||||
id: "ws-repo-feature",
|
id: "ws-repo-feature",
|
||||||
projectId: "proj-repo-main",
|
projectId: "proj-repo-main",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: REPO_CWD,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "worktree",
|
workspaceKind: "worktree",
|
||||||
name: "feature",
|
name: "feature",
|
||||||
@@ -2376,14 +2389,14 @@ test("open_project_request registers a workspace before any agent exists", async
|
|||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "open_project_request",
|
type: "open_project_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
requestId: "req-open",
|
requestId: "req-open",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaces.get("/tmp/repo")).toBeTruthy();
|
expect(workspaces.get(REPO_CWD)).toBeTruthy();
|
||||||
const response = findByType(emitted, "open_project_response");
|
const response = findByType(emitted, "open_project_response");
|
||||||
expect(response?.payload.error).toBeNull();
|
expect(response?.payload.error).toBeNull();
|
||||||
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
|
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
|
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
|
||||||
@@ -2391,7 +2404,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const cwd = "/tmp/slow-github-repo";
|
const cwd = path.resolve("/tmp/slow-github-repo");
|
||||||
|
|
||||||
session.emit = (message) => {
|
session.emit = (message) => {
|
||||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||||
@@ -2453,7 +2466,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const cwd = "/tmp/github-runtime-repo";
|
const cwd = path.resolve("/tmp/github-runtime-repo");
|
||||||
const snapshot = createWorkspaceRuntimeSnapshot(cwd);
|
const snapshot = createWorkspaceRuntimeSnapshot(cwd);
|
||||||
|
|
||||||
let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
@@ -2545,8 +2558,8 @@ test("open_project_request does not match a new child directory to an existing p
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const home = "/Users/moboudra";
|
const home = path.resolve("/Users/moboudra");
|
||||||
const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea";
|
const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
home,
|
home,
|
||||||
@@ -2609,8 +2622,8 @@ test("open_project_request does not unarchive an archived parent workspace for a
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const home = "/Users/moboudra";
|
const home = path.resolve("/Users/moboudra");
|
||||||
const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea";
|
const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
|
||||||
const archivedAt = "2026-04-24T08:00:00.000Z";
|
const archivedAt = "2026-04-24T08:00:00.000Z";
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
@@ -2677,8 +2690,14 @@ test("open_project_request reclassifies an archived directory workspace when git
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings";
|
const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
|
||||||
const repoRoot = "/Users/moboudra/dev/paseo";
|
const cwd = path.join(
|
||||||
|
path.resolve("/Users/moboudra"),
|
||||||
|
".paseo",
|
||||||
|
"worktrees",
|
||||||
|
"orchestrate",
|
||||||
|
"desktop-daemon-settings",
|
||||||
|
);
|
||||||
const remoteProjectId = "remote:github.com/getpaseo/paseo";
|
const remoteProjectId = "remote:github.com/getpaseo/paseo";
|
||||||
const archivedAt = "2026-04-24T09:48:36.168Z";
|
const archivedAt = "2026-04-24T09:48:36.168Z";
|
||||||
|
|
||||||
@@ -2769,8 +2788,14 @@ test("open_project_request reclassifies an active directory workspace when git m
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings";
|
const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
|
||||||
const repoRoot = "/Users/moboudra/dev/paseo";
|
const cwd = path.join(
|
||||||
|
path.resolve("/Users/moboudra"),
|
||||||
|
".paseo",
|
||||||
|
"worktrees",
|
||||||
|
"orchestrate",
|
||||||
|
"desktop-daemon-settings",
|
||||||
|
);
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -2879,8 +2904,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings";
|
const repoRoot = path.resolve("/Users/moboudra/dev/paseo");
|
||||||
const repoRoot = "/Users/moboudra/dev/paseo";
|
const cwd = path.join(
|
||||||
|
path.resolve("/Users/moboudra"),
|
||||||
|
".paseo",
|
||||||
|
"worktrees",
|
||||||
|
"orchestrate",
|
||||||
|
"desktop-daemon-settings",
|
||||||
|
);
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
@@ -2966,7 +2997,7 @@ test("open_project_request unarchives an existing archived workspace and project
|
|||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
|
|
||||||
const cwd = "/tmp/repo";
|
const cwd = REPO_CWD;
|
||||||
projects.set(
|
projects.set(
|
||||||
cwd,
|
cwd,
|
||||||
createPersistedProjectRecord({
|
createPersistedProjectRecord({
|
||||||
@@ -3030,7 +3061,7 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
const repoRoot = "/tmp/repo";
|
const repoRoot = REPO_CWD;
|
||||||
const subdir = "/tmp/repo/packages/app";
|
const subdir = "/tmp/repo/packages/app";
|
||||||
|
|
||||||
session.emit = (message) => {
|
session.emit = (message) => {
|
||||||
@@ -3213,10 +3244,10 @@ test("open_in_editor_request launches the selected target", async () => {
|
|||||||
type: "open_in_editor_request",
|
type: "open_in_editor_request",
|
||||||
requestId: "req-open-editor",
|
requestId: "req-open-editor",
|
||||||
editorId: "vscode",
|
editorId: "vscode",
|
||||||
path: "/tmp/repo",
|
path: REPO_CWD,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
|
expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]);
|
||||||
const response = emitted.find((message) => message.type === "open_in_editor_response") as
|
const response = emitted.find((message) => message.type === "open_in_editor_response") as
|
||||||
| { payload: Record<string, unknown> }
|
| { payload: Record<string, unknown> }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -3229,7 +3260,7 @@ test("archive_workspace_request hides non-destructive workspace records", async
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-repo-archive",
|
workspaceId: "ws-repo-archive",
|
||||||
projectId: "proj-repo-archive",
|
projectId: "proj-repo-archive",
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3271,7 +3302,7 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem
|
|||||||
const localProjectId = mainWorkspaceId;
|
const localProjectId = mainWorkspaceId;
|
||||||
const remoteProjectId = "remote:github.com/zimakki/inkwell";
|
const remoteProjectId = "remote:github.com/zimakki/inkwell";
|
||||||
|
|
||||||
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`);
|
mkdirSync(worktreeWorkspaceId, { recursive: true });
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
localProjectId,
|
localProjectId,
|
||||||
@@ -3381,7 +3412,7 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w
|
|||||||
const oldProjectId = "remote:github.com/old-owner/inkwell";
|
const oldProjectId = "remote:github.com/old-owner/inkwell";
|
||||||
const newProjectId = "remote:github.com/new-owner/inkwell";
|
const newProjectId = "remote:github.com/new-owner/inkwell";
|
||||||
|
|
||||||
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`);
|
mkdirSync(worktreeWorkspaceId, { recursive: true });
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
oldProjectId,
|
oldProjectId,
|
||||||
@@ -3476,7 +3507,7 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
|
|||||||
const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
|
const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
|
||||||
const projectId = "remote:github.com/acme/repo";
|
const projectId = "remote:github.com/acme/repo";
|
||||||
|
|
||||||
execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`);
|
mkdirSync(subdirWorkspaceId, { recursive: true });
|
||||||
|
|
||||||
projects.set(
|
projects.set(
|
||||||
projectId,
|
projectId,
|
||||||
@@ -3566,7 +3597,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-baseline",
|
projectId: "proj-baseline",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3575,7 +3606,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-baseline",
|
workspaceId: "ws-baseline",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3627,7 +3658,7 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () =>
|
|||||||
const archivingAt = "2026-04-30T20:45:00.000Z";
|
const archivingAt = "2026-04-30T20:45:00.000Z";
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-archiving-map",
|
projectId: "proj-archiving-map",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3669,7 +3700,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
|
|||||||
const archivingAt = "2026-04-30T20:45:00.000Z";
|
const archivingAt = "2026-04-30T20:45:00.000Z";
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-archiving-emit",
|
projectId: "proj-archiving-emit",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3725,7 +3756,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
|
|||||||
|
|
||||||
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
|
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "runtime-branch",
|
currentBranch: "runtime-branch",
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
@@ -3758,7 +3789,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
|||||||
);
|
);
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-runtime-fetch",
|
projectId: "proj-runtime-fetch",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3767,7 +3798,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-runtime-fetch",
|
workspaceId: "ws-runtime-fetch",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3803,7 +3834,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
|||||||
| { type: "fetch_workspaces_response"; payload: Record<string, unknown> }
|
| { type: "fetch_workspaces_response"; payload: Record<string, unknown> }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith("/tmp/repo");
|
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD);
|
||||||
expect(response?.payload.entries).toEqual([
|
expect(response?.payload.entries).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "ws-runtime-fetch",
|
id: "ws-runtime-fetch",
|
||||||
@@ -3856,7 +3887,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
|
|||||||
);
|
);
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-fetch-boundary",
|
projectId: "proj-fetch-boundary",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3865,7 +3896,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-fetch-boundary",
|
workspaceId: "ws-fetch-boundary",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3895,7 +3926,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor
|
|||||||
|
|
||||||
test("workspace_update includes updated runtime fields", async () => {
|
test("workspace_update includes updated runtime fields", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
@@ -3922,7 +3953,7 @@ test("workspace_update includes updated runtime fields", async () => {
|
|||||||
);
|
);
|
||||||
const project = createPersistedProjectRecord({
|
const project = createPersistedProjectRecord({
|
||||||
projectId: "proj-runtime-update",
|
projectId: "proj-runtime-update",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3931,7 +3962,7 @@ test("workspace_update includes updated runtime fields", async () => {
|
|||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-runtime-update",
|
workspaceId: "ws-runtime-update",
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -3966,11 +3997,11 @@ test("workspace_update includes updated runtime fields", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.emitWorkspaceUpdateForCwd("/tmp/repo", {
|
await session.emitWorkspaceUpdateForCwd(REPO_CWD, {
|
||||||
skipReconcile: true,
|
skipReconcile: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith("/tmp/repo");
|
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD);
|
||||||
expect(emitted).toContainEqual({
|
expect(emitted).toContainEqual({
|
||||||
type: "workspace_update",
|
type: "workspace_update",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -3998,7 +4029,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
|
|||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const gitProject = createPersistedProjectRecord({
|
const gitProject = createPersistedProjectRecord({
|
||||||
projectId: "proj-git-subscribe",
|
projectId: "proj-git-subscribe",
|
||||||
rootPath: "/tmp/repo",
|
rootPath: REPO_CWD,
|
||||||
kind: "git",
|
kind: "git",
|
||||||
displayName: "repo",
|
displayName: "repo",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
@@ -4015,7 +4046,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
|
|||||||
const gitWorkspace = createPersistedWorkspaceRecord({
|
const gitWorkspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId: "ws-git-subscribe",
|
workspaceId: "ws-git-subscribe",
|
||||||
projectId: gitProject.projectId,
|
projectId: gitProject.projectId,
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
kind: "local_checkout",
|
kind: "local_checkout",
|
||||||
displayName: "main",
|
displayName: "main",
|
||||||
createdAt: "2026-03-01T12:00:00.000Z",
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
|||||||
setAgentAttentionCallback: vi.fn(),
|
setAgentAttentionCallback: vi.fn(),
|
||||||
getAgent: vi.fn(() => null),
|
getAgent: vi.fn(() => null),
|
||||||
getLastAssistantMessage: vi.fn(async () => null),
|
getLastAssistantMessage: vi.fn(async () => null),
|
||||||
|
getMetricsSnapshot: vi.fn(() => ({
|
||||||
|
total: 0,
|
||||||
|
byLifecycle: {},
|
||||||
|
withActiveForegroundTurn: 0,
|
||||||
|
timelineStats: {
|
||||||
|
totalItems: 0,
|
||||||
|
maxItemsPerAgent: 0,
|
||||||
|
},
|
||||||
|
})),
|
||||||
...agentManagerOverrides,
|
...agentManagerOverrides,
|
||||||
};
|
};
|
||||||
const daemonConfigStore = {
|
const daemonConfigStore = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join, resolve as resolvePath } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
createGitHubService,
|
createGitHubService,
|
||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
type WorkspaceGitRuntimeSnapshot,
|
||||||
} from "./workspace-git-service.js";
|
} from "./workspace-git-service.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
|
const REPO_CWD = resolvePath("/tmp/repo");
|
||||||
|
|
||||||
function createLogger() {
|
function createLogger() {
|
||||||
const logger = {
|
const logger = {
|
||||||
@@ -230,11 +233,11 @@ function buildDefaultServiceDeps() {
|
|||||||
listBranchSuggestions: vi.fn(async () => []),
|
listBranchSuggestions: vi.fn(async () => []),
|
||||||
listPaseoWorktrees: vi.fn(async () => []),
|
listPaseoWorktrees: vi.fn(async () => []),
|
||||||
github: createGitHubServiceStub(),
|
github: createGitHubServiceStub(),
|
||||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
hasOriginRemote: vi.fn(async () => false),
|
hasOriginRemote: vi.fn(async () => false),
|
||||||
runGitFetch: vi.fn(async () => {}),
|
runGitFetch: vi.fn(async () => {}),
|
||||||
runGitCommand: vi.fn(async () => ({
|
runGitCommand: vi.fn(async () => ({
|
||||||
stdout: "/tmp/repo\n",
|
stdout: `${REPO_CWD}\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
truncated: false,
|
truncated: false,
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -273,9 +276,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
nowMs += 1_000;
|
nowMs += 1_000;
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
||||||
@@ -307,22 +310,22 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const service = createService({ getCheckoutStatus });
|
const service = createService({ getCheckoutStatus });
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
|
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
||||||
expect(getCheckoutStatus).not.toHaveBeenCalled();
|
expect(getCheckoutStatus).not.toHaveBeenCalled();
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||||
|
|
||||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -336,9 +339,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
nowMs = 1;
|
nowMs = 1;
|
||||||
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -348,15 +351,15 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
test("forced getSnapshot emits even when the fingerprint matches", async () => {
|
test("forced getSnapshot emits even when the fingerprint matches", async () => {
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({ getCheckoutStatus });
|
const service = createService({ getCheckoutStatus });
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo"));
|
expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -371,13 +374,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
nowMs = 3_000;
|
nowMs = 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
|
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -390,17 +393,17 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||||
const service = createService({ getCheckoutStatus });
|
const service = createService({ getCheckoutStatus });
|
||||||
|
|
||||||
const first = service.getSnapshot("/tmp/repo");
|
const first = service.getSnapshot(REPO_CWD);
|
||||||
const second = service.getSnapshot("/tmp/repo/.");
|
const second = service.getSnapshot(join(REPO_CWD, "."));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
|
|
||||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||||
createSnapshot("/tmp/repo"),
|
createSnapshot(REPO_CWD),
|
||||||
createSnapshot("/tmp/repo"),
|
createSnapshot(REPO_CWD),
|
||||||
]);
|
]);
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -424,24 +427,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
|
||||||
createSnapshot("/tmp/repo", {
|
createSnapshot(REPO_CWD, {
|
||||||
git: { diffStat: { additions: 4, deletions: 2 } },
|
git: { diffStat: { additions: 4, deletions: 2 } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
nowMs += 16_000;
|
nowMs += 16_000;
|
||||||
const first = service.getSnapshot("/tmp/repo");
|
const first = service.getSnapshot(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
const second = service.getSnapshot("/tmp/repo");
|
const second = service.getSnapshot(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
refreshStatus.resolve(createCheckoutStatus("/tmp/repo", { currentBranch: "feature" }));
|
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||||
|
|
||||||
const freshSnapshot = createSnapshot("/tmp/repo", {
|
const freshSnapshot = createSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "feature",
|
currentBranch: "feature",
|
||||||
diffStat: { additions: 4, deletions: 2 },
|
diffStat: { additions: 4, deletions: 2 },
|
||||||
@@ -461,11 +464,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
nowMs = 3_000;
|
nowMs = 3_000;
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
service.scheduleRefreshForCwd("/tmp/repo");
|
service.scheduleRefreshForCwd(REPO_CWD);
|
||||||
}
|
}
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -480,20 +483,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const secondRefresh = createDeferred<CheckoutStatusGit>();
|
const secondRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||||
.mockImplementationOnce(async () => secondRefresh.promise)
|
.mockImplementationOnce(async () => secondRefresh.promise)
|
||||||
.mockImplementation(async () => createCheckoutStatus("/tmp/repo"));
|
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
nowMs = 3_000;
|
nowMs = 3_000;
|
||||||
const refreshPromise = service.refresh("/tmp/repo");
|
const refreshPromise = service.refresh(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
const forcedPromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
const forcedPromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
const duplicateForcedPromise = service.getSnapshot("/tmp/repo", {
|
const duplicateForcedPromise = service.getSnapshot(REPO_CWD, {
|
||||||
force: true,
|
force: true,
|
||||||
reason: "test",
|
reason: "test",
|
||||||
});
|
});
|
||||||
@@ -501,7 +504,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
secondRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
secondRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]);
|
await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
|
||||||
@@ -513,19 +516,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||||
.mockImplementationOnce(async () => forcedRefresh.promise);
|
.mockImplementationOnce(async () => forcedRefresh.promise);
|
||||||
const service = createService({ getCheckoutStatus });
|
const service = createService({ getCheckoutStatus });
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
const first = service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
const first = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
const second = service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
const second = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
forcedRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
@@ -537,18 +540,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||||
.mockImplementationOnce(async () => forcedRefresh.promise);
|
.mockImplementationOnce(async () => forcedRefresh.promise);
|
||||||
const service = createService({ getCheckoutStatus });
|
const service = createService({ getCheckoutStatus });
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
const forcePromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
|
const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
service.scheduleRefreshForCwd("/tmp/repo");
|
service.scheduleRefreshForCwd(REPO_CWD);
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
forcedRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await forcePromise;
|
await forcePromise;
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
@@ -563,12 +566,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
nowMs = 3_000;
|
nowMs = 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
nowMs = 3_001;
|
nowMs = 3_001;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -582,10 +585,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
nowMs = 16_000;
|
nowMs = 16_000;
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -601,7 +604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
@@ -609,7 +612,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getPullRequestStatus).toHaveBeenLastCalledWith("/tmp/repo", expect.anything(), {
|
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
|
||||||
force: false,
|
force: false,
|
||||||
reason: "self-heal-git",
|
reason: "self-heal-git",
|
||||||
});
|
});
|
||||||
@@ -623,7 +626,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const resolveAbsoluteGitDir = vi
|
const resolveAbsoluteGitDir = vi
|
||||||
.fn<() => Promise<string | null>>()
|
.fn<() => Promise<string | null>>()
|
||||||
.mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
|
.mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
|
||||||
.mockResolvedValue("/tmp/repo/.git");
|
.mockResolvedValue(join(REPO_CWD, ".git"));
|
||||||
const watch = vi.fn(() => createWatcher() as never);
|
const watch = vi.fn(() => createWatcher() as never);
|
||||||
const service = createService({
|
const service = createService({
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
@@ -631,7 +634,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
@@ -642,7 +645,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo");
|
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith(REPO_CWD);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -659,11 +662,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
watch,
|
watch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
@@ -697,7 +700,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
github,
|
github,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
@@ -744,7 +747,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
github,
|
github,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 20_000;
|
nowMs = 20_000;
|
||||||
@@ -777,7 +780,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
github,
|
github,
|
||||||
});
|
});
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
|
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
|
||||||
@@ -801,7 +804,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
github,
|
github,
|
||||||
});
|
});
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
@@ -819,8 +822,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
|
const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
@@ -841,7 +844,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
@@ -860,7 +863,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
@@ -875,24 +878,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo"))
|
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
||||||
.mockImplementationOnce(async () => selfHealRefresh.promise);
|
.mockImplementationOnce(async () => selfHealRefresh.promise);
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
nowMs = 60_000;
|
nowMs = 60_000;
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
const directRead = service.getSnapshot("/tmp/repo");
|
const directRead = service.getSnapshot(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
selfHealRefresh.resolve(createCheckoutStatus("/tmp/repo"));
|
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await directRead;
|
await directRead;
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
@@ -923,8 +926,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.validateBranchRef("/tmp/repo", "feature");
|
const first = service.validateBranchRef(REPO_CWD, "feature");
|
||||||
const second = service.validateBranchRef("/tmp/repo/.", "feature");
|
const second = service.validateBranchRef(join(REPO_CWD, "."), "feature");
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
||||||
@@ -935,10 +938,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.validateBranchRef("/tmp/repo", "feature");
|
await service.validateBranchRef(REPO_CWD, "feature");
|
||||||
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await service.validateBranchRef("/tmp/repo", "feature", { force: true, reason: "test" });
|
await service.validateBranchRef(REPO_CWD, "feature", { force: true, reason: "test" });
|
||||||
expect(resolveBranchCheckout).toHaveBeenCalledTimes(2);
|
expect(resolveBranchCheckout).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -968,15 +971,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.hasLocalBranch("/tmp/repo", "feature");
|
const first = service.hasLocalBranch(REPO_CWD, "feature");
|
||||||
const second = service.hasLocalBranch("/tmp/repo/.", "feature");
|
const second = service.hasLocalBranch(join(REPO_CWD, "."), "feature");
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
||||||
expect(runGitCommand).toHaveBeenCalledWith(
|
expect(runGitCommand).toHaveBeenCalledWith(
|
||||||
["rev-parse", "--verify", "--quiet", "refs/heads/feature"],
|
["rev-parse", "--verify", "--quiet", "refs/heads/feature"],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
cwd: "/tmp/repo",
|
cwd: REPO_CWD,
|
||||||
acceptExitCodes: [0, 1],
|
acceptExitCodes: [0, 1],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -990,11 +993,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await expect(service.hasLocalBranch("/tmp/repo", "feature")).resolves.toBe(true);
|
await expect(service.hasLocalBranch(REPO_CWD, "feature")).resolves.toBe(true);
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.hasLocalBranch("/tmp/repo", "feature", { force: true, reason: "test" }),
|
service.hasLocalBranch(REPO_CWD, "feature", { force: true, reason: "test" }),
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(false);
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(2);
|
expect(runGitCommand).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -1013,18 +1016,18 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({
|
await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({
|
||||||
kind: "local",
|
kind: "local",
|
||||||
name: "feature-old",
|
name: "feature-old",
|
||||||
});
|
});
|
||||||
|
|
||||||
nowMs = 16_000;
|
nowMs = 16_000;
|
||||||
resolveBranchCheckout.mockClear();
|
resolveBranchCheckout.mockClear();
|
||||||
await expect(service.validateBranchRef("/tmp/repo", "feature")).rejects.toThrow("git is busy");
|
await expect(service.validateBranchRef(REPO_CWD, "feature")).rejects.toThrow("git is busy");
|
||||||
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
expect(resolveBranchCheckout).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
nowMs = 16_500;
|
nowMs = 16_500;
|
||||||
await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({
|
await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({
|
||||||
kind: "local",
|
kind: "local",
|
||||||
name: "feature-old",
|
name: "feature-old",
|
||||||
});
|
});
|
||||||
@@ -1046,8 +1049,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 });
|
const first = service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 });
|
||||||
const second = service.suggestBranchesForCwd("/tmp/repo/.", {
|
const second = service.suggestBranchesForCwd(join(REPO_CWD, "."), {
|
||||||
query: "feat",
|
query: "feat",
|
||||||
limit: 5,
|
limit: 5,
|
||||||
});
|
});
|
||||||
@@ -1058,11 +1061,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]);
|
await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 });
|
await service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 });
|
||||||
expect(listBranchSuggestions).toHaveBeenCalledTimes(1);
|
expect(listBranchSuggestions).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await service.suggestBranchesForCwd(
|
await service.suggestBranchesForCwd(
|
||||||
"/tmp/repo",
|
REPO_CWD,
|
||||||
{ query: "feat", limit: 5 },
|
{ query: "feat", limit: 5 },
|
||||||
{ force: true, reason: "test" },
|
{ force: true, reason: "test" },
|
||||||
);
|
);
|
||||||
@@ -1096,8 +1099,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.listStashes("/tmp/repo", { paseoOnly: true });
|
const first = service.listStashes(REPO_CWD, { paseoOnly: true });
|
||||||
const second = service.listStashes("/tmp/repo/.", { paseoOnly: true });
|
const second = service.listStashes(join(REPO_CWD, "."), { paseoOnly: true });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
||||||
@@ -1114,10 +1117,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.listStashes("/tmp/repo", { paseoOnly: true });
|
await service.listStashes(REPO_CWD, { paseoOnly: true });
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
expect(runGitCommand).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await service.listStashes("/tmp/repo", { paseoOnly: true }, { force: true, reason: "test" });
|
await service.listStashes(REPO_CWD, { paseoOnly: true }, { force: true, reason: "test" });
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(2);
|
expect(runGitCommand).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -1138,16 +1141,16 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.listWorktrees("/tmp/repo");
|
const first = service.listWorktrees(REPO_CWD);
|
||||||
const second = service.listWorktrees("/tmp/repo/.");
|
const second = service.listWorktrees(join(REPO_CWD, "."));
|
||||||
await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]);
|
await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]);
|
||||||
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.listWorktrees("/tmp/repo");
|
await service.listWorktrees(REPO_CWD);
|
||||||
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await service.listWorktrees("/tmp/repo", { force: true, reason: "test" });
|
await service.listWorktrees(REPO_CWD, { force: true, reason: "test" });
|
||||||
expect(listPaseoWorktrees).toHaveBeenCalledTimes(2);
|
expect(listPaseoWorktrees).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -1158,7 +1161,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
const nestedWorkspaceDir = join(repoDir, "packages", "app");
|
const nestedWorkspaceDir = join(repoDir, "packages", "app");
|
||||||
mkdirSync(nestedWorkspaceDir, { recursive: true });
|
mkdirSync(nestedWorkspaceDir, { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const worktrees = [
|
const worktrees = [
|
||||||
{
|
{
|
||||||
@@ -1181,7 +1184,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
|
|
||||||
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
expect(listPaseoWorktrees).toHaveBeenCalledTimes(1);
|
||||||
expect(listPaseoWorktrees).toHaveBeenCalledWith({
|
expect(listPaseoWorktrees).toHaveBeenCalledWith({
|
||||||
cwd: repoDir,
|
cwd: realpathSync.native(repoDir).replace(/\\/g, "/"),
|
||||||
paseoHome: "/tmp/paseo-test",
|
paseoHome: "/tmp/paseo-test",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1202,8 +1205,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.resolveDefaultBranch("/tmp/repo");
|
const first = service.resolveDefaultBranch(REPO_CWD);
|
||||||
const second = service.resolveDefaultBranch("/tmp/repo/.");
|
const second = service.resolveDefaultBranch(join(REPO_CWD, "."));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
|
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
|
||||||
@@ -1211,10 +1214,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]);
|
await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.resolveDefaultBranch("/tmp/repo");
|
await service.resolveDefaultBranch(REPO_CWD);
|
||||||
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
|
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await service.resolveDefaultBranch("/tmp/repo", { force: true, reason: "test" });
|
await service.resolveDefaultBranch(REPO_CWD, { force: true, reason: "test" });
|
||||||
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(2);
|
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -1226,25 +1229,25 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementationOnce(async () => checkoutDeferred.promise)
|
.mockImplementationOnce(async () => checkoutDeferred.promise)
|
||||||
.mockResolvedValue(createCheckoutStatus("/tmp/repo"));
|
.mockResolvedValue(createCheckoutStatus(REPO_CWD));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.resolveRepoRoot("/tmp/repo");
|
const first = service.resolveRepoRoot(REPO_CWD);
|
||||||
const second = service.resolveRepoRoot("/tmp/repo/.");
|
const second = service.resolveRepoRoot(join(REPO_CWD, "."));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
checkoutDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
checkoutDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await expect(Promise.all([first, second])).resolves.toEqual(["/tmp/repo", "/tmp/repo"]);
|
await expect(Promise.all([first, second])).resolves.toEqual([REPO_CWD, REPO_CWD]);
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.resolveRepoRoot("/tmp/repo");
|
await service.resolveRepoRoot(REPO_CWD);
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await service.resolveRepoRoot("/tmp/repo", { force: true, reason: "test" });
|
await service.resolveRepoRoot(REPO_CWD, { force: true, reason: "test" });
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -1262,11 +1265,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.resolveRepoRemoteUrl("/tmp/repo")).resolves.toBe(
|
await expect(service.resolveRepoRemoteUrl(REPO_CWD)).resolves.toBe(
|
||||||
"https://github.com/getpaseo/paseo.git",
|
"https://github.com/getpaseo/paseo.git",
|
||||||
);
|
);
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await expect(service.resolveRepoRemoteUrl("/tmp/repo/.")).resolves.toBe(
|
await expect(service.resolveRepoRemoteUrl(join(REPO_CWD, "."))).resolves.toBe(
|
||||||
"https://github.com/getpaseo/paseo.git",
|
"https://github.com/getpaseo/paseo.git",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1281,7 +1284,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
createCheckoutStatus(cwd, {
|
createCheckoutStatus(cwd, {
|
||||||
currentBranch: "feature/service-metadata",
|
currentBranch: "feature/service-metadata",
|
||||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||||
repoRoot: "/tmp/repo",
|
repoRoot: REPO_CWD,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -1290,7 +1293,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getWorkspaceGitMetadata("/tmp/repo", { directoryName: "Local Repo" }),
|
service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
projectDisplayName: "getpaseo/paseo",
|
projectDisplayName: "getpaseo/paseo",
|
||||||
@@ -1298,13 +1301,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
gitRemote: "https://github.com/getpaseo/paseo.git",
|
gitRemote: "https://github.com/getpaseo/paseo.git",
|
||||||
isWorktree: false,
|
isWorktree: false,
|
||||||
projectSlug: "paseo",
|
projectSlug: "paseo",
|
||||||
repoRoot: "/tmp/repo",
|
repoRoot: REPO_CWD,
|
||||||
currentBranch: "feature/service-metadata",
|
currentBranch: "feature/service-metadata",
|
||||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||||
});
|
});
|
||||||
|
|
||||||
nowMs = 1_000;
|
nowMs = 1_000;
|
||||||
await service.getWorkspaceGitMetadata("/tmp/repo/.", { directoryName: "Local Repo" });
|
await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" });
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -1314,18 +1317,21 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-")));
|
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-")));
|
||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
mkdirSync(repoDir, { recursive: true });
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(join(repoDir, "tracked.txt"), "before\n");
|
writeFileSync(join(repoDir, "tracked.txt"), "before\n");
|
||||||
execSync("git add tracked.txt", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\n");
|
writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\n");
|
||||||
writeFileSync(join(repoDir, "staged.txt"), "staged\n");
|
writeFileSync(join(repoDir, "staged.txt"), "staged\n");
|
||||||
execSync("git add staged.txt", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "staged.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutDiff: getCheckoutDiffUncached as never,
|
getCheckoutDiff: getCheckoutDiffUncached as never,
|
||||||
@@ -1357,8 +1363,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
.mockResolvedValue({ diff: "second" });
|
.mockResolvedValue({ diff: "second" });
|
||||||
const service = createService({ getCheckoutDiff, now: () => new Date(0) });
|
const service = createService({ getCheckoutDiff, now: () => new Date(0) });
|
||||||
|
|
||||||
const first = service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" });
|
const first = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
|
||||||
const second = service.getCheckoutDiff("/tmp/repo/.", { mode: "uncommitted" });
|
const second = service.getCheckoutDiff(join(REPO_CWD, "."), { mode: "uncommitted" });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
||||||
@@ -1383,16 +1389,12 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({
|
await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({
|
||||||
diff: "first",
|
diff: "first",
|
||||||
});
|
});
|
||||||
nowMs = 1;
|
nowMs = 1;
|
||||||
await expect(
|
await expect(
|
||||||
service.getCheckoutDiff(
|
service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }, { force: true, reason: "test" }),
|
||||||
"/tmp/repo",
|
|
||||||
{ mode: "uncommitted" },
|
|
||||||
{ force: true, reason: "test" },
|
|
||||||
),
|
|
||||||
).resolves.toEqual({ diff: "forced" });
|
).resolves.toEqual({ diff: "forced" });
|
||||||
|
|
||||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
|
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
|
||||||
@@ -1412,15 +1414,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({
|
await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({
|
||||||
diff: "first",
|
diff: "first",
|
||||||
});
|
});
|
||||||
nowMs = 16_000;
|
nowMs = 16_000;
|
||||||
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).rejects.toThrow(
|
await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).rejects.toThrow(
|
||||||
"git is busy",
|
"git is busy",
|
||||||
);
|
);
|
||||||
nowMs = 16_500;
|
nowMs = 16_500;
|
||||||
await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({
|
await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({
|
||||||
diff: "first",
|
diff: "first",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1441,13 +1443,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "main" }),
|
service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }),
|
||||||
).resolves.toEqual({ diff: "main" });
|
).resolves.toEqual({ diff: "main" });
|
||||||
await expect(
|
await expect(
|
||||||
service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "release" }),
|
service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "release" }),
|
||||||
).resolves.toEqual({ diff: "release" });
|
).resolves.toEqual({ diff: "release" });
|
||||||
await expect(
|
await expect(
|
||||||
service.getCheckoutDiff("/tmp/repo", {
|
service.getCheckoutDiff(REPO_CWD, {
|
||||||
mode: "base",
|
mode: "base",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
@@ -1459,7 +1461,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Linux working tree walker excludes gitignored directories", async () => {
|
// POSIX-only: this asserts Linux working-tree walker behavior around ignored directories.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"Linux working tree walker excludes gitignored directories",
|
||||||
|
async () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
||||||
|
|
||||||
@@ -1467,7 +1472,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
|
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
|
||||||
mkdirSync(join(repoDir, "kept"), { recursive: true });
|
mkdirSync(join(repoDir, "kept"), { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
|
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
|
||||||
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
|
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
|
||||||
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
|
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
|
||||||
@@ -1501,5 +1506,6 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import path from "node:path";
|
import os from "node:os";
|
||||||
|
import path, { join } from "node:path";
|
||||||
import type { FSWatcher } from "node:fs";
|
import type { FSWatcher } from "node:fs";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
type WorkspaceGitRuntimeSnapshot,
|
||||||
} from "./workspace-git-service.js";
|
} from "./workspace-git-service.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
|
const REPO_CWD = path.resolve("/tmp/repo");
|
||||||
|
|
||||||
interface ServiceInternals {
|
interface ServiceInternals {
|
||||||
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
|
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
|
||||||
@@ -202,11 +206,11 @@ function buildDefaultTestServiceDeps() {
|
|||||||
})),
|
})),
|
||||||
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()),
|
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()),
|
||||||
github: createGitHubServiceStub(),
|
github: createGitHubServiceStub(),
|
||||||
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
hasOriginRemote: vi.fn(async () => false),
|
hasOriginRemote: vi.fn(async () => false),
|
||||||
runGitFetch: vi.fn(async () => {}),
|
runGitFetch: vi.fn(async () => {}),
|
||||||
runGitCommand: vi.fn(async () => ({
|
runGitCommand: vi.fn(async () => ({
|
||||||
stdout: "/tmp/repo\n",
|
stdout: `${REPO_CWD}\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
truncated: false,
|
truncated: false,
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -237,12 +241,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const service = createService();
|
const service = createService();
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
||||||
expect("initial" in subscription).toBe(false);
|
expect("initial" in subscription).toBe(false);
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
|
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -267,8 +271,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date("2026-04-12T02:03:04.000Z"),
|
now: () => new Date("2026-04-12T02:03:04.000Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
|
||||||
createSnapshot("/tmp/repo", {
|
createSnapshot(REPO_CWD, {
|
||||||
github: {
|
github: {
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
url: "https://github.com/acme/repo/pull/999",
|
url: "https://github.com/acme/repo/pull/999",
|
||||||
@@ -304,10 +308,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
|
||||||
createSnapshot("/tmp/repo", {
|
createSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
repoRoot: "/tmp/repo",
|
repoRoot: REPO_CWD,
|
||||||
currentBranch: "feature/worktree",
|
currentBranch: "feature/worktree",
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
mainRepoRoot: "/tmp/main-repo",
|
mainRepoRoot: "/tmp/main-repo",
|
||||||
@@ -325,11 +329,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
@@ -342,7 +346,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
@@ -350,27 +354,27 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstSnapshotPromise = service.getSnapshot("/tmp/repo");
|
const firstSnapshotPromise = service.getSnapshot(REPO_CWD);
|
||||||
const secondSnapshotPromise = service.getSnapshot("/tmp/repo/.");
|
const secondSnapshotPromise = service.getSnapshot(join(REPO_CWD, "."));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
|
|
||||||
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
|
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
|
||||||
createSnapshot("/tmp/repo"),
|
createSnapshot(REPO_CWD),
|
||||||
createSnapshot("/tmp/repo"),
|
createSnapshot(REPO_CWD),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -379,7 +383,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -388,8 +392,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
@@ -402,7 +406,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
test("equivalent cwd strings share one workspace target across service entry points", async () => {
|
test("equivalent cwd strings share one workspace target across service entry points", async () => {
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -411,14 +415,18 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
now: () => new Date(nowMs),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
|
const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn());
|
||||||
|
|
||||||
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
createSnapshot(REPO_CWD),
|
||||||
|
);
|
||||||
|
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
|
||||||
|
createSnapshot(REPO_CWD),
|
||||||
|
);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
@@ -430,7 +438,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
||||||
const runGitFetch = vi.fn(async () => {});
|
const runGitFetch = vi.fn(async () => {});
|
||||||
const hasOriginRemote = vi.fn(async () => true);
|
const hasOriginRemote = vi.fn(async () => true);
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
const service = createService({
|
const service = createService({
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
@@ -438,8 +446,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
runGitFetch,
|
runGitFetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn());
|
const second = service.registerWorkspace(
|
||||||
|
{ cwd: join(REPO_CWD, "packages", "server") },
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
|
||||||
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||||
@@ -493,18 +504,18 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
const initialSnapshot = await service.getSnapshot(REPO_CWD);
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
||||||
|
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).toHaveBeenCalledWith(
|
expect(listener).toHaveBeenCalledWith(
|
||||||
createSnapshot("/tmp/repo", {
|
createSnapshot(REPO_CWD, {
|
||||||
github: {
|
github: {
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
url: "https://github.com/acme/repo/pull/123",
|
url: "https://github.com/acme/repo/pull/123",
|
||||||
@@ -525,9 +536,9 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
test("unchanged runtime snapshots do not emit duplicate updates", async () => {
|
test("unchanged runtime snapshots do not emit duplicate updates", async () => {
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null }))
|
.mockResolvedValueOnce(createCheckoutStatus(REPO_CWD, { remoteUrl: null }))
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
createCheckoutStatus("/tmp/repo", {
|
createCheckoutStatus(REPO_CWD, {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
aheadBehind: { ahead: 2, behind: 0 },
|
aheadBehind: { ahead: 2, behind: 0 },
|
||||||
@@ -535,7 +546,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
createCheckoutStatus("/tmp/repo", {
|
createCheckoutStatus(REPO_CWD, {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
aheadBehind: { ahead: 2, behind: 0 },
|
aheadBehind: { ahead: 2, behind: 0 },
|
||||||
@@ -563,22 +574,22 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
const initialSnapshot = await service.getSnapshot(REPO_CWD);
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
expect(initialSnapshot.git.currentBranch).toBe("main");
|
expect(initialSnapshot.git.currentBranch).toBe("main");
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).toHaveBeenCalledWith(
|
expect(listener).toHaveBeenCalledWith(
|
||||||
createSnapshot("/tmp/repo", {
|
createSnapshot(REPO_CWD, {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
@@ -597,7 +608,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("forced snapshot refresh emits even when the fingerprint matches", async () => {
|
test("forced snapshot refresh emits even when the fingerprint matches", async () => {
|
||||||
const getCheckoutStatus = vi.fn(async () => createCheckoutStatus("/tmp/repo"));
|
const getCheckoutStatus = vi.fn(async () => createCheckoutStatus(REPO_CWD));
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -607,23 +618,24 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
await service.getSnapshot("/tmp/repo");
|
await service.getSnapshot(REPO_CWD);
|
||||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
|
||||||
await service.getSnapshot("/tmp/repo", {
|
await service.getSnapshot(REPO_CWD, {
|
||||||
force: true,
|
force: true,
|
||||||
reason: "test-force-emit",
|
reason: "test-force-emit",
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo"));
|
expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("watches nested repository directories on Linux", async () => {
|
// POSIX-only: this asserts Linux recursive-watch fallback behavior.
|
||||||
|
test.skipIf(isPlatform("win32"))("watches nested repository directories on Linux", async () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
Object.defineProperty(process, "platform", {
|
Object.defineProperty(process, "platform", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -637,20 +649,20 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
return watcher;
|
return watcher;
|
||||||
});
|
});
|
||||||
const readdir = vi.fn(async (directory: string) => {
|
const readdir = vi.fn(async (directory: string) => {
|
||||||
if (directory === "/tmp/repo") {
|
if (directory === REPO_CWD) {
|
||||||
return [
|
return [
|
||||||
createDirent("packages", true),
|
createDirent("packages", true),
|
||||||
createDirent(".git", true),
|
createDirent(".git", true),
|
||||||
createDirent("README.md", false),
|
createDirent("README.md", false),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (directory === path.join("/tmp/repo", "packages")) {
|
if (directory === path.join(REPO_CWD, "packages")) {
|
||||||
return [createDirent("server", true), createDirent("app", true)];
|
return [createDirent("server", true), createDirent("app", true)];
|
||||||
}
|
}
|
||||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
if (directory === path.join(REPO_CWD, "packages", "server")) {
|
||||||
return [createDirent("src", true)];
|
return [createDirent("src", true)];
|
||||||
}
|
}
|
||||||
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
if (directory === path.join(REPO_CWD, "packages", "server", "src")) {
|
||||||
return [createDirent("server", true)];
|
return [createDirent("server", true)];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -658,19 +670,19 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
const service = createService({ watch, readdir });
|
const service = createService({ watch, readdir });
|
||||||
const subscription = await service.requestWorkingTreeWatch(
|
const subscription = await service.requestWorkingTreeWatch(
|
||||||
path.join("/tmp/repo", "packages", "server"),
|
path.join(REPO_CWD, "packages", "server"),
|
||||||
vi.fn(),
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(subscription.repoRoot).toBe("/tmp/repo");
|
expect(subscription.repoRoot).toBe(REPO_CWD);
|
||||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||||
"/tmp/repo",
|
REPO_CWD,
|
||||||
"/tmp/repo/.git",
|
join(REPO_CWD, ".git"),
|
||||||
"/tmp/repo/packages",
|
join(REPO_CWD, "packages"),
|
||||||
"/tmp/repo/packages/app",
|
join(REPO_CWD, "packages", "app"),
|
||||||
"/tmp/repo/packages/server",
|
join(REPO_CWD, "packages", "server"),
|
||||||
"/tmp/repo/packages/server/src",
|
join(REPO_CWD, "packages", "server", "src"),
|
||||||
"/tmp/repo/packages/server/src/server",
|
join(REPO_CWD, "packages", "server", "src", "server"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -688,11 +700,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
const firstListener = vi.fn();
|
const firstListener = vi.fn();
|
||||||
const secondListener = vi.fn();
|
const secondListener = vi.fn();
|
||||||
const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener);
|
const first = await service.requestWorkingTreeWatch(REPO_CWD, firstListener);
|
||||||
const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener);
|
const second = await service.requestWorkingTreeWatch(join(REPO_CWD, "."), secondListener);
|
||||||
|
|
||||||
expect(first.repoRoot).toBe("/tmp/repo");
|
expect(first.repoRoot).toBe(REPO_CWD);
|
||||||
expect(second.repoRoot).toBe("/tmp/repo");
|
expect(second.repoRoot).toBe(REPO_CWD);
|
||||||
expect(watch).toHaveBeenCalledTimes(2);
|
expect(watch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
first.unsubscribe();
|
first.unsubscribe();
|
||||||
@@ -726,10 +738,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
.mockImplementationOnce(() => createWatcher());
|
.mockImplementationOnce(() => createWatcher());
|
||||||
|
|
||||||
const service = createService({ watch });
|
const service = createService({ watch });
|
||||||
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
|
||||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(
|
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD);
|
||||||
"/tmp/repo",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||||
|
|
||||||
@@ -749,19 +759,18 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn());
|
const plainCwd = path.join(os.tmpdir(), "plain");
|
||||||
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(
|
const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn());
|
||||||
"/tmp/plain",
|
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd);
|
||||||
);
|
|
||||||
|
|
||||||
expect(subscription.repoRoot).toBeNull();
|
expect(subscription.repoRoot).toBeNull();
|
||||||
const expectedRecursive = process.platform !== "linux";
|
const expectedRecursive = process.platform !== "linux";
|
||||||
expect(watch).toHaveBeenCalledWith(
|
expect(watch).toHaveBeenCalledWith(
|
||||||
"/tmp/plain",
|
plainCwd,
|
||||||
{ recursive: expectedRecursive },
|
{ recursive: expectedRecursive },
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(target?.repoWatchPath).toBe("/tmp/plain");
|
expect(target?.repoWatchPath).toBe(plainCwd);
|
||||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -780,13 +789,13 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
|
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
|
|
||||||
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener);
|
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener);
|
||||||
expect(watchCallbacks).toHaveLength(2);
|
expect(watchCallbacks).toHaveLength(2);
|
||||||
|
|
||||||
watchCallbacks[0]?.();
|
watchCallbacks[0]?.();
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo", {
|
expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, {
|
||||||
force: true,
|
force: true,
|
||||||
reason: "working-tree-watch",
|
reason: "working-tree-watch",
|
||||||
});
|
});
|
||||||
@@ -810,15 +819,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const service = createService({ getCheckoutShortstat, watch });
|
const service = createService({ getCheckoutShortstat, watch });
|
||||||
const workspaceListener = vi.fn();
|
const workspaceListener = vi.fn();
|
||||||
|
|
||||||
const initialSnapshot = await service.getSnapshot("/tmp/repo");
|
const initialSnapshot = await service.getSnapshot(REPO_CWD);
|
||||||
const workspaceSubscription = service.registerWorkspace(
|
const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, workspaceListener);
|
||||||
{ cwd: "/tmp/repo" },
|
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
|
||||||
workspaceListener,
|
|
||||||
);
|
|
||||||
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
|
||||||
|
|
||||||
expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 });
|
expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 });
|
||||||
const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo");
|
const repoRootWatch = watchCallbacks.find((entry) => entry.path === REPO_CWD);
|
||||||
expect(repoRootWatch).toBeDefined();
|
expect(repoRootWatch).toBeDefined();
|
||||||
|
|
||||||
repoRootWatch?.callback();
|
repoRootWatch?.callback();
|
||||||
@@ -826,12 +832,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
|
expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
|
||||||
"/tmp/repo",
|
REPO_CWD,
|
||||||
{ paseoHome: "/tmp/paseo-test" },
|
{ paseoHome: "/tmp/paseo-test" },
|
||||||
{ force: true },
|
{ force: true },
|
||||||
);
|
);
|
||||||
expect(workspaceListener).toHaveBeenCalledWith(
|
expect(workspaceListener).toHaveBeenCalledWith(
|
||||||
createSnapshot("/tmp/repo", {
|
createSnapshot(REPO_CWD, {
|
||||||
git: { diffStat: { additions: 8, deletions: 3 } },
|
git: { diffStat: { additions: 8, deletions: 3 } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -117,13 +117,13 @@ function createWorkspaceGitServiceStub(
|
|||||||
function createTempGitRepo(prefix: string): string {
|
function createTempGitRepo(prefix: string): string {
|
||||||
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
const dir = realpathSync(raw);
|
const dir = realpathSync(raw);
|
||||||
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
|
||||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" });
|
||||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "ignore" });
|
||||||
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir, stdio: "ignore" });
|
||||||
writeFileSync(path.join(dir, "README.md"), "# Test\n");
|
writeFileSync(path.join(dir, "README.md"), "# Test\n");
|
||||||
execSync("git add .", { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" });
|
||||||
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" });
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,12 +253,18 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Initialize as git repo
|
// Initialize as git repo
|
||||||
execSync("git init -b main", { cwd: resolved, stdio: "ignore" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: resolved, stdio: "ignore" });
|
||||||
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" });
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
||||||
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" });
|
cwd: resolved,
|
||||||
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" });
|
stdio: "ignore",
|
||||||
execSync("git add .", { cwd: resolved, stdio: "ignore" });
|
});
|
||||||
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" });
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: resolved, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||||
|
cwd: resolved,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["add", "."], { cwd: resolved, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["commit", "-m", "init"], { cwd: resolved, stdio: "ignore" });
|
||||||
|
|
||||||
const service = new WorkspaceReconciliationService({
|
const service = new WorkspaceReconciliationService({
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
@@ -311,7 +317,7 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Change the remote
|
// Change the remote
|
||||||
execSync("git remote add origin git@github.com:new-owner/new-repo.git", {
|
execFileSync("git", ["remote", "add", "origin", "git@github.com:new-owner/new-repo.git"], {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
@@ -341,7 +347,7 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
const dir = createTempGitRepo("reconcile-branch-");
|
const dir = createTempGitRepo("reconcile-branch-");
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" });
|
execFileSync("git", ["checkout", "-b", "feature-branch"], { cwd: dir, stdio: "ignore" });
|
||||||
|
|
||||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
|||||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
|
|
||||||
|
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
|
||||||
|
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
|
||||||
|
|
||||||
describe("bootstrapWorkspaceRegistries", () => {
|
describe("bootstrapWorkspaceRegistries", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let paseoHome: string;
|
let paseoHome: string;
|
||||||
@@ -44,7 +47,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
await agentStorage.upsert({
|
await agentStorage.upsert({
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/non-git-project",
|
cwd: NON_GIT_PROJECT,
|
||||||
createdAt: "2026-03-01T00:00:00.000Z",
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||||
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||||
@@ -61,7 +64,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
await agentStorage.upsert({
|
await agentStorage.upsert({
|
||||||
id: "agent-2",
|
id: "agent-2",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/non-git-project",
|
cwd: NON_GIT_PROJECT,
|
||||||
createdAt: "2026-03-01T01:00:00.000Z",
|
createdAt: "2026-03-01T01:00:00.000Z",
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
lastActivityAt: "2026-03-03T00:00:00.000Z",
|
lastActivityAt: "2026-03-03T00:00:00.000Z",
|
||||||
@@ -78,7 +81,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
await agentStorage.upsert({
|
await agentStorage.upsert({
|
||||||
id: "agent-archived",
|
id: "agent-archived",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/tmp/archived-project",
|
cwd: ARCHIVED_PROJECT,
|
||||||
createdAt: "2026-03-01T00:00:00.000Z",
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
lastActivityAt: "2026-03-01T00:00:00.000Z",
|
lastActivityAt: "2026-03-01T00:00:00.000Z",
|
||||||
@@ -104,13 +107,13 @@ describe("bootstrapWorkspaceRegistries", () => {
|
|||||||
|
|
||||||
const workspaces = await workspaceRegistry.list();
|
const workspaces = await workspaceRegistry.list();
|
||||||
expect(workspaces).toHaveLength(1);
|
expect(workspaces).toHaveLength(1);
|
||||||
expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project");
|
expect(workspaces[0]?.workspaceId).toBe(NON_GIT_PROJECT);
|
||||||
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
||||||
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||||
|
|
||||||
const projects = await projectRegistry.list();
|
const projects = await projectRegistry.list();
|
||||||
expect(projects).toHaveLength(1);
|
expect(projects).toHaveLength(1);
|
||||||
expect(projects[0]?.projectId).toBe("/tmp/non-git-project");
|
expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT);
|
||||||
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
||||||
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||||
});
|
});
|
||||||
|
|||||||
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 { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { execSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
createWorktree as createWorktreePrimitive,
|
createWorktree as createWorktreePrimitive,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type { TerminalSession } from "../terminal/terminal.js";
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
|
|
||||||
interface CreateAgentWorktreeTestOptions {
|
interface CreateAgentWorktreeTestOptions {
|
||||||
@@ -69,30 +69,22 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
let paseoHome: string;
|
let paseoHome: string;
|
||||||
let realTerminalManagers: TerminalManager[];
|
let realTerminalManagers: TerminalManager[];
|
||||||
|
|
||||||
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
|
||||||
if (existsSync(targetPath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for path: ${targetPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
realTerminalManagers = [];
|
realTerminalManagers = [];
|
||||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||||
repoDir = join(tempDir, "repo");
|
repoDir = join(tempDir, "repo");
|
||||||
paseoHome = join(tempDir, "paseo-home");
|
paseoHome = join(tempDir, "paseo-home");
|
||||||
|
|
||||||
execSync(`mkdir -p ${repoDir}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" });
|
writeFileSync(join(repoDir, "file.txt"), "hello\n");
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -100,114 +92,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams running setup updates live and persists only a final setup timeline row", async () => {
|
|
||||||
writeFileSync(
|
|
||||||
join(repoDir, "paseo.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
worktree: {
|
|
||||||
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
|
||||||
cwd: repoDir,
|
|
||||||
branchName: "feature-streaming-setup",
|
|
||||||
baseBranch: "main",
|
|
||||||
worktreeSlug: "feature-streaming-setup",
|
|
||||||
paseoHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
const persisted: AgentTimelineItem[] = [];
|
|
||||||
const live: AgentTimelineItem[] = [];
|
|
||||||
|
|
||||||
await runAsyncWorktreeBootstrap({
|
|
||||||
agentId: "agent-test",
|
|
||||||
worktree: worktreeBootstrap.worktree,
|
|
||||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
|
||||||
terminalManager: null,
|
|
||||||
appendTimelineItem: async (item) => {
|
|
||||||
persisted.push(item);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
|
|
||||||
live.push(item);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const liveSetupItems = live.filter(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "paseo_worktree_setup" &&
|
|
||||||
item.status === "running",
|
|
||||||
);
|
|
||||||
expect(liveSetupItems.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const persistedSetupItems = persisted.filter(
|
|
||||||
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
|
||||||
);
|
|
||||||
expect(persistedSetupItems).toHaveLength(1);
|
|
||||||
expect(persistedSetupItems[0]?.type).toBe("tool_call");
|
|
||||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
|
||||||
expect(persistedSetupItems[0].status).toBe("completed");
|
|
||||||
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
|
|
||||||
|
|
||||||
if (persistedSetupItems[0].detail.type === "worktree_setup") {
|
|
||||||
expect(persistedSetupItems[0].detail.log).toContain(
|
|
||||||
'==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2',
|
|
||||||
);
|
|
||||||
expect(persistedSetupItems[0].detail.log).toContain("line-one");
|
|
||||||
expect(persistedSetupItems[0].detail.log).toContain("line-two");
|
|
||||||
expect(persistedSetupItems[0].detail.log).toContain('==> [2/2] Running: echo "line-three"');
|
|
||||||
expect(persistedSetupItems[0].detail.log).toContain("line-three");
|
|
||||||
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
|
|
||||||
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
|
|
||||||
|
|
||||||
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
|
|
||||||
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
|
|
||||||
index: 1,
|
|
||||||
command: 'echo "line-one"; echo "line-two" 1>&2',
|
|
||||||
log: expect.stringContaining("line-one"),
|
|
||||||
status: "completed",
|
|
||||||
exitCode: 0,
|
|
||||||
});
|
|
||||||
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
|
|
||||||
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
|
|
||||||
index: 2,
|
|
||||||
command: 'echo "line-three"',
|
|
||||||
log: "line-three\n",
|
|
||||||
status: "completed",
|
|
||||||
exitCode: 0,
|
|
||||||
});
|
|
||||||
expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const liveCallIds = new Set(
|
|
||||||
liveSetupItems
|
|
||||||
.filter(
|
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
|
||||||
item.type === "tool_call",
|
|
||||||
)
|
|
||||||
.map((item) => item.callId),
|
|
||||||
);
|
|
||||||
expect(liveCallIds.size).toBe(1);
|
|
||||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
|
||||||
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not fail setup when live timeline emission throws", async () => {
|
it("does not fail setup when live timeline emission throws", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(repoDir, "paseo.json"),
|
join(repoDir, "paseo.json"),
|
||||||
@@ -217,8 +101,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
@@ -268,8 +152,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
@@ -316,59 +200,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps only the final carriage-return-updated content in command logs", async () => {
|
|
||||||
writeFileSync(
|
|
||||||
join(repoDir, "paseo.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
worktree: {
|
|
||||||
setup: [
|
|
||||||
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
|
||||||
cwd: repoDir,
|
|
||||||
branchName: "feature-carriage-return",
|
|
||||||
baseBranch: "main",
|
|
||||||
worktreeSlug: "feature-carriage-return",
|
|
||||||
paseoHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
const persisted: AgentTimelineItem[] = [];
|
|
||||||
await runAsyncWorktreeBootstrap({
|
|
||||||
agentId: "agent-carriage-return",
|
|
||||||
worktree: worktreeBootstrap.worktree,
|
|
||||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
|
||||||
terminalManager: null,
|
|
||||||
appendTimelineItem: async (item) => {
|
|
||||||
persisted.push(item);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
emitLiveTimelineItem: async () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const persistedSetupItem = persisted.find(
|
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
|
||||||
item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
|
||||||
);
|
|
||||||
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
|
|
||||||
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
|
|
||||||
throw new Error("Expected worktree_setup tool detail");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
|
|
||||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
|
|
||||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
|
|
||||||
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits for terminal output before sending bootstrap commands", async () => {
|
it("waits for terminal output before sending bootstrap commands", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(repoDir, "paseo.json"),
|
join(repoDir, "paseo.json"),
|
||||||
@@ -383,11 +214,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", {
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "commit.gpgsign=false", "commit", "-m", "add terminal bootstrap config"],
|
||||||
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
@@ -467,114 +302,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
expect(sendAt).toBeGreaterThanOrEqual(readyAt);
|
expect(sendAt).toBeGreaterThanOrEqual(readyAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
|
|
||||||
writeFileSync(
|
|
||||||
join(repoDir, "paseo.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
worktree: {
|
|
||||||
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
|
|
||||||
terminals: [
|
|
||||||
{
|
|
||||||
name: "Port Terminal",
|
|
||||||
command: "true",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
|
||||||
cwd: repoDir,
|
|
||||||
branchName: "feature-shared-runtime-port",
|
|
||||||
baseBranch: "main",
|
|
||||||
worktreeSlug: "feature-shared-runtime-port",
|
|
||||||
paseoHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
|
|
||||||
const createTerminalEnvs: Record<string, string>[] = [];
|
|
||||||
const persisted: AgentTimelineItem[] = [];
|
|
||||||
await runAsyncWorktreeBootstrap({
|
|
||||||
agentId: "agent-shared-runtime-port",
|
|
||||||
worktree: worktreeBootstrap.worktree,
|
|
||||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
|
||||||
terminalManager: {
|
|
||||||
async getTerminals() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
async createTerminal(options) {
|
|
||||||
createTerminalEnvs.push(options.env ?? {});
|
|
||||||
return {
|
|
||||||
id: "term-1",
|
|
||||||
name: options.name ?? "Terminal",
|
|
||||||
cwd: options.cwd,
|
|
||||||
send: () => {},
|
|
||||||
subscribe: () => () => {},
|
|
||||||
onExit: () => () => {},
|
|
||||||
onCommandFinished: () => () => {},
|
|
||||||
onTitleChange: () => () => {},
|
|
||||||
getSize: () => ({ rows: 1, cols: 1 }),
|
|
||||||
getTitle: () => undefined,
|
|
||||||
getExitInfo: () => null,
|
|
||||||
getState: () => ({
|
|
||||||
rows: 1,
|
|
||||||
cols: 1,
|
|
||||||
grid: [[{ char: "$" }]],
|
|
||||||
scrollback: [],
|
|
||||||
cursor: { row: 0, col: 0 },
|
|
||||||
}),
|
|
||||||
kill: () => {},
|
|
||||||
killAndWait: async () => {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
registerCwdEnv(options) {
|
|
||||||
registeredEnvs.push({ cwd: options.cwd, env: options.env });
|
|
||||||
},
|
|
||||||
getTerminal() {
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
killTerminal() {},
|
|
||||||
async killTerminalAndWait() {},
|
|
||||||
listDirectories() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
killAll() {},
|
|
||||||
subscribeTerminalsChanged() {
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appendTimelineItem: async (item) => {
|
|
||||||
persisted.push(item);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
emitLiveTimelineItem: async () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
|
|
||||||
await waitForPathExists(setupPortPath);
|
|
||||||
|
|
||||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
|
||||||
expect(setupPort.length).toBeGreaterThan(0);
|
|
||||||
expect(registeredEnvs).toHaveLength(1);
|
|
||||||
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
|
|
||||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
|
||||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
|
||||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
|
||||||
|
|
||||||
const terminalToolCall = persisted.find(
|
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "paseo_worktree_terminals" &&
|
|
||||||
item.status === "completed",
|
|
||||||
);
|
|
||||||
expect(terminalToolCall?.status).toBe("completed");
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CreateTerminalCall {
|
interface CreateTerminalCall {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -679,21 +406,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvFile(path: string): Record<string, string> {
|
|
||||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
||||||
throw new Error(`Expected env file to contain a JSON object: ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertServiceTerminalCallSelfEnv(params: {
|
function assertServiceTerminalCallSelfEnv(params: {
|
||||||
createTerminalCalls: CreateTerminalCall[];
|
createTerminalCalls: CreateTerminalCall[];
|
||||||
terminalRecords: StubTerminalRecord[];
|
terminalRecords: StubTerminalRecord[];
|
||||||
@@ -759,8 +471,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
message = "add script config",
|
message = "add script config",
|
||||||
): void {
|
): void {
|
||||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts }));
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts }));
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync(`git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
@@ -1117,11 +829,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add respawn service script config'", {
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "commit.gpgsign=false", "commit", "-m", "add respawn service script config"],
|
||||||
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const routeStore = new ScriptRouteStore();
|
const routeStore = new ScriptRouteStore();
|
||||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
@@ -1216,11 +932,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add renamed service script config'", {
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "commit.gpgsign=false", "commit", "-m", "add renamed service script config"],
|
||||||
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const routeStore = new ScriptRouteStore();
|
const routeStore = new ScriptRouteStore();
|
||||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
@@ -1278,11 +998,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add colliding service config'", {
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "commit.gpgsign=false", "commit", "-m", "add colliding service config"],
|
||||||
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const routeStore = new ScriptRouteStore();
|
const routeStore = new ScriptRouteStore();
|
||||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
@@ -1350,97 +1074,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT");
|
expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("injects real peer service env into terminal-backed services", async () => {
|
|
||||||
writeFileSync(
|
|
||||||
join(repoDir, "paseo.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
scripts: {
|
|
||||||
api: {
|
|
||||||
type: "service",
|
|
||||||
command:
|
|
||||||
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
type: "service",
|
|
||||||
command:
|
|
||||||
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add real peer env services'", {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeStore = new ScriptRouteStore();
|
|
||||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
|
||||||
const terminalManager = createTerminalManager();
|
|
||||||
realTerminalManagers.push(terminalManager);
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
["api", "web"].map((scriptName) =>
|
|
||||||
spawnWorkspaceScript({
|
|
||||||
repoRoot: repoDir,
|
|
||||||
workspaceId: repoDir,
|
|
||||||
projectSlug: "repo",
|
|
||||||
branchName: "feature-peer-env",
|
|
||||||
scriptName,
|
|
||||||
daemonPort: 6767,
|
|
||||||
routeStore,
|
|
||||||
runtimeStore,
|
|
||||||
terminalManager,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const apiEnvPath = join(repoDir, "api-env.json");
|
|
||||||
const webEnvPath = join(repoDir, "web-env.json");
|
|
||||||
await waitForPathExists(apiEnvPath);
|
|
||||||
await waitForPathExists(webEnvPath);
|
|
||||||
|
|
||||||
const apiEnv = readEnvFile(apiEnvPath);
|
|
||||||
const webEnv = readEnvFile(webEnvPath);
|
|
||||||
|
|
||||||
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
|
|
||||||
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
|
|
||||||
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
|
|
||||||
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
|
|
||||||
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
|
|
||||||
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
|
|
||||||
expect(apiEnv).not.toHaveProperty("PORT");
|
|
||||||
|
|
||||||
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
|
|
||||||
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
|
|
||||||
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
|
|
||||||
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
|
|
||||||
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
|
|
||||||
expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT);
|
|
||||||
expect(webEnv).not.toHaveProperty("PORT");
|
|
||||||
|
|
||||||
const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT);
|
|
||||||
const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT);
|
|
||||||
expect(Number.isInteger(apiPort)).toBe(true);
|
|
||||||
expect(Number.isInteger(webPort)).toBe(true);
|
|
||||||
expect(routeStore.listRoutes()).toEqual([
|
|
||||||
{
|
|
||||||
hostname: "api.feature-peer-env.repo.localhost",
|
|
||||||
port: apiPort,
|
|
||||||
workspaceId: repoDir,
|
|
||||||
projectSlug: "repo",
|
|
||||||
scriptName: "api",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hostname: "web.feature-peer-env.repo.localhost",
|
|
||||||
port: webPort,
|
|
||||||
workspaceId: repoDir,
|
|
||||||
projectSlug: "repo",
|
|
||||||
scriptName: "web",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("binds services to the network when the daemon listens on a non-loopback host", async () => {
|
it("binds services to the network when the daemon listens on a non-loopback host", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(repoDir, "paseo.json"),
|
join(repoDir, "paseo.json"),
|
||||||
@@ -1453,11 +1086,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add remote service script config'", {
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "commit.gpgsign=false", "commit", "-m", "add remote service script config"],
|
||||||
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const routeStore = new ScriptRouteStore();
|
const routeStore = new ScriptRouteStore();
|
||||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
|
|||||||
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 {
|
import {
|
||||||
|
mkdirSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
interface LegacyCreateWorktreeTestOptions {
|
interface LegacyCreateWorktreeTestOptions {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
@@ -484,49 +486,49 @@ function createWorkspaceArchivingDeps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
|
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
if (options?.paseoConfig) {
|
if (options?.paseoConfig) {
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
}
|
}
|
||||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGitHubPrRemoteRepo() {
|
function createGitHubPrRemoteRepo() {
|
||||||
const { tempDir, repoDir } = createGitRepo();
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
const featureBranch = "feature/review-pr";
|
const featureBranch = "feature/review-pr";
|
||||||
execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
|
||||||
writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
|
writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
|
||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'review branch'", {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "review branch"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
|
const featureSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
const remoteDir = path.join(tempDir, "remote.git");
|
const remoteDir = path.join(tempDir, "remote.git");
|
||||||
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
execSync(
|
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", featureSha], {
|
||||||
`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`,
|
|
||||||
{
|
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
},
|
});
|
||||||
);
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
|
||||||
|
|
||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
@@ -645,8 +647,8 @@ describe("runWorktreeSetupInBackground", () => {
|
|||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
|
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
|
||||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'broken config'", {
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "broken config"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
@@ -713,7 +715,10 @@ describe("runWorktreeSetupInBackground", () => {
|
|||||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits running setup snapshots before completed for real setup commands", async () => {
|
// POSIX-only: setup command is hardcoded to sh, printf, and sleep.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"emits running setup snapshots before completed for real setup commands",
|
||||||
|
async () => {
|
||||||
const { tempDir, repoDir } = createGitRepo({
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
worktree: {
|
worktree: {
|
||||||
@@ -765,7 +770,9 @@ describe("runWorktreeSetupInBackground", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const progressMessages = emitted.filter(
|
const progressMessages = emitted.filter(
|
||||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
(
|
||||||
|
message,
|
||||||
|
): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||||
message.type === "workspace_setup_progress",
|
message.type === "workspace_setup_progress",
|
||||||
);
|
);
|
||||||
expect(progressMessages.length).toBeGreaterThan(1);
|
expect(progressMessages.length).toBeGreaterThan(1);
|
||||||
@@ -789,7 +796,9 @@ describe("runWorktreeSetupInBackground", () => {
|
|||||||
expect(runningMessages.length).toBeGreaterThan(0);
|
expect(runningMessages.length).toBeGreaterThan(0);
|
||||||
expect(
|
expect(
|
||||||
progressMessages.findIndex((message) => message.payload.status === "running"),
|
progressMessages.findIndex((message) => message.payload.status === "running"),
|
||||||
).toBeLessThan(progressMessages.findIndex((message) => message.payload.status === "completed"));
|
).toBeLessThan(
|
||||||
|
progressMessages.findIndex((message) => message.payload.status === "completed"),
|
||||||
|
);
|
||||||
|
|
||||||
const setupOutputMessage = runningMessages.find((message) =>
|
const setupOutputMessage = runningMessages.find((message) =>
|
||||||
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
||||||
@@ -824,7 +833,8 @@ describe("runWorktreeSetupInBackground", () => {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => {
|
test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => {
|
||||||
const { tempDir, repoDir } = createGitRepo({
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
@@ -1199,7 +1209,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = execSync("git branch --show-current", { cwd: worktreePath, stdio: "pipe" })
|
const branch = execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
expect(branch).toBe("feature/review-pr");
|
expect(branch).toBe("feature/review-pr");
|
||||||
@@ -1248,7 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
|
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
|
||||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||||
|
|
||||||
const branch = execSync("git branch --show-current", {
|
const branch = execFileSync("git", ["branch", "--show-current"], {
|
||||||
cwd: result.sessionConfig.cwd,
|
cwd: result.sessionConfig.cwd,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
@@ -1445,7 +1458,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
branchName: "resolver-feature",
|
branchName: "resolver-feature",
|
||||||
});
|
});
|
||||||
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
const resolvedCwd = resolveDefaultBranch.mock.calls[0]?.[0];
|
||||||
|
expect(resolvedCwd).toBeDefined();
|
||||||
|
expect(realpathSync.native(resolvedCwd ?? "")).toBe(realpathSync.native(repoDir));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1577,10 +1592,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
cwd: registeredWorktreePath,
|
cwd: registeredWorktreePath,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(backgroundWork).toHaveBeenCalledWith(
|
const backgroundInput = backgroundWork.mock.calls[0]?.[0];
|
||||||
|
expect(backgroundInput).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestCwd: repoDir,
|
requestCwd: repoDir,
|
||||||
repoRoot: repoDir,
|
|
||||||
worktree: {
|
worktree: {
|
||||||
branchName: "response-after-create",
|
branchName: "response-after-create",
|
||||||
worktreePath: registeredWorktreePath,
|
worktreePath: registeredWorktreePath,
|
||||||
@@ -1588,6 +1603,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
shouldBootstrap: true,
|
shouldBootstrap: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(realpathSync.native(backgroundInput?.repoRoot ?? "")).toBe(
|
||||||
|
realpathSync.native(repoDir),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { it, expect, afterEach } from "vitest";
|
import { it, expect, afterEach } from "vitest";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
|
||||||
|
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForCondition(
|
async function waitForCondition(
|
||||||
predicate: () => boolean,
|
predicate: () => boolean,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -19,47 +24,52 @@ async function waitForCondition(
|
|||||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
|
||||||
const originalShell = process.env.SHELL;
|
|
||||||
process.env.SHELL = shell;
|
|
||||||
try {
|
|
||||||
return await run();
|
|
||||||
} finally {
|
|
||||||
if (originalShell === undefined) {
|
|
||||||
delete process.env.SHELL;
|
|
||||||
} else {
|
|
||||||
process.env.SHELL = originalShell;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let manager: TerminalManager;
|
let manager: TerminalManager;
|
||||||
const temporaryDirs: string[] = [];
|
const temporaryDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
if (manager) {
|
if (manager) {
|
||||||
|
const terminalsByCwd = await Promise.all(
|
||||||
|
manager.listDirectories().map((cwd) => manager.getTerminals(cwd)),
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
terminalsByCwd
|
||||||
|
.flat()
|
||||||
|
.map((terminal) =>
|
||||||
|
manager.killTerminalAndWait(terminal.id, { gracefulTimeoutMs: 50, forceTimeoutMs: 50 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
manager.killAll();
|
manager.killAll();
|
||||||
}
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
while (temporaryDirs.length > 0) {
|
while (temporaryDirs.length > 0) {
|
||||||
const dir = temporaryDirs.pop();
|
const dir = temporaryDirs.pop();
|
||||||
if (dir) {
|
if (dir) {
|
||||||
|
try {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "EBUSY") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty list for new cwd", async () => {
|
it("returns empty list for new cwd", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const cwd = realpathSync(tmpdir());
|
||||||
|
const terminals = await manager.getTerminals(cwd);
|
||||||
|
|
||||||
expect(terminals).toHaveLength(0);
|
expect(terminals).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns existing terminals on subsequent calls", async () => {
|
it("returns existing terminals on subsequent calls", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const created = await manager.createTerminal({ cwd: "/tmp" });
|
const cwd = realpathSync(tmpdir());
|
||||||
const first = await manager.getTerminals("/tmp");
|
const created = await manager.createTerminal({ cwd });
|
||||||
const second = await manager.getTerminals("/tmp");
|
const first = await manager.getTerminals(cwd);
|
||||||
|
const second = await manager.getTerminals(cwd);
|
||||||
|
|
||||||
expect(first.length).toBe(1);
|
expect(first.length).toBe(1);
|
||||||
expect(first[0].id).toBe(created.id);
|
expect(first[0].id).toBe(created.id);
|
||||||
@@ -79,8 +89,11 @@ it("accepts Windows absolute paths", async () => {
|
|||||||
|
|
||||||
it("creates separate terminals for different cwds", async () => {
|
it("creates separate terminals for different cwds", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-"));
|
||||||
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
|
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-"));
|
||||||
|
temporaryDirs.push(firstCwd, secondCwd);
|
||||||
|
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })];
|
||||||
|
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })];
|
||||||
|
|
||||||
expect(tmpTerminals.length).toBe(1);
|
expect(tmpTerminals.length).toBe(1);
|
||||||
expect(homeTerminals.length).toBe(1);
|
expect(homeTerminals.length).toBe(1);
|
||||||
@@ -89,29 +102,31 @@ it("creates separate terminals for different cwds", async () => {
|
|||||||
|
|
||||||
it("creates additional terminal with auto-incrementing name", async () => {
|
it("creates additional terminal with auto-incrementing name", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await manager.createTerminal({ cwd: "/tmp" });
|
const cwd = realpathSync(tmpdir());
|
||||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
await manager.createTerminal({ cwd });
|
||||||
|
const second = await manager.createTerminal({ cwd });
|
||||||
|
|
||||||
expect(second.name).toBe("Terminal 2");
|
expect(second.name).toBe("Terminal 2");
|
||||||
|
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const terminals = await manager.getTerminals(cwd);
|
||||||
expect(terminals.length).toBe(2);
|
expect(terminals.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses custom name when provided", async () => {
|
it("uses custom name when provided", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" });
|
||||||
|
|
||||||
expect(session.name).toBe("Dev Server");
|
expect(session.name).toBe("Dev Server");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates first terminal if none exist", async () => {
|
it("creates first terminal if none exist", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
const cwd = realpathSync(tmpdir());
|
||||||
|
const session = await manager.createTerminal({ cwd });
|
||||||
|
|
||||||
expect(session.name).toBe("Terminal 1");
|
expect(session.name).toBe("Terminal 1");
|
||||||
|
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const terminals = await manager.getTerminals(cwd);
|
||||||
expect(terminals.length).toBe(1);
|
expect(terminals.length).toBe(1);
|
||||||
expect(terminals[0].id).toBe(session.id);
|
expect(terminals[0].id).toBe(session.id);
|
||||||
});
|
});
|
||||||
@@ -123,18 +138,17 @@ it("throws for relative paths", async () => {
|
|||||||
|
|
||||||
it("does not reject Windows absolute paths as relative", async () => {
|
it("does not reject Windows absolute paths as relative", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
// Should pass path validation (not throw "cwd must be absolute path").
|
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-absolute-path-"));
|
||||||
// The terminal may or may not spawn successfully on non-Windows hosts,
|
temporaryDirs.push(cwd);
|
||||||
// so we only assert the validation error is absent.
|
|
||||||
try {
|
try {
|
||||||
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
|
await manager.createTerminal({ cwd });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect((error as Error).message).not.toBe("cwd must be absolute path");
|
expect((error as Error).message).not.toBe("cwd must be absolute path");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inherits registered env for the worktree root cwd", async () => {
|
it("inherits registered env for the worktree root cwd", async () => {
|
||||||
await withShell("/bin/sh", async () => {
|
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
|
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
|
||||||
temporaryDirs.push(cwd);
|
temporaryDirs.push(cwd);
|
||||||
@@ -144,22 +158,20 @@ it("inherits registered env for the worktree root cwd", async () => {
|
|||||||
cwd,
|
cwd,
|
||||||
env: { PASEO_WORKTREE_PORT: "45678" },
|
env: { PASEO_WORKTREE_PORT: "45678" },
|
||||||
});
|
});
|
||||||
const session = await manager.createTerminal({ cwd });
|
await manager.createTerminal({
|
||||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
cwd,
|
||||||
session.send({
|
command: process.execPath,
|
||||||
type: "input",
|
args: [
|
||||||
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
|
"-e",
|
||||||
|
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||||
expect(readFileSync(markerPath, "utf8")).toBe("45678");
|
expect(readFileSync(markerPath, "utf8")).toBe("45678");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("inherits registered env for subdirectories within the worktree", async () => {
|
it("inherits registered env for subdirectories within the worktree", async () => {
|
||||||
await withShell("/bin/sh", async () => {
|
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
|
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
|
||||||
const subdirCwd = join(rootCwd, "packages", "app");
|
const subdirCwd = join(rootCwd, "packages", "app");
|
||||||
@@ -171,23 +183,22 @@ it("inherits registered env for subdirectories within the worktree", async () =>
|
|||||||
cwd: rootCwd,
|
cwd: rootCwd,
|
||||||
env: { PASEO_WORKTREE_PORT: "45679" },
|
env: { PASEO_WORKTREE_PORT: "45679" },
|
||||||
});
|
});
|
||||||
const session = await manager.createTerminal({ cwd: subdirCwd });
|
await manager.createTerminal({
|
||||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
cwd: subdirCwd,
|
||||||
session.send({
|
command: process.execPath,
|
||||||
type: "input",
|
args: [
|
||||||
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
|
"-e",
|
||||||
|
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||||
expect(readFileSync(markerPath, "utf8")).toBe("45679");
|
expect(readFileSync(markerPath, "utf8")).toBe("45679");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("returns terminal by id", async () => {
|
it("returns terminal by id", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||||
const found = manager.getTerminal(session.id);
|
const found = manager.getTerminal(session.id);
|
||||||
|
|
||||||
expect(found).toBe(session);
|
expect(found).toBe(session);
|
||||||
@@ -202,7 +213,7 @@ it("returns undefined for unknown id", () => {
|
|||||||
|
|
||||||
it("removes terminal from manager", async () => {
|
it("removes terminal from manager", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||||
const id = session.id;
|
const id = session.id;
|
||||||
|
|
||||||
manager.killTerminal(id);
|
manager.killTerminal(id);
|
||||||
@@ -212,24 +223,26 @@ it("removes terminal from manager", async () => {
|
|||||||
|
|
||||||
it("removes cwd entry when last terminal is killed", async () => {
|
it("removes cwd entry when last terminal is killed", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const created = await manager.createTerminal({ cwd: "/tmp" });
|
const cwd = realpathSync(tmpdir());
|
||||||
|
const created = await manager.createTerminal({ cwd });
|
||||||
manager.killTerminal(created.id);
|
manager.killTerminal(created.id);
|
||||||
|
|
||||||
const remaining = await manager.getTerminals("/tmp");
|
const remaining = await manager.getTerminals(cwd);
|
||||||
expect(remaining).toHaveLength(0);
|
expect(remaining).toHaveLength(0);
|
||||||
expect(manager.listDirectories()).not.toContain("/tmp");
|
expect(manager.listDirectories()).not.toContain(cwd);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps cwd entry when other terminals remain", async () => {
|
it("keeps cwd entry when other terminals remain", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await manager.createTerminal({ cwd: "/tmp" });
|
const cwd = realpathSync(tmpdir());
|
||||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
await manager.createTerminal({ cwd });
|
||||||
|
const second = await manager.createTerminal({ cwd });
|
||||||
|
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const terminals = await manager.getTerminals(cwd);
|
||||||
manager.killTerminal(terminals[0].id);
|
manager.killTerminal(terminals[0].id);
|
||||||
|
|
||||||
expect(manager.listDirectories()).toContain("/tmp");
|
expect(manager.listDirectories()).toContain(cwd);
|
||||||
const remaining = await manager.getTerminals("/tmp");
|
const remaining = await manager.getTerminals(cwd);
|
||||||
expect(remaining.length).toBe(1);
|
expect(remaining.length).toBe(1);
|
||||||
expect(remaining[0].id).toBe(second.id);
|
expect(remaining[0].id).toBe(second.id);
|
||||||
});
|
});
|
||||||
@@ -241,7 +254,8 @@ it("is no-op for unknown id", () => {
|
|||||||
|
|
||||||
it("auto-removes terminal when shell exits", async () => {
|
it("auto-removes terminal when shell exits", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
const cwd = realpathSync(tmpdir());
|
||||||
|
const session = await manager.createTerminal({ cwd });
|
||||||
const exitedId = session.id;
|
const exitedId = session.id;
|
||||||
session.kill();
|
session.kill();
|
||||||
|
|
||||||
@@ -249,7 +263,7 @@ it("auto-removes terminal when shell exits", async () => {
|
|||||||
|
|
||||||
expect(manager.getTerminal(exitedId)).toBeUndefined();
|
expect(manager.getTerminal(exitedId)).toBeUndefined();
|
||||||
|
|
||||||
const remaining = await manager.getTerminals("/tmp");
|
const remaining = await manager.getTerminals(cwd);
|
||||||
expect(remaining).toHaveLength(0);
|
expect(remaining).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,19 +274,25 @@ it("returns empty array initially", () => {
|
|||||||
|
|
||||||
it("returns all cwds with active terminals", async () => {
|
it("returns all cwds with active terminals", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await manager.createTerminal({ cwd: "/tmp" });
|
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-"));
|
||||||
await manager.createTerminal({ cwd: "/home" });
|
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-"));
|
||||||
|
temporaryDirs.push(firstCwd, secondCwd);
|
||||||
|
await manager.createTerminal({ cwd: firstCwd });
|
||||||
|
await manager.createTerminal({ cwd: secondCwd });
|
||||||
|
|
||||||
const dirs = manager.listDirectories();
|
const dirs = manager.listDirectories();
|
||||||
expect(dirs).toContain("/tmp");
|
expect(dirs).toContain(firstCwd);
|
||||||
expect(dirs).toContain("/home");
|
expect(dirs).toContain(secondCwd);
|
||||||
expect(dirs.length).toBe(2);
|
expect(dirs.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills all terminals and clears state", async () => {
|
it("kills all terminals and clears state", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
|
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-"));
|
||||||
const homeSession = await manager.createTerminal({ cwd: "/home" });
|
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-"));
|
||||||
|
temporaryDirs.push(firstCwd, secondCwd);
|
||||||
|
const tmpSession = await manager.createTerminal({ cwd: firstCwd });
|
||||||
|
const homeSession = await manager.createTerminal({ cwd: secondCwd });
|
||||||
const tmpId = tmpSession.id;
|
const tmpId = tmpSession.id;
|
||||||
const homeId = homeSession.id;
|
const homeId = homeSession.id;
|
||||||
|
|
||||||
@@ -285,6 +305,7 @@ it("kills all terminals and clears state", async () => {
|
|||||||
|
|
||||||
it("emits cwd snapshots when terminals are created", async () => {
|
it("emits cwd snapshots when terminals are created", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
|
const cwd = realpathSync(tmpdir());
|
||||||
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
|
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
|
||||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||||
snapshots.push({
|
snapshots.push({
|
||||||
@@ -296,15 +317,15 @@ it("emits cwd snapshots when terminals are created", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.createTerminal({ cwd: "/tmp" });
|
await manager.createTerminal({ cwd });
|
||||||
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
await manager.createTerminal({ cwd, name: "Dev Server" });
|
||||||
|
|
||||||
expect(snapshots).toContainEqual({
|
expect(snapshots).toContainEqual({
|
||||||
cwd: "/tmp",
|
cwd,
|
||||||
terminals: [{ name: "Terminal 1" }],
|
terminals: [{ name: "Terminal 1" }],
|
||||||
});
|
});
|
||||||
expect(snapshots).toContainEqual({
|
expect(snapshots).toContainEqual({
|
||||||
cwd: "/tmp",
|
cwd,
|
||||||
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
|
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,24 +351,26 @@ function hasLogsTitle(sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("emits updated terminal titles after debounced title changes", async () => {
|
it("emits updated terminal titles after debounced title changes", async () => {
|
||||||
await withShell("/bin/sh", async () => {
|
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const snapshots: TerminalTitleEntry[][] = [];
|
const snapshots: TerminalTitleEntry[][] = [];
|
||||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||||
snapshots.push(input.terminals.map(toTitleEntry));
|
snapshots.push(input.terminals.map(toTitleEntry));
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
const session = await manager.createTerminal({
|
||||||
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
|
cwd: realpathSync(tmpdir()),
|
||||||
|
command: process.execPath,
|
||||||
|
args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"],
|
||||||
|
});
|
||||||
|
|
||||||
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
|
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
|
||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
it("emits empty snapshot when last terminal is removed", async () => {
|
it("emits empty snapshot when last terminal is removed", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
|
const cwd = realpathSync(tmpdir());
|
||||||
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
||||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||||
snapshots.push({
|
snapshots.push({
|
||||||
@@ -356,11 +379,11 @@ it("emits empty snapshot when last terminal is removed", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
const session = await manager.createTerminal({ cwd });
|
||||||
manager.killTerminal(session.id);
|
manager.killTerminal(session.id);
|
||||||
|
|
||||||
expect(snapshots).toContainEqual({
|
expect(snapshots).toContainEqual({
|
||||||
cwd: "/tmp",
|
cwd,
|
||||||
terminalCount: 0,
|
terminalCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -38,16 +38,18 @@ function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoD
|
|||||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
|
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
|
||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
|
|
||||||
execSync(`mkdir -p ${repoDir}`);
|
mkdirSync(repoDir, { recursive: true });
|
||||||
execSync("git init -b main", { cwd: repoDir });
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
|
||||||
execSync("git config user.name 'Test'", { cwd: repoDir });
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
|
||||||
|
|
||||||
for (let i = 0; i < fileCount; i += 1) {
|
for (let i = 0; i < fileCount; i += 1) {
|
||||||
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
|
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
|
||||||
}
|
}
|
||||||
execSync("git add .", { cwd: repoDir });
|
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 0; i < fileCount; i += 1) {
|
for (let i = 0; i < fileCount; i += 1) {
|
||||||
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
|
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,10 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSyn
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
||||||
|
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = isPlatform("win32");
|
||||||
|
|
||||||
describe("searchHomeDirectories", () => {
|
describe("searchHomeDirectories", () => {
|
||||||
let tempRoot: string;
|
let tempRoot: string;
|
||||||
@@ -18,6 +19,8 @@ describe("searchHomeDirectories", () => {
|
|||||||
|
|
||||||
mkdirSync(homeDir, { recursive: true });
|
mkdirSync(homeDir, { recursive: true });
|
||||||
mkdirSync(outsideDir, { recursive: true });
|
mkdirSync(outsideDir, { recursive: true });
|
||||||
|
homeDir = realpathSync(homeDir);
|
||||||
|
outsideDir = realpathSync(outsideDir);
|
||||||
|
|
||||||
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
||||||
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
||||||
@@ -26,7 +29,9 @@ describe("searchHomeDirectories", () => {
|
|||||||
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
||||||
|
|
||||||
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
||||||
|
if (!isWindows) {
|
||||||
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -50,8 +55,9 @@ describe("searchHomeDirectories", () => {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(results).toContain(path.join(homeDir, "projects"));
|
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||||
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects")));
|
||||||
|
expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects", "paseo")));
|
||||||
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +68,9 @@ describe("searchHomeDirectories", () => {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(results).toEqual([path.join(homeDir, "projects", "paseo")]);
|
expect(results.map((result) => realpathSync.native(result))).toEqual([
|
||||||
|
realpathSync.native(path.join(homeDir, "projects", "paseo")),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
||||||
@@ -77,8 +85,9 @@ describe("searchHomeDirectories", () => {
|
|||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const exactIndex = results.indexOf(exactSegmentPath);
|
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||||
const prefixIndex = results.indexOf(prefixSegmentPath);
|
const exactIndex = resolvedResults.indexOf(realpathSync.native(exactSegmentPath));
|
||||||
|
const prefixIndex = resolvedResults.indexOf(realpathSync.native(prefixSegmentPath));
|
||||||
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(exactIndex).toBeLessThan(prefixIndex);
|
expect(exactIndex).toBeLessThan(prefixIndex);
|
||||||
@@ -96,8 +105,9 @@ describe("searchHomeDirectories", () => {
|
|||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const earlierIndex = results.indexOf(earlierPath);
|
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||||
const laterIndex = results.indexOf(laterPath);
|
const earlierIndex = resolvedResults.indexOf(realpathSync.native(earlierPath));
|
||||||
|
const laterIndex = resolvedResults.indexOf(realpathSync.native(laterPath));
|
||||||
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(earlierIndex).toBeLessThan(laterIndex);
|
expect(earlierIndex).toBeLessThan(laterIndex);
|
||||||
@@ -125,7 +135,8 @@ describe("searchHomeDirectories", () => {
|
|||||||
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
|
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not return paths that escape home through symlinks", async () => {
|
// POSIX-only: creates and follows a symlink escape fixture.
|
||||||
|
it.skipIf(isWindows)("does not return paths that escape home through symlinks", async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "outside",
|
query: "outside",
|
||||||
@@ -170,7 +181,9 @@ describe("searchWorkspaceEntries", () => {
|
|||||||
);
|
);
|
||||||
writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n");
|
writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n");
|
||||||
|
|
||||||
|
if (!isWindows) {
|
||||||
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
|
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -214,7 +227,10 @@ describe("searchWorkspaceEntries", () => {
|
|||||||
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports path-style queries and does not escape cwd through symlinks", async () => {
|
// POSIX-only: creates and follows a symlink escape fixture.
|
||||||
|
it.skipIf(isWindows)(
|
||||||
|
"supports path-style queries and does not escape cwd through symlinks",
|
||||||
|
async () => {
|
||||||
const pathResults = await searchWorkspaceEntries({
|
const pathResults = await searchWorkspaceEntries({
|
||||||
cwd: workspaceDir,
|
cwd: workspaceDir,
|
||||||
query: "src/co",
|
query: "src/co",
|
||||||
@@ -235,7 +251,8 @@ describe("searchWorkspaceEntries", () => {
|
|||||||
includeDirectories: true,
|
includeDirectories: true,
|
||||||
});
|
});
|
||||||
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
|
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
|
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
|
||||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { probeExecutable } from "./executable.js";
|
import { probeExecutable } from "./executable.js";
|
||||||
|
|
||||||
const timeoutMs = 1000;
|
const timeoutMs = 1000;
|
||||||
@@ -142,7 +143,10 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("probeExecutable", () => {
|
describe("probeExecutable", () => {
|
||||||
test.each(fixtures)("$name", async ({ create, expected }) => {
|
// POSIX-only: positive fixtures rely on direct script probing; Windows command-script probing has separate coverage.
|
||||||
|
test.skipIf(isPlatform("win32")).each(fixtures.filter((fixture) => fixture.expected))(
|
||||||
|
"$name",
|
||||||
|
async ({ create, expected }) => {
|
||||||
const { executablePath, pidFile } = create(makeTempDir());
|
const { executablePath, pidFile } = create(makeTempDir());
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
|
|
||||||
@@ -155,5 +159,24 @@ describe("probeExecutable", () => {
|
|||||||
const pid = Number(readFileSync(pidFile, "utf8"));
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
||||||
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(fixtures.filter((fixture) => !fixture.expected))(
|
||||||
|
"$name",
|
||||||
|
async ({ create, expected }) => {
|
||||||
|
const { executablePath, pidFile } = create(makeTempDir());
|
||||||
|
const startedAt = performance.now();
|
||||||
|
|
||||||
|
const result = await probeExecutable(executablePath, timeoutMs);
|
||||||
|
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
|
||||||
|
if (pidFile) {
|
||||||
|
await waitForFile(pidFile);
|
||||||
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
||||||
|
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
quoteWindowsArgument,
|
quoteWindowsArgument,
|
||||||
quoteWindowsCommand,
|
quoteWindowsCommand,
|
||||||
} from "./executable.js";
|
} from "./executable.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
PATH: process.env.PATH,
|
PATH: process.env.PATH,
|
||||||
@@ -28,24 +29,16 @@ function prependPath(...dirs: string[]): void {
|
|||||||
|
|
||||||
function writeExecutable(filePath: string, content: string): string {
|
function writeExecutable(filePath: string, content: string): string {
|
||||||
writeFileSync(filePath, content);
|
writeFileSync(filePath, content);
|
||||||
if (process.platform !== "win32") {
|
if (!isPlatform("win32")) {
|
||||||
chmodSync(filePath, 0o755);
|
chmodSync(filePath, 0o755);
|
||||||
}
|
}
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeInvokableFixture(dir: string, name: string): string {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return writeExecutable(path.join(dir, `${name}.cmd`), "@echo off\r\necho 0.1\r\n");
|
|
||||||
}
|
|
||||||
return writeExecutable(path.join(dir, name), "#!/bin/sh\necho 0.1\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeBrokenAbsoluteFixture(dir: string): string {
|
function writeBrokenAbsoluteFixture(dir: string): string {
|
||||||
const filePath =
|
const filePath = isPlatform("win32") ? path.join(dir, "broken.exe") : path.join(dir, "broken");
|
||||||
process.platform === "win32" ? path.join(dir, "broken.exe") : path.join(dir, "broken");
|
|
||||||
writeFileSync(filePath, "not executable");
|
writeFileSync(filePath, "not executable");
|
||||||
if (process.platform !== "win32") {
|
if (!isPlatform("win32")) {
|
||||||
chmodSync(filePath, 0o644);
|
chmodSync(filePath, 0o644);
|
||||||
}
|
}
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -64,7 +57,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("findExecutable", () => {
|
describe("findExecutable", () => {
|
||||||
describe.skipIf(process.platform === "win32")("POSIX", () => {
|
describe.skipIf(isPlatform("win32"))("POSIX", () => {
|
||||||
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
|
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
|
||||||
const executableDir = makeTempDir();
|
const executableDir = makeTempDir();
|
||||||
const nonExecutableDir = makeTempDir();
|
const nonExecutableDir = makeTempDir();
|
||||||
@@ -78,7 +71,7 @@ describe("findExecutable", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.runIf(process.platform === "win32")("Windows", () => {
|
describe.runIf(isPlatform("win32"))("Windows", () => {
|
||||||
test("returns a working .cmd when an invalid .exe candidate appears first", async () => {
|
test("returns a working .cmd when an invalid .exe candidate appears first", async () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
|
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
|
||||||
@@ -110,10 +103,7 @@ describe("findExecutable", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns an invokable absolute path", async () => {
|
test("returns an invokable absolute path", async () => {
|
||||||
const dir = makeTempDir();
|
await expect(findExecutable(process.execPath)).resolves.toBe(process.execPath);
|
||||||
const fixture = writeInvokableFixture(dir, "absolute-ok");
|
|
||||||
|
|
||||||
await expect(findExecutable(fixture)).resolves.toBe(fixture);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for an absolute path that cannot spawn", async () => {
|
test("returns null for an absolute path that cannot spawn", async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
|
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
|
||||||
import {
|
import {
|
||||||
readPaseoConfigForEdit,
|
readPaseoConfigForEdit,
|
||||||
@@ -97,7 +98,10 @@ describe("paseo config file substrate", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects stale writes when the current revision changed before rename", () => {
|
// POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture.
|
||||||
|
it.skipIf(isPlatform("win32"))(
|
||||||
|
"rejects stale writes when the current revision changed before rename",
|
||||||
|
() => {
|
||||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
|
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
|
||||||
@@ -116,7 +120,8 @@ describe("paseo config file substrate", () => {
|
|||||||
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
||||||
JSON.stringify({ worktree: { setup: "new" } }),
|
JSON.stringify({ worktree: { setup: "new" } }),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
|
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import { findExecutable } from "./executable.js";
|
import { findExecutable } from "./executable.js";
|
||||||
import { spawnProcess } from "./spawn.js";
|
import { spawnProcess } from "./spawn.js";
|
||||||
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
|
||||||
interface SpawnResult {
|
interface SpawnResult {
|
||||||
code: number | null;
|
code: number | null;
|
||||||
@@ -136,8 +137,7 @@ async function runFixture(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
|
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
|
||||||
const pathKey =
|
const pathKey = isPlatform("win32")
|
||||||
process.platform === "win32"
|
|
||||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
|
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
|
||||||
: "PATH";
|
: "PATH";
|
||||||
const previousPath = process.env[pathKey];
|
const previousPath = process.env[pathKey];
|
||||||
@@ -169,7 +169,7 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => {
|
describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
|
||||||
test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => {
|
test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => {
|
||||||
const fixture = makeFixture();
|
const fixture = makeFixture();
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ describe.runIf(process.platform === "win32")("Windows spawn launch regression",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => {
|
describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
|
||||||
test("direct launch with a space-containing executable works on this platform", async () => {
|
test("direct launch with a space-containing executable works on this platform", async () => {
|
||||||
const fixture = makeFixture();
|
const fixture = makeFixture();
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe("execCommand", () => {
|
|||||||
|
|
||||||
const result = await execCommand(command.command, command.args, { cwd });
|
const result = await execCommand(command.command, command.args, { cwd });
|
||||||
|
|
||||||
expect(result.stdout.trim()).toBe(cwd);
|
expect(realpathSync(result.stdout.trim())).toBe(cwd);
|
||||||
expect(result.stderr).toBe("");
|
expect(result.stderr).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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