Skip to content

Commit 2c098c0

Browse files
committed
Add unit tests
1 parent 7bdeaa2 commit 2c098c0

2 files changed

Lines changed: 131 additions & 1 deletion

File tree

src/views/solution-outline/commands/merge-command.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { COutlineItem } from '../tree-structure/solution-outline-item';
2929
import * as fs from 'fs';
3030
import * as child_process from 'child_process';
3131
import * as os from 'os';
32+
import * as path from 'path';
3233

3334
jest.mock('fs');
3435
jest.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
});

src/views/solution-outline/commands/merge-command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { ActiveSolutionTracker } from '../../../solutions/active-solution-tracke
2626

2727
export class MergeCommand {
2828
public static readonly mergeFile = `${PACKAGE_NAME}.mergeFile`;
29-
private static readonly disallowedCmdChars = /[\r\n&|<>^%]/;
29+
private static readonly disallowedCmdChars = /[\r\n&|<>^%"']/;
3030

3131
constructor(
3232
private readonly commandsProvider: CommandsProvider,

0 commit comments

Comments
 (0)