From fdb092f56877b6d11a8a4251581719741e39d4f4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 13:47:28 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20Consolidate=20duplicated=20t?= =?UTF-8?q?est=20infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the SSH runtime commit (#178), testing infrastructure had significant duplication across helper modules and inline functions. Changes: - Consolidated shared utilities into tests/ipcMain/helpers.ts - Added extractTextFromEvents(), sendMessageAndWait(), createWorkspaceWithInit() - Centralized test constants (INIT_HOOK_WAIT_MS, SSH_INIT_WAIT_MS, HAIKU_MODEL, etc.) - Deleted tests/ipcMain/test-helpers/runtimeTestHelpers.ts (149 lines) - Removed inline helper duplicates from test files - runtimeFileEditing.test.ts: -120 lines - removeWorkspace.test.ts: -39 lines - renameWorkspace.test.ts: -45 lines - runtimeExecuteBash.test.ts: simplified imports Result: 240 net lines removed, improved consistency across integration tests. All tests use consistent patterns from single source of truth. --- tests/ipcMain/helpers.ts | 110 +++++++++++ tests/ipcMain/removeWorkspace.test.ts | 77 ++------ tests/ipcMain/renameWorkspace.test.ts | 117 ++++------- tests/ipcMain/runtimeExecuteBash.test.ts | 31 +-- tests/ipcMain/runtimeFileEditing.test.ts | 184 +++--------------- .../test-helpers/runtimeTestHelpers.ts | 149 -------------- 6 files changed, 214 insertions(+), 454 deletions(-) delete mode 100644 tests/ipcMain/test-helpers/runtimeTestHelpers.ts diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts index f0333ba5b..ac388a349 100644 --- a/tests/ipcMain/helpers.ts +++ b/tests/ipcMain/helpers.ts @@ -7,6 +7,18 @@ import type { WorkspaceMetadataWithPaths } from "../../src/types/workspace"; import * as path from "path"; import * as os from "os"; import { detectDefaultTrunkBranch } from "../../src/git"; +import type { TestEnvironment } from "./setup"; +import type { RuntimeConfig } from "../../src/types/runtime"; +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 TEST_TIMEOUT_LOCAL_MS = 25000; // Recommended timeout for local runtime tests +export const TEST_TIMEOUT_SSH_MS = 60000; // Recommended timeout for SSH runtime tests +export const STREAM_TIMEOUT_LOCAL_MS = 15000; // Stream timeout for local runtime +export const STREAM_TIMEOUT_SSH_MS = 25000; // Stream timeout for SSH runtime /** * Generate a unique branch name @@ -98,6 +110,104 @@ export async function clearHistory( )) as Result; } +/** + * Extract text content from stream events + * Filters for stream-delta events and concatenates the delta text + */ +export function extractTextFromEvents(events: WorkspaceChatMessage[]): string { + return events + .filter((e: any) => e.type === "stream-delta" && "delta" in e) + .map((e: any) => e.delta || "") + .join(""); +} + +/** + * Create workspace with optional init hook wait + * Enhanced version that can wait for init hook completion (needed for runtime tests) + */ +export async function createWorkspaceWithInit( + env: TestEnvironment, + projectPath: string, + branchName: string, + runtimeConfig?: RuntimeConfig, + waitForInit: boolean = false, + isSSH: boolean = false +): Promise<{ workspaceId: string; workspacePath: string; cleanup: () => Promise }> { + const trunkBranch = await detectDefaultTrunkBranch(projectPath); + + const result: any = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + projectPath, + branchName, + trunkBranch, + runtimeConfig + ); + + if (!result.success) { + throw new Error(`Failed to create workspace: ${result.error}`); + } + + const workspaceId = result.metadata.id; + const workspacePath = result.metadata.namedWorkspacePath; + + // Wait for init hook to complete if requested + if (waitForInit) { + const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS; + const collector = createEventCollector(env.sentEvents, workspaceId); + try { + await collector.waitForEvent("init-end", initTimeout); + } catch (err) { + // Init hook might not exist or might have already completed before we started waiting + // This is not necessarily an error - just log it + console.log( + `Note: init-end event not detected within ${initTimeout}ms (may have completed early)` + ); + } + } + + const cleanup = async () => { + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); + }; + + return { workspaceId, workspacePath, cleanup }; +} + +/** + * Send message and wait for stream completion + * Convenience helper that combines message sending with event collection + */ +export async function sendMessageAndWait( + env: TestEnvironment, + workspaceId: string, + message: string, + model: string, + toolPolicy?: ToolPolicy, + timeoutMs: number = STREAM_TIMEOUT_LOCAL_MS +): Promise { + // Clear previous events + env.sentEvents.length = 0; + + // Send message + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + workspaceId, + message, + { + model, + toolPolicy, + } + ); + + if (!result.success) { + throw new Error(`Failed to send message: ${result.error}`); + } + + // Wait for stream completion + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-end", timeoutMs); + return collector.getEvents(); +} + /** * Event collector for capturing stream events */ diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index f7ffcf0ec..c5a448de6 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -22,8 +22,12 @@ import { addSubmodule, waitForFileNotExists, waitForInitComplete, + createWorkspaceWithInit, + TEST_TIMEOUT_LOCAL_MS, + TEST_TIMEOUT_SSH_MS, + INIT_HOOK_WAIT_MS, + SSH_INIT_WAIT_MS, } from "./helpers"; -import { detectDefaultTrunkBranch } from "../../src/git"; import { isDockerAvailable, startSSHServer, @@ -33,12 +37,6 @@ import { import type { RuntimeConfig } from "../../src/types/runtime"; import { execAsync } from "../../src/utils/disposableExec"; -// Test constants -const TEST_TIMEOUT_LOCAL_MS = 20000; -const TEST_TIMEOUT_SSH_MS = 45000; -const INIT_HOOK_WAIT_MS = 1500; -const SSH_INIT_WAIT_MS = 7000; - // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -49,49 +47,6 @@ let sshConfig: SSHServerConfig | undefined; // Test Helpers // ============================================================================ -/** - * Create workspace helper and wait for init hook to complete - */ -async function createWorkspaceHelper( - env: TestEnvironment, - projectPath: string, - branchName: string, - runtimeConfig?: RuntimeConfig, - isSSH: boolean = false -): Promise<{ - workspaceId: string; - workspacePath: string; - cleanup: () => Promise; -}> { - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - console.log( - `[createWorkspaceHelper] Creating workspace with trunk=${trunkBranch}, branch=${branchName}` - ); - const result = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - projectPath, - branchName, - trunkBranch, - runtimeConfig - ); - - if (!result.success) { - throw new Error(`Failed to create workspace: ${result.error}`); - } - - const workspaceId = result.metadata.id; - const workspacePath = result.metadata.namedWorkspacePath; - - // Wait for init hook to complete in real-time - await waitForInitComplete(env, workspaceId, isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS); - - const cleanup = async () => { - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); - }; - - return { workspaceId, workspacePath, cleanup }; -} - /** * Execute bash command in workspace context (works for both local and SSH) */ @@ -200,11 +155,12 @@ describeIntegration("Workspace deletion integration tests", () => { try { const branchName = generateBranchName("delete-test"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, workspacePath } = await createWorkspaceHelper( + const { workspaceId, workspacePath } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -272,11 +228,12 @@ describeIntegration("Workspace deletion integration tests", () => { try { const branchName = generateBranchName("already-deleted"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, workspacePath } = await createWorkspaceHelper( + const { workspaceId, workspacePath } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -317,11 +274,12 @@ describeIntegration("Workspace deletion integration tests", () => { try { const branchName = generateBranchName("delete-dirty"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId } = await createWorkspaceHelper( + const { workspaceId } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -363,11 +321,12 @@ describeIntegration("Workspace deletion integration tests", () => { try { const branchName = generateBranchName("delete-dirty-force"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId } = await createWorkspaceHelper( + const { workspaceId } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -410,12 +369,13 @@ describeIntegration("Workspace deletion integration tests", () => { await addSubmodule(tempGitRepo); const branchName = generateBranchName("delete-submodule-clean"); - const { workspaceId, workspacePath } = await createWorkspaceHelper( + const { workspaceId, workspacePath } = await createWorkspaceWithInit( env, tempGitRepo, branchName, undefined, - false + true, // waitForInit + false // not SSH ); // Initialize submodule in the worktree @@ -462,12 +422,13 @@ describeIntegration("Workspace deletion integration tests", () => { await addSubmodule(tempGitRepo); const branchName = generateBranchName("delete-submodule-dirty"); - const { workspaceId, workspacePath } = await createWorkspaceHelper( + const { workspaceId, workspacePath } = await createWorkspaceWithInit( env, tempGitRepo, branchName, undefined, - false + true, // waitForInit + false // not SSH ); // Initialize submodule in the worktree diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index bd3416089..a849ec07b 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -17,8 +17,15 @@ import { promisify } from "util"; import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import type { TestEnvironment } from "./setup"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; -import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; -import { detectDefaultTrunkBranch } from "../../src/git"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createWorkspaceWithInit, + INIT_HOOK_WAIT_MS, + SSH_INIT_WAIT_MS, + TEST_TIMEOUT_SSH_MS, +} from "./helpers"; import { isDockerAvailable, startSSHServer, @@ -26,15 +33,11 @@ import { type SSHServerConfig, } from "../runtime/ssh-fixture"; import type { RuntimeConfig } from "../../src/types/runtime"; -import type { FrontendWorkspaceMetadata } from "../../src/types/workspace"; -import { waitForInitComplete } from "./helpers"; const execAsync = promisify(exec); // Test constants -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 TEST_TIMEOUT_MS = TEST_TIMEOUT_SSH_MS; // Use SSH timeout for consistency // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -43,41 +46,9 @@ const describeIntegration = shouldRunIntegrationTests() ? describe : describe.sk let sshConfig: SSHServerConfig | undefined; // ============================================================================ -// Test Helpers +// Tests // ============================================================================ -/** - * Create workspace and handle cleanup on test failure - */ -async function createWorkspaceWithCleanup( - env: TestEnvironment, - projectPath: string, - branchName: string, - trunkBranch: string, - runtimeConfig?: RuntimeConfig -): Promise<{ - result: - | { success: true; metadata: FrontendWorkspaceMetadata } - | { success: false; error: string }; - cleanup: () => Promise; -}> { - const result = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - projectPath, - branchName, - trunkBranch, - runtimeConfig - ); - - const cleanup = async () => { - if (result.success) { - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, result.metadata.id); - } - }; - - return { result, cleanup }; -} - describeIntegration("WORKSPACE_RENAME with both runtimes", () => { beforeAll(async () => { // Check if Docker is available (required for SSH tests) @@ -129,30 +100,21 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { try { const branchName = generateBranchName("rename-test"); - const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); const runtimeConfig = getRuntimeConfig(branchName); - // Create workspace - const { result, cleanup } = await createWorkspaceWithCleanup( + // Create workspace and wait for init + const { workspaceId, workspacePath, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, - trunkBranch, - runtimeConfig + runtimeConfig, + true, // waitForInit + type === "ssh" ); - expect(result.success).toBe(true); - if (!result.success) { - throw new Error(`Failed to create workspace: ${result.error}`); - } - - const workspaceId = result.metadata.id; - const oldWorkspacePath = result.metadata.namedWorkspacePath; + const oldWorkspacePath = workspacePath; const oldSessionDir = env.config.getSessionDir(workspaceId); - // Wait for init hook to complete before renaming - await waitForInitComplete(env, workspaceId, getInitWaitTime()); - // Clear events before rename env.sentEvents.length = 0; @@ -232,40 +194,33 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { try { const branchName = generateBranchName("first"); const secondBranchName = generateBranchName("second"); - const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); const runtimeConfig = getRuntimeConfig(branchName); // Create first workspace - const { result: firstResult, cleanup: firstCleanup } = await createWorkspaceWithCleanup( - env, - tempGitRepo, - branchName, - trunkBranch, - runtimeConfig - ); - expect(firstResult.success).toBe(true); - if (!firstResult.success) { - throw new Error(`Failed to create first workspace: ${firstResult.error}`); - } - - // Create second workspace - const { result: secondResult, cleanup: secondCleanup } = - await createWorkspaceWithCleanup( + const { workspaceId: firstWorkspaceId, cleanup: firstCleanup } = + await createWorkspaceWithInit( env, tempGitRepo, - secondBranchName, - trunkBranch, - runtimeConfig + branchName, + runtimeConfig, + true, // waitForInit + type === "ssh" ); - expect(secondResult.success).toBe(true); - if (!secondResult.success) { - throw new Error(`Failed to create second workspace: ${secondResult.error}`); - } + + // Create second workspace + const { cleanup: secondCleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + secondBranchName, + runtimeConfig, + true, // waitForInit + type === "ssh" + ); // Try to rename first workspace to the second workspace's name const renameResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_RENAME, - firstResult.metadata.id, + firstWorkspaceId, secondBranchName ); expect(renameResult.success).toBe(false); @@ -274,10 +229,10 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { // Verify original workspace still exists and wasn't modified const metadataResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_GET_INFO, - firstResult.metadata.id + firstWorkspaceId ); expect(metadataResult).toBeTruthy(); - expect(metadataResult.id).toBe(firstResult.metadata.id); + expect(metadataResult.id).toBe(firstWorkspaceId); await firstCleanup(); await secondCleanup(); diff --git a/tests/ipcMain/runtimeExecuteBash.test.ts b/tests/ipcMain/runtimeExecuteBash.test.ts index 5fe8df2d1..96523b2df 100644 --- a/tests/ipcMain/runtimeExecuteBash.test.ts +++ b/tests/ipcMain/runtimeExecuteBash.test.ts @@ -15,7 +15,17 @@ import { setupProviders, } from "./setup"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; -import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createWorkspaceWithInit, + sendMessageAndWait, + extractTextFromEvents, + HAIKU_MODEL, + TEST_TIMEOUT_LOCAL_MS, + TEST_TIMEOUT_SSH_MS, +} from "./helpers"; import { isDockerAvailable, startSSHServer, @@ -24,16 +34,6 @@ import { } from "../runtime/ssh-fixture"; import type { RuntimeConfig } from "../../src/types/runtime"; import type { ToolPolicy } from "../../src/utils/tools/toolPolicy"; -import { - createWorkspaceHelper, - sendMessageAndWait, - extractTextFromEvents, -} from "./test-helpers/runtimeTestHelpers"; - -// Test constants -const TEST_TIMEOUT_LOCAL_MS = 25000; -const TEST_TIMEOUT_SSH_MS = 45000; -const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; // Tool policy: Only allow bash tool const BASH_ONLY: ToolPolicy = [ @@ -109,11 +109,12 @@ describeIntegration("Runtime Bash Execution", () => { // Create workspace const branchName = generateBranchName("bash-simple"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, cleanup } = await createWorkspaceHelper( + const { workspaceId, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -166,11 +167,12 @@ describeIntegration("Runtime Bash Execution", () => { // Create workspace const branchName = generateBranchName("bash-env"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, cleanup } = await createWorkspaceHelper( + const { workspaceId, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -223,11 +225,12 @@ describeIntegration("Runtime Bash Execution", () => { // Create workspace const branchName = generateBranchName("bash-special"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, cleanup } = await createWorkspaceHelper( + const { workspaceId, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); diff --git a/tests/ipcMain/runtimeFileEditing.test.ts b/tests/ipcMain/runtimeFileEditing.test.ts index 2143a574c..505c1fcf0 100644 --- a/tests/ipcMain/runtimeFileEditing.test.ts +++ b/tests/ipcMain/runtimeFileEditing.test.ts @@ -19,9 +19,20 @@ import { preloadTestModules, type TestEnvironment, } from "./setup"; -import { IPC_CHANNELS, getChatChannel } from "../../src/constants/ipc-constants"; -import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; -import { detectDefaultTrunkBranch } from "../../src/git"; +import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createWorkspaceWithInit, + sendMessageAndWait, + extractTextFromEvents, + HAIKU_MODEL, + TEST_TIMEOUT_LOCAL_MS, + TEST_TIMEOUT_SSH_MS, + STREAM_TIMEOUT_LOCAL_MS, + STREAM_TIMEOUT_SSH_MS, +} from "./helpers"; import { isDockerAvailable, startSSHServer, @@ -29,19 +40,8 @@ import { type SSHServerConfig, } from "../runtime/ssh-fixture"; import type { RuntimeConfig } from "../../src/types/runtime"; -import type { FrontendWorkspaceMetadata } from "../../src/types/workspace"; -import type { WorkspaceChatMessage } from "../../src/types/ipc"; import type { ToolPolicy } from "../../src/utils/tools/toolPolicy"; -// Test constants -const TEST_TIMEOUT_LOCAL_MS = 25000; // Includes init wait time -const TEST_TIMEOUT_SSH_MS = 60000; // SSH has more overhead (network, rsync, etc.) -const STREAM_TIMEOUT_LOCAL_MS = 15000; // Stream timeout for local runtime -const STREAM_TIMEOUT_SSH_MS = 25000; // SSH needs longer due to network latency -const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; -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 - // Tool policy: Only allow file tools (disable bash to isolate file tool issues) const FILE_TOOLS_ONLY: ToolPolicy = [ { regex_match: "file_.*", action: "enable" }, @@ -59,141 +59,6 @@ if (shouldRunIntegrationTests()) { // SSH server config (shared across all SSH tests) let sshConfig: SSHServerConfig | undefined; -// ============================================================================ -// Test Helpers -// ============================================================================ - -/** - * Wait for a specific event type to appear in the stream - */ -async function waitForEvent( - sentEvents: Array<{ channel: string; data: unknown }>, - workspaceId: string, - eventType: string, - timeoutMs: number -): Promise { - const startTime = Date.now(); - const chatChannel = getChatChannel(workspaceId); - let pollInterval = 50; - - while (Date.now() - startTime < timeoutMs) { - const events = sentEvents - .filter((e) => e.channel === chatChannel) - .map((e) => e.data as WorkspaceChatMessage); - - // Check if the event has appeared - const targetEvent = events.find((e) => "type" in e && e.type === eventType); - if (targetEvent) { - return events; - } - - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - pollInterval = Math.min(pollInterval * 1.5, 500); - } - - throw new Error(`Event ${eventType} did not appear within ${timeoutMs}ms`); -} - -/** - * Wait for stream to complete and collect all events - */ -async function waitForStreamCompletion( - sentEvents: Array<{ channel: string; data: unknown }>, - workspaceId: string, - timeoutMs = 15000 // Reduced for simple operations with fast model -): Promise { - return waitForEvent(sentEvents, workspaceId, "stream-end", timeoutMs); -} - -/** - * Extract text content from stream events - */ -function extractTextFromEvents(events: WorkspaceChatMessage[]): string { - return events - .filter((e) => "type" in e && e.type === "stream-delta" && "delta" in e) - .map((e: any) => e.delta || "") - .join(""); -} - -/** - * Create workspace helper and wait for init hook to complete - */ -async function createWorkspaceHelper( - env: TestEnvironment, - projectPath: string, - branchName: string, - runtimeConfig?: RuntimeConfig, - isSSH: boolean = false -): Promise<{ - workspaceId: string; - cleanup: () => Promise; -}> { - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - const result = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - projectPath, - branchName, - trunkBranch, - runtimeConfig - ); - - if (!result.success) { - throw new Error(`Failed to create workspace: ${result.error}`); - } - - const workspaceId = result.metadata.id; - - // Wait for init hook to complete by watching for init-end event - // This is critical - file operations will fail if init hasn't finished - const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS; - try { - await waitForEvent(env.sentEvents, workspaceId, "init-end", initTimeout); - } catch (err) { - // Init hook might not exist or might have already completed before we started waiting - // This is not necessarily an error - just log it - console.log( - `Note: init-end event not detected within ${initTimeout}ms (may have completed early)` - ); - } - - const cleanup = async () => { - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); - }; - - return { workspaceId, cleanup }; -} - -/** - * Send message and wait for completion - */ -async function sendMessageAndWait( - env: TestEnvironment, - workspaceId: string, - message: string, - streamTimeout?: number -): Promise { - // Clear previous events - env.sentEvents.length = 0; - - // Send message with Haiku model and file-tools-only policy - const result = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, - workspaceId, - message, - { - model: HAIKU_MODEL, - toolPolicy: FILE_TOOLS_ONLY, - } - ); - - if (!result.success) { - throw new Error(`Failed to send message: ${result.error}`); - } - - // Wait for stream completion - return await waitForStreamCompletion(env.sentEvents, workspaceId, streamTimeout); -} - // ============================================================================ // Tests // ============================================================================ @@ -258,11 +123,12 @@ describeIntegration("Runtime File Editing Tools", () => { // Create workspace const branchName = generateBranchName("read-test"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, cleanup } = await createWorkspaceHelper( + const { workspaceId, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -275,6 +141,8 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Create a file called ${testFileName} with the content: "Hello from cmux file tools!"`, + HAIKU_MODEL, + FILE_TOOLS_ONLY, streamTimeout ); @@ -290,6 +158,8 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Read the file ${testFileName} and tell me what it contains.`, + HAIKU_MODEL, + FILE_TOOLS_ONLY, streamTimeout ); @@ -336,11 +206,12 @@ describeIntegration("Runtime File Editing Tools", () => { // Create workspace const branchName = generateBranchName("replace-test"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, cleanup } = await createWorkspaceHelper( + const { workspaceId, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -353,6 +224,8 @@ 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, + FILE_TOOLS_ONLY, streamTimeout ); @@ -368,6 +241,8 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `In ${testFileName}, replace "brown fox" with "red panda".`, + HAIKU_MODEL, + FILE_TOOLS_ONLY, streamTimeout ); @@ -420,11 +295,12 @@ describeIntegration("Runtime File Editing Tools", () => { // Create workspace const branchName = generateBranchName("insert-test"); const runtimeConfig = getRuntimeConfig(branchName); - const { workspaceId, cleanup } = await createWorkspaceHelper( + const { workspaceId, cleanup } = await createWorkspaceWithInit( env, tempGitRepo, branchName, runtimeConfig, + true, // waitForInit type === "ssh" ); @@ -437,6 +313,8 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `Create a file called ${testFileName} with two lines: "Line 1" and "Line 3".`, + HAIKU_MODEL, + FILE_TOOLS_ONLY, streamTimeout ); @@ -452,6 +330,8 @@ describeIntegration("Runtime File Editing Tools", () => { env, workspaceId, `In ${testFileName}, insert "Line 2" between Line 1 and Line 3.`, + HAIKU_MODEL, + FILE_TOOLS_ONLY, streamTimeout ); diff --git a/tests/ipcMain/test-helpers/runtimeTestHelpers.ts b/tests/ipcMain/test-helpers/runtimeTestHelpers.ts deleted file mode 100644 index ab2a68d45..000000000 --- a/tests/ipcMain/test-helpers/runtimeTestHelpers.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Shared test helpers for runtime integration tests - * - * These helpers are used across multiple test files (runtimeFileEditing, runtimeExecuteBash, etc.) - * to reduce code duplication and ensure consistent test patterns. - */ - -import { IPC_CHANNELS, getChatChannel } from "../../../src/constants/ipc-constants"; -import { detectDefaultTrunkBranch } from "../../../src/git"; -import type { TestEnvironment } from "../setup"; -import type { RuntimeConfig } from "../../../src/types/runtime"; -import type { WorkspaceChatMessage } from "../../../src/types/ipc"; -import type { ToolPolicy } from "../../../src/utils/tools/toolPolicy"; - -// Constants -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 - -/** - * Wait for a specific event type to appear in the stream - */ -async function waitForEvent( - sentEvents: Array<{ channel: string; data: unknown }>, - workspaceId: string, - eventType: string, - timeoutMs: number -): Promise { - const startTime = Date.now(); - const chatChannel = getChatChannel(workspaceId); - let pollInterval = 50; - - while (Date.now() - startTime < timeoutMs) { - const events = sentEvents - .filter((e) => e.channel === chatChannel) - .map((e) => e.data as WorkspaceChatMessage); - - // Check if the event has appeared - const targetEvent = events.find((e) => "type" in e && e.type === eventType); - if (targetEvent) { - return events; - } - - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - pollInterval = Math.min(pollInterval * 1.5, 500); - } - - throw new Error(`Event ${eventType} did not appear within ${timeoutMs}ms`); -} - -/** - * Wait for stream to complete and collect all events - */ -async function waitForStreamCompletion( - sentEvents: Array<{ channel: string; data: unknown }>, - workspaceId: string, - timeoutMs = 20000 // Sufficient for most operations with fast models -): Promise { - return waitForEvent(sentEvents, workspaceId, "stream-end", timeoutMs); -} - -/** - * Create a workspace and wait for init hook completion - */ -export async function createWorkspaceHelper( - env: TestEnvironment, - repoPath: string, - branchName: string, - runtimeConfig: RuntimeConfig | undefined, - isSSH: boolean -): Promise<{ workspaceId: string; cleanup: () => Promise }> { - // Detect trunk branch - const trunkBranch = await detectDefaultTrunkBranch(repoPath); - - // Create workspace - const result: any = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - repoPath, - branchName, - trunkBranch, - runtimeConfig - ); - - if (!result.success) { - throw new Error(`Failed to create workspace: ${result.error}`); - } - - const workspaceId = result.metadata.id; - - // Wait for init hook to complete by watching for init-end event - // This is critical - file operations will fail if init hasn't finished - const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS; - try { - await waitForEvent(env.sentEvents, workspaceId, "init-end", initTimeout); - } catch (err) { - // Init hook might not exist or might have already completed before we started waiting - // This is not necessarily an error - just log it - console.log( - `Note: init-end event not detected within ${initTimeout}ms (may have completed early)` - ); - } - - const cleanup = async () => { - await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); - }; - - return { workspaceId, cleanup }; -} - -/** - * Send message and wait for completion - */ -export async function sendMessageAndWait( - env: TestEnvironment, - workspaceId: string, - message: string, - model: string, - toolPolicy: ToolPolicy -): Promise { - // Clear previous events - env.sentEvents.length = 0; - - // Send message - const result = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, - workspaceId, - message, - { - model, - toolPolicy, - } - ); - - if (!result.success) { - throw new Error(`Failed to send message: ${result.error}`); - } - - // Wait for stream completion - return await waitForStreamCompletion(env.sentEvents, workspaceId); -} - -/** - * Extract text content from stream events - */ -export function extractTextFromEvents(events: WorkspaceChatMessage[]): string { - return events - .filter((e: any) => e.type === "stream-delta" && "delta" in e) - .map((e: any) => e.delta || "") - .join(""); -} From eb907c03d84b44b17b53f6124da007cc1e421b6e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 14:47:24 -0500 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20Deduplicate=20copy-to-clipbo?= =?UTF-8?q?ard=20logic=20with=20useCopyToClipboard=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy feedback pattern (copied state + 2000ms timeout) was duplicated across 4 components with identical implementations. Changes: - Created src/hooks/useCopyToClipboard.ts hook for reusable copy functionality - Added COPY_FEEDBACK_DURATION_MS constant to src/constants/ui.ts (2000ms) - Updated components to use the hook: - AssistantMessage: -11 lines - UserMessage: -18 lines (also removed useState import) - ProposePlanToolCall: -12 lines - FileEditToolCall: -13 lines Result: 54 lines of duplicated code replaced with single 33-line hook. Single source of truth for copy feedback duration and implementation. --- src/components/Messages/AssistantMessage.tsx | 15 +++------ src/components/Messages/UserMessage.tsx | 23 +++----------- src/components/tools/FileEditToolCall.tsx | 17 +++------- src/components/tools/ProposePlanToolCall.tsx | 17 +++------- src/constants/ui.ts | 5 +++ src/hooks/useCopyToClipboard.ts | 33 ++++++++++++++++++++ 6 files changed, 56 insertions(+), 54 deletions(-) create mode 100644 src/hooks/useCopyToClipboard.ts diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index 864fe89ae..f8d0836bc 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -5,6 +5,7 @@ import { TypewriterMarkdown } from "./TypewriterMarkdown"; import type { ButtonConfig } from "./MessageWindow"; import { MessageWindow } from "./MessageWindow"; import { useStartHere } from "@/hooks/useStartHere"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { COMPACTED_EMOJI } from "@/constants/ui"; import { ModelDisplay } from "./ModelDisplay"; import { CompactingMessageContent } from "./CompactingMessageContent"; @@ -27,7 +28,6 @@ export const AssistantMessage: React.FC = ({ clipboardWriteText = (data: string) => navigator.clipboard.writeText(data), }) => { const [showRaw, setShowRaw] = useState(false); - const [copied, setCopied] = useState(false); const content = message.content; const isStreaming = message.isStreaming; @@ -42,15 +42,8 @@ export const AssistantMessage: React.FC = ({ modal, } = useStartHere(workspaceId, content, isCompacted); - const handleCopy = async () => { - try { - await clipboardWriteText(content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; + // Copy to clipboard with feedback + const { copied, copyToClipboard } = useCopyToClipboard(clipboardWriteText); // Keep only Copy button visible (most common action) // Kebab menu saves horizontal space by collapsing less-used actions into a single ⋮ button @@ -59,7 +52,7 @@ export const AssistantMessage: React.FC = ({ : [ { label: copied ? "✓ Copied" : "Copy", - onClick: () => void handleCopy(), + onClick: () => void copyToClipboard(content), }, ]; diff --git a/src/components/Messages/UserMessage.tsx b/src/components/Messages/UserMessage.tsx index 75968b351..54edf39f6 100644 --- a/src/components/Messages/UserMessage.tsx +++ b/src/components/Messages/UserMessage.tsx @@ -1,9 +1,10 @@ -import React, { useState } from "react"; +import React from "react"; import type { DisplayedMessage } from "@/types/message"; import type { ButtonConfig } from "./MessageWindow"; import { MessageWindow } from "./MessageWindow"; import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import type { KebabMenuItem } from "@/components/KebabMenu"; interface UserMessageProps { @@ -30,8 +31,6 @@ export const UserMessage: React.FC = ({ isCompacting, clipboardWriteText = defaultClipboardWriteText, }) => { - const [copied, setCopied] = useState(false); - const content = message.content; console.assert( @@ -48,20 +47,8 @@ export const UserMessage: React.FC = ({ ? content.slice("".length, -"".length).trim() : ""; - const handleCopy = async () => { - console.assert( - typeof content === "string", - "UserMessage copy handler expects message content to be a string." - ); - - try { - await clipboardWriteText(content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; + // Copy to clipboard with feedback + const { copied, copyToClipboard } = useCopyToClipboard(clipboardWriteText); const handleEdit = () => { if (onEdit && !isLocalCommandOutput) { @@ -86,7 +73,7 @@ export const UserMessage: React.FC = ({ : []), { label: copied ? "✓ Copied" : "Copy", - onClick: () => void handleCopy(), + onClick: () => void copyToClipboard(content), }, ]; diff --git a/src/components/tools/FileEditToolCall.tsx b/src/components/tools/FileEditToolCall.tsx index 75011a767..cfb3d0034 100644 --- a/src/components/tools/FileEditToolCall.tsx +++ b/src/components/tools/FileEditToolCall.tsx @@ -19,6 +19,7 @@ import { LoadingDots, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { DiffContainer, DiffRenderer, SelectableDiffRenderer } from "../shared/DiffRenderer"; import { KebabMenu, type KebabMenuItem } from "../KebabMenu"; @@ -104,21 +105,11 @@ export const FileEditToolCall: React.FC = ({ const { expanded, toggleExpanded } = useToolExpansion(initialExpanded); const [showRaw, setShowRaw] = React.useState(false); - const [copied, setCopied] = React.useState(false); const filePath = "file_path" in args ? args.file_path : undefined; - const handleCopyPatch = async () => { - if (result && result.success && result.diff) { - try { - await navigator.clipboard.writeText(result.diff); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - } - }; + // Copy to clipboard with feedback + const { copied, copyToClipboard } = useCopyToClipboard(); // Build kebab menu items for successful edits with diffs const kebabMenuItems: KebabMenuItem[] = @@ -126,7 +117,7 @@ export const FileEditToolCall: React.FC = ({ ? [ { label: copied ? "✓ Copied" : "Copy Patch", - onClick: () => void handleCopyPatch(), + onClick: () => void copyToClipboard(result.diff), }, { label: showRaw ? "Show Parsed" : "Show Patch", diff --git a/src/components/tools/ProposePlanToolCall.tsx b/src/components/tools/ProposePlanToolCall.tsx index 90b08ecfd..715d5198a 100644 --- a/src/components/tools/ProposePlanToolCall.tsx +++ b/src/components/tools/ProposePlanToolCall.tsx @@ -12,6 +12,7 @@ import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/to import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { useStartHere } from "@/hooks/useStartHere"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { cn } from "@/lib/utils"; @@ -30,7 +31,6 @@ export const ProposePlanToolCall: React.FC = ({ }) => { const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default const [showRaw, setShowRaw] = useState(false); - const [copied, setCopied] = useState(false); // Format: Title as H1 + plan content for "Start Here" functionality const startHereContent = `# ${args.title}\n\n${args.plan}`; @@ -46,20 +46,13 @@ export const ProposePlanToolCall: React.FC = ({ false // Plans are never already compacted ); + // Copy to clipboard with feedback + const { copied, copyToClipboard } = useCopyToClipboard(); + const [isHovered, setIsHovered] = useState(false); const statusDisplay = getStatusDisplay(status); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(args.plan); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; - return ( @@ -134,7 +127,7 @@ export const ProposePlanToolCall: React.FC = ({ )}