Skip to content

Commit 9db5f11

Browse files
authored
🤖 Deduplicate test infrastructure and copy-to-clipboard logic (#441)
After the SSH runtime commit (#178, commit 5f200a6), there was significant duplication in test infrastructure and repeated copy-to-clipboard patterns across components. ## Test Infrastructure Consolidation Consolidated 3 overlapping test helper modules and inline duplicates into `tests/ipcMain/helpers.ts`: - Added `extractTextFromEvents()`, `sendMessageAndWait()`, `createWorkspaceWithInit()` - Centralized test constants (INIT_HOOK_WAIT_MS, SSH_INIT_WAIT_MS, HAIKU_MODEL, timeouts) - Updated 4 test files to use consolidated helpers - Deleted `tests/ipcMain/test-helpers/runtimeTestHelpers.ts` (149 lines) **Result:** -240 lines, single source of truth for ipcMain integration test utilities. ## Copy-to-Clipboard Deduplication The copy feedback pattern (`copied` state + 2000ms timeout) was duplicated across 4 components with identical implementations. - Created `src/hooks/useCopyToClipboard.ts` for reusable copy functionality - Added `COPY_FEEDBACK_DURATION_MS` constant to `src/constants/ui.ts` - Updated AssistantMessage, UserMessage, ProposePlanToolCall, FileEditToolCall **Result:** 54 lines of duplicated code replaced with single 33-line hook. _Generated with `cmux`_
1 parent 7ed9493 commit 9db5f11

File tree

12 files changed

+277
-509
lines changed

12 files changed

+277
-509
lines changed

src/components/Messages/AssistantMessage.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { TypewriterMarkdown } from "./TypewriterMarkdown";
55
import type { ButtonConfig } from "./MessageWindow";
66
import { MessageWindow } from "./MessageWindow";
77
import { useStartHere } from "@/hooks/useStartHere";
8+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
89
import { COMPACTED_EMOJI } from "@/constants/ui";
910
import { ModelDisplay } from "./ModelDisplay";
1011
import { CompactingMessageContent } from "./CompactingMessageContent";
@@ -27,7 +28,6 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
2728
clipboardWriteText = (data: string) => navigator.clipboard.writeText(data),
2829
}) => {
2930
const [showRaw, setShowRaw] = useState(false);
30-
const [copied, setCopied] = useState(false);
3131

3232
const content = message.content;
3333
const isStreaming = message.isStreaming;
@@ -42,15 +42,8 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
4242
modal,
4343
} = useStartHere(workspaceId, content, isCompacted);
4444

45-
const handleCopy = async () => {
46-
try {
47-
await clipboardWriteText(content);
48-
setCopied(true);
49-
setTimeout(() => setCopied(false), 2000);
50-
} catch (err) {
51-
console.error("Failed to copy:", err);
52-
}
53-
};
45+
// Copy to clipboard with feedback
46+
const { copied, copyToClipboard } = useCopyToClipboard(clipboardWriteText);
5447

5548
// Keep only Copy button visible (most common action)
5649
// Kebab menu saves horizontal space by collapsing less-used actions into a single ⋮ button
@@ -59,7 +52,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
5952
: [
6053
{
6154
label: copied ? "✓ Copied" : "Copy",
62-
onClick: () => void handleCopy(),
55+
onClick: () => void copyToClipboard(content),
6356
},
6457
];
6558

src/components/Messages/UserMessage.tsx

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, { useState } from "react";
1+
import React from "react";
22
import type { DisplayedMessage } from "@/types/message";
33
import type { ButtonConfig } from "./MessageWindow";
44
import { MessageWindow } from "./MessageWindow";
55
import { TerminalOutput } from "./TerminalOutput";
66
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
7+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
78
import type { KebabMenuItem } from "@/components/KebabMenu";
89

