Skip to content

Commit 0914d2b

Browse files
committed
Add mock Claude client support for E2E testing
Introduces a testing framework for Claude integration that allows Playwright tests to run with deterministic, pre-recorded Claude interactions instead of the real Claude CLI. This enables reliable, fast E2E testing of permission flows and user interactions without requiring an actual Claude API connection. Key components: - Mock scenario loader (mock_scenario.rs) that reads session snapshots from JSON files and creates mock transports for the Claude Agent SDK - Three initial test scenarios covering common permission flows: * permission-bash-echo.json - Basic Bash tool approval * permission-wildcard-test.json - Wildcard permission scoping * ask-user-question.json - AskUserQuestion tool interaction - Integration in session.rs via `testing` feature flag and CLAUDE_MOCK_SCENARIO environment variable - E2E test suite (claudePermissions.spec.ts) demonstrating permission approval UI interactions - Improved test stability with process cleanup and RUST_LOG passthrough The mock scenarios use the SDK's SessionSnapshot format and handle permission callback timing automatically - when a control_request appears in the scenario, subsequent messages are held until the SDK responds with control_response. Each test specifies its own scenario by passing CLAUDE_MOCK_SCENARIO to startGitButler(), which spawns a fresh but-server process with that env var.
1 parent ca23747 commit 0914d2b

File tree

22 files changed

+1188
-82
lines changed

22 files changed

