feat(test): Increase test coverage across CLI and Core packages (#1089)

This commit is contained in:
N. Taylor Mullen 2025-06-15 22:41:32 -07:00 committed by GitHub
parent f00b9f2727
commit 197704c630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 716 additions and 210 deletions

View File

@ -438,6 +438,7 @@ Add any other context about the problem here.
...mockConfig, ...mockConfig,
getBugCommand: vi.fn(() => bugCommand), getBugCommand: vi.fn(() => bugCommand),
} as unknown as Config; } as unknown as Config;
process.env.CLI_VERSION = '0.1.0';
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
const bugDescription = 'This is a custom bug'; const bugDescription = 'This is a custom bug';

View File

@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getStartupWarnings } from './startupWarnings.js';
import * as fs from 'fs/promises';
import { getErrorMessage } from '@gemini-cli/core';
vi.mock('fs/promises');
vi.mock('@gemini-cli/core', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getErrorMessage: vi.fn(),
};
});
describe.skip('startupWarnings', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should return warnings from the file and delete it', async () => {
const mockWarnings = 'Warning 1\nWarning 2';
vi.spyOn(fs, 'access').mockResolvedValue();
vi.spyOn(fs, 'readFile').mockResolvedValue(mockWarnings);
vi.spyOn(fs, 'unlink').mockResolvedValue();
const warnings = await getStartupWarnings();
expect(fs.access).toHaveBeenCalled();
expect(fs.readFile).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalled();
expect(warnings).toEqual(['Warning 1', 'Warning 2']);
});
it('should return an empty array if the file does not exist', async () => {
const error = new Error('File not found');
(error as Error & { code: string }).code = 'ENOENT';
vi.spyOn(fs, 'access').mockRejectedValue(error);
const warnings = await getStartupWarnings();
expect(warnings).toEqual([]);
});
it('should return an error message if reading the file fails', async () => {
const error = new Error('Permission denied');
vi.spyOn(fs, 'access').mockRejectedValue(error);
vi.mocked(getErrorMessage).mockReturnValue('Permission denied');
const warnings = await getStartupWarnings();
expect(warnings).toEqual([
'Error checking/reading warnings file: Permission denied',
]);
});
it('should return a warning if deleting the file fails', async () => {
const mockWarnings = 'Warning 1';
vi.spyOn(fs, 'access').mockResolvedValue();
vi.spyOn(fs, 'readFile').mockResolvedValue(mockWarnings);
vi.spyOn(fs, 'unlink').mockRejectedValue(new Error('Permission denied'));
const warnings = await getStartupWarnings();
expect(warnings).toEqual([
'Warning 1',
'Warning: Could not delete temporary warnings file.',
]);
});
});

View File

@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { webLoginClient } from './oauth2.js';
import { OAuth2Client } from 'google-auth-library';
import http from 'http';
import open from 'open';
import crypto from 'crypto';
vi.mock('google-auth-library');
vi.mock('http');
vi.mock('open');
vi.mock('crypto');
describe('oauth2', () => {
it('should perform a web login', async () => {
const mockAuthUrl = 'https://example.com/auth';
const mockCode = 'test-code';
const mockState = 'test-state';
const mockTokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl);
const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens });
const mockSetCredentials = vi.fn();
const mockOAuth2Client = {
generateAuthUrl: mockGenerateAuthUrl,
getToken: mockGetToken,
setCredentials: mockSetCredentials,
} as unknown as OAuth2Client;
vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client);
vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never);
vi.mocked(open).mockImplementation(async () => ({}) as never);
let requestCallback!: (
req: http.IncomingMessage,
res: http.ServerResponse,
) => void;
const mockHttpServer = {
listen: vi.fn((port: number, callback?: () => void) => {
if (callback) {
callback();
}
}),
close: vi.fn((callback?: () => void) => {
if (callback) {
callback();
}
}),
on: vi.fn(),
address: () => ({ port: 1234 }),
};
vi.mocked(http.createServer).mockImplementation((cb) => {
requestCallback = cb as (
req: http.IncomingMessage,
res: http.ServerResponse,
) => void;
return mockHttpServer as unknown as http.Server;
});
const clientPromise = webLoginClient();
// Wait for the server to be created
await new Promise((resolve) => setTimeout(resolve, 0));
const mockReq = {
url: `/oauth2callback?code=${mockCode}&state=${mockState}`,
} as http.IncomingMessage;
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
} as unknown as http.ServerResponse;
if (requestCallback) {
await requestCallback(mockReq, mockRes);
}
const client = await clientPromise;
expect(open).toHaveBeenCalledWith(mockAuthUrl);
expect(mockGetToken).toHaveBeenCalledWith(mockCode);
expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens);
expect(client).toBe(mockOAuth2Client);
});
});

