Skip to content

Commit 188b53c

Browse files
committed
test(settings): strengthen mode sync change detection
1 parent ed8d7ce commit 188b53c

1 file changed

Lines changed: 184 additions & 38 deletions

File tree

webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx

Lines changed: 184 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { act, render, screen, fireEvent, waitFor, configure } from "@testing-library/react"
2-
import { vi, describe, it, expect, beforeEach } from "vitest"
2+
import { vi, describe, it, expect, beforeEach, beforeAll } from "vitest"
33
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
44
import React from "react"
55

@@ -19,19 +19,19 @@ vi.mock("@src/utils/vscode", () => ({
1919
},
2020
}))
2121

22-
// Import the actual component
23-
import SettingsView from "../SettingsView"
2422
import { useExtensionState } from "@src/context/ExtensionStateContext"
2523

2624
// Mock the extension state context
2725
vi.mock("@src/context/ExtensionStateContext", () => ({
2826
useExtensionState: vi.fn(),
2927
}))
3028

29+
const mockTranslate = vi.hoisted(() => (key: string) => key)
30+
3131
// Mock the translation context
3232
vi.mock("@src/i18n/TranslationContext", () => ({
3333
useAppTranslation: () => ({
34-
t: (key: string) => key,
34+
t: mockTranslate,
3535
}),
3636
}))
3737

@@ -193,39 +193,94 @@ vi.mock("@src/components/mcp/McpView", () => ({
193193
default: () => null,
194194
}))
195195

