11import { 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"
33import { QueryClient , QueryClientProvider } from "@tanstack/react-query"
44import 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"
2422import { useExtensionState } from "@src/context/ExtensionStateContext"
2523
2624// Mock the extension state context
2725vi . mock ( "@src/context/ExtensionStateContext" , ( ) => ( {
2826 useExtensionState : vi . fn ( ) ,
2927} ) )
3028
29+ const mockTranslate = vi . hoisted ( ( ) => ( key : string ) => key )
30+
3131// Mock the translation context
3232vi . 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
206256vi . 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+
210279vi . 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
231286vi . 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
244336vi . mock ( "../CheckpointSettings" , ( ) => ( {
245337 CheckpointSettings : ( ) => null ,
@@ -275,6 +367,11 @@ vi.mock("../UISettings", () => ({
275367vi . 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
279376describe ( "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