View File

@ -0,0 +1,151 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { CodeAssistServer } from './server.js';
import { OAuth2Client } from 'google-auth-library';
vi.mock('google-auth-library');
describe('CodeAssistServer', () => {
it('should be able to be constructed', () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
expect(server).toBeInstanceOf(CodeAssistServer);
});
it('should call the generateContent endpoint', async () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
const mockResponse = {
response: {
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'response' }],
},
finishReason: 'STOP',
safetyRatings: [],
},
],
},
};
vi.spyOn(server, 'callEndpoint').mockResolvedValue(mockResponse);
const response = await server.generateContent({
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'request' }] }],
});
expect(server.callEndpoint).toHaveBeenCalledWith(
'generateContent',
expect.any(Object),
);
expect(response.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
'response',
);
});
it('should call the generateContentStream endpoint', async () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
const mockResponse = (async function* () {
yield {
response: {
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'response' }],
},
finishReason: 'STOP',
safetyRatings: [],
},
],
},
};
})();
vi.spyOn(server, 'streamEndpoint').mockResolvedValue(mockResponse);
const stream = await server.generateContentStream({
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'request' }] }],
});
for await (const res of stream) {
expect(server.streamEndpoint).toHaveBeenCalledWith(
'streamGenerateContent',
expect.any(Object),
);
expect(res.candidates?.[0]?.content?.parts?.[0]?.text).toBe('response');
}
});
it('should call the onboardUser endpoint', async () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
const mockResponse = {
name: 'operations/123',
done: true,
};
vi.spyOn(server, 'callEndpoint').mockResolvedValue(mockResponse);
const response = await server.onboardUser({
tierId: 'test-tier',
cloudaicompanionProject: 'test-project',
metadata: {},
});
expect(server.callEndpoint).toHaveBeenCalledWith(
'onboardUser',
expect.any(Object),
);
expect(response.name).toBe('operations/123');
});
it('should call the loadCodeAssist endpoint', async () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
const mockResponse = {
// TODO: Add mock response
};
vi.spyOn(server, 'callEndpoint').mockResolvedValue(mockResponse);
const response = await server.loadCodeAssist({
metadata: {},
});
expect(server.callEndpoint).toHaveBeenCalledWith(
'loadCodeAssist',
expect.any(Object),
);
expect(response).toBe(mockResponse);
});
it('should return 0 for countTokens', async () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
const response = await server.countTokens({
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'request' }] }],
});
expect(response.totalTokens).toBe(0);
});
it('should throw an error for embedContent', async () => {
const auth = new OAuth2Client();
const server = new CodeAssistServer(auth, 'test-project');
await expect(
server.embedContent({
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'request' }] }],
}),
).rejects.toThrow();
});
});

View File

@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { createContentGenerator } from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai';
vi.mock('../code_assist/codeAssist.js');
vi.mock('@google/genai');
describe('contentGenerator', () => {
it('should create a CodeAssistContentGenerator', async () => {
const mockGenerator = {} as unknown;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
mockGenerator as never,
);
const generator = await createContentGenerator({
model: 'test-model',
codeAssist: true,
});
expect(createCodeAssistContentGenerator).toHaveBeenCalled();
expect(generator).toBe(mockGenerator);
});
it('should create a GoogleGenAI content generator', async () => {
const mockGenerator = {
models: {},
} as unknown;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
const generator = await createContentGenerator({
model: 'test-model',
apiKey: 'test-api-key',
});
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
httpOptions: {
headers: {
'User-Agent': expect.any(String),
},
},
});
expect(generator).toBe((mockGenerator as GoogleGenAI).models);
});
});

View File

