diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 25fa973a0..2782b879d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -229,6 +229,7 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for - ❌ **Bad**: `expect(isValid("foo")).toBe(true)` for every valid value - Duplicates implementation - ✅ **Good**: `expect(isValid("invalid")).toBe(false)` - Tests boundary/error cases - **Rule of thumb**: If changing the implementation requires changing the test in the same way, the test is probably useless +- **Avoid requiring manual setup in tests** - If every test needs the same initialization (provider setup, config, etc.), move that logic into the test helper functions. Tests should call one function and get a working environment, not repeat boilerplate setup steps. - Strive to decompose complex logic away from the components and into `.src/utils/` - utils should be either pure functions or easily isolated (e.g. if they operate on the FS they accept a path). Testing them should not require complex mocks or setup. diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index dab77a1b6..328acf33c 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -59,3 +59,8 @@ export const IPC_CHANNELS = { // Helper functions for dynamic channels export const getChatChannel = (workspaceId: string): string => `${IPC_CHANNELS.WORKSPACE_CHAT_PREFIX}${workspaceId}`; + +// Event type constants for workspace init events +export const EVENT_TYPE_PREFIX_INIT = "init-"; +export const EVENT_TYPE_INIT_OUTPUT = "init-output" as const; +export const EVENT_TYPE_INIT_END = "init-end" as const; diff --git a/src/runtime/LocalRuntime.ts b/src/runtime/LocalRuntime.ts index e64c162ab..4d6f6a912 100644 --- a/src/runtime/LocalRuntime.ts +++ b/src/runtime/LocalRuntime.ts @@ -370,6 +370,9 @@ export class LocalRuntime implements Runtime { const { projectPath, workspacePath, initLogger } = params; try { + // Note: sourceWorkspacePath is only used by SSH runtime (to copy workspace) + // Local runtime creates git worktrees which are instant, so we don't need it here + // Run .cmux/init hook if it exists // Note: runInitHook calls logComplete() internally if hook exists const hookExists = await checkInitHookExists(projectPath); @@ -588,7 +591,10 @@ export class LocalRuntime implements Runtime { }; } + initLogger.logStep(`Detected source branch: ${sourceBranch}`); + // Use createWorkspace with sourceBranch as trunk to fork from source branch + // For local workspaces (worktrees), this is instant - no init needed const createResult = await this.createWorkspace({ projectPath, branchName: newWorkspaceName, @@ -604,9 +610,12 @@ export class LocalRuntime implements Runtime { }; } + initLogger.logStep("Workspace forked successfully"); + return { success: true, workspacePath: createResult.workspacePath, + sourceWorkspacePath, sourceBranch, }; } catch (error) { diff --git a/src/runtime/Runtime.ts b/src/runtime/Runtime.ts index 610a3057a..ebe5c48c5 100644 --- a/src/runtime/Runtime.ts +++ b/src/runtime/Runtime.ts @@ -149,6 +149,8 @@ export interface WorkspaceInitParams { initLogger: InitLogger; /** Optional abort signal for cancellation */ abortSignal?: AbortSignal; + /** If provided, copy from this workspace instead of syncing from local (fork scenario) */ + sourceWorkspacePath?: string; } /** @@ -187,6 +189,8 @@ export interface WorkspaceForkResult { success: boolean; /** Path to the new workspace (if successful) */ workspacePath?: string; + /** Path to the source workspace (needed for init phase) */ + sourceWorkspacePath?: string; /** Branch that was forked from */ sourceBranch?: string; /** Error message (if failed) */ @@ -302,7 +306,7 @@ export interface Runtime { /** * Initialize workspace asynchronously (may be slow, streams progress) * - LocalRuntime: Runs init hook if present - * - SSHRuntime: Syncs files, checks out branch, runs init hook + * - SSHRuntime: Syncs files (or copies from source), checks out branch, runs init hook * Streams progress via initLogger. * @param params Workspace initialization parameters * @returns Result indicating success or error diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index c35eb2e35..21c9453bc 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -838,32 +838,72 @@ export class SSHRuntime implements Runtime { } async initWorkspace(params: WorkspaceInitParams): Promise { - const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params; + const { + projectPath, + branchName, + trunkBranch, + workspacePath, + initLogger, + abortSignal, + sourceWorkspacePath, + } = params; try { - // 1. Sync project to remote (opportunistic rsync with scp fallback) - initLogger.logStep("Syncing project files to remote..."); - try { - await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal); - } catch (error) { - const errorMsg = getErrorMessage(error); - initLogger.logStderr(`Failed to sync project: ${errorMsg}`); - initLogger.logComplete(-1); - return { - success: false, - error: `Failed to sync project: ${errorMsg}`, - }; + // Fork scenario: Copy from source workspace instead of syncing from local + if (sourceWorkspacePath) { + // 1. Copy workspace directory on remote host + // cp -a preserves all attributes (permissions, timestamps, symlinks, uncommitted changes) + initLogger.logStep("Copying workspace from source..."); + // Expand tilde paths before using in remote command + const expandedSourcePath = expandTildeForSSH(sourceWorkspacePath); + const expandedWorkspacePath = expandTildeForSSH(workspacePath); + const copyStream = await this.exec( + `cp -a ${expandedSourcePath}/. ${expandedWorkspacePath}/`, + { cwd: "~", timeout: 300, abortSignal } // 5 minute timeout for large workspaces + ); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(copyStream.stdout), + streamToString(copyStream.stderr), + copyStream.exitCode, + ]); + + if (exitCode !== 0) { + const errorMsg = `Failed to copy workspace: ${stderr || stdout}`; + initLogger.logStderr(errorMsg); + initLogger.logComplete(-1); + return { + success: false, + error: errorMsg, + }; + } + initLogger.logStep("Workspace copied successfully"); + } else { + // Normal scenario: Sync from local project + // 1. Sync project to remote (opportunistic rsync with scp fallback) + initLogger.logStep("Syncing project files to remote..."); + try { + await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal); + } catch (error) { + const errorMsg = getErrorMessage(error); + initLogger.logStderr(`Failed to sync project: ${errorMsg}`); + initLogger.logComplete(-1); + return { + success: false, + error: `Failed to sync project: ${errorMsg}`, + }; + } + initLogger.logStep("Files synced successfully"); } - initLogger.logStep("Files synced successfully"); // 2. Checkout branch remotely - // If branch exists locally, check it out; otherwise create it from the specified trunk branch - // Note: We've already created local branches for all remote refs in syncProjectToRemote initLogger.logStep(`Checking out branch: ${branchName}`); - // Try to checkout existing branch, or create new branch from trunk - // Since we've created local branches for all remote refs, we can use branch names directly - const checkoutCmd = `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`; + // For forked workspaces (copied with cp -a), HEAD is already on the source branch + // For synced workspaces, we need to specify the trunk branch to create from + const checkoutCmd = sourceWorkspacePath + ? `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)}` + : `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`; const checkoutStream = await this.exec(checkoutCmd, { cwd: workspacePath, // Use the full workspace path for git operations @@ -1141,16 +1181,46 @@ export class SSHRuntime implements Runtime { } } - forkWorkspace(_params: WorkspaceForkParams): Promise { - // SSH forking is not yet implemented due to unresolved complexities: - // - Users expect the new workspace's filesystem state to match the remote workspace, - // not the local project (which may be out of sync or on a different commit) - // - This requires: detecting the branch, copying remote state, handling uncommitted changes - // - For now, users should create a new workspace from the desired branch instead - return Promise.resolve({ - success: false, - error: "Forking SSH workspaces is not yet implemented. Create a new workspace instead.", - }); + async forkWorkspace(params: WorkspaceForkParams): Promise { + const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger } = params; + + // Get source and destination workspace paths + const sourceWorkspacePath = this.getWorkspacePath(projectPath, sourceWorkspaceName); + const newWorkspacePath = this.getWorkspacePath(projectPath, newWorkspaceName); + + // Expand tilde path for the new workspace directory + const expandedNewPath = expandTildeForSSH(newWorkspacePath); + + try { + // Step 1: Create empty directory for new workspace (instant) + // The actual copy happens in initWorkspace (fire-and-forget) + initLogger.logStep("Creating workspace directory..."); + const mkdirStream = await this.exec(`mkdir -p ${expandedNewPath}`, { cwd: "~", timeout: 10 }); + + await mkdirStream.stdin.abort(); + const mkdirExitCode = await mkdirStream.exitCode; + if (mkdirExitCode !== 0) { + const stderr = await streamToString(mkdirStream.stderr); + return { + success: false, + error: `Failed to create workspace directory: ${stderr}`, + }; + } + + initLogger.logStep("Workspace directory created"); + + // Return immediately - copy and init happen in initWorkspace (fire-and-forget) + return { + success: true, + workspacePath: newWorkspacePath, + sourceWorkspacePath, + }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } } } diff --git a/src/runtime/initHook.ts b/src/runtime/initHook.ts index 401b71f00..76b633f38 100644 --- a/src/runtime/initHook.ts +++ b/src/runtime/initHook.ts @@ -3,13 +3,19 @@ import * as fsPromises from "fs/promises"; import * as path from "path"; import type { InitLogger } from "./Runtime"; +/** + * Constants for init hook functionality + */ +export const CMUX_DIR = ".cmux"; +export const INIT_HOOK_FILENAME = "init"; + /** * Check if .cmux/init hook exists and is executable * @param projectPath - Path to the project root * @returns true if hook exists and is executable, false otherwise */ export async function checkInitHookExists(projectPath: string): Promise { - const hookPath = path.join(projectPath, ".cmux", "init"); + const hookPath = path.join(projectPath, CMUX_DIR, INIT_HOOK_FILENAME); try { await fsPromises.access(hookPath, fs.constants.X_OK); @@ -23,7 +29,7 @@ export async function checkInitHookExists(projectPath: string): Promise * Get the init hook path for a project */ export function getInitHookPath(projectPath: string): string { - return path.join(projectPath, ".cmux", "init"); + return path.join(projectPath, CMUX_DIR, INIT_HOOK_FILENAME); } /** diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 773e86177..3255923af 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -15,7 +15,6 @@ import { countTokens, countTokensBatch } from "@/utils/main/tokenizer"; import { calculateTokenStats } from "@/utils/tokens/tokenStatsCalculator"; import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants"; import { SUPPORTED_PROVIDERS } from "@/constants/providers"; -import { DEFAULT_RUNTIME_CONFIG } from "@/constants/workspace"; import type { SendMessageError } from "@/types/errors"; import type { SendMessageOptions, DeleteMessage } from "@/types/ipc"; import { Ok, Err } from "@/types/result"; @@ -688,6 +687,16 @@ export class IpcMain { return { success: false, error: validation.error }; } + // Check if source workspace is currently initializing + const initState = this.initStateManager.getInitState(sourceWorkspaceId); + if (initState?.status === "running") { + return { + success: false, + error: + "Cannot fork workspace while it is initializing. Please wait for initialization to complete.", + }; + } + // If streaming, commit the partial response to history first // This preserves the streamed content in both workspaces if (this.aiService.isStreaming(sourceWorkspaceId)) { @@ -706,12 +715,16 @@ export class IpcMain { const foundProjectPath = sourceMetadata.projectPath; const projectName = sourceMetadata.projectName; - // Create runtime for source workspace - const sourceRuntimeConfig = sourceMetadata.runtimeConfig ?? { - type: "local", - srcBaseDir: this.config.srcDir, - }; - const runtime = createRuntime(sourceRuntimeConfig); + // Preserve source runtime config (undefined for local default, or explicit SSH config) + const sourceRuntimeConfig = sourceMetadata.runtimeConfig; + + // Create runtime for fork operation + const runtimeForFork = createRuntime( + sourceRuntimeConfig ?? { + type: "local", + srcBaseDir: this.config.srcDir, + } + ); // Generate stable workspace ID for the new workspace const newWorkspaceId = this.config.generateStableId(); @@ -724,8 +737,8 @@ export class IpcMain { const initLogger = this.createInitLogger(newWorkspaceId); - // Delegate fork operation to runtime - const forkResult = await runtime.forkWorkspace({ + // Delegate fork operation to runtime (fast path - returns before init hook) + const forkResult = await runtimeForFork.forkWorkspace({ projectPath: foundProjectPath, sourceWorkspaceName: sourceMetadata.name, newWorkspaceName: newName, @@ -736,6 +749,13 @@ export class IpcMain { return { success: false, error: forkResult.error }; } + if (!forkResult.workspacePath) { + return { success: false, error: "Fork succeeded but no workspace path returned" }; + } + + // Store validated workspace path for type safety + const workspacePath = forkResult.workspacePath; + // Copy session files (chat.jsonl, partial.json) - local backend operation const sourceSessionDir = this.config.getSessionDir(sourceWorkspaceId); const newSessionDir = this.config.getSessionDir(newWorkspaceId); @@ -770,7 +790,7 @@ export class IpcMain { } } catch (copyError) { // If copy fails, clean up everything we created - await runtime.deleteWorkspace(foundProjectPath, newName, true); + await runtimeForFork.deleteWorkspace(foundProjectPath, newName, true); try { await fsPromises.rm(newSessionDir, { recursive: true, force: true }); } catch (cleanupError) { @@ -787,7 +807,7 @@ export class IpcMain { projectName, projectPath: foundProjectPath, createdAt: new Date().toISOString(), - runtimeConfig: DEFAULT_RUNTIME_CONFIG, + runtimeConfig: sourceRuntimeConfig, }; // Write metadata to config.json @@ -796,6 +816,26 @@ export class IpcMain { // Emit metadata event session.emitMetadata(metadata); + // Run initialization in background (fire-and-forget like createWorkspace) + // For SSH: copies workspace + creates branch + runs init hook + // For Local: just runs init hook (worktree already created) + // This allows fork to return immediately while init streams progress + void runtimeForFork + .initWorkspace({ + projectPath: foundProjectPath, + branchName: newName, + trunkBranch: "main", // Only used for non-fork (sync) case in SSH runtime + workspacePath, + initLogger, + sourceWorkspacePath: forkResult.sourceWorkspacePath, // Fork from source, not sync from local + }) + .catch((error: unknown) => { + const errorMsg = error instanceof Error ? error.message : String(error); + log.error(`initWorkspace failed for forked workspace ${newWorkspaceId}:`, error); + initLogger.logStderr(`Initialization failed: ${errorMsg}`); + initLogger.logComplete(-1); + }); + return { success: true, metadata, diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..a2b21b43b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,61 @@ +# Tests + +## Running Tests + +### Unit Tests +```bash +make test +``` + +Runs fast, isolated unit tests that don't require external services. + +### Integration Tests +```bash +TEST_INTEGRATION=1 make test-integration +``` + +Runs integration tests that test IPC handlers, runtimes, and end-to-end workflows. + +**Requirements:** +- **Docker** (required for SSH runtime tests) +- API keys for provider tests: + - `ANTHROPIC_API_KEY` - For Anthropic provider tests + +**Why Docker is required:** Integration tests for SSH workspaces start a temporary SSH server in a Docker container to test remote workspace operations (creation, forking, editing, etc.) without requiring a real remote server. + +If Docker is not available: +1. Install Docker for your platform +2. Or skip integration tests by unsetting `TEST_INTEGRATION` + +**Note:** Tests will fail loudly with a clear error message if Docker is not available rather than silently skipping, ensuring CI catches missing dependencies. + +## Test Organization + +- `tests/ipcMain/` - Integration tests for IPC handlers +- `tests/runtime/` - Runtime-specific fixtures and helpers +- `tests/e2e/` - End-to-end tests +- `src/**/*.test.ts` - Unit tests colocated with source code + +## SSH Test Setup + +For tests that need SSH runtime, use the shared helpers in `tests/ipcMain/setup.ts`: + +```typescript +import { setupSSHServer, cleanupSSHServer } from "./setup"; + +let sshConfig: Awaited> | undefined; + +beforeAll(async () => { + sshConfig = await setupSSHServer(); +}, 120000); + +afterAll(async () => { + await cleanupSSHServer(sshConfig); +}, 30000); +``` + +This ensures: +- Docker availability is checked (fails test if missing) +- SSH server is started once per test suite +- Consistent error messages across all SSH tests +- Proper cleanup after tests complete diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index 892f70ec7..2ff5ccaa5 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -16,7 +16,12 @@ import { exec } from "child_process"; import { promisify } from "util"; import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import type { TestEnvironment } from "./setup"; -import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; +import { + IPC_CHANNELS, + EVENT_TYPE_PREFIX_INIT, + EVENT_TYPE_INIT_OUTPUT, + EVENT_TYPE_INIT_END, +} from "../../src/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; import { detectDefaultTrunkBranch } from "../../src/git"; import { @@ -30,6 +35,7 @@ import type { FrontendWorkspaceMetadata } from "../../src/types/workspace"; import { createRuntime } from "../../src/runtime/runtimeFactory"; import type { SSHRuntime } from "../../src/runtime/SSHRuntime"; import { streamToString } from "../../src/runtime/SSHRuntime"; +import { CMUX_DIR, INIT_HOOK_FILENAME } from "../../src/runtime/initHook"; const execAsync = promisify(exec); @@ -37,14 +43,6 @@ const execAsync = promisify(exec); const TEST_TIMEOUT_MS = 60000; const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime) const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer -const CMUX_DIR = ".cmux"; -const INIT_HOOK_FILENAME = "init"; - -// Event type constants -const EVENT_PREFIX_WORKSPACE_CHAT = "workspace:chat:"; -const EVENT_TYPE_PREFIX_INIT = "init-"; -const EVENT_TYPE_INIT_OUTPUT = "init-output"; -const EVENT_TYPE_INIT_END = "init-end"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -91,7 +89,7 @@ function setupInitEventCapture(env: TestEnvironment): Array<{ channel: string; d const originalSend = env.mockWindow.webContents.send; env.mockWindow.webContents.send = ((channel: string, data: unknown) => { - if (channel.startsWith(EVENT_PREFIX_WORKSPACE_CHAT) && isInitEvent(data)) { + if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX) && isInitEvent(data)) { capturedEvents.push({ channel, data }); } originalSend.call(env.mockWindow.webContents, channel, data); diff --git a/tests/ipcMain/forkWorkspace.test.ts b/tests/ipcMain/forkWorkspace.test.ts index c818ba482..484ee8946 100644 --- a/tests/ipcMain/forkWorkspace.test.ts +++ b/tests/ipcMain/forkWorkspace.test.ts @@ -1,3 +1,19 @@ +/** + * Integration tests for WORKSPACE_FORK IPC handler + * + * Tests both LocalRuntime and SSHRuntime without mocking to verify: + * - Fork mechanics (directory copy, branch creation) + * - Preserving uncommitted changes and git state + * - Init hook execution + * - Parity between runtime implementations + * + * Uses real IPC handlers, real git operations, and Docker SSH server. + */ + +import * as fs from "fs/promises"; +import * as path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; import { shouldRunIntegrationTests, createTestEnvironment, @@ -5,345 +21,590 @@ import { setupWorkspace, validateApiKeys, } from "./setup"; -import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; +import type { TestEnvironment } from "./setup"; +import { + IPC_CHANNELS, + EVENT_TYPE_PREFIX_INIT, + EVENT_TYPE_INIT_OUTPUT, + EVENT_TYPE_INIT_END, +} from "../../src/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, - sendMessageWithModel, + sendMessage, createEventCollector, assertStreamSuccess, - waitFor, + generateBranchName, + DEFAULT_TEST_MODEL, } from "./helpers"; import { detectDefaultTrunkBranch } from "../../src/git"; import { HistoryService } from "../../src/services/historyService"; import { createCmuxMessage } from "../../src/types/message"; +import { + isDockerAvailable, + startSSHServer, + stopSSHServer, + type SSHServerConfig, +} from "../runtime/ssh-fixture"; +import type { RuntimeConfig } from "../../src/types/runtime"; +import { createRuntime } from "../../src/runtime/runtimeFactory"; +import { streamToString } from "../../src/runtime/SSHRuntime"; +import { CMUX_DIR, INIT_HOOK_FILENAME } from "../../src/runtime/initHook"; + +const execAsync = promisify(exec); + +// Test constants +const TEST_TIMEOUT_MS = 90000; +const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime) +const SSH_INIT_WAIT_MS = 7000; // SSH init takes longer // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; +// SSH server config (shared across all SSH tests) +let sshConfig: SSHServerConfig | undefined; + // Validate API keys for tests that need them if (shouldRunIntegrationTests()) { validateApiKeys(["ANTHROPIC_API_KEY"]); } -describeIntegration("IpcMain fork workspace integration tests", () => { - // Enable retries in CI for flaky API tests - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } +// ============================================================================ +// Test Helpers +// ============================================================================ + +/** + * Type guard to check if an event is an init event with a type field + */ +function isInitEvent(data: unknown): data is { type: string } { + return ( + data !== null && + typeof data === "object" && + "type" in data && + typeof (data as { type: unknown }).type === "string" && + (data as { type: string }).type.startsWith(EVENT_TYPE_PREFIX_INIT) + ); +} - test.concurrent( - "should fail to fork workspace with invalid name", - async () => { - const env = await createTestEnvironment(); - const tempGitRepo = await createTempGitRepo(); - - try { - // Create source workspace - const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - tempGitRepo, - "source-workspace", - trunkBranch - ); - expect(createResult.success).toBe(true); - const sourceWorkspaceId = createResult.metadata.id; - - // Test various invalid names - const invalidNames = [ - { name: "", expectedError: "empty" }, - { name: "Invalid-Name", expectedError: "lowercase" }, - { name: "name with spaces", expectedError: "lowercase" }, - { name: "name@special", expectedError: "lowercase" }, - { name: "a".repeat(65), expectedError: "64 characters" }, - ]; - - for (const { name, expectedError } of invalidNames) { - const forkResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_FORK, - sourceWorkspaceId, - name - ); - expect(forkResult.success).toBe(false); - expect(forkResult.error.toLowerCase()).toContain(expectedError.toLowerCase()); - } +/** + * Filter events by type + */ +function filterEventsByType( + events: Array<{ channel: string; data: unknown }>, + eventType: string +): Array<{ channel: string; data: { type: string } }> { + return events.filter((e) => isInitEvent(e.data) && e.data.type === eventType) as Array<{ + channel: string; + data: { type: string }; + }>; +} - // Cleanup - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); - } finally { - await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempGitRepo); - } - }, - 15000 - ); +/** + * Set up event capture for init events on workspace chat channel + * Returns array that will be populated with captured events + */ +function setupInitEventCapture(env: TestEnvironment): Array<{ channel: string; data: unknown }> { + const capturedEvents: Array<{ channel: string; data: unknown }> = []; + const originalSend = env.mockWindow.webContents.send; + + env.mockWindow.webContents.send = ((channel: string, data: unknown) => { + if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX) && isInitEvent(data)) { + capturedEvents.push({ channel, data }); + } + originalSend.call(env.mockWindow.webContents, channel, data); + }) as typeof originalSend; + + return capturedEvents; +} - test.concurrent( - "should fork workspace and send message successfully", - async () => { - const { env, workspaceId: sourceWorkspaceId, cleanup } = await setupWorkspace("anthropic"); - - try { - // Fork the workspace - const forkResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_FORK, - sourceWorkspaceId, - "forked-workspace" - ); - expect(forkResult.success).toBe(true); - const forkedWorkspaceId = forkResult.metadata.id; - - // User expects: forked workspace is functional - can send messages to it - env.sentEvents.length = 0; - const sendResult = await sendMessageWithModel( - env.mockIpcRenderer, - forkedWorkspaceId, - "What is 2+2? Answer with just the number.", - "anthropic", - "claude-sonnet-4-5" - ); - expect(sendResult.success).toBe(true); - - // Verify stream completes successfully - const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); - await collector.waitForEvent("stream-end", 30000); - assertStreamSuccess(collector); - - const finalMessage = collector.getFinalMessage(); - expect(finalMessage).toBeDefined(); - } finally { - await cleanup(); - } - }, - 45000 - ); +/** + * Create init hook file in git repo + */ +async function createInitHook(repoPath: string, hookContent: string): Promise { + const cmuxDir = path.join(repoPath, CMUX_DIR); + await fs.mkdir(cmuxDir, { recursive: true }); + const initHookPath = path.join(cmuxDir, INIT_HOOK_FILENAME); + await fs.writeFile(initHookPath, hookContent, { mode: 0o755 }); +} - test.concurrent( - "should preserve chat history when forking workspace", - async () => { - const { env, workspaceId: sourceWorkspaceId, cleanup } = await setupWorkspace("anthropic"); - - try { - // Add history to source workspace - const historyService = new HistoryService(env.config); - const uniqueWord = `testword-${Date.now()}`; - const historyMessages = [ - createCmuxMessage("msg-1", "user", `Remember this word: ${uniqueWord}`, {}), - createCmuxMessage("msg-2", "assistant", `I will remember the word "${uniqueWord}".`, {}), - ]; - - for (const msg of historyMessages) { - const result = await historyService.appendToHistory(sourceWorkspaceId, msg); - expect(result.success).toBe(true); - } +/** + * Commit changes in git repo + */ +async function commitChanges(repoPath: string, message: string): Promise { + await execAsync(`git add -A && git commit -m "${message}"`, { + cwd: repoPath, + }); +} - // Fork the workspace - const forkResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_FORK, - sourceWorkspaceId, - "forked-with-history" - ); - expect(forkResult.success).toBe(true); - const forkedWorkspaceId = forkResult.metadata.id; - - // User expects: forked workspace has access to history - // Send a message that requires the historical context - env.sentEvents.length = 0; - const sendResult = await sendMessageWithModel( - env.mockIpcRenderer, - forkedWorkspaceId, - "What word did I ask you to remember? Reply with just the word.", - "anthropic", - "claude-sonnet-4-5" - ); - expect(sendResult.success).toBe(true); - - // Verify stream completes successfully - const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); - await collector.waitForEvent("stream-end", 30000); - assertStreamSuccess(collector); - - const finalMessage = collector.getFinalMessage(); - expect(finalMessage).toBeDefined(); - - // Verify the response contains the word from history - if (finalMessage && "parts" in finalMessage && Array.isArray(finalMessage.parts)) { - const content = finalMessage.parts - .filter((part) => part.type === "text") - .map((part) => (part as { text: string }).text) - .join(""); - expect(content.toLowerCase()).toContain(uniqueWord.toLowerCase()); - } - } finally { - await cleanup(); - } - }, - 45000 - ); +/** + * Set up test environment and git repo with automatic cleanup + */ +async function setupForkTest() { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); - test.concurrent( - "should create independent workspaces that can send messages concurrently", - async () => { - const { env, workspaceId: sourceWorkspaceId, cleanup } = await setupWorkspace("anthropic"); - - try { - // Fork the workspace - const forkResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_FORK, - sourceWorkspaceId, - "forked-independent" - ); - expect(forkResult.success).toBe(true); - const forkedWorkspaceId = forkResult.metadata.id; - - // User expects: both workspaces work independently - // Send different messages to both concurrently - env.sentEvents.length = 0; - - const [sourceResult, forkedResult] = await Promise.all([ - sendMessageWithModel( - env.mockIpcRenderer, - sourceWorkspaceId, - "What is 5+5? Answer with just the number.", - "anthropic", - "claude-sonnet-4-5" - ), - sendMessageWithModel( - env.mockIpcRenderer, - forkedWorkspaceId, - "What is 3+3? Answer with just the number.", - "anthropic", - "claude-sonnet-4-5" - ), - ]); - - expect(sourceResult.success).toBe(true); - expect(forkedResult.success).toBe(true); - - // Verify both streams complete successfully - const sourceCollector = createEventCollector(env.sentEvents, sourceWorkspaceId); - const forkedCollector = createEventCollector(env.sentEvents, forkedWorkspaceId); - - await Promise.all([ - sourceCollector.waitForEvent("stream-end", 30000), - forkedCollector.waitForEvent("stream-end", 30000), - ]); - - assertStreamSuccess(sourceCollector); - assertStreamSuccess(forkedCollector); - - expect(sourceCollector.getFinalMessage()).toBeDefined(); - expect(forkedCollector.getFinalMessage()).toBeDefined(); - } finally { - await cleanup(); - } - }, - 45000 - ); + const cleanup = async () => { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + }; - test.concurrent( - "should preserve partial streaming response when forking mid-stream", - async () => { - const { env, workspaceId: sourceWorkspaceId, cleanup } = await setupWorkspace("anthropic"); - - try { - // Start a stream in the source workspace (don't await) - void sendMessageWithModel( - env.mockIpcRenderer, - sourceWorkspaceId, - "Count from 1 to 10, one number per line. Then say 'Done counting.'", - "anthropic", - "claude-sonnet-4-5" - ); + return { env, tempGitRepo, cleanup }; +} - // Wait for stream to start and produce some content - const sourceCollector = createEventCollector(env.sentEvents, sourceWorkspaceId); - await sourceCollector.waitForEvent("stream-start", 5000); - - // Wait for some deltas to ensure we have partial content - await waitFor(() => { - sourceCollector.collect(); - return sourceCollector.getDeltas().length > 2; - }, 10000); - - // Fork while stream is active (this should commit partial to history) - const forkResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_FORK, - sourceWorkspaceId, - "forked-mid-stream" - ); - expect(forkResult.success).toBe(true); - const forkedWorkspaceId = forkResult.metadata.id; - - // Wait for source stream to complete - await sourceCollector.waitForEvent("stream-end", 30000); - - // User expects: forked workspace is functional despite being forked mid-stream - // Send a message to the forked workspace - env.sentEvents.length = 0; - const forkedSendResult = await sendMessageWithModel( - env.mockIpcRenderer, - forkedWorkspaceId, - "What is 7+3? Answer with just the number.", - "anthropic", - "claude-sonnet-4-5" - ); - expect(forkedSendResult.success).toBe(true); - - // Verify forked workspace stream completes successfully - const forkedCollector = createEventCollector(env.sentEvents, forkedWorkspaceId); - await forkedCollector.waitForEvent("stream-end", 30000); - assertStreamSuccess(forkedCollector); - - expect(forkedCollector.getFinalMessage()).toBeDefined(); - } finally { - await cleanup(); - } - }, - 60000 - ); +/** + * Wrapper that handles setup/cleanup for fork tests + */ +async function withForkTest( + fn: (ctx: { env: TestEnvironment; tempGitRepo: string }) => Promise +): Promise { + const { env, tempGitRepo, cleanup } = await setupForkTest(); + try { + await fn({ env, tempGitRepo }); + } finally { + await cleanup(); + } +} + +describeIntegration("WORKSPACE_FORK with both runtimes", () => { + // Enable retries in CI for flaky API tests + if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { + jest.retryTimes(3, { logErrorsBeforeRetry: true }); + } - test.concurrent( - "should make forked workspace available in workspace list", - async () => { - const env = await createTestEnvironment(); - const tempGitRepo = await createTempGitRepo(); - - try { - // Create source workspace - const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - tempGitRepo, - "source-workspace", - trunkBranch + beforeAll(async () => { + // Check if Docker is available (required for SSH tests) + if (!(await isDockerAvailable())) { + throw new Error( + "Docker is required for SSH runtime tests. Please install Docker or skip tests by unsetting TEST_INTEGRATION." + ); + } + + // Start SSH server (shared across all tests for speed) + console.log("Starting SSH server container for forkWorkspace tests..."); + sshConfig = await startSSHServer(); + console.log(`SSH server ready on port ${sshConfig.port}`); + }, 60000); // 60s timeout for Docker operations + + afterAll(async () => { + if (sshConfig) { + console.log("Stopping SSH server container..."); + await stopSSHServer(sshConfig); + } + }, 30000); + + // Test matrix: Run tests for both local and SSH runtimes + describe.each<{ type: "local" | "ssh" }>([{ type: "local" }, { type: "ssh" }])( + "Runtime: $type", + ({ type }) => { + // Helper to build runtime config + const getRuntimeConfig = (): RuntimeConfig | undefined => { + if (type === "ssh" && sshConfig) { + return { + type: "ssh", + host: `testuser@localhost`, + srcBaseDir: sshConfig.workdir, + identityFile: sshConfig.privateKeyPath, + port: sshConfig.port, + }; + } + return undefined; // undefined = defaults to local + }; + + // Get runtime-specific init wait time (SSH needs more time) + const getInitWaitTime = () => (type === "ssh" ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS); + + describe("Fork operations", () => { + test.concurrent( + "validates workspace name", + () => + withForkTest(async ({ env, tempGitRepo }) => { + // Create source workspace + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const sourceBranchName = generateBranchName(); + const runtimeConfig = getRuntimeConfig(); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + sourceBranchName, + trunkBranch, + runtimeConfig + ); + expect(createResult.success).toBe(true); + const sourceWorkspaceId = createResult.metadata.id; + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Test various invalid names + const invalidNames = [ + { name: "", expectedError: "empty" }, + { name: "Invalid-Name", expectedError: "lowercase" }, + { name: "name with spaces", expectedError: "lowercase" }, + { name: "name@special", expectedError: "lowercase" }, + { name: "a".repeat(65), expectedError: "64 characters" }, + ]; + + for (const { name, expectedError } of invalidNames) { + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, + sourceWorkspaceId, + name + ); + expect(forkResult.success).toBe(false); + expect(forkResult.error.toLowerCase()).toContain(expectedError.toLowerCase()); + } + + // Cleanup + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); + }), + TEST_TIMEOUT_MS ); - expect(createResult.success).toBe(true); - const sourceWorkspaceId = createResult.metadata.id; - - // Fork the workspace - const forkResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_FORK, - sourceWorkspaceId, - "forked-workspace" + + test.concurrent( + "preserves runtime config and creates usable workspace", + () => + withForkTest(async ({ env, tempGitRepo }) => { + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const sourceBranchName = generateBranchName(); + const runtimeConfig = getRuntimeConfig(); + + // Create source workspace + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + sourceBranchName, + trunkBranch, + runtimeConfig + ); + expect(createResult.success).toBe(true); + const sourceWorkspaceId = createResult.metadata.id; + const sourceMetadata = createResult.metadata; + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Fork the workspace + const forkedName = generateBranchName(); + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, + sourceWorkspaceId, + forkedName + ); + expect(forkResult.success).toBe(true); + expect(forkResult.metadata).toBeDefined(); + expect(forkResult.metadata.id).toBeDefined(); + + // CRITICAL: Check that runtime config is preserved from source + // (Not from the original input, since WORKSPACE_CREATE may normalize it) + expect(forkResult.metadata.runtimeConfig).toEqual(sourceMetadata.runtimeConfig); + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Property test: Forked workspace should be immediately usable for tool execution + // This will fail if runtime config is wrong or directory doesn't exist + const forkedWorkspaceId = forkResult.metadata.id; + env.sentEvents.length = 0; + + const sendResult = await sendMessage( + env.mockIpcRenderer, + forkedWorkspaceId, + "Run this bash command: echo 'fork-test-success'", + { model: DEFAULT_TEST_MODEL } + ); + expect(sendResult.success).toBe(true); + + // Verify stream completes successfully (would fail if workspace broken) + const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); + await collector.waitForEvent("stream-end", 30000); + assertStreamSuccess(collector); + + // Cleanup + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, forkedWorkspaceId); + }), + TEST_TIMEOUT_MS ); - expect(forkResult.success).toBe(true); - - // User expects: both workspaces appear in workspace list - const workspaces = await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST); - const workspaceIds = workspaces.map((w: { id: string }) => w.id); - expect(workspaceIds).toContain(sourceWorkspaceId); - expect(workspaceIds).toContain(forkResult.metadata.id); - - // Cleanup - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, forkResult.metadata.id); - } finally { - await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempGitRepo); - } - }, - 15000 + }); + + test.concurrent( + "preserves chat history", + async () => { + // Note: setupWorkspace doesn't support runtimeConfig, only testing local for API tests + if (type === "ssh") { + return; // Skip SSH for API tests + } + + const { + env, + workspaceId: sourceWorkspaceId, + cleanup, + } = await setupWorkspace("anthropic"); + + try { + // Add history to source workspace + const historyService = new HistoryService(env.config); + const uniqueWord = `testword-${Date.now()}`; + const historyMessages = [ + createCmuxMessage("msg-1", "user", `Remember this word: ${uniqueWord}`, {}), + createCmuxMessage( + "msg-2", + "assistant", + `I will remember the word "${uniqueWord}".`, + {} + ), + ]; + + for (const msg of historyMessages) { + const result = await historyService.appendToHistory(sourceWorkspaceId, msg); + expect(result.success).toBe(true); + } + + // Fork the workspace + const forkedName = generateBranchName(); + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, + sourceWorkspaceId, + forkedName + ); + expect(forkResult.success).toBe(true); + const forkedWorkspaceId = forkResult.metadata.id; + + // Wait for fork init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // User expects: forked workspace has access to history + // Send a message that requires the historical context + env.sentEvents.length = 0; + const sendResult = await sendMessage( + env.mockIpcRenderer, + forkedWorkspaceId, + "What word did I ask you to remember? Reply with just the word.", + { model: DEFAULT_TEST_MODEL } + ); + expect(sendResult.success).toBe(true); + + // Verify stream completes successfully + const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); + await collector.waitForEvent("stream-end", 30000); + assertStreamSuccess(collector); + + const finalMessage = collector.getFinalMessage(); + expect(finalMessage).toBeDefined(); + + // Verify the response contains the word from history + if (finalMessage && "parts" in finalMessage && Array.isArray(finalMessage.parts)) { + const content = finalMessage.parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + expect(content.toLowerCase()).toContain(uniqueWord.toLowerCase()); + } + } finally { + await cleanup(); + } + }, + 45000 + ); + + test.concurrent( + "preserves uncommitted changes (SSH only)", + () => { + // Note: Local runtime creates git worktrees which are clean checkouts + // Uncommitted changes are only preserved in SSH runtime (uses cp -a) + if (type === "local") { + return Promise.resolve(); // Skip for local + } + + return withForkTest(async ({ env, tempGitRepo }) => { + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const sourceBranchName = generateBranchName(); + const runtimeConfig = getRuntimeConfig(); + + // Create workspace + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + sourceBranchName, + trunkBranch, + runtimeConfig + ); + expect(createResult.success).toBe(true); + const sourceWorkspaceId = createResult.metadata.id; + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // For SSH, construct path manually since namedWorkspacePath doesn't work for SSH + const projectName = tempGitRepo.split("/").pop() ?? "unknown"; + const sourceWorkspacePath = + type === "ssh" && sshConfig + ? `${sshConfig.workdir}/${projectName}/${sourceBranchName}` + : (await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST)).find( + (w: any) => w.id === sourceWorkspaceId + )?.namedWorkspacePath; + + expect(sourceWorkspacePath).toBeDefined(); + + // Create runtime for file operations + const runtime = createRuntime( + runtimeConfig ?? { type: "local", srcBaseDir: "~/.cmux/src" } + ); + + const testContent = `Test content - ${Date.now()}`; + const testFilePath = + type === "ssh" + ? `${sourceWorkspacePath}/uncommitted-test.txt` + : path.join(sourceWorkspacePath, "uncommitted-test.txt"); + + // Write file using runtime + if (type === "ssh") { + const writeStream = await runtime.writeFile(testFilePath); + const writer = writeStream.getWriter(); + const encoder = new TextEncoder(); + await writer.write(encoder.encode(testContent)); + await writer.close(); + } else { + await fs.writeFile(testFilePath, testContent); + } + + // Fork the workspace + const forkedName = generateBranchName(); + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, + sourceWorkspaceId, + forkedName + ); + expect(forkResult.success).toBe(true); + const forkedWorkspaceId = forkResult.metadata.id; + + // Wait for fork init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Get forked workspace path from metadata (or construct for SSH) + const forkedWorkspacePath = + type === "ssh" && sshConfig + ? `${sshConfig.workdir}/${projectName}/${forkedName}` + : (await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST)).find( + (w: any) => w.id === forkedWorkspaceId + )?.namedWorkspacePath; + + expect(forkedWorkspacePath).toBeDefined(); + + const forkedFilePath = + type === "ssh" + ? `${forkedWorkspacePath}/uncommitted-test.txt` + : path.join(forkedWorkspacePath, "uncommitted-test.txt"); + + if (type === "ssh") { + const readStream = await runtime.readFile(forkedFilePath); + const forkedContent = await streamToString(readStream); + expect(forkedContent).toBe(testContent); + } else { + const forkedContent = await fs.readFile(forkedFilePath, "utf-8"); + expect(forkedContent).toBe(testContent); + } + + // Cleanup + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, forkedWorkspaceId); + }); + }, + TEST_TIMEOUT_MS + ); + + test.concurrent( + "manages init state correctly", + () => + withForkTest(async ({ env, tempGitRepo }) => { + // Create init hook that takes time + const initHookContent = `#!/bin/bash +echo "Init starting" +sleep 3 +echo "Init complete" +`; + await createInitHook(tempGitRepo, initHookContent); + await commitChanges(tempGitRepo, "Add init hook"); + + // Create source workspace + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const sourceBranchName = generateBranchName(); + const runtimeConfig = getRuntimeConfig(); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + sourceBranchName, + trunkBranch, + runtimeConfig + ); + expect(createResult.success).toBe(true); + const sourceWorkspaceId = createResult.metadata.id; + + // Wait for source workspace init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Test 1: Can't fork workspace that's currently initializing + // Create another workspace that will be initializing + const anotherBranchName = generateBranchName(); + const createResult2 = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + anotherBranchName, + trunkBranch, + runtimeConfig + ); + expect(createResult2.success).toBe(true); + const initializingWorkspaceId = createResult2.metadata.id; + + // Immediately try to fork (while init is running) + const tempForkName = generateBranchName(); + const tempForkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, + initializingWorkspaceId, + tempForkName + ); + expect(tempForkResult.success).toBe(false); + expect(tempForkResult.error).toMatch(/initializing/i); + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Test 2: Tools are blocked in forked workspace until init completes + // Fork the first workspace (triggers init for new workspace) + const forkedName = generateBranchName(); + const forkTime = Date.now(); + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, + sourceWorkspaceId, + forkedName + ); + expect(forkResult.success).toBe(true); + const forkedWorkspaceId = forkResult.metadata.id; + + // Clear events BEFORE sending message + env.sentEvents.length = 0; + + // Send message that will use file_read tool + // The tool should block until init completes + await sendMessage(env.mockIpcRenderer, forkedWorkspaceId, "Read the README.md file", { + model: DEFAULT_TEST_MODEL, + }); + + // Wait for stream to complete + const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); + await collector.waitForEvent("stream-end", 30000); + assertStreamSuccess(collector); + + // If we get here without errors, init blocking worked + // (If init didn't complete, file_read would fail with "file not found") + // The presence of both stream-end and successful tool execution proves it + + // Cleanup + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, forkedWorkspaceId); + }), + TEST_TIMEOUT_MS + ); + } ); }); diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts index 08c305dcf..11a08a1aa 100644 --- a/tests/ipcMain/helpers.ts +++ b/tests/ipcMain/helpers.ts @@ -19,7 +19,7 @@ import type { ToolPolicy } from "../../src/utils/tools/toolPolicy"; // Test constants - centralized for consistency across all tests export const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime) export const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer -export const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; // Fast model for tests +export const DEFAULT_TEST_MODEL = "anthropic:claude-haiku-4-5"; // Default fast model for tests export const GPT_5_MINI_MODEL = "openai:gpt-5-mini"; // Fastest model for performance-critical tests export const TEST_TIMEOUT_LOCAL_MS = 25000; // Recommended timeout for local runtime tests export const TEST_TIMEOUT_SSH_MS = 60000; // Recommended timeout for SSH runtime tests @@ -43,8 +43,12 @@ export function modelString(provider: string, model: string): string { return `${provider}:${model}`; } +// Track which IpcRenderer instances have been set up to avoid duplicate setup +const setupProviderCache = new WeakSet(); + /** * Send a message via IPC + * Automatically sets up Anthropic provider on first use (idempotent) */ export async function sendMessage( mockIpcRenderer: IpcRenderer, @@ -52,6 +56,17 @@ export async function sendMessage( message: string, options?: SendMessageOptions & { imageParts?: Array<{ url: string; mediaType: string }> } ): Promise> { + // Setup provider on first use (idempotent across all sendMessage calls) + if (!setupProviderCache.has(mockIpcRenderer)) { + const { setupProviders, getApiKey } = await import("./setup"); + await setupProviders(mockIpcRenderer, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + setupProviderCache.add(mockIpcRenderer); + } + return (await mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, @@ -193,22 +208,13 @@ export async function sendMessageAndWait( // Clear previous events env.sentEvents.length = 0; - // Send message - const result = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, - workspaceId, - message, - { - model, - toolPolicy, - thinkingLevel: "off", // Disable reasoning for fast test execution - mode: "exec", // Execute commands directly, don't propose plans - } - ); - - if (!result.success) { - throw new Error(`Failed to send message: ${JSON.stringify(result, null, 2)}`); - } + // Use sendMessage for provider setup, then send message + await sendMessage(env.mockIpcRenderer, workspaceId, message, { + model, + toolPolicy, + thinkingLevel: "off", // Disable reasoning for fast test execution + mode: "exec", // Execute commands directly, don't propose plans + }); // Wait for stream completion const collector = createEventCollector(env.sentEvents, workspaceId); diff --git a/tests/ipcMain/initWorkspace.test.ts b/tests/ipcMain/initWorkspace.test.ts index c639613e8..193b5bb67 100644 --- a/tests/ipcMain/initWorkspace.test.ts +++ b/tests/ipcMain/initWorkspace.test.ts @@ -3,8 +3,6 @@ import { createTestEnvironment, cleanupTestEnvironment, validateApiKeys, - getApiKey, - setupProviders, type TestEnvironment, } from "./setup"; import { IPC_CHANNELS, getChatChannel } from "../../src/constants/ipc-constants"; @@ -15,6 +13,7 @@ import { waitForInitEnd, collectInitEvents, waitFor, + sendMessage, } from "./helpers"; import type { WorkspaceChatMessage, WorkspaceInitEvent } from "../../src/types/ipc"; import { isInitStart, isInitOutput, isInitEnd } from "../../src/types/ipc"; @@ -511,14 +510,8 @@ describeIntegration("Init Queue - Runtime Matrix", () => { const env = await createTestEnvironment(); const branchName = generateBranchName("init-wait-file-read"); - // Setup API provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); - // Create repo with init hook that sleeps 5s, writes a file, then FAILS + // Provider setup happens automatically in sendMessage // This tests that tools proceed even when init hook fails (exit code 1) const tempGitRepo = await createTempGitRepoWithInitHook({ exitCode: 1, // EXIT WITH FAILURE @@ -551,8 +544,9 @@ exit 1 env.sentEvents.length = 0; // IMMEDIATELY ask AI to read the file (before init completes) - const sendResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + // Use sendMessage helper which includes provider setup + const sendResult = await sendMessage( + env.mockIpcRenderer, workspaceId, "Read the file init_created_file.txt and tell me what it says", { diff --git a/tests/ipcMain/runtimeExecuteBash.test.ts b/tests/ipcMain/runtimeExecuteBash.test.ts index 4bd8464dc..3d4b85a58 100644 --- a/tests/ipcMain/runtimeExecuteBash.test.ts +++ b/tests/ipcMain/runtimeExecuteBash.test.ts @@ -11,8 +11,6 @@ import { cleanupTestEnvironment, shouldRunIntegrationTests, validateApiKeys, - getApiKey, - setupProviders, } from "./setup"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; import { @@ -22,7 +20,7 @@ import { createWorkspaceWithInit, sendMessageAndWait, extractTextFromEvents, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, TEST_TIMEOUT_LOCAL_MS, TEST_TIMEOUT_SSH_MS, } from "./helpers"; @@ -100,11 +98,6 @@ describeIntegration("Runtime Bash Execution", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("bash-simple"); @@ -124,7 +117,7 @@ describeIntegration("Runtime Bash Execution", () => { env, workspaceId, 'Run the bash command "echo Hello World"', - HAIKU_MODEL, + DEFAULT_TEST_MODEL, BASH_ONLY ); @@ -158,11 +151,6 @@ describeIntegration("Runtime Bash Execution", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("bash-env"); @@ -182,7 +170,7 @@ describeIntegration("Runtime Bash Execution", () => { env, workspaceId, 'Run bash command: export TEST_VAR="test123" && echo "Value: $TEST_VAR"', - HAIKU_MODEL, + DEFAULT_TEST_MODEL, BASH_ONLY ); @@ -216,11 +204,6 @@ describeIntegration("Runtime Bash Execution", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("bash-stdin"); @@ -241,7 +224,7 @@ describeIntegration("Runtime Bash Execution", () => { env, workspaceId, 'Run bash: echo \'{"test": "data"}\' > /tmp/test.json', - HAIKU_MODEL, + DEFAULT_TEST_MODEL, BASH_ONLY ); @@ -253,7 +236,7 @@ describeIntegration("Runtime Bash Execution", () => { env, workspaceId, "Run bash: cat /tmp/test.json | grep test", - HAIKU_MODEL, + DEFAULT_TEST_MODEL, BASH_ONLY, 10000 // 10s timeout - should complete in ~4s per API call ); @@ -295,11 +278,6 @@ describeIntegration("Runtime Bash Execution", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("bash-grep-head"); @@ -319,7 +297,7 @@ describeIntegration("Runtime Bash Execution", () => { env, workspaceId, 'Run bash: for i in {1..1000}; do echo "terminal bench line $i" >> testfile.txt; done', - HAIKU_MODEL, + DEFAULT_TEST_MODEL, BASH_ONLY ); @@ -330,7 +308,7 @@ describeIntegration("Runtime Bash Execution", () => { env, workspaceId, 'Run bash: grep -n "terminal bench" testfile.txt | head -n 200', - HAIKU_MODEL, + DEFAULT_TEST_MODEL, BASH_ONLY, 15000 // 15s timeout - should complete quickly ); diff --git a/tests/ipcMain/runtimeFileEditing.test.ts b/tests/ipcMain/runtimeFileEditing.test.ts index 037b8db36..1e2462214 100644 --- a/tests/ipcMain/runtimeFileEditing.test.ts +++ b/tests/ipcMain/runtimeFileEditing.test.ts @@ -14,8 +14,6 @@ import { cleanupTestEnvironment, shouldRunIntegrationTests, validateApiKeys, - getApiKey, - setupProviders, type TestEnvironment, } from "./setup"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; @@ -26,7 +24,7 @@ import { createWorkspaceWithInit, sendMessageAndWait, extractTextFromEvents, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, TEST_TIMEOUT_LOCAL_MS, TEST_TIMEOUT_SSH_MS, STREAM_TIMEOUT_LOCAL_MS, @@ -110,11 +108,6 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("read-test"); @@ -137,7 +130,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Create a file called ${testFileName} with the content: "Hello from cmux file tools!"`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -154,7 +147,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Use the file_read tool to read ${testFileName} and tell me what it contains.`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -193,11 +186,6 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("replace-test"); @@ -220,7 +208,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Create a file called ${testFileName} with the content: "The quick brown fox jumps over the lazy dog."`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -237,7 +225,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Use the file_edit_replace_string tool to replace "brown fox" with "red panda" in ${testFileName}.`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -282,11 +270,6 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("insert-test"); @@ -309,7 +292,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Create a file called ${testFileName} with two lines: "Line 1" and "Line 3".`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -326,7 +309,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Use the file_edit_insert or file_edit_replace_string tool to insert "Line 2" between Line 1 and Line 3 in ${testFileName}.`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -372,11 +355,6 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env.mockIpcRenderer, { - anthropic: { - apiKey: getApiKey("ANTHROPIC_API_KEY"), - }, - }); // Create workspace const branchName = generateBranchName("relative-path-test"); @@ -400,7 +378,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Create a file at path "${relativeTestFile}" with content: "Original content"`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -417,7 +395,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Replace the text in ${relativeTestFile}: change "Original" to "Modified"`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); @@ -441,7 +419,7 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Read the file ${relativeTestFile} and tell me its content`, - HAIKU_MODEL, + DEFAULT_TEST_MODEL, FILE_TOOLS_ONLY, streamTimeout ); diff --git a/tests/ipcMain/sendMessage.test.ts b/tests/ipcMain/sendMessage.test.ts index 544ec8cda..2d1e4889b 100644 --- a/tests/ipcMain/sendMessage.test.ts +++ b/tests/ipcMain/sendMessage.test.ts @@ -885,12 +885,16 @@ These are general instructions that apply to all modes. ); try { // Try to send message without API key configured - const result = await sendMessageWithModel( - env.mockIpcRenderer, + // Call IPC directly to bypass provider setup + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, "Hello", - provider, - model + { + model: modelString(provider, model), + thinkingLevel: "off", + mode: "exec", + } ); // Should fail with api_key_not_found error diff --git a/tests/ipcMain/setup.ts b/tests/ipcMain/setup.ts index 0b221269b..cfad33acd 100644 --- a/tests/ipcMain/setup.ts +++ b/tests/ipcMain/setup.ts @@ -270,3 +270,56 @@ export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Prom cleanup, }; } + +/** + * Setup SSH server for tests that need SSH runtime + * + * This helper ensures consistent SSH test setup across all test files: + * - Checks Docker availability (REQUIRED for SSH tests) + * - Starts SSH server container + * - Returns config for cleanup + * + * @throws Error if Docker is not available (fails test loudly rather than silently skipping) + * + * Usage: + * ``` + * let sshConfig: SSHServerConfig | undefined; + * + * beforeAll(async () => { + * sshConfig = await setupSSHServer(); + * }, 120000); + * + * afterAll(async () => { + * await cleanupSSHServer(sshConfig); + * }, 30000); + * ``` + */ +export async function setupSSHServer() { + const { isDockerAvailable, startSSHServer } = await import("../runtime/ssh-fixture"); + + // Check if Docker is available (required for SSH tests) + if (!(await isDockerAvailable())) { + throw new Error( + "Docker is required for SSH runtime tests. Please install Docker or skip tests by unsetting TEST_INTEGRATION." + ); + } + + // Start SSH server (shared across all tests for speed) + console.log("Starting SSH server container..."); + const config = await startSSHServer(); + console.log(`SSH server ready on port ${config.port}`); + return config; +} + +/** + * Cleanup SSH server after tests complete + */ +export async function cleanupSSHServer( + sshConfig: Awaited> | undefined +) { + if (sshConfig) { + const { stopSSHServer } = await import("../runtime/ssh-fixture"); + console.log("Stopping SSH server container..."); + await stopSSHServer(sshConfig); + } +}