From a18d9ec3d87baefdac6d11153b243a527c6701f7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 20:30:45 +0200 Subject: [PATCH] test(workspaces): characterize runtime boundaries --- ...space-runtime-characterization.e2e.test.ts | 285 ++++++++++++++++++ .../fixtures/workspace-runtime-acp-agent.mjs | 76 +++++ ...it-service.observation.integration.test.ts | 86 +++++- 3 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts create mode 100644 packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts new file mode 100644 index 0000000000..c990d4c94e --- /dev/null +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -0,0 +1,285 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { FileVersion } from "@getpaseo/protocol/messages"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const FIXTURE_PROVIDER = "workspace-runtime-fixture"; +const FIXTURE_MODEL = "fixture-model"; +const fixtureAgentPath = fileURLToPath( + new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url), +); + +interface CharacterizedWorkspace { + cwd: string; + id: string; + kind: "local" | "worktree"; +} + +let daemon: TestPaseoDaemon; +let client: DaemonClient; +const cleanupRoots: string[] = []; + +beforeAll(async () => { + daemon = await createTestPaseoDaemon({ + mcpEnabled: false, + providerOverrides: { + [FIXTURE_PROVIDER]: { + extends: "acp", + label: "Workspace Runtime Fixture", + command: [process.execPath, fixtureAgentPath], + models: [{ id: FIXTURE_MODEL, label: "Fixture Model", isDefault: true }], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + }); + client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.3.0-beta.2", + reconnect: { enabled: false }, + }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "runtime-characterization-agents" } }); +}); + +afterAll(async () => { + await client?.close().catch(() => undefined); + await daemon?.close(); + for (const root of cleanupRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function createRepository(): string { + const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-characterization-")); + cleanupRoots.push(root); + const repo = path.join(root, "repo"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repo, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo, stdio: "pipe" }); + writeFileSync(path.join(repo, "characterized.txt"), "before\n"); + writeFileSync( + path.join(repo, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + `${JSON.stringify(process.execPath)} -e "require('fs').writeFileSync('setup-output.txt', 'setup complete\\n')"`, + ], + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repo, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "characterization fixture"], { + cwd: repo, + stdio: "pipe", + }); + return repo; +} + +async function createCharacterizedWorkspace(kind: "local" | "worktree") { + const repo = createRepository(); + const result = await client.createWorkspace({ + source: + kind === "local" + ? { kind: "directory", path: repo } + : { + kind: "worktree", + cwd: repo, + action: "branch-off", + branchName: "characterized-worktree", + worktreeSlug: "characterized-worktree", + baseBranch: "main", + }, + }); + const workspace = result.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(result.error ?? `Failed to create ${kind} workspace`); + } + return { + cwd: workspace.workspaceDirectory, + id: workspace.id, + kind, + } satisfies CharacterizedWorkspace; +} + +async function waitForTerminalOutput(terminalId: string, marker: string): Promise { + return new Promise((resolve, reject) => { + const decoder = new TextDecoder(); + let output = ""; + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for terminal output: ${marker}`)); + }, 15_000); + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.terminalId !== terminalId || event.type !== "output") return; + output += decoder.decode(event.data, { stream: true }); + if (!output.includes(marker)) return; + clearTimeout(timeout); + unsubscribe(); + resolve(output); + }); + }); +} + +async function expectFileEditAndWatch(workspace: CharacterizedWorkspace): Promise { + const listing = await client.listDirectory(workspace.cwd, "."); + expect(listing.entries.map((entry) => entry.name)).toContain("characterized.txt"); + + const initialRead = await client.readFile(workspace.cwd, "characterized.txt"); + expect(new TextDecoder().decode(initialRead.bytes)).toBe("before\n"); + + let resolveUpdate!: (version: FileVersion) => void; + const updated = new Promise((resolve) => { + resolveUpdate = resolve; + }); + const fileSubscription = await client.subscribeFile( + { cwd: workspace.cwd, path: "characterized.txt" }, + resolveUpdate, + ); + expect(fileSubscription.initial).toMatchObject({ status: "ready", size: 7 }); + if (fileSubscription.initial.status !== "ready") { + throw new Error("Expected characterized.txt to be ready"); + } + + const write = await client.writeFile({ + cwd: workspace.cwd, + path: "characterized.txt", + content: "after\n", + expectedModifiedAt: fileSubscription.initial.modifiedAt, + expectedRevision: fileSubscription.initial.revision, + }); + expect(write).toMatchObject({ status: "written", size: 6 }); + await expect(updated).resolves.toMatchObject({ status: "ready", size: 6 }); + fileSubscription.unsubscribe(); + + const writtenRead = await client.readFile(workspace.cwd, "characterized.txt"); + expect(new TextDecoder().decode(writtenRead.bytes)).toBe("after\n"); +} + +async function expectGitObservation(workspace: CharacterizedWorkspace): Promise { + await expect + .poll(() => client.getCheckoutStatus(workspace.cwd), { timeout: 15_000 }) + .toMatchObject({ + isGit: true, + isDirty: true, + }); + const diff = await client.getCheckoutDiff(workspace.cwd, { mode: "uncommitted" }); + expect(diff.error).toBeNull(); + expect(diff.files).toContainEqual( + expect.objectContaining({ path: "characterized.txt", status: "ok" }), + ); +} + +async function expectTerminalCommand(workspace: CharacterizedWorkspace): Promise { + const terminal = await client.createTerminal( + workspace.cwd, + `${workspace.kind} terminal`, + undefined, + { + workspaceId: workspace.id, + }, + ); + const terminalId = terminal.terminal?.id; + if (!terminalId) throw new Error(terminal.error ?? "Failed to create terminal"); + const marker = "runtime-terminal-ok"; + const command = "printf '%s%s\\n' runtime-terminal- ok"; + expect(command).not.toContain(marker); + try { + const terminalOutput = waitForTerminalOutput(terminalId, marker); + await client.subscribeTerminal(terminalId); + client.sendTerminalInput(terminalId, { type: "input", data: `${command}\r` }); + await expect(terminalOutput).resolves.toContain(marker); + } finally { + await client.killTerminal(terminalId); + } +} + +async function expectProviderDiscoveryAndAgentExecution( + workspace: CharacterizedWorkspace, +): Promise { + await client.refreshProvidersSnapshot({ cwd: workspace.cwd, providers: [FIXTURE_PROVIDER] }); + const providers = await client.getProvidersSnapshot({ cwd: workspace.cwd }); + expect(providers.entries).toContainEqual( + expect.objectContaining({ + provider: FIXTURE_PROVIDER, + label: "Workspace Runtime Fixture", + status: "ready", + models: [expect.objectContaining({ id: FIXTURE_MODEL })], + }), + ); + + const agent = await client.createAgent({ + provider: FIXTURE_PROVIDER, + model: FIXTURE_MODEL, + cwd: workspace.cwd, + workspaceId: workspace.id, + title: `${workspace.kind} stdio fixture`, + }); + await client.sendMessage(agent.id, `characterize ${workspace.kind}`); + const finished = await client.waitForFinish(agent.id, 30_000); + expect(finished.status).toBe("idle"); + const agentRead = await client.readFile(workspace.cwd, "stdio-agent-output.txt"); + expect(new TextDecoder().decode(agentRead.bytes)).toBe(`characterize ${workspace.kind}\n`); +} + +async function readWorkspaceTextFile(cwd: string, filePath: string): Promise { + const file = await client.readFile(cwd, filePath); + return new TextDecoder().decode(file.bytes); +} + +describe("current workspace runtime journeys", () => { + test("local workspace uses the public daemon/client behavior", async () => { + const workspace = await createCharacterizedWorkspace("local"); + try { + await expectFileEditAndWatch(workspace); + await expectGitObservation(workspace); + await expectTerminalCommand(workspace); + await expectProviderDiscoveryAndAgentExecution(workspace); + const archive = await client.archiveWorkspace(workspace.id); + expect(archive.error).toBeNull(); + expect(existsSync(workspace.cwd)).toBe(true); + expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe("after\n"); + } finally { + await client.archiveWorkspace(workspace.id).catch(() => undefined); + } + }, 120_000); + + test("owned worktree runs setup and uses the same public daemon/client behavior", async () => { + const workspace = await createCharacterizedWorkspace("worktree"); + try { + await expect + .poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 30_000 }) + .toMatchObject({ snapshot: { status: "completed", error: null } }); + await expect(readWorkspaceTextFile(workspace.cwd, "setup-output.txt")).resolves.toBe( + "setup complete\n", + ); + + await expectFileEditAndWatch(workspace); + await expectGitObservation(workspace); + await expectTerminalCommand(workspace); + await expectProviderDiscoveryAndAgentExecution(workspace); + + const removal = await client.archivePaseoWorktree({ + worktreePath: workspace.cwd, + workspaceId: workspace.id, + }); + expect(removal).toMatchObject({ success: true, error: null }); + await expect.poll(() => existsSync(workspace.cwd), { timeout: 15_000 }).toBe(false); + } finally { + if (existsSync(workspace.cwd)) { + await client + .archivePaseoWorktree({ worktreePath: workspace.cwd, workspaceId: workspace.id }) + .catch(() => undefined); + } + } + }, 120_000); +}); diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs b/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs new file mode 100644 index 0000000000..99f0e4588a --- /dev/null +++ b/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk"; + +if (process.argv.includes("--version")) { + process.stdout.write("workspace-runtime-fixture 1.0.0\n"); + process.exit(0); +} + +class WorkspaceRuntimeFixtureAgent { + constructor(connection) { + this.connection = connection; + this.sessions = new Map(); + } + + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: false }, + }; + } + + async newSession(params) { + const sessionId = `fixture-session-${this.sessions.size + 1}`; + this.sessions.set(sessionId, { cwd: params.cwd }); + return { + sessionId, + models: { + availableModels: [{ modelId: "fixture-model", name: "Fixture Model" }], + currentModelId: "fixture-model", + }, + }; + } + + async authenticate() { + return {}; + } + + async setSessionMode() { + return {}; + } + + async unstable_setSessionModel() { + return {}; + } + + async cancel() {} + + async prompt(params) { + const session = this.sessions.get(params.sessionId); + if (!session) throw new Error(`Unknown fixture session: ${params.sessionId}`); + const text = params.prompt + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + await writeFile(path.join(session.cwd, "stdio-agent-output.txt"), `${text}\n`); + await this.connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `fixture completed: ${text}` }, + }, + }); + return { stopReason: "end_turn" }; + } +} + +const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)); +const connection = new AgentSideConnection( + (agentConnection) => new WorkspaceRuntimeFixtureAgent(agentConnection), + stream, +); +await connection.closed; diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 95d490e914..3d0d3b757c 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,21 +1,25 @@ +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import type pino from "pino"; +import pino, { type Logger } from "pino"; import { afterEach, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { createFileObserver } from "./file-observer/index.js"; -import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; import type { FileChange, SubscribeToFileChanges } from "./file-observer/index.js"; -function createLogger(): pino.Logger { +function createLogger(): Logger { const logger = { child: () => logger, debug: vi.fn(), warn: vi.fn(), }; - return logger as unknown as pino.Logger; + return logger as unknown as Logger; } function createFacts(cwd: string): CheckoutSnapshotFacts { @@ -414,3 +418,77 @@ test("recursive observation updates tracked state and prunes ignored storms", as expect(getCheckoutDiff).not.toHaveBeenCalled(); expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); }, 30_000); + +test("sibling worktrees share one common-repository observer with one observer per checkout", async () => { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-shared-git-observation-"))); + const repoDir = path.join(tempDir, "repo"); + const paseoHome = path.join(tempDir, "paseo-home"); + const worktrees = Array.from({ length: 4 }, (_, index) => + path.join(tempDir, `worktree-${index}`), + ); + + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "tracked.txt"), "base\n"); + execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + for (const [index, worktree] of worktrees.entries()) { + execFileSync("git", ["worktree", "add", "-b", `fixture-${index}`, worktree, "main"], { + cwd: repoDir, + stdio: "pipe", + }); + } + + const service = new WorkspaceGitServiceImpl({ + logger: pino({ level: "silent" }), + paseoHome, + }); + const updates = new Map( + worktrees.map((worktree) => [worktree, [] as WorkspaceGitRuntimeSnapshot["git"][]]), + ); + const subscriptions = worktrees.map((cwd) => + service.registerWorkspace({ cwd }, (snapshot) => { + updates.get(cwd)?.push(snapshot.git); + }), + ); + + try { + await vi.waitFor( + () => { + expect(service.getMetrics()).toMatchObject({ + workspaceTargetCount: 4, + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 4, + workingTreeWatchTargetCount: 4, + workspaceObservationSetupInFlightCount: 0, + workingTreeWatchSetupInFlightCount: 0, + fileObserver: { activeObservationCount: 5 }, + }); + for (const worktree of worktrees) expect(service.peekSnapshot(worktree)).not.toBeNull(); + }, + { timeout: 15_000 }, + ); + + writeFileSync(path.join(worktrees[2]!, "tracked.txt"), "changed\n"); + await vi.waitFor( + () => { + expect(updates.get(worktrees[2]!)?.at(-1)).toMatchObject({ + isDirty: true, + diffStat: { additions: 1, deletions: 1 }, + }); + }, + { timeout: 15_000 }, + ); + } finally { + for (const subscription of subscriptions) subscription.unsubscribe(); + await service.dispose(); + rmSync(tempDir, { recursive: true, force: true }); + } +}, 30_000);