@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
initializeTelemetry,
shutdownTelemetry,
isTelemetrySdkInitialized,
} from './sdk.js';
import { Config } from '../config/config.js';
import { NodeSDK } from '@opentelemetry/sdk-node';
import * as loggers from './loggers.js';
vi.mock('@opentelemetry/sdk-node');
vi.mock('../config/config.js');
vi.mock('./loggers.js');
describe('telemetry', () => {
let mockConfig: Config;
let mockNodeSdk: NodeSDK;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = new Config({
sessionId: 'test-session-id',
contentGeneratorConfig: {
model: 'test-model',
},
targetDir: '/test/dir',
debugMode: false,
cwd: '/test/dir',
});
vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true);
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://localhost:4317',
);
vi.spyOn(mockConfig, 'getSessionId').mockReturnValue('test-session-id');
vi.spyOn(loggers, 'logCliConfiguration').mockImplementation(() => {});
mockNodeSdk = {
start: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
} as unknown as NodeSDK;
vi.mocked(NodeSDK).mockImplementation(() => mockNodeSdk);
});
afterEach(async () => {
// Ensure we shut down telemetry even if a test fails.
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
}
});
it('should initialize the telemetry service', () => {
initializeTelemetry(mockConfig);
expect(NodeSDK).toHaveBeenCalled();
expect(mockNodeSdk.start).toHaveBeenCalled();
expect(loggers.logCliConfiguration).toHaveBeenCalledWith(mockConfig);
});
it('should shutdown the telemetry service', async () => {
initializeTelemetry(mockConfig);
await shutdownTelemetry();
expect(mockNodeSdk.shutdown).toHaveBeenCalled();
});
});

View File