196-
// Mock Tab components
197-
vi.mock("../common/Tab", () => ({
196+
vi.mock("../../common/Tab", () => ({
198197
Tab: ({ children }: any) => <div>{children}</div>,
199-
TabContent: React.forwardRef<HTMLDivElement, any>(({ children }, ref) => <div ref={ref}>{children}</div>),
198+
TabContent: React.forwardRef<HTMLDivElement, any>(({ children, ...props }, ref) => (
199+
<div ref={ref} {...props}>
200+
{children}
201+
</div>
202+
)),
200203
TabHeader: ({ children }: any) => <div>{children}</div>,
201-
TabList: ({ children }: any) => <div>{children}</div>,
202-
TabTrigger: React.forwardRef<HTMLButtonElement, any>(({ children }, ref) => <button ref={ref}>{children}</button>),
204+
TabList: ({ children, value, onValueChange }: any) => (
205+
<div>
206+
{React.Children.map(children, (child) => {
207+
if (!React.isValidElement(child)) {
208+
return child
209+
}
210+
211+
const element = child as React.ReactElement<any>
212+
return React.cloneElement(element, {
213+
isSelected: element.props.value === value,
214+
onSelect: () => onValueChange(element.props.value),
215+
})
216+
})}
217+
</div>
218+
),
219+
TabTrigger: React.forwardRef<HTMLButtonElement, any>(({ children, onSelect, ...props }, ref) => (
220+
<button ref={ref} onClick={onSelect} {...props}>
221+
{children}
222+
</button>
223+
)),
224+
}))
225+
vi.mock("@src/components/common/Tab", () => ({
226+
Tab: ({ children }: any) => <div>{children}</div>,
227+
TabContent: React.forwardRef<HTMLDivElement, any>(({ children, ...props }, ref) => (
228+
<div ref={ref} {...props}>
229+
{children}
230+
</div>
231+
)),
232+
TabHeader: ({ children }: any) => <div>{children}</div>,
233+
TabList: ({ children, value, onValueChange }: any) => (
234+
<div>
235+
{React.Children.map(children, (child) => {
236+
if (!React.isValidElement(child)) {
237+
return child
238+
}
239+
240+
const element = child as React.ReactElement<any>
241+
return React.cloneElement(element, {
242+
isSelected: element.props.value === value,
243+
onSelect: () => onValueChange(element.props.value),
244+
})
245+
})}
246+
</div>
247+
),
248+
TabTrigger: React.forwardRef<HTMLButtonElement, any>(({ children, onSelect, ...props }, ref) => (
249+
<button ref={ref} onClick={onSelect} {...props}>
250+
{children}
251+
</button>
252+
)),
203253
}))
204254

205255
// Mock all child components to isolate the test
206256
vi.mock("../ApiConfigManager", () => ({
207257
default: () => null,
208258
}))
209259

260+
const mockApiOptions = ({ apiConfiguration, setApiConfigurationField }: any) => (
261+
<div>
262+
<span data-testid="provider-value">{apiConfiguration.apiProvider}</span>
263+
<input
264+
data-testid="baseten-api-key"
265+
value={apiConfiguration.basetenApiKey ?? ""}
266+
onChange={(event) => setApiConfigurationField("basetenApiKey", event.target.value)}
267+
/>
268+
{["openrouter", "baseten", "deepseek", "friendli"].map((provider) => (
269+
<button
270+
key={provider}
271+
data-testid={`set-provider-${provider}`}
272+
onClick={() => setApiConfigurationField("apiProvider", provider)}>
273+
{provider}
274+
</button>
275+
))}
276+
</div>
277+
)
278+
210279
vi.mock("../ApiOptions", () => ({
211-
default: ({ apiConfiguration, setApiConfigurationField }: any) => (
212-
<div>
213-
<span data-testid="provider-value">{apiConfiguration.apiProvider}</span>
214-
<input
215-
data-testid="baseten-api-key"
216-
value={apiConfiguration.basetenApiKey ?? ""}
217-
onChange={(event) => setApiConfigurationField("basetenApiKey", event.target.value)}
218-
/>
219-
{["openrouter", "baseten", "deepseek", "friendli"].map((provider) => (
220-
<button
221-
key={provider}
222-
data-testid={`set-provider-${provider}`}
223-
onClick={() => setApiConfigurationField("apiProvider", provider)}>
224-
{provider}
225-
</button>
226-
))}
227-
</div>
228-
),
280+
default: mockApiOptions,
281+
}))
282+
vi.mock("@src/components/settings/ApiOptions", () => ({
283+
default: mockApiOptions,
229284
}))
230285

231286
vi.mock("../AutoApproveSettings", () => ({
@@ -240,6 +295,43 @@ vi.mock("../Section", () => ({
240295
Section: ({ children }: any) => <div>{children}</div>,
241296
}))
242297

298+
vi.mock("../SearchableSetting", () => ({
299+
SearchableSetting: ({ children }: any) => <div>{children}</div>,
300+
}))
301+
vi.mock("../useSettingsSearch", () => ({
302+
SearchIndexProvider: ({ children }: any) => <>{children}</>,
303+
useSearchIndexRegistry: () => ({
304+
contextValue: { registerSetting: vi.fn() },
305+
index: [],
306+
}),
307+
useSettingsSearch: () => ({
308+
searchQuery: "",
309+
setSearchQuery: vi.fn(),
310+
results: [],
311+
isOpen: false,
312+
setIsOpen: vi.fn(),
313+
clearSearch: vi.fn(),
314+
}),
315+
}))
316+
vi.mock("@src/components/settings/SearchableSetting", () => ({
317+
SearchableSetting: ({ children }: any) => <div>{children}</div>,
318+
}))
319+
vi.mock("@src/components/settings/useSettingsSearch", () => ({
320+
SearchIndexProvider: ({ children }: any) => <>{children}</>,
321+
useSearchIndexRegistry: () => ({
322+
contextValue: { registerSetting: vi.fn() },
323+
index: [],
324+
}),
325+
useSettingsSearch: () => ({
326+
searchQuery: "",
327+
setSearchQuery: vi.fn(),
328+
results: [],
329+
isOpen: false,
330+
setIsOpen: vi.fn(),
331+
clearSearch: vi.fn(),
332+
}),
333+
}))
334+
243335
// Mock all settings components
244336
vi.mock("../CheckpointSettings", () => ({
245337
CheckpointSettings: () => null,
@@ -275,6 +367,11 @@ vi.mock("../UISettings", () => ({
275367
vi.mock("../SettingsSearch", () => ({
276368
SettingsSearch: () => null,
277369
}))
370+
vi.mock("@src/components/settings/SettingsSearch", () => ({
371+
SettingsSearch: () => null,
372+
}))
373+
374+
let SettingsView: typeof import("../SettingsView").default
278375

279376
describe("SettingsView - Change Detection Fix", () => {
280377
let queryClient: QueryClient
@@ -348,6 +445,12 @@ describe("SettingsView - Change Detection Fix", () => {
348445
...overrides,
349446
})
350447

448+
beforeAll(async () => {
449+
// Import after mocks are registered so the isolated tests use the
450+
// lightweight child component mocks above instead of the full settings UI.
451+
SettingsView = (await import("../SettingsView")).default
452+
})
453+
351454
beforeEach(() => {
352455
vi.clearAllMocks()
353456
queryClient = new QueryClient({
@@ -571,16 +674,15 @@ describe("SettingsView - Change Detection Fix", () => {
571674
const saveButton = screen.getByTestId("save-button") as HTMLButtonElement
572675
expect(saveButton.disabled).toBe(false)
573676

574-
// Now change the mode - this should trigger the mode sync effect
677+
// Now change only the mode-dependent values while keeping extensionState's
678+
// object identity stable. This makes the `mode` dependency load-bearing:
679+
// without it, React would not re-run the sync effect.
575680
await act(async () => {
576-
extensionState = createExtensionState({
577-
mode: "ask",
578-
apiConfiguration: {
579-
apiProvider: "openrouter",
580-
apiModelId: "claude-3.5-sonnet",
581-
},
582-
})
583-
;(useExtensionState as any).mockImplementation(() => extensionState)
681+
extensionState.mode = "ask"
682+
extensionState.apiConfiguration = {
683+
apiProvider: "openrouter",
684+
apiModelId: "claude-3.5-sonnet",
685+
}
584686

585687
rerender(
586688
<QueryClientProvider client={queryClient}>
@@ -602,11 +704,44 @@ describe("SettingsView - Change Detection Fix", () => {
602704
// Verify changeDetected is reset (save button should be disabled)
603705
const updatedSaveButton = screen.getByTestId("save-button") as HTMLButtonElement
604706
expect(updatedSaveButton.disabled).toBe(true)
707+
708+
// Make another dirty change while already in the new mode.
709+
fireEvent.click(screen.getByTestId("set-provider-deepseek"))
710+
expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek")
711+
expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false)
712+
713+
// Re-render with a new extensionState identity but the same mode and config
714+
// name. If prevMode.current is not updated during the first mode transition,
715+
// the stale ref makes this same-mode render look like another mode change and
716+
// incorrectly overwrites the dirty cached provider below.
717+
await act(async () => {
718+
extensionState = createExtensionState({
719+
mode: "ask",
720+
apiConfiguration: {
721+
apiProvider: "friendli",
722+
apiModelId: "friendli-model",
723+
},
724+
})
725+
;(useExtensionState as any).mockImplementation(() => extensionState)
726+
727+
rerender(
728+
<QueryClientProvider client={queryClient}>
729+
<SettingsView onDone={onDone} />
730+
</QueryClientProvider>,
731+
)
732+
})
733+
734+
await act(async () => {
735+
await new Promise((resolve) => setTimeout(resolve, 0))
736+
})
737+
738+
expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek")
739+
expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false)
605740
}, 20000)
606741

607742
it("does not trigger sync when mode has not changed", async () => {
608743
const onDone = vi.fn()
609-
const extensionState = createExtensionState({
744+
let extensionState = createExtensionState({
610745
mode: "code",
611746
apiConfiguration: {
612747
apiProvider: "openai",
@@ -630,8 +765,19 @@ describe("SettingsView - Change Detection Fix", () => {
630765
fireEvent.click(screen.getByTestId("set-provider-baseten"))
631766
expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten")
632767

633-
// Re-render with same mode - should not trigger sync
768+
// Re-render with a new extensionState identity but the same mode and config
769+
// name. This makes the guard load-bearing because the effect is eligible to
770+
// re-run from the extensionState dependency, but must not sync cachedState.
634771
await act(async () => {
772+
extensionState = createExtensionState({
773+
mode: "code",
774+
apiConfiguration: {
775+
apiProvider: "openai",
776+
apiModelId: "gpt-4.1",
777+
},
778+
})
779+
;(useExtensionState as any).mockImplementation(() => extensionState)
780+
635781
rerender(
636782
<QueryClientProvider client={queryClient}>
637783
<SettingsView onDone={onDone} />

0 commit comments

Comments
 (0)