@@ -29,6 +29,7 @@ import { COutlineItem } from '../tree-structure/solution-outline-item';
2929import * as fs from 'fs' ;
3030import * as child_process from 'child_process' ;
3131import * as os from 'os' ;
32+ import * as path from 'path' ;
3233
3334jest . mock ( 'fs' ) ;
3435jest . mock ( 'child_process' ) ;
@@ -46,6 +47,7 @@ describe('MergeCommand', () => {
4647 const mockedFs = fs as jest . Mocked < typeof fs > ;
4748 const mockedExec = child_process . exec as jest . MockedFunction < typeof child_process . exec > ;
4849 const mockedExecSync = child_process . execSync as jest . MockedFunction < typeof child_process . execSync > ;
50+ const mockedPath = path as jest . Mocked < typeof path > ;
4951
5052 beforeEach ( async ( ) => {
5153 commandsProvider = commandsProviderFactory ( ) ;
@@ -91,6 +93,27 @@ describe('MergeCommand', () => {
9193 expect ( showErrorMessageSpy ) . toHaveBeenCalledWith ( 'Required local file is missing to perform merge.' ) ;
9294 } ) ;
9395
96+ it ( 'shows error if update file attribute is missing' , async ( ) => {
97+ const showErrorMessageSpy = jest . spyOn ( vscode . window , 'showErrorMessage' ) ;
98+ const node = new COutlineItem ( 'file' ) ;
99+ node . setAttribute ( 'local' , '/tmp/local.c' ) ;
100+
101+ await command [ 'runVSCodeMerge' ] ( node ) ;
102+
103+ expect ( showErrorMessageSpy ) . toHaveBeenCalledWith ( 'Required update file is missing to perform merge.' ) ;
104+ } ) ;
105+
106+ it ( 'shows error if base file attribute is missing' , async ( ) => {
107+ const showErrorMessageSpy = jest . spyOn ( vscode . window , 'showErrorMessage' ) ;
108+ const node = new COutlineItem ( 'file' ) ;
109+ node . setAttribute ( 'local' , '/tmp/local.c' ) ;
110+ node . setAttribute ( 'update' , '/tmp/update.c' ) ;
111+
112+ await command [ 'runVSCodeMerge' ] ( node ) ;
113+
114+ expect ( showErrorMessageSpy ) . toHaveBeenCalledWith ( 'Required base file is missing to perform merge.' ) ;
115+ } ) ;
116+
94117 it ( 'shows error if VS Code executable not found' , async ( ) => {
95118 jest . spyOn ( os , 'platform' ) . mockReturnValue ( 'linux' ) ;
96119 mockedExecSync . mockImplementation ( ( ) => {
@@ -115,4 +138,111 @@ describe('MergeCommand', () => {
115138 await command [ 'runVSCodeMerge' ] ( fileNode ) ;
116139 expect ( errorSpy ) . toHaveBeenCalledWith ( 'Merge operations failed:' , expect . any ( Error ) ) ;
117140 } ) ;
141+
142+ it ( 'warns and skips post-merge file operations on non-zero merge exit code' , async ( ) => {
143+ const commandPrivate = command as unknown as {
144+ getVSCodeExecutablePath : ( ) => string | undefined ;
145+ doOpen3WayMerge : ( cmd : string ) => Promise < number > ;
146+ } ;
147+ jest . spyOn ( commandPrivate , 'getVSCodeExecutablePath' ) . mockReturnValue ( '/usr/bin/code' ) ;
148+ jest . spyOn ( commandPrivate , 'doOpen3WayMerge' ) . mockResolvedValue ( 1 ) ;
149+ mockedPath . resolve . mockImplementation ( ( p : string ) => p ) ;
150+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
151+ mockedFs . copyFileSync . mockImplementation ( ( ) => { } ) ;
152+ mockedFs . existsSync . mockReturnValue ( true ) ;
153+ mockedFs . statSync . mockReturnValue ( { mtimeMs : 1000 } as fs . Stats ) ;
154+
155+ const warningSpy = jest . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
156+
157+ await command [ 'runVSCodeMerge' ] ( fileNode ) ;
158+
159+ expect ( warningSpy ) . toHaveBeenCalledWith ( 'Merge exited with code 1. Conflicts may exist.' ) ;
160+ expect ( mockedFs . unlinkSync ) . not . toHaveBeenCalled ( ) ;
161+ expect ( mockedFs . renameSync ) . not . toHaveBeenCalled ( ) ;
162+ expect ( activeSolutionTracker . triggerReload ) . not . toHaveBeenCalled ( ) ;
163+ } ) ;
164+
165+ it ( 'performs post-merge file operations and triggers reload when merged file changes' , async ( ) => {
166+ const commandPrivate = command as unknown as {
167+ getVSCodeExecutablePath : ( ) => string | undefined ;
168+ doOpen3WayMerge : ( cmd : string ) => Promise < number > ;
169+ } ;
170+ jest . spyOn ( commandPrivate , 'getVSCodeExecutablePath' ) . mockReturnValue ( '/usr/bin/code' ) ;
171+ jest . spyOn ( commandPrivate , 'doOpen3WayMerge' ) . mockResolvedValue ( 0 ) ;
172+ mockedPath . resolve . mockImplementation ( ( p : string ) => p ) ;
173+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
174+ mockedPath . basename . mockReturnValue ( 'component.update.c' ) ;
175+ mockedPath . dirname . mockReturnValue ( '/tmp' ) ;
176+ mockedPath . join . mockReturnValue ( '/tmp/component.base.c' ) ;
177+ mockedFs . copyFileSync . mockImplementation ( ( ) => { } ) ;
178+ mockedFs . existsSync . mockReturnValue ( true ) ;
179+ mockedFs . statSync
180+ . mockReturnValueOnce ( { mtimeMs : 1000 } as fs . Stats )
181+ . mockReturnValueOnce ( { mtimeMs : 2000 } as fs . Stats ) ;
182+
183+ await command [ 'runVSCodeMerge' ] ( fileNode ) ;
184+
185+ expect ( mockedFs . copyFileSync ) . toHaveBeenCalledWith ( 'localPath' , 'localPath.merged' ) ;
186+ expect ( mockedFs . copyFileSync ) . toHaveBeenCalledWith ( 'localPath' , 'localPath.bak' ) ;
187+ expect ( mockedFs . unlinkSync ) . toHaveBeenCalledWith ( 'localPath' ) ;
188+ expect ( mockedFs . unlinkSync ) . toHaveBeenCalledWith ( 'basePath' ) ;
189+ expect ( mockedFs . renameSync ) . toHaveBeenCalledWith ( 'updatePath' , '/tmp/component.base.c' ) ;
190+ expect ( mockedFs . renameSync ) . toHaveBeenCalledWith ( 'localPath.merged' , 'localPath' ) ;
191+ expect ( activeSolutionTracker . triggerReload ) . toHaveBeenCalledTimes ( 1 ) ;
192+ } ) ;
193+
194+ it ( 'builds merge command with validated absolute paths' , ( ) => {
195+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
196+
197+ const result = command [ 'buildMergeCommand' ] (
198+ '/usr/bin/code' ,
199+ '/tmp/local.c' ,
200+ '/tmp/update.c' ,
201+ '/tmp/base.c' ,
202+ '/tmp/local.c.merged' ,
203+ ) ;
204+
205+ expect ( result ) . toEqual ( '"/usr/bin/code" --wait --merge "/tmp/local.c" "/tmp/update.c" "/tmp/base.c" "/tmp/local.c.merged"' ) ;
206+ } ) ;
207+
208+ it ( 'throws for non-absolute merge paths' , ( ) => {
209+ mockedPath . isAbsolute . mockReturnValue ( false ) ;
210+
211+ expect ( ( ) => command [ 'assertMergeFilePath' ] ( 'relative/path' , 'local file' ) ) . toThrow ( 'Invalid local file: path must be absolute.' ) ;
212+ } ) ;
213+
214+ it ( 'throws for shell-sensitive characters in merge paths' , ( ) => {
215+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
216+
217+ expect ( ( ) => command [ 'assertMergeFilePath' ] ( 'C:/safe/path&bad' , 'local file' ) ) . toThrow ( 'Invalid local file: contains unsupported shell-sensitive characters.' ) ;
218+ } ) ;
219+
220+ it ( 'throws for double quotes in merge paths' , ( ) => {
221+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
222+
223+ expect ( ( ) => command [ 'assertMergeFilePath' ] ( 'C:/safe/"quoted"/path' , 'local file' ) ) . toThrow ( 'Invalid local file: contains unsupported shell-sensitive characters.' ) ;
224+ } ) ;
225+
226+ it ( 'throws for single quotes in merge paths' , ( ) => {
227+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
228+
229+ expect ( ( ) => command [ 'assertMergeFilePath' ] ( "C:/safe/'quoted'/path" , 'local file' ) ) . toThrow ( 'Invalid local file: contains unsupported shell-sensitive characters.' ) ;
230+ } ) ;
231+
232+ it . each ( [
233+ [ 'ampersand' , 'C:/safe/path&bad' ] ,
234+ [ 'pipe' , 'C:/safe/path|bad' ] ,
235+ [ 'input redirection' , 'C:/safe/path<bad' ] ,
236+ [ 'output redirection' , 'C:/safe/path>bad' ] ,
237+ [ 'caret' , 'C:/safe/path^bad' ] ,
238+ [ 'percent' , 'C:/safe/path%bad' ] ,
239+ [ 'double quote' , 'C:/safe/path"bad' ] ,
240+ [ 'single quote' , "C:/safe/path'bad" ] ,
241+ [ 'line feed' , 'C:/safe/path\nbad' ] ,
242+ [ 'carriage return' , 'C:/safe/path\rbad' ] ,
243+ ] ) ( 'rejects shell-sensitive edge case: %s' , ( _label , filePath ) => {
244+ mockedPath . isAbsolute . mockReturnValue ( true ) ;
245+
246+ expect ( ( ) => command [ 'assertMergeFilePath' ] ( filePath , 'local file' ) ) . toThrow ( 'Invalid local file: contains unsupported shell-sensitive characters.' ) ;
247+ } ) ;
118248} ) ;
0 commit comments