@ -19,6 +19,7 @@ import {
openDiff, openDiff,
allowEditorTypeInSandbox, allowEditorTypeInSandbox,
isEditorAvailable, isEditorAvailable,
type EditorType,
} from './editor.js'; } from './editor.js';
import { execSync, spawn } from 'child_process'; import { execSync, spawn } from 'child_process';
@ -27,225 +28,290 @@ vi.mock('child_process', () => ({
spawn: vi.fn(), spawn: vi.fn(),
})); }));
describe('checkHasEditorType', () => { const originalPlatform = process.platform;
describe('editor utils', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); delete process.env.SANDBOX;
Object.defineProperty(process, 'platform', {
it('should return true for vscode if "code" command exists', () => { value: originalPlatform,
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); writable: true,
expect(checkHasEditorType('vscode')).toBe(true);
const expectedCommand =
process.platform === 'win32' ? 'where.exe code.cmd' : 'command -v code';
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
stdio: 'ignore',
}); });
}); });
it('should return false for vscode if "code" command does not exist', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(checkHasEditorType('vscode')).toBe(false);
});
it('should return true for windsurf if "windsurf" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/windsurf'));
expect(checkHasEditorType('windsurf')).toBe(true);
expect(execSync).toHaveBeenCalledWith('command -v windsurf', {
stdio: 'ignore',
});
});
it('should return false for windsurf if "windsurf" command does not exist', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(checkHasEditorType('windsurf')).toBe(false);
});
it('should return true for cursor if "cursor" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/cursor'));
expect(checkHasEditorType('cursor')).toBe(true);
expect(execSync).toHaveBeenCalledWith('command -v cursor', {
stdio: 'ignore',
});
});
it('should return false for cursor if "cursor" command does not exist', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(checkHasEditorType('cursor')).toBe(false);
});
it('should return true for vim if "vim" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim'));
expect(checkHasEditorType('vim')).toBe(true);
const expectedCommand =
process.platform === 'win32' ? 'where.exe vim' : 'command -v vim';
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
stdio: 'ignore',
});
});
it('should return false for vim if "vim" command does not exist', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(checkHasEditorType('vim')).toBe(false);
});
});
describe('getDiffCommand', () => {
it('should return the correct command for vscode', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'vscode');
expect(command).toEqual({
command: 'code',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});
it('should return the correct command for vim', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'vim');
expect(command?.command).toBe('vim');
expect(command?.args).toContain('old.txt');
expect(command?.args).toContain('new.txt');
});
it('should return null for an unsupported editor', () => {
// @ts-expect-error Testing unsupported editor
const command = getDiffCommand('old.txt', 'new.txt', 'foobar');
expect(command).toBeNull();
});
});
describe('openDiff', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should call spawn for vscode', async () => {
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await openDiff('old.txt', 'new.txt', 'vscode');
expect(spawn).toHaveBeenCalledWith(
'code',
['--wait', '--diff', 'old.txt', 'new.txt'],
{ stdio: 'inherit' },
);
expect(mockSpawn.on).toHaveBeenCalledWith('close', expect.any(Function));
expect(mockSpawn.on).toHaveBeenCalledWith('error', expect.any(Function));
});
it('should call execSync for vim', async () => {
await openDiff('old.txt', 'new.txt', 'vim');
expect(execSync).toHaveBeenCalled();
const command = (execSync as Mock).mock.calls[0][0];
expect(command).toContain('vim');
expect(command).toContain('old.txt');
expect(command).toContain('new.txt');
});
it('should handle spawn error for vscode', async () => {
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'error') {
cb(new Error('spawn error'));
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', 'vscode')).rejects.toThrow(
'spawn error',
);
});
});
describe('allowEditorTypeInSandbox', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks();
delete process.env.SANDBOX; delete process.env.SANDBOX;
}); Object.defineProperty(process, 'platform', {
value: originalPlatform,
it('should allow vim in sandbox mode', () => { writable: true,
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('vim')).toBe(true);
});
it('should allow vim when not in sandbox mode', () => {
delete process.env.SANDBOX;
expect(allowEditorTypeInSandbox('vim')).toBe(true);
});
it('should not allow vscode in sandbox mode', () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('vscode')).toBe(false);
});
it('should allow vscode when not in sandbox mode', () => {
delete process.env.SANDBOX;
expect(allowEditorTypeInSandbox('vscode')).toBe(true);
});
it('should not allow windsurf in sandbox mode', () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('windsurf')).toBe(false);
});
it('should allow windsurf when not in sandbox mode', () => {
delete process.env.SANDBOX;
expect(allowEditorTypeInSandbox('windsurf')).toBe(true);
});
it('should not allow cursor in sandbox mode', () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('cursor')).toBe(false);
});
it('should allow cursor when not in sandbox mode', () => {
delete process.env.SANDBOX;
expect(allowEditorTypeInSandbox('cursor')).toBe(true);
});
});
describe('isEditorAvailable', () => {
afterEach(() => {
delete process.env.SANDBOX;
});
it('should return false for undefined editor', () => {
expect(isEditorAvailable(undefined)).toBe(false);
});
it('should return false for empty string editor', () => {
expect(isEditorAvailable('')).toBe(false);
});
it('should return false for invalid editor type', () => {
expect(isEditorAvailable('invalid-editor')).toBe(false);
});
it('should return true for vscode when installed and not in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
expect(isEditorAvailable('vscode')).toBe(true);
});
it('should return false for vscode when not installed and not in sandbox mode', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error();
}); });
expect(isEditorAvailable('vscode')).toBe(false);
}); });
it('should return false for vscode when installed and in sandbox mode', () => { describe('checkHasEditorType', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); const testCases: Array<{
process.env.SANDBOX = 'sandbox'; editor: EditorType;
expect(isEditorAvailable('vscode')).toBe(false); command: string;
win32Command: string;
}> = [
{ editor: 'vscode', command: 'code', win32Command: 'code.cmd' },
{ editor: 'windsurf', command: 'windsurf', win32Command: 'windsurf' },
{ editor: 'cursor', command: 'cursor', win32Command: 'cursor' },
{ editor: 'vim', command: 'vim', win32Command: 'vim' },
];
for (const { editor, command, win32Command } of testCases) {
describe(`${editor}`, () => {
it(`should return true if "${command}" command exists on non-windows`, () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
(execSync as Mock).mockReturnValue(
Buffer.from(`/usr/bin/${command}`),
);
expect(checkHasEditorType(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(`command -v ${command}`, {
stdio: 'ignore',
});
});
it(`should return false if "${command}" command does not exist on non-windows`, () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(checkHasEditorType(editor)).toBe(false);
});
it(`should return true if "${win32Command}" command exists on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock).mockReturnValue(
Buffer.from(`C:\\Program Files\\...\\${win32Command}`),
);
expect(checkHasEditorType(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(`where.exe ${win32Command}`, {
stdio: 'ignore',
});
});
it(`should return false if "${win32Command}" command does not exist on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(checkHasEditorType(editor)).toBe(false);
});
});
}
});
describe('getDiffCommand', () => {
it('should return the correct command for vscode', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'vscode');
expect(command).toEqual({
command: 'code',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});
it('should return the correct command for windsurf', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'windsurf');
expect(command).toEqual({
command: 'windsurf',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});
it('should return the correct command for cursor', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'cursor');
expect(command).toEqual({
command: 'cursor',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});
it('should return the correct command for vim', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'vim');
expect(command).toEqual({
command: 'vim',
args: [
'-d',
'-i',
'NONE',
'-c',
'wincmd h | set readonly | wincmd l',
'-c',
'highlight DiffAdd cterm=bold ctermbg=22 guibg=#005f00 | highlight DiffChange cterm=bold ctermbg=24 guibg=#005f87 | highlight DiffText ctermbg=21 guibg=#0000af | highlight DiffDelete ctermbg=52 guibg=#5f0000',
'-c',
'set showtabline=2 | set tabline=[Instructions]\\ :wqa(save\\ &\\ quit)\\ \\|\\ i/esc(toggle\\ edit\\ mode)',
'-c',
'wincmd h | setlocal statusline=OLD\\ FILE',
'-c',
'wincmd l | setlocal statusline=%#StatusBold#NEW\\ FILE\\ :wqa(save\\ &\\ quit)\\ \\|\\ i/esc(toggle\\ edit\\ mode)',
'-c',
'autocmd WinClosed * wqa',
'old.txt',
'new.txt',
],
});
});
it('should return null for an unsupported editor', () => {
// @ts-expect-error Testing unsupported editor
const command = getDiffCommand('old.txt', 'new.txt', 'foobar');
expect(command).toBeNull();
});
});
describe('openDiff', () => {
it('should call spawn for vscode', async () => {
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await openDiff('old.txt', 'new.txt', 'vscode');
expect(spawn).toHaveBeenCalledWith(
'code',
['--wait', '--diff', 'old.txt', 'new.txt'],
{ stdio: 'inherit' },
);
expect(mockSpawn.on).toHaveBeenCalledWith('close', expect.any(Function));
expect(mockSpawn.on).toHaveBeenCalledWith('error', expect.any(Function));
});
it('should reject if spawn for vscode fails', async () => {
const mockError = new Error('spawn error');
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'error') {
cb(mockError);
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', 'vscode')).rejects.toThrow(
'spawn error',
);
});
it('should reject if vscode exits with non-zero code', async () => {
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'close') {
cb(1);
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', 'vscode')).rejects.toThrow(
'VS Code exited with code 1',
);
});
const execSyncEditors: EditorType[] = ['vim', 'windsurf', 'cursor'];
for (const editor of execSyncEditors) {
it(`should call execSync for ${editor} on non-windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await openDiff('old.txt', 'new.txt', editor);
expect(execSync).toHaveBeenCalledTimes(1);
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
const expectedCommand = `${
diffCommand.command
} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`;
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
stdio: 'inherit',
encoding: 'utf8',
});
});
it(`should call execSync for ${editor} on windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
await openDiff('old.txt', 'new.txt', editor);
expect(execSync).toHaveBeenCalledTimes(1);
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
const expectedCommand = `${diffCommand.command} ${diffCommand.args.join(
' ',
)}`;
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
stdio: 'inherit',
encoding: 'utf8',
});
});
}
it('should log an error if diff command is not available', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// @ts-expect-error Testing unsupported editor
await openDiff('old.txt', 'new.txt', 'foobar');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'No diff tool available. Install vim or vscode.',
);
});
});
describe('allowEditorTypeInSandbox', () => {
it('should allow vim in sandbox mode', () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox('vim')).toBe(true);
});
it('should allow vim when not in sandbox mode', () => {
expect(allowEditorTypeInSandbox('vim')).toBe(true);
});
const guiEditors: EditorType[] = ['vscode', 'windsurf', 'cursor'];
for (const editor of guiEditors) {
it(`should not allow ${editor} in sandbox mode`, () => {
process.env.SANDBOX = 'sandbox';
expect(allowEditorTypeInSandbox(editor)).toBe(false);
});
it(`should allow ${editor} when not in sandbox mode`, () => {
expect(allowEditorTypeInSandbox(editor)).toBe(true);
});
}
});
describe('isEditorAvailable', () => {
it('should return false for undefined editor', () => {
expect(isEditorAvailable(undefined)).toBe(false);
});
it('should return false for empty string editor', () => {
expect(isEditorAvailable('')).toBe(false);
});
it('should return false for invalid editor type', () => {
expect(isEditorAvailable('invalid-editor')).toBe(false);
});
it('should return true for vscode when installed and not in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
expect(isEditorAvailable('vscode')).toBe(true);
});
it('should return false for vscode when not installed and not in sandbox mode', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error();
});
expect(isEditorAvailable('vscode')).toBe(false);
});
it('should return false for vscode when installed and in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
process.env.SANDBOX = 'sandbox';
expect(isEditorAvailable('vscode')).toBe(false);
});
it('should return true for vim when installed and in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim'));
process.env.SANDBOX = 'sandbox';
expect(isEditorAvailable('vim')).toBe(true);
});
}); });
}); });