Skip to content

Commit ddfbeb9

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 f2a3228 commit ddfbeb9

File tree

22 files changed

+1108
-26
lines changed

22 files changed

+1108
-26
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export function formatMessages(
165165
} else if (contentBlock.type === 'tool_use') {
166166
const content = contentBlock;
167167

168+
// Handle AskUserQuestion tool calls specially - render as a question UI
168169
if (content.name === 'AskUserQuestion') {
169170
const input = content.input as { questions: AskUserQuestion[] };
170171
const askMessage: Message = {

crates/but-claude/Cargo.toml

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

12+
[features]
13+
default = []
14+
# Enable mock client support for E2E testing. With this feature enabled,
15+
# set CLAUDE_MOCK_SCENARIO env var to a scenario file path to use MockClient.
16+
# If the env var is not set, the real Claude CLI is used.
17+
testing = ["claude-agent-sdk-rs/testing"]
18+
1219
[dependencies]
1320
but-core.workspace = true
1421
but-action.workspace = true

0 commit comments

Comments
 (0)