910
interface UserMessageProps {
@@ -30,8 +31,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
3031
isCompacting,
3132
clipboardWriteText = defaultClipboardWriteText,
3233
}) => {
33-
const [copied, setCopied] = useState(false);
34-
3534
const content = message.content;
3635

3736
console.assert(
@@ -48,20 +47,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({
4847
? content.slice("<local-command-stdout>".length, -"</local-command-stdout>".length).trim()
4948
: "";
5049

51-
const handleCopy = async () => {
52-
console.assert(
53-
typeof content === "string",
54-
"UserMessage copy handler expects message content to be a string."
55-
);
56-
57-
try {
58-
await clipboardWriteText(content);
59-
setCopied(true);
60-
setTimeout(() => setCopied(false), 2000);
61-
} catch (err) {
62-
console.error("Failed to copy:", err);
63-
}
64-
};
50+
// Copy to clipboard with feedback
51+
const { copied, copyToClipboard } = useCopyToClipboard(clipboardWriteText);
6552

6653
const handleEdit = () => {
6754
if (onEdit && !isLocalCommandOutput) {
@@ -86,7 +73,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
8673
: []),
8774
{
8875
label: copied ? "✓ Copied" : "Copy",
89-
onClick: () => void handleCopy(),
76+
onClick: () => void copyToClipboard(content),
9077
},
9178
];
9279

src/components/tools/FileEditToolCall.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
LoadingDots,
2020
} from "./shared/ToolPrimitives";
2121
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
22+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
2223
import { TooltipWrapper, Tooltip } from "../Tooltip";
2324
import { DiffContainer, DiffRenderer, SelectableDiffRenderer } from "../shared/DiffRenderer";
2425
import { KebabMenu, type KebabMenuItem } from "../KebabMenu";
@@ -104,29 +105,19 @@ export const FileEditToolCall: React.FC<FileEditToolCallProps> = ({
104105

105106
const { expanded, toggleExpanded } = useToolExpansion(initialExpanded);
106107
const [showRaw, setShowRaw] = React.useState(false);
107-
const [copied, setCopied] = React.useState(false);
108108

109109
const filePath = "file_path" in args ? args.file_path : undefined;
110110

111-
const handleCopyPatch = async () => {
112-
if (result && result.success && result.diff) {
113-
try {
114-
await navigator.clipboard.writeText(result.diff);
115-
setCopied(true);
116-
setTimeout(() => setCopied(false), 2000);
117-
} catch (err) {
118-
console.error("Failed to copy:", err);
119-
}
120-
}
121-
};
111+
// Copy to clipboard with feedback
112+
const { copied, copyToClipboard } = useCopyToClipboard();
122113

123114
// Build kebab menu items for successful edits with diffs
124115
const kebabMenuItems: KebabMenuItem[] =
125116
result && result.success && result.diff
126117
? [
127118
{
128119
label: copied ? "✓ Copied" : "Copy Patch",
129-
onClick: () => void handleCopyPatch(),
120+
onClick: () => void copyToClipboard(result.diff),
130121
},
131122
{
132123
label: showRaw ? "Show Parsed" : "Show Patch",

src/components/tools/ProposePlanToolCall.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/to
1212
import { MarkdownRenderer } from "../Messages/MarkdownRenderer";
1313
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
1414
import { useStartHere } from "@/hooks/useStartHere";
15+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
1516
import { TooltipWrapper, Tooltip } from "../Tooltip";
1617
import { cn } from "@/lib/utils";
1718

@@ -30,7 +31,6 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
3031
}) => {
3132
const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default
3233
const [showRaw, setShowRaw] = useState(false);
33-
const [copied, setCopied] = useState(false);
3434

3535
// Format: Title as H1 + plan content for "Start Here" functionality
3636
const startHereContent = `# ${args.title}\n\n${args.plan}`;
@@ -46,20 +46,13 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
4646
false // Plans are never already compacted
4747
);
4848

49+
// Copy to clipboard with feedback
50+
const { copied, copyToClipboard } = useCopyToClipboard();
51+
4952
const [isHovered, setIsHovered] = useState(false);
5053

5154
const statusDisplay = getStatusDisplay(status);
5255

53-
const handleCopy = async () => {
54-
try {
55-
await navigator.clipboard.writeText(args.plan);
56-
setCopied(true);
57-
setTimeout(() => setCopied(false), 2000);
58-
} catch (err) {
59-
console.error("Failed to copy:", err);
60-
}
61-
};
62-
6356
return (
6457
<ToolContainer expanded={expanded}>
6558
<ToolHeader onClick={toggleExpanded}>
@@ -134,7 +127,7 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
134127
</TooltipWrapper>
135128
)}
136129
<button
137-
onClick={() => void handleCopy()}
130+
onClick={() => void copyToClipboard(args.plan)}
138131
className="text-muted hover:text-plan-mode cursor-pointer rounded-sm bg-transparent px-2 py-1 font-mono text-[10px] transition-all duration-150 active:translate-y-px"
139132
style={{
140133
border: "1px solid rgba(136, 136, 136, 0.3)",

src/constants/ui.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@
99
* - Start Here button (plans and assistant messages)
1010
*/
1111
export const COMPACTED_EMOJI = "📦";
12+
13+
/**
14+
* Duration (ms) to show "copied" feedback after copying to clipboard
15+
*/
16+
export const COPY_FEEDBACK_DURATION_MS = 2000;

src/hooks/useCopyToClipboard.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useState, useCallback } from "react";
2+
import { COPY_FEEDBACK_DURATION_MS } from "@/constants/ui";
3+
4+
/**
5+
* Hook for copy-to-clipboard functionality with temporary "copied" feedback state.
6+
*
7+
* @param clipboardWriteText - Optional custom clipboard write function (defaults to navigator.clipboard.writeText)
8+
* @returns Object with:
9+
* - copied: boolean indicating if content was just copied (resets after COPY_FEEDBACK_DURATION_MS)
10+
* - copyToClipboard: async function to copy text and trigger feedback
11+
*/
12+
export function useCopyToClipboard(
13+
clipboardWriteText: (text: string) => Promise<void> = (text: string) =>
14+
navigator.clipboard.writeText(text)
15+
) {
16+
const [copied, setCopied] = useState(false);
17+
18+
const copyToClipboard = useCallback(
19+
async (text: string) => {
20+
try {
21+
await clipboardWriteText(text);
22+
setCopied(true);
23+
setTimeout(() => setCopied(false), COPY_FEEDBACK_DURATION_MS);
24+
} catch (err) {
25+
console.error("Failed to copy:", err);
26+
}
27+
},
28+
[clipboardWriteText]
29+
);
30+
31+
return { copied, copyToClipboard };
32+
}

tests/ipcMain/helpers.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import type { WorkspaceMetadataWithPaths } from "../../src/types/workspace";
77
import * as path from "path";
88
import * as os from "os";
99
import { detectDefaultTrunkBranch } from "../../src/git";
10+
import type { TestEnvironment } from "./setup";
11+
import type { RuntimeConfig } from "../../src/types/runtime";
12+
import type { ToolPolicy } from "../../src/utils/tools/toolPolicy";
13+
14+
// Test constants - centralized for consistency across all tests
15+
export const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime)
16+
export const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer
17+
export const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; // Fast model for tests
18+
export const TEST_TIMEOUT_LOCAL_MS = 25000; // Recommended timeout for local runtime tests
19+
export const TEST_TIMEOUT_SSH_MS = 60000; // Recommended timeout for SSH runtime tests
20+
export const STREAM_TIMEOUT_LOCAL_MS = 15000; // Stream timeout for local runtime
21+
export const STREAM_TIMEOUT_SSH_MS = 25000; // Stream timeout for SSH runtime
1022

1123
/**
1224
* Generate a unique branch name
@@ -98,6 +110,104 @@ export async function clearHistory(
98110
)) as Result<void, string>;
99111
}
100112

113+
/**
114+
* Extract text content from stream events
115+
* Filters for stream-delta events and concatenates the delta text
116+
*/
117+
export function extractTextFromEvents(events: WorkspaceChatMessage[]): string {
118+
return events
119+
.filter((e: any) => e.type === "stream-delta" && "delta" in e)
120+
.map((e: any) => e.delta || "")
121+
.join("");
122+
}
123+
124+
/**
125+
* Create workspace with optional init hook wait
126+
* Enhanced version that can wait for init hook completion (needed for runtime tests)
127+
*/
128+
export async function createWorkspaceWithInit(
129+
env: TestEnvironment,
130+
projectPath: string,
131+
branchName: string,
132+
runtimeConfig?: RuntimeConfig,
133+
waitForInit: boolean = false,
134+
isSSH: boolean = false
135+
): Promise<{ workspaceId: string; workspacePath: string; cleanup: () => Promise<void> }> {
136+
const trunkBranch = await detectDefaultTrunkBranch(projectPath);
137+
138+
const result: any = await env.mockIpcRenderer.invoke(
139+
IPC_CHANNELS.WORKSPACE_CREATE,
140+
projectPath,
141+
branchName,
142+
trunkBranch,
143+
runtimeConfig
144+
);
145+
146+
if (!result.success) {
147+
throw new Error(`Failed to create workspace: ${result.error}`);
148+
}
149+
150+
const workspaceId = result.metadata.id;
151+
const workspacePath = result.metadata.namedWorkspacePath;
152+
153+
// Wait for init hook to complete if requested
154+
if (waitForInit) {
155+
const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS;
156+
const collector = createEventCollector(env.sentEvents, workspaceId);
157+
try {
158+
await collector.waitForEvent("init-end", initTimeout);
159+
} catch (err) {
160+
// Init hook might not exist or might have already completed before we started waiting
161+
// This is not necessarily an error - just log it
162+
console.log(
163+
`Note: init-end event not detected within ${initTimeout}ms (may have completed early)`
164+
);
165+
}
166+
}
167+
168+
const cleanup = async () => {
169+
await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId);
170+
};
171+
172+
return { workspaceId, workspacePath, cleanup };
173+
}
174+
175+
/**
176+
* Send message and wait for stream completion
177+
* Convenience helper that combines message sending with event collection
178+
*/
179+
export async function sendMessageAndWait(
180+
env: TestEnvironment,
181+
workspaceId: string,
182+
message: string,
183+
model: string,
184+
toolPolicy?: ToolPolicy,
185+
timeoutMs: number = STREAM_TIMEOUT_LOCAL_MS
186+
): Promise<WorkspaceChatMessage[]> {
187+
// Clear previous events
188+
env.sentEvents.length = 0;
189+
190+
// Send message
191+
const result = await env.mockIpcRenderer.invoke(
192+
IPC_CHANNELS.WORKSPACE_SEND_MESSAGE,
193+
workspaceId,
194+
message,
195+
{
196+
model,
197+
toolPolicy,
198+
}
199+
);
200+
201+
if (!result.success) {
202+
throw new Error(`Failed to send message: ${result.error}`);
203+
}
204+
205+
// Wait for stream completion
206+
const collector = createEventCollector(env.sentEvents, workspaceId);
207+
await collector.waitForEvent("stream-end", timeoutMs);
208+
return collector.getEvents();
209+
}
210+
101211
/**
102212
* Event collector for capturing stream events
103213
*/

0 commit comments

Comments
 (0)