@@ -1905,6 +1905,146 @@ describe("webviewMessageHandler - mcpEnabled", () => {
19051905 } )
19061906} )
19071907
1908+ describe ( "webviewMessageHandler - updateSettings is all-or-nothing" , ( ) => {
1909+ const stored : Record < string , unknown > = { }
1910+
1911+ beforeEach ( ( ) => {
1912+ vi . clearAllMocks ( )
1913+ for ( const key of Object . keys ( stored ) ) delete stored [ key ]
1914+ Object . assign ( stored , { soundEnabled : false , writeDelayMs : 1000 , language : "en" } )
1915+ vi . mocked ( mockClineProvider . contextProxy . getValue ) . mockImplementation ( ( key : string ) => stored [ key ] as never )
1916+ vi . mocked ( mockClineProvider . contextProxy . setValue ) . mockImplementation ( async ( key : string , value : unknown ) => {
1917+ stored [ key ] = value
1918+ } )
1919+ } )
1920+
1921+ it ( "stores every setting and confirms the save by request id" , async ( ) => {
1922+ await webviewMessageHandler ( mockClineProvider , {
1923+ type : "updateSettings" ,
1924+ requestId : "save-1" ,
1925+ updatedSettings : { soundEnabled : true , writeDelayMs : 2000 } ,
1926+ } )
1927+
1928+ expect ( stored ) . toMatchObject ( { soundEnabled : true , writeDelayMs : 2000 } )
1929+ expect ( mockClineProvider . postStateToWebview ) . toHaveBeenCalled ( )
1930+ expect ( mockClineProvider . postMessageToWebview ) . toHaveBeenCalledWith ( {
1931+ type : "settingsSaveResult" ,
1932+ requestId : "save-1" ,
1933+ success : true ,
1934+ } )
1935+ expect ( vscode . window . showErrorMessage ) . not . toHaveBeenCalled ( )
1936+ } )
1937+
1938+ it ( "restores the settings already written when a later one fails, and reports the failure" , async ( ) => {
1939+ const real = vi . mocked ( mockClineProvider . contextProxy . setValue ) . getMockImplementation ( ) !
1940+ vi . mocked ( mockClineProvider . contextProxy . setValue ) . mockImplementation ( async ( key : string , value : unknown ) => {
1941+ if ( key === "writeDelayMs" && value === 2000 ) throw new Error ( "storage unavailable" )
1942+ return real ( key as never , value as never )
1943+ } )
1944+
1945+ await webviewMessageHandler ( mockClineProvider , {
1946+ type : "updateSettings" ,
1947+ requestId : "save-2" ,
1948+ updatedSettings : { soundEnabled : true , writeDelayMs : 2000 } ,
1949+ } )
1950+
1951+ // soundEnabled was written first, then rolled back; writeDelayMs never changed.
1952+ expect ( stored ) . toMatchObject ( { soundEnabled : false , writeDelayMs : 1000 } )
1953+ expect ( mockClineProvider . postMessageToWebview ) . toHaveBeenCalledWith ( {
1954+ type : "settingsSaveResult" ,
1955+ requestId : "save-2" ,
1956+ success : false ,
1957+ error : "storage unavailable" ,
1958+ } )
1959+ expect ( mockClineProvider . postMessageToWebview ) . not . toHaveBeenCalledWith (
1960+ expect . objectContaining ( { success : true } ) ,
1961+ )
1962+ expect ( vscode . window . showErrorMessage ) . toHaveBeenCalledWith ( "common:errors.settings_save_failed" )
1963+ expect ( mockClineProvider . log ) . toHaveBeenCalledWith ( expect . stringContaining ( "rolled back" ) )
1964+ // The webview is re-synced with what is really stored.
1965+ expect ( mockClineProvider . postStateToWebview ) . toHaveBeenCalled ( )
1966+ } )
1967+
1968+ it ( "rolls back in reverse order, so the first-written value is restored last" , async ( ) => {
1969+ const order : string [ ] = [ ]
1970+ const real = vi . mocked ( mockClineProvider . contextProxy . setValue ) . getMockImplementation ( ) !
1971+ vi . mocked ( mockClineProvider . contextProxy . setValue ) . mockImplementation ( async ( key : string , value : unknown ) => {
1972+ order . push ( `${ key } =${ String ( value ) } ` )
1973+ if ( key === "terminalCommandDelay" && value === 50 ) throw new Error ( "boom" )
1974+ return real ( key as never , value as never )
1975+ } )
1976+ stored . terminalCommandDelay = 0
1977+
1978+ await webviewMessageHandler ( mockClineProvider , {
1979+ type : "updateSettings" ,
1980+ requestId : "save-3" ,
1981+ updatedSettings : { soundEnabled : true , writeDelayMs : 2000 , terminalCommandDelay : 50 } ,
1982+ } )
1983+
1984+ expect ( stored ) . toMatchObject ( { soundEnabled : false , writeDelayMs : 1000 , terminalCommandDelay : 0 } )
1985+
1986+ // Writes go forward in order; restores come back in the opposite order.
1987+ const failedAt = order . indexOf ( "terminalCommandDelay=50" )
1988+ const restoreOrder = order
1989+ . slice ( failedAt + 1 )
1990+ . map ( ( entry ) => entry . split ( "=" ) [ 0 ] )
1991+ . filter ( ( key , index , all ) => all . indexOf ( key ) === index )
1992+ expect ( restoreOrder . indexOf ( "writeDelayMs" ) ) . toBeGreaterThanOrEqual ( 0 )
1993+ expect ( restoreOrder . indexOf ( "soundEnabled" ) ) . toBeGreaterThan ( restoreOrder . indexOf ( "writeDelayMs" ) )
1994+ } )
1995+
1996+ it ( "keeps going and still reports the failure when a restore itself fails" , async ( ) => {
1997+ const real = vi . mocked ( mockClineProvider . contextProxy . setValue ) . getMockImplementation ( ) !
1998+ vi . mocked ( mockClineProvider . contextProxy . setValue ) . mockImplementation ( async ( key : string , value : unknown ) => {
1999+ if ( key === "writeDelayMs" ) throw new Error ( "writeDelayMs is broken" )
2000+ if ( key === "soundEnabled" && value === false ) throw new Error ( "cannot restore" )
2001+ return real ( key as never , value as never )
2002+ } )
2003+
2004+ await webviewMessageHandler ( mockClineProvider , {
2005+ type : "updateSettings" ,
2006+ requestId : "save-4" ,
2007+ updatedSettings : { soundEnabled : true , writeDelayMs : 2000 } ,
2008+ } )
2009+
2010+ expect ( mockClineProvider . log ) . toHaveBeenCalledWith ( expect . stringContaining ( "Could not restore soundEnabled" ) )
2011+ expect ( mockClineProvider . postMessageToWebview ) . toHaveBeenCalledWith (
2012+ expect . objectContaining ( { type : "settingsSaveResult" , requestId : "save-4" , success : false } ) ,
2013+ )
2014+ } )
2015+
2016+ it ( "reports a non-Error failure by its text" , async ( ) => {
2017+ vi . mocked ( mockClineProvider . contextProxy . setValue ) . mockRejectedValue ( "plain string failure" )
2018+
2019+ await webviewMessageHandler ( mockClineProvider , {
2020+ type : "updateSettings" ,
2021+ requestId : "save-5" ,
2022+ updatedSettings : { soundEnabled : true } ,
2023+ } )
2024+
2025+ expect ( mockClineProvider . postMessageToWebview ) . toHaveBeenCalledWith (
2026+ expect . objectContaining ( { success : false , error : "plain string failure" } ) ,
2027+ )
2028+ } )
2029+
2030+ it ( "still saves and re-syncs, without a result message, when the request has no id" , async ( ) => {
2031+ await webviewMessageHandler ( mockClineProvider , {
2032+ type : "updateSettings" ,
2033+ updatedSettings : { soundEnabled : true } ,
2034+ } )
2035+
2036+ expect ( stored . soundEnabled ) . toBe ( true )
2037+ expect ( mockClineProvider . postStateToWebview ) . toHaveBeenCalled ( )
2038+ expect ( mockClineProvider . postMessageToWebview ) . not . toHaveBeenCalled ( )
2039+ } )
2040+
2041+ it ( "does nothing when there are no settings to save" , async ( ) => {
2042+ await webviewMessageHandler ( mockClineProvider , { type : "updateSettings" , requestId : "save-6" } )
2043+ expect ( mockClineProvider . contextProxy . setValue ) . not . toHaveBeenCalled ( )
2044+ expect ( mockClineProvider . postMessageToWebview ) . not . toHaveBeenCalled ( )
2045+ } )
2046+ } )
2047+
19082048describe ( "webviewMessageHandler - destructiveCommandGuardEnabled" , ( ) => {
19092049 beforeEach ( ( ) => {
19102050 vi . clearAllMocks ( )
0 commit comments