Skip to content

Commit 35cc3dc

Browse files
author
Zoo (VP)
committed
Merge branch 'pr/b04-shell-contracts-v2' into pr/b05-shell-resolution-v2
2 parents 9a5e2ae + 60c5c77 commit 35cc3dc

2 files changed

Lines changed: 354 additions & 1 deletion

File tree

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
916916
terminalProfile={terminalProfile}
917917
terminalShellSelection={pendingTerminalShellSelection ?? terminalShellSelection}
918918
onTerminalProfilePickerOpened={() => setChangeDetected(true)}
919-
onShellSelectionChange={setPendingTerminalShellSelection}
919+
onShellSelectionChange={(selection) => {
920+
// Buffer the selection and explicitly mark the settings as
921+
// dirty so the Save button enables on shell-only changes.
922+
// (Previously the dirty flag was only set incidentally via
923+
// onTerminalProfilePickerOpened.)
924+
setPendingTerminalShellSelection(selection)
925+
setChangeDetected(true)
926+
}}
920927
setCachedStateField={setCachedStateField}
921928
/>
922929
)}
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
// pnpm --filter @roo-code/vscode-webview test src/components/settings/__tests__/SettingsView.shell-selection.spec.tsx
2+
3+
/**
4+
* Tests for the SettingsView ↔ TerminalSettings shell-selection wiring.
5+
*
6+
* Verifies that:
7+
* - Changing the shell selection marks the settings as dirty so the Save
8+
* button enables on shell-only changes (previously the dirty flag was
9+
* only set incidentally via onTerminalProfilePickerOpened).
10+
* - Save posts the pending selection through the existing
11+
* `setTerminalShellSelection` message (the only path that persists it).
12+
*/
13+
14+
import { render, screen, fireEvent, act } from "@/utils/test-utils"
15+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
16+
17+
import { vscode } from "@/utils/vscode"
18+
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
19+
20+
import SettingsView from "../SettingsView"
21+
22+
vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } }))
23+
24+
vi.mock("../ApiConfigManager", () => ({
25+
__esModule: true,
26+
default: ({ currentApiConfigName }: any) => (
27+
<div data-testid="api-config-management">
28+
<span>Current config: {currentApiConfigName}</span>
29+
</div>
30+
),
31+
}))
32+
33+
// Capture the props SettingsView passes to TerminalSettings so tests can
34+
// drive onShellSelectionChange directly.
35+
const capturedTerminalProps = vi.hoisted(() => ({ current: null as any }))
36+
37+
vi.mock("../TerminalSettings", () => ({
38+
DEFAULT_PROFILE_VALUE: "__zoo_code_follow_vscode_sentinel__",
39+
TerminalSettings: (props: any) => {
40+
capturedTerminalProps.current = props
41+
return <div data-testid="terminal-settings-stub" />
42+
},
43+
}))
44+
45+
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
46+
VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) =>
47+
appearance === "icon" ? (
48+
<button onClick={onClick} aria-label="Remove command" data-testid={dataTestId}>
49+
<span className="codicon codicon-close" />
50+
</button>
51+
) : (
52+
<button onClick={onClick} data-appearance={appearance} data-testid={dataTestId}>
53+
{children}
54+
</button>
55+
),
56+
VSCodeCheckbox: ({ children, onChange, checked, "data-testid": dataTestId }: any) => (
57+
<label>
58+
<input
59+
type="checkbox"
60+
checked={checked}
61+
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
62+
data-testid={dataTestId}
63+
/>
64+
{children}
65+
</label>
66+
),
67+
VSCodeTextField: ({ value, onInput, placeholder, "data-testid": dataTestId }: any) => (
68+
<input
69+
type="text"
70+
value={value}
71+
onChange={(e) => onInput({ target: { value: e.target.value } })}
72+
placeholder={placeholder}
73+
data-testid={dataTestId}
74+
/>
75+
),
76+
VSCodeLink: ({ children, href }: any) => <a href={href || "#"}>{children}</a>,
77+
VSCodeRadio: ({ value, checked, onChange }: any) => (
78+
<input type="radio" value={value} checked={checked} onChange={onChange} />
79+
),
80+
VSCodeRadioGroup: ({ children, onChange }: any) => <div onChange={onChange}>{children}</div>,
81+
VSCodeTextArea: ({ value, onChange, rows, className, "data-testid": dataTestId }: any) => (
82+
<textarea
83+
value={value}
84+
onChange={onChange}
85+
rows={rows}
86+
className={className}
87+
data-testid={dataTestId}
88+
role="textbox"
89+
/>
90+
),
91+
}))
92+
93+
vi.mock("../../../components/common/Tab", () => ({
94+
...vi.importActual("../../../components/common/Tab"),
95+
Tab: ({ children }: any) => <div data-testid="tab-container">{children}</div>,
96+
TabHeader: ({ children }: any) => <div data-testid="tab-header">{children}</div>,
97+
TabContent: ({ children, "data-testid": dataTestId }: any) => (
98+
<div data-testid={dataTestId || "tab-content"}>{children}</div>
99+
),
100+
TabList: ({ children, value, "data-testid": dataTestId }: any) => (
101+
<div data-testid={dataTestId} data-value={value}>
102+
{children}
103+
</div>
104+
),
105+
TabTrigger: ({ children, value, "data-testid": dataTestId, onClick, isSelected }: any) => (
106+
<button data-testid={dataTestId} data-value={value} data-selected={isSelected} onClick={onClick}>
107+
{children}
108+
</button>
109+
),
110+
}))
111+
112+
vi.mock("@/components/ui", () => ({
113+
...vi.importActual("@/components/ui"),
114+
ToggleSwitch: ({ checked, onChange, "aria-label": ariaLabel, "data-testid": dataTestId }: any) => (
115+
<button role="switch" aria-checked={checked} aria-label={ariaLabel} data-testid={dataTestId} onClick={onChange}>
116+
Toggle
117+
</button>
118+
),
119+
Checkbox: ({ checked, onCheckedChange, id, className, ...props }: any) => (
120+
<input
121+
type="checkbox"
122+
checked={checked}
123+
onChange={(e) => onCheckedChange?.(e.target.checked)}
124+
id={id}
125+
className={className}
126+
{...props}
127+
/>
128+
),
129+
Textarea: ({ value, onChange, placeholder, id, className, ...props }: any) => (
130+
<textarea
131+
value={value}
132+
onChange={onChange}
133+
placeholder={placeholder}
134+
id={id}
135+
className={className}
136+
{...props}
137+
/>
138+
),
139+
Popover: ({ children }: any) => <div data-testid="popover">{children}</div>,
140+
PopoverTrigger: ({ children }: any) => <div data-testid="popover-trigger">{children}</div>,
141+
PopoverContent: ({ children }: any) => <div data-testid="popover-content">{children}</div>,
142+
Command: ({ children }: any) => <div data-testid="command">{children}</div>,
143+
CommandInput: ({ value, onValueChange }: any) => (
144+
<input data-testid="command-input" value={value} onChange={(e) => onValueChange(e.target.value)} />
145+
),
146+
CommandGroup: ({ children }: any) => <div data-testid="command-group">{children}</div>,
147+
CommandItem: ({ children, onSelect }: any) => (
148+
<div data-testid="command-item" onClick={onSelect}>
149+
{children}
150+
</div>
151+
),
152+
CommandList: ({ children }: any) => <div data-testid="command-list">{children}</div>,
153+
CommandEmpty: ({ children }: any) => <div data-testid="command-empty">{children}</div>,
154+
Slider: ({ value, onValueChange, "data-testid": dataTestId }: any) => (
155+
<input
156+
type="range"
157+
value={value?.[0] ?? 0}
158+
onChange={(e) => onValueChange?.([parseFloat(e.target.value)])}
159+
data-testid={dataTestId}
160+
/>
161+
),
162+
// Unlike the SettingsView.spec.tsx mock, this one forwards `disabled` so
163+
// the Save button's dirty-tracking gating can be asserted.
164+
Button: ({ children, onClick, disabled, variant, className, "data-testid": dataTestId }: any) => (
165+
<button
166+
onClick={onClick}
167+
disabled={disabled}
168+
data-variant={variant}
169+
className={className}
170+
data-testid={dataTestId}>
171+
{children}
172+
</button>
173+
),
174+
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
175+
Input: ({ value, onChange, placeholder, "data-testid": dataTestId }: any) => (
176+
<input type="text" value={value} onChange={onChange} placeholder={placeholder} data-testid={dataTestId} />
177+
),
178+
Select: ({ children, value, onValueChange }: any) => (
179+
<div data-testid="select" data-value={value}>
180+
<button onClick={() => onValueChange && onValueChange("test-change")}>{value}</button>
181+
{children}
182+
</div>
183+
),
184+
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
185+
SelectGroup: ({ children }: any) => <div data-testid="select-group">{children}</div>,
186+
SelectItem: ({ children, value }: any) => (
187+
<div data-testid={`select-item-${value}`} data-value={value}>
188+
{children}
189+
</div>
190+
),
191+
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
192+
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
193+
SearchableSelect: ({ value, onValueChange, options, placeholder }: any) => (
194+
<select value={value} onChange={(e) => onValueChange(e.target.value)} data-testid="searchable-select">
195+
{placeholder && <option value="">{placeholder}</option>}
196+
{options?.map((opt: any) => (
197+
<option key={opt.value} value={opt.value}>
198+
{opt.label}
199+
</option>
200+
))}
201+
</select>
202+
),
203+
AlertDialog: ({ children, open }: any) => (
204+
<div data-testid="alert-dialog" data-open={open}>
205+
{children}
206+
</div>
207+
),
208+
AlertDialogContent: ({ children }: any) => <div data-testid="alert-dialog-content">{children}</div>,
209+
AlertDialogHeader: ({ children }: any) => <div data-testid="alert-dialog-header">{children}</div>,
210+
AlertDialogTitle: ({ children }: any) => <div data-testid="alert-dialog-title">{children}</div>,
211+
AlertDialogDescription: ({ children }: any) => <div data-testid="alert-dialog-description">{children}</div>,
212+
AlertDialogFooter: ({ children }: any) => <div data-testid="alert-dialog-footer">{children}</div>,
213+
AlertDialogAction: ({ children, onClick }: any) => (
214+
<button data-testid="alert-dialog-action" onClick={onClick}>
215+
{children}
216+
</button>
217+
),
218+
AlertDialogCancel: ({ children, onClick }: any) => (
219+
<button data-testid="alert-dialog-cancel" onClick={onClick}>
220+
{children}
221+
</button>
222+
),
223+
Collapsible: ({ children, open }: any) => (
224+
<div className="collapsible-mock" data-open={open}>
225+
{children}
226+
</div>
227+
),
228+
CollapsibleTrigger: ({ children, className, onClick }: any) => (
229+
<div className={`collapsible-trigger-mock ${className || ""}`} onClick={onClick}>
230+
{children}
231+
</div>
232+
),
233+
CollapsibleContent: ({ children, className }: any) => (
234+
<div className={`collapsible-content-mock ${className || ""}`}>{children}</div>
235+
),
236+
Dialog: ({ children, ...props }: any) => (
237+
<div data-testid="dialog" {...props}>
238+
{children}
239+
</div>
240+
),
241+
DialogContent: ({ children, ...props }: any) => (
242+
<div data-testid="dialog-content" {...props}>
243+
{children}
244+
</div>
245+
),
246+
DialogHeader: ({ children, ...props }: any) => (
247+
<div data-testid="dialog-header" {...props}>
248+
{children}
249+
</div>
250+
),
251+
DialogTitle: ({ children, ...props }: any) => (
252+
<div data-testid="dialog-title" {...props}>
253+
{children}
254+
</div>
255+
),
256+
DialogDescription: ({ children, ...props }: any) => (
257+
<div data-testid="dialog-description" {...props}>
258+
{children}
259+
</div>
260+
),
261+
DialogFooter: ({ children, ...props }: any) => (
262+
<div data-testid="dialog-footer" {...props}>
263+
{children}
264+
</div>
265+
),
266+
}))
267+
268+
// Mock window.postMessage to trigger state hydration
269+
const mockPostMessage = (state: any) => {
270+
window.postMessage(
271+
{
272+
type: "state",
273+
state: {
274+
version: "1.0.0",
275+
clineMessages: [],
276+
taskHistory: [],
277+
shouldShowAnnouncement: false,
278+
allowedCommands: [],
279+
alwaysAllowExecute: false,
280+
ttsEnabled: false,
281+
ttsSpeed: 1,
282+
soundEnabled: false,
283+
soundVolume: 0.5,
284+
...state,
285+
},
286+
},
287+
"*",
288+
)
289+
}
290+
291+
const renderTerminalTab = (initialState: any = {}) => {
292+
const onDone = vi.fn()
293+
const queryClient = new QueryClient()
294+
295+
render(
296+
<ExtensionStateContextProvider>
297+
<QueryClientProvider client={queryClient}>
298+
<SettingsView onDone={onDone} targetSection="terminal" />
299+
</QueryClientProvider>
300+
</ExtensionStateContextProvider>,
301+
)
302+
303+
// Hydrate initial state.
304+
act(() => {
305+
mockPostMessage(initialState)
306+
})
307+
308+
return { onDone }
309+
}
310+
311+
describe("SettingsView — terminal shell selection", () => {
312+
beforeEach(() => {
313+
vi.clearAllMocks()
314+
capturedTerminalProps.current = null
315+
})
316+
317+
it("enables the Save button on a shell-only selection change", () => {
318+
renderTerminalTab()
319+
320+
expect(capturedTerminalProps.current).not.toBeNull()
321+
expect(screen.getByTestId("save-button")).toBeDisabled()
322+
323+
// Simulate the shell dropdown selection change. This must mark the
324+
// settings as dirty on its own — not via onTerminalProfilePickerOpened.
325+
act(() => {
326+
capturedTerminalProps.current.onShellSelectionChange({ kind: "auto" })
327+
})
328+
329+
expect(screen.getByTestId("save-button")).toBeEnabled()
330+
})
331+
332+
it("posts setTerminalShellSelection with the pending selection on Save", () => {
333+
renderTerminalTab()
334+
335+
act(() => {
336+
capturedTerminalProps.current.onShellSelectionChange({ kind: "path", path: "/usr/bin/zsh" })
337+
})
338+
339+
fireEvent.click(screen.getByTestId("save-button"))
340+
341+
expect(vscode.postMessage).toHaveBeenCalledWith({
342+
type: "setTerminalShellSelection",
343+
terminalShellSelection: { kind: "path", path: "/usr/bin/zsh" },
344+
})
345+
})
346+
})

0 commit comments

Comments
 (0)