+1188
-82
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/components/BranchList.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@
327327
/>
328328
{#if !$codegenDisabled && first && codegenQuery?.response?.length === 0}
329329
<Button
330+
testId={TestId.BranchHeaderContextMenu_StartCodegenAgent}
330331
icon="ai-small"
331332
style="gray"
332333
size="tag"

apps/desktop/src/components/StackView.svelte

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,21 @@
319319
320320
let mcpConfigModal = $state<CodegenMcpConfigModal>();
321321
322-
const mcpConfigQuery = $derived(claudeCodeService.mcpConfig({ projectId }));
323-
const isStackActiveQuery = $derived(claudeCodeService.isStackActive(projectId, stackId));
322+
const mcpConfigQuery = $derived(claudeCodeService.mcpConfig({ projectId: stableProjectId }));
323+
const isStackActiveQuery = $derived(
324+
claudeCodeService.isStackActive(stableProjectId, stableStackId)
325+
);
324326
const isStackActive = $derived(isStackActiveQuery?.response || false);
325-
const events = $derived(claudeCodeService.messages({ projectId, stackId }));
326-
const sessionId = $derived(rulesService.aiSessionId(projectId, stackId));
327-
const hasRulesToClear = $derived(rulesService.hasRulesToClear(projectId, stackId));
328-
const permissionRequests = $derived(claudeCodeService.permissionRequests({ projectId }));
327+
const events = $derived(
328+
stableStackId
329+
? claudeCodeService.messages({ projectId: stableProjectId, stackId: stableStackId })
330+
: undefined
331+
);
332+
const sessionId = $derived(rulesService.aiSessionId(stableProjectId, stableStackId));
333+
const hasRulesToClear = $derived(rulesService.hasRulesToClear(stableProjectId, stableStackId));
334+
const permissionRequests = $derived(
335+
claudeCodeService.permissionRequests({ projectId: stableProjectId })
336+
);
329337
const attachments = $derived(attachmentService.getByBranch(branchName));
330338
331339
const selectedThinkingLevel = $derived(projectState.thinkingLevel.current);
@@ -404,14 +412,14 @@
404412
>
405413
<ReduxResult
406414
projectId={stableProjectId}
407-
result={combineResults(branchesQuery.result, hasRulesToClear.result)}
415+
result={combineResults(branchesQuery.result, hasRulesToClear.result, permissionRequests.result)}
408416
>
409417
{#snippet loading()}
410418
<div style:width="{$persistedStackWidth}rem" class="lane-skeleton">
411419
<FullviewLoading />
412420
</div>
413421
{/snippet}
414-
{#snippet children([branches, hasRulesToClear])}
422+
{#snippet children([branches, hasRulesToClear, permissionRequests])}
415423
<ConfigurableScrollableContainer childrenWrapHeight="100%" enableDragScroll>
416424
<div
417425
class="stack-view"
@@ -542,18 +550,18 @@
542550
>
543551
{#if stableStackId && selection?.branchName && selection?.codegen}
544552
<CodegenMessages
545-
projectId={stableProjectId}
546-
stackId={stableStackId}
553+
{projectId}
554+
{stackId}
547555
{laneId}
548-
branchName={selection.branchName}
556+
branchName={selection.branchName!}
549557
onclose={onclosePreview}
550558
onMcpSettings={() => {
551559
mcpConfigModal?.open();
552560
}}
553561
{onAbort}
554562
{initialPrompt}
555-
events={events.response || []}
556-
permissionRequests={permissionRequests.response || []}
563+
events={events?.response || []}
564+
permissionRequests={permissionRequests || []}
557565
onSubmit={sendMessage}
558566
onChange={(prompt) => messageSender?.setPrompt(prompt)}
559567
sessionId={sessionId.response}

apps/desktop/src/components/codegen/CodegenApprovalToolCall.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Button,
1111
Icon
1212
} from '@gitbutler/ui';
13+
import { TestId } from '@gitbutler/ui/utils/testIds';
1314
import type { PermissionDecision } from '$lib/codegen/types';
1415
1516
type Props = {
@@ -91,7 +92,7 @@
9192
});
9293
</script>
9394

94-
<div class="tool-call">
95+
<div class="tool-call" data-testid={TestId.CodegenPermissionApproval}>
9596
<div class="tool-call__details">
9697
<div class="tool-call__header">
9798
<Icon name={getToolIcon(toolCall.name)} color="var(--clr-text-3)" />
@@ -117,6 +118,7 @@
117118
kind="outline"
118119
icon="select-chevron"
119120
shrinkable
121+
testId={TestId.CodegenPermissionApproval_WildcardButton}
120122
onclick={() => {
121123
wildcardContextMenu?.toggle();
122124
}}
@@ -145,6 +147,7 @@
145147
bind:this={denyDropdownButton}
146148
style="danger"
147149
kind="outline"
150+
testId={TestId.CodegenPermissionApproval_DenyButton}
148151
onclick={async () => {
149152
await onPermissionDecision(
150153
toolCall.id,
@@ -192,6 +195,7 @@
192195
<DropdownButton
193196
bind:this={allowDropdownButton}
194197
style="pop"
198+
testId={TestId.CodegenPermissionApproval_AllowButton}
195199
onclick={async () => {
196200
await onPermissionDecision(
197201
toolCall.id,

apps/desktop/src/components/codegen/CodegenAskUserQuestion.svelte

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { AsyncButton, Icon, Textbox } from '@gitbutler/ui';
3+
import { TestId } from '@gitbutler/ui/utils/testIds';
34
import type { AskUserQuestion } from '$lib/codegen/types';
45
56
type Props = {
@@ -106,7 +107,7 @@
106107
}
107108
</script>
108109

109-
<div class="ask-user-question">
110+
<div class="ask-user-question" data-testid={TestId.CodegenAskUserQuestion}>
110111
<div class="ask-user-question__header">
111112
<Icon name="ai-small" color="var(--clr-text-3)" />
112113
<span class="text-13 header-text">Claude needs your input</span>
@@ -126,6 +127,7 @@
126127
type="button"
127128
class="option"
128129
class:selected={isOptionSelected(q.question, option.label)}
130+
data-testid={TestId.CodegenAskUserQuestion_Option}
129131
disabled={answered}
130132
onclick={() => {
131133
if (q.multiSelect) {
@@ -219,7 +221,12 @@
219221
Answered
220222
</span>
221223
{:else}
222-
<AsyncButton style="pop" disabled={!allAnswered} action={handleSubmit}>
224+
<AsyncButton
225+
style="pop"
226+
disabled={!allAnswered}
227+
testId={TestId.CodegenAskUserQuestion_SubmitButton}
228+
action={handleSubmit}
229+
>
223230
Submit answers
224231
</AsyncButton>
225232
{/if}

apps/desktop/src/components/codegen/CodegenInput.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { showError } from '$lib/notifications/toasts';
1818
import { inject } from '@gitbutler/core/context';
1919
import { Tooltip, AsyncButton, RichTextEditor, FilePlugin, UpDownPlugin } from '@gitbutler/ui';
20+
import { TestId } from '@gitbutler/ui/utils/testIds';
2021
import { tick, type Snippet } from 'svelte';
2122
import { fade } from 'svelte/transition';
2223
import type { FileSuggestionUpdate } from '@gitbutler/ui/richText/plugins/FilePlugin.svelte';
@@ -222,6 +223,7 @@
222223

223224
<div
224225
class="text-input dialog-input"
226+
data-testid={TestId.CodegenInput}
225227
data-remove-from-panning
226228
role="button"
227229
tabindex="-1"
@@ -330,6 +332,7 @@
330332
type="button"
331333
class:loading
332334
style="pop"
335+
data-testid={TestId.CodegenInputSendButton}
333336
onclick={handleSubmit}
334337
aria-label="Send"
335338
>

apps/desktop/src/components/codegen/CodegenMessages.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
4848
import VirtualList from '@gitbutler/ui/components/VirtualList.svelte';
4949
import { focusable } from '@gitbutler/ui/focus/focusable';
50+
import { TestId } from '@gitbutler/ui/utils/testIds';
5051
import type {
5152
ClaudeMessage,
5253
ThinkingLevel,
@@ -409,7 +410,7 @@
409410
{/snippet}
410411
</PreviewHeader>
411412

412-
<div class="chat-container">
413+
<div class="chat-container" data-testid={TestId.CodegenMessages}>
413414
{#if claudeAvailable.status !== 'available' && formattedMessages.length === 0}
414415
<ConfigurableScrollableContainer childrenWrapDisplay="contents">
415416
<div class="no-agent-placeholder">

apps/desktop/src/components/codegen/CodegenSidebar.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// import ConfigurableScrollableContainer from '$components/ConfigurableScrollableContainer.svelte';
33
import Resizer from '$components/Resizer.svelte';
44
import { focusable } from '@gitbutler/ui/focus/focusable';
5+
import { TestId } from '@gitbutler/ui/utils/testIds';
56
import type { Snippet } from 'svelte';
67
78
type Props = {
@@ -13,7 +14,12 @@
1314
let sidebarViewportRef = $state<HTMLDivElement>();
1415
</script>
1516

16-
<div class="sidebar" bind:this={sidebarViewportRef} use:focusable={{ vertical: true }}>
17+
<div
18+
class="sidebar"
19+
data-testid={TestId.CodegenSidebar}
20+
bind:this={sidebarViewportRef}
21+
use:focusable={{ vertical: true }}
22+
>
1723
<div class="sidebar-header" use:focusable>
1824
<p class="text-14 text-semibold">Current sessions</p>
1925
<div class="sidebar-header-actions">

apps/desktop/src/lib/codegen/messages.ts

Lines changed: 76 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -126,67 +126,87 @@ export function formatMessages(
126126
// We've either triggered a tool call, or sent a message
127127
if (payload.data.type === 'assistant') {
128128
const claudeOutput = payload.data.message;
129-
if (claudeOutput.content[0]!.type === 'text') {
130-
if (claudeOutput.content[0]!.text === loginRequiredMessage) {
131-
continue;
132-
}
133-
lastAssistantMessage = {
134-
createdAt: message.createdAt,
135-
source: 'claude',
136-
message: claudeOutput.content[0]!.text,
137-
toolCalls: [],
138-
toolCallsPendingApproval: []
139-
};
140-
out.push(lastAssistantMessage);
141-
} else if (claudeOutput.content[0]!.type === 'tool_use') {
142-
const content = claudeOutput.content[0]!;
143-
144-
// Handle AskUserQuestion tool calls specially - render as a question UI
145-
if (content.name === 'AskUserQuestion') {
146-
const input = content.input as { questions: AskUserQuestion[] };
147-
const askMessage: Message = {
148-
createdAt: message.createdAt,
149-
source: 'claude',
150-
subtype: 'askUserQuestion',
151-
toolUseId: content.id,
152-
questions: input.questions,
153-
answered: false
154-
};
155-
out.push(askMessage);
156-
askUserQuestionToolCalls[content.id] = askMessage;
157-
// Clear lastAssistantMessage since AskUserQuestion is not a standard message
158-
lastAssistantMessage = undefined;
159-
continue;
160-
}
161129

162-
const toolCall: ToolCall = {
163-
id: content.id,
164-
name: content.name,
165-
input: content.input as object,
166-
result: undefined,
167-
requestAt: normalizeDate(new Date(message.createdAt))
168-
};
169-
if (!lastAssistantMessage) {
170-
lastAssistantMessage = {
171-
source: 'claude',
172-
createdAt: message.createdAt,
173-
message: '',
174-
toolCalls: [],
175-
toolCallsPendingApproval: []
130+
// Process all content blocks in the message
131+
for (const contentBlock of claudeOutput.content) {
132+
if (contentBlock.type === 'text') {
133+
if (contentBlock.text === loginRequiredMessage) {
134+
continue;
135+
}
136+
if (!lastAssistantMessage) {
137+
lastAssistantMessage = {
138+
createdAt: message.createdAt,
139+
source: 'claude',
140+
message: contentBlock.text,
141+
toolCalls: [],
142+
toolCallsPendingApproval: [],
143+
contentBlocks: [{ type: 'text', text: contentBlock.text }]
144+
};
145+
out.push(lastAssistantMessage);
146+
} else {
147+
// Append text to existing message
148+
lastAssistantMessage.message += contentBlock.text;
149+
// Add to content blocks preserving order
150+
lastAssistantMessage.contentBlocks.push({ type: 'text', text: contentBlock.text });
151+
}
152+
} else if (contentBlock.type === 'tool_use') {
153+
const content = contentBlock;
154+
155+
// Handle AskUserQuestion tool calls specially - render as a question UI
156+
if (content.name === 'AskUserQuestion') {
157+
const input = content.input as { questions: AskUserQuestion[] };
158+
const askMessage: Message = {
159+
createdAt: message.createdAt,
160+
source: 'claude',
161+
subtype: 'askUserQuestion',
162+
toolUseId: content.id,
163+
questions: input.questions,
164+
answered: false
165+
};
166+
out.push(askMessage);
167+
askUserQuestionToolCalls[content.id] = askMessage;
168+
// Clear lastAssistantMessage since AskUserQuestion is not a standard message
169+
lastAssistantMessage = undefined;
170+
continue;
171+
}
172+
173+
const toolCall: ToolCall = {
174+
id: content.id,
175+
name: content.name,
176+
input: content.input as object,
177+
result: undefined,
178+
requestAt: normalizeDate(new Date(message.createdAt))
176179
};
177-
out.push(lastAssistantMessage);
178-
}
180+
if (!lastAssistantMessage) {
181+
lastAssistantMessage = {
182+
source: 'claude',
183+
createdAt: message.createdAt,
184+
message: '',
185+
toolCalls: [],
186+
toolCallsPendingApproval: [],
187+
contentBlocks: []
188+
};
189+
out.push(lastAssistantMessage);
190+
}
179191

180-
const permReq = permReqsById[toolCall.id];
181-
if (permReq && !isDefined(permReq.decision)) {
182-
lastAssistantMessage.toolCallsPendingApproval.push(toolCall);
183-
} else {
184-
if (permReq) {
185-
toolCall.approvedAt = new Date(permReq.updatedAt);
192+
const permReq = permReqsById[toolCall.id];
193+
if (permReq && !isDefined(permReq.decision)) {
194+
lastAssistantMessage.toolCallsPendingApproval.push(toolCall);
195+
// Add to content blocks preserving order
196+
lastAssistantMessage.contentBlocks.push({
197+
type: 'toolCallPendingApproval',
198+
toolCall
199+
});
200+
} else {
201+
if (permReq) {
202+
toolCall.approvedAt = new Date(permReq.updatedAt);
203+
}
204+
lastAssistantMessage.toolCalls.push(toolCall);
205+
// Add to content blocks preserving order
206+
lastAssistantMessage.contentBlocks.push({ type: 'toolCall', toolCall });
186207
}
187-
lastAssistantMessage.toolCalls.push(toolCall);
208+
toolCalls[toolCall.id] = toolCall;
188209
}
189-
toolCalls[toolCall.id] = toolCall;
190210
}
191211
} else if (payload.data.type === 'user') {
192212
const content = payload.data.message.content;

crates/but-claude/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ rust-version.workspace = true
99
[lib]
1010
doctest = false
1111

12+
[features]
13+
default = []
14+
# Enable mock client support for E2E testing. When enabled with CLAUDE_MOCK_SCENARIO
15+
# environment variable set, uses MockClient instead of real Claude CLI.
16+
testing = ["claude-agent-sdk-rs/testing"]
17+
1218
[dependencies]
1319
but-core.workspace = true
1420
but-action.workspace = true

0 commit comments

Comments
 (0)