diff --git a/packages/server/package.json b/packages/server/package.json index a69a2c3c2a..6e5e2448a2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index b377c22374..69c1da2efc 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, onTestFinished, test } from "vitest"; @@ -18,6 +19,7 @@ function createGitRepoWithBranch(): { repoDir: string; tempRoot: string } { const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-create-worktree-source-")); const repoDir = path.join(tempRoot, "repo"); execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "core.autocrlf", "false"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repoDir, stdio: "pipe", @@ -60,10 +62,134 @@ test("workspace.create worktree source forwards action=checkout + refName into t } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); +test.each([false, true])( + "workspace.create opens worktree.terminals (setup service: %s)", + async (withSetupService) => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + ...(withSetupService + ? { + // Launch the Windows service asynchronously so it outlives + // the setup command instead of holding setup open. + setup: + process.platform === "win32" + ? "Start-Process -FilePath node -ArgumentList 'setup.cjs','serve' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden; while (!(Test-Path setup-pid.txt)) { Start-Sleep -Milliseconds 25 }" + : "node setup.cjs", + } + : {}), + terminals: [ + { name: "preview", command: "echo paseo-auto-terminal >> terminal-marker.txt" }, + ], + }, + }), + ); + writeFileSync( + path.join(repoDir, "setup.cjs"), + ` +const fs = require("node:fs"); +if (process.argv[2] === "serve") { + require("node:net").createServer().listen(Number(process.env.PASEO_WORKTREE_PORT), "127.0.0.1", () => { + fs.writeFileSync("setup-pid.txt", String(process.pid)); + process.send?.("ready"); + }); +} else { + const child = require("node:child_process").spawn(process.execPath, [__filename, "serve"], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + child.once("message", () => { child.disconnect(); child.unref(); }); +} +`, + ); + execFileSync("git", ["add", "paseo.json", "setup.cjs"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + stdio: "pipe", + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + let workspaceCwd: string | undefined; + try { + await client.connect(); + + // This is the request the New Workspace screen sends: the workspace is + // created on its own, with no agent in the same request. + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + branchName: "feature/auto-terminals", + worktreeSlug: "feature-auto-terminals", + baseBranch: "main", + }, + }); + + expect(result.error).toBeNull(); + const workspace = result.workspace; + if (!workspace) { + throw new Error("Expected a created workspace"); + } + + workspaceCwd = workspace.workspaceDirectory; + if (withSetupService) { + await expect + .poll(() => existsSync(path.join(workspaceCwd!, "setup-pid.txt")), { timeout: 30000 }) + .toBe(true); + } + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(workspace.id)).snapshot, { + timeout: 30000, + }) + .toMatchObject({ status: "completed", error: null }); + await expect + .poll( + async () => + ( + await client.listTerminals(workspace.workspaceDirectory, undefined, { + workspaceId: workspace.id, + }) + ).terminals.map((terminal) => terminal.name), + { timeout: 30000 }, + ) + .toContain("preview"); + await expect + .poll( + () => { + const marker = path.join(workspaceCwd!, "terminal-marker.txt"); + return existsSync(marker) ? readFileSync(marker, "utf8").trim() : null; + }, + { timeout: 10000 }, + ) + .toBe("paseo-auto-terminal"); + expect( + (await client.listTerminals(workspaceCwd, undefined, { workspaceId: workspace.id })) + .terminals, + ).toHaveLength(1); + } finally { + const pidFile = workspaceCwd && path.join(workspaceCwd, "setup-pid.txt"); + if (pidFile && existsSync(pidFile)) { + process.kill(Number(readFileSync(pidFile, "utf8"))); + } + await client.close().catch(() => undefined); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 180000, +); + test("existing branch changes remain visible after archive and restore", async () => { const daemon = await createTestPaseoDaemon(); const { repoDir, tempRoot } = createGitRepoWithBranch(); @@ -74,7 +200,7 @@ test("existing branch changes remain visible after archive and restore", async ( onTestFinished(async () => { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); runGit(repoDir, "switch", "feature/existing-branch"); commitFile(repoDir, "feature.txt", "feature change\n"); @@ -134,7 +260,7 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -168,7 +294,7 @@ test("workspace.create accepts a Git-valid branch-off name outside Paseo slug sy } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -210,7 +336,7 @@ test("workspace.create always creates a new worktree when the slug is already oc } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -255,7 +381,7 @@ test("workspace.create suffixes past an occupied detached worktree", async () => } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -297,7 +423,7 @@ test("workspace.create suffixes an occupied checkout branch", async () => { } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -340,7 +466,7 @@ async function createRestorableWorkspace(baseRef: string) { onTestFinished(async () => { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); createDivergentBases(repoDir); await client.connect(); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 8e69908f59..4bd49b1c2d 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -593,13 +593,10 @@ export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; workspaceCwd: string; + runtimeEnv: WorktreeRuntimeEnv; terminalManager: TerminalManager | null; logger?: Logger; }): Promise { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktree.worktreePath, - branchName: options.worktree.branchName, - }); await runWorktreeTerminalBootstrap( { agentId: options.workspaceId, @@ -610,7 +607,7 @@ export async function runWorktreeAutoTerminals(options: { appendTimelineItem: async () => true, logger: options.logger, }, - runtimeEnv, + options.runtimeEnv, ); } diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cdb0119b64..82f0113bd5 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -817,7 +817,6 @@ export async function handleWorkspaceSetupRunRequest( slug: basename(worktree.worktreePath), worktreePath: worktree.worktreePath, workspaceCwd: workspace.cwd, - runAutoTerminals: true, }, signal, ), @@ -857,7 +856,6 @@ export async function runWorktreeSetupInBackground( slug: string; worktreePath: string; workspaceCwd?: string; - runAutoTerminals?: boolean; }, signal?: AbortSignal, ): Promise { @@ -898,18 +896,18 @@ export async function runWorktreeSetupInBackground( } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + repoRootPath: options.repoRoot, + }); + dependencies.terminalManager?.registerCwdEnv({ + cwd: workspaceCwd, + env: runtimeEnv, + }); if (setupCommands.length === 0) { emitSetupProgress("completed", null); } else { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - repoRootPath: options.repoRoot, - }); - dependencies.terminalManager?.registerCwdEnv({ - cwd: workspaceCwd, - env: runtimeEnv, - }); setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, @@ -924,15 +922,14 @@ export async function runWorktreeSetupInBackground( }); emitSetupProgress("completed", null); } - if (options.runAutoTerminals) { - await runWorktreeAutoTerminals({ - workspaceId, - worktree, - workspaceCwd, - terminalManager: dependencies.terminalManager, - logger: dependencies.sessionLogger, - }); - } + await runWorktreeAutoTerminals({ + workspaceId, + worktree, + workspaceCwd, + runtimeEnv, + terminalManager: dependencies.terminalManager, + logger: dependencies.sessionLogger, + }); } } catch (error) { if (error instanceof WorktreeSetupError) {