mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Added tests for extensions functionality (#3794)
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { extensionApiCall, addToAgent, removeFromAgent, sanitizeName } from './agent-api';
|
||||
import * as config from '../../../config';
|
||||
import * as toasts from '../../../toasts';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../config');
|
||||
vi.mock('../../../toasts');
|
||||
vi.mock('./utils');
|
||||
|
||||
const mockGetApiUrl = vi.mocked(config.getApiUrl);
|
||||
const mockToastService = vi.mocked(toasts.toastService);
|
||||
|
||||
// Mock window.electron
|
||||
const mockElectron = {
|
||||
getSecretKey: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: mockElectron,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
(globalThis as typeof globalThis & { fetch: typeof mockFetch }).fetch = mockFetch;
|
||||
|
||||
describe('Agent API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetApiUrl.mockImplementation((path: string) => `http://localhost:8080${path}`);
|
||||
mockElectron.getSecretKey.mockResolvedValue('secret-key');
|
||||
mockToastService.configure = vi.fn();
|
||||
mockToastService.loading = vi.fn().mockReturnValue('toast-id');
|
||||
mockToastService.success = vi.fn();
|
||||
mockToastService.error = vi.fn();
|
||||
mockToastService.dismiss = vi.fn();
|
||||
});
|
||||
|
||||
describe('sanitizeName', () => {
|
||||
it('should sanitize extension names correctly', () => {
|
||||
expect(sanitizeName('Test Extension')).toBe('testextension');
|
||||
expect(sanitizeName('My-Extension_Name')).toBe('myextensionname');
|
||||
expect(sanitizeName('UPPERCASE')).toBe('uppercase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensionApiCall', () => {
|
||||
const mockExtensionConfig: ExtensionConfig = {
|
||||
type: 'stdio',
|
||||
name: 'test-extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
};
|
||||
|
||||
it('should make successful API call for adding extension', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await extensionApiCall('/extensions/add', mockExtensionConfig);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify(mockExtensionConfig),
|
||||
});
|
||||
|
||||
expect(mockToastService.loading).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Activating test-extension extension...',
|
||||
});
|
||||
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Successfully activated extension',
|
||||
});
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('should make successful API call for removing extension', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await extensionApiCall('/extensions/remove', 'test-extension');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify('test-extension'),
|
||||
});
|
||||
|
||||
expect(mockToastService.loading).not.toHaveBeenCalled(); // No loading toast for removal
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Successfully deactivated extension',
|
||||
});
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle HTTP error responses', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(extensionApiCall('/extensions/add', mockExtensionConfig)).rejects.toThrow(
|
||||
'Server returned 500: Internal Server Error'
|
||||
);
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Failed to add test-extension extension: Server returned 500: Internal Server Error',
|
||||
traceback: 'Server returned 500: Internal Server Error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 428 error specially', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 428,
|
||||
statusText: 'Precondition Required',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(extensionApiCall('/extensions/add', mockExtensionConfig)).rejects.toThrow(
|
||||
'Agent is not initialized. Please initialize the agent first.'
|
||||
);
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Failed to add extension. Goose Agent was still starting up. Please try again.',
|
||||
traceback: 'Server returned 428: Precondition Required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API error responses', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": true, "message": "Extension not found"}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(extensionApiCall('/extensions/remove', 'test-extension')).rejects.toThrow(
|
||||
'Error deactivating extension: Extension not found'
|
||||
);
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Error deactivating extension: Extension not found',
|
||||
traceback: 'Error deactivating extension: Extension not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON parse errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('invalid json'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await extensionApiCall('/extensions/add', mockExtensionConfig);
|
||||
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Successfully activated extension',
|
||||
});
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const networkError = new Error('Network error');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(extensionApiCall('/extensions/add', mockExtensionConfig)).rejects.toThrow(
|
||||
'Network error'
|
||||
);
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Network error',
|
||||
traceback: 'Network error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should configure toast service with options', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await extensionApiCall('/extensions/add', mockExtensionConfig, { silent: true });
|
||||
|
||||
expect(mockToastService.configure).toHaveBeenCalledWith({ silent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToAgent', () => {
|
||||
const mockExtensionConfig: ExtensionConfig = {
|
||||
type: 'stdio',
|
||||
name: 'Test Extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
};
|
||||
|
||||
it('should add stdio extension to agent with shim replacement', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
// Mock replaceWithShims
|
||||
const { replaceWithShims } = await import('./utils');
|
||||
vi.mocked(replaceWithShims).mockResolvedValue('/path/to/python');
|
||||
|
||||
await addToAgent(mockExtensionConfig);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...mockExtensionConfig,
|
||||
name: 'testextension',
|
||||
cmd: '/path/to/python',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 428 error with enhanced message', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 428,
|
||||
statusText: 'Precondition Required',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(addToAgent(mockExtensionConfig)).rejects.toThrow(
|
||||
'Agent is not initialized. Please initialize the agent first.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add non-stdio extension without shim replacement', async () => {
|
||||
const sseConfig: ExtensionConfig = {
|
||||
type: 'sse',
|
||||
name: 'SSE Extension',
|
||||
uri: 'http://localhost:8080/events',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await addToAgent(sseConfig);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...sseConfig,
|
||||
name: 'sseextension',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromAgent', () => {
|
||||
it('should remove extension from agent', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await removeFromAgent('Test Extension');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify('testextension'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle removal errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(removeFromAgent('Test Extension')).rejects.toThrow();
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
activateExtension,
|
||||
addToAgentOnStartup,
|
||||
updateExtension,
|
||||
toggleExtension,
|
||||
deleteExtension,
|
||||
} from './extension-manager';
|
||||
import * as agentApi from './agent-api';
|
||||
import * as toasts from '../../../toasts';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./agent-api');
|
||||
vi.mock('../../../toasts');
|
||||
|
||||
const mockAddToAgent = vi.mocked(agentApi.addToAgent);
|
||||
const mockRemoveFromAgent = vi.mocked(agentApi.removeFromAgent);
|
||||
const mockSanitizeName = vi.mocked(agentApi.sanitizeName);
|
||||
const mockToastService = vi.mocked(toasts.toastService);
|
||||
|
||||
describe('Extension Manager', () => {
|
||||
const mockAddToConfig = vi.fn();
|
||||
const mockRemoveFromConfig = vi.fn();
|
||||
|
||||
const mockExtensionConfig = {
|
||||
type: 'stdio' as const,
|
||||
name: 'test-extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
timeout: 300,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSanitizeName.mockImplementation((name: string) => name.toLowerCase());
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('activateExtension', () => {
|
||||
it('should successfully activate extension', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
|
||||
await activateExtension({
|
||||
addToConfig: mockAddToConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, { silent: false });
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
});
|
||||
|
||||
it('should add to config as disabled if agent fails', async () => {
|
||||
const agentError = new Error('Agent failed');
|
||||
mockAddToAgent.mockRejectedValue(agentError);
|
||||
|
||||
await expect(
|
||||
activateExtension({
|
||||
addToConfig: mockAddToConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
})
|
||||
).rejects.toThrow('Agent failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, { silent: false });
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
|
||||
it('should remove from agent if config fails', async () => {
|
||||
const configError = new Error('Config failed');
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToConfig.mockRejectedValue(configError);
|
||||
|
||||
await expect(
|
||||
activateExtension({
|
||||
addToConfig: mockAddToConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
})
|
||||
).rejects.toThrow('Config failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, { silent: false });
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToAgentOnStartup', () => {
|
||||
it('should successfully add extension on startup', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
|
||||
await addToAgentOnStartup({
|
||||
addToConfig: mockAddToConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, { silent: true });
|
||||
expect(mockAddToConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should retry on 428 errors', async () => {
|
||||
const error428 = new Error('428 Precondition Required');
|
||||
mockAddToAgent
|
||||
.mockRejectedValueOnce(error428)
|
||||
.mockRejectedValueOnce(error428)
|
||||
.mockResolvedValue({} as Response);
|
||||
|
||||
await addToAgentOnStartup({
|
||||
addToConfig: mockAddToConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should disable extension after max retries', async () => {
|
||||
const error428 = new Error('428 Precondition Required');
|
||||
mockAddToAgent.mockRejectedValue(error428);
|
||||
mockToastService.configure = vi.fn();
|
||||
mockToastService.error = vi.fn();
|
||||
|
||||
await addToAgentOnStartup({
|
||||
addToConfig: mockAddToConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Extension failed to start and will be disabled.',
|
||||
traceback: '428 Precondition Required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateExtension', () => {
|
||||
it('should update extension without name change', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
mockToastService.success = vi.fn();
|
||||
|
||||
await updateExtension({
|
||||
enabled: true,
|
||||
addToConfig: mockAddToConfig,
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
originalName: 'test-extension',
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
{ ...mockExtensionConfig, name: 'test-extension' },
|
||||
{ silent: true }
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
{ ...mockExtensionConfig, name: 'test-extension' },
|
||||
true
|
||||
);
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'Update extension',
|
||||
msg: 'Successfully updated test-extension extension',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle name change by removing old and adding new', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
mockToastService.success = vi.fn();
|
||||
|
||||
await updateExtension({
|
||||
enabled: true,
|
||||
addToConfig: mockAddToConfig,
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
extensionConfig: { ...mockExtensionConfig, name: 'new-extension' },
|
||||
originalName: 'old-extension',
|
||||
});
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('old-extension', { silent: true });
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension');
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
{ ...mockExtensionConfig, name: 'new-extension' },
|
||||
{ silent: true }
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||
'new-extension',
|
||||
{ ...mockExtensionConfig, name: 'new-extension' },
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should update disabled extension without calling agent', async () => {
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
mockToastService.success = vi.fn();
|
||||
|
||||
await updateExtension({
|
||||
enabled: false,
|
||||
addToConfig: mockAddToConfig,
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
extensionConfig: mockExtensionConfig,
|
||||
originalName: 'test-extension',
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).not.toHaveBeenCalled();
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
{ ...mockExtensionConfig, name: 'test-extension' },
|
||||
false
|
||||
);
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'Update extension',
|
||||
msg: 'Successfully updated test-extension extension',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleExtension', () => {
|
||||
it('should toggle extension on successfully', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
|
||||
await toggleExtension({
|
||||
toggle: 'toggleOn',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
addToConfig: mockAddToConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {});
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
});
|
||||
|
||||
it('should toggle extension off successfully', async () => {
|
||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
|
||||
await toggleExtension({
|
||||
toggle: 'toggleOff',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
addToConfig: mockAddToConfig,
|
||||
});
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {});
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
|
||||
it('should rollback on agent failure when toggling on', async () => {
|
||||
const agentError = new Error('Agent failed');
|
||||
mockAddToAgent.mockRejectedValue(agentError);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
toggleExtension({
|
||||
toggle: 'toggleOn',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
addToConfig: mockAddToConfig,
|
||||
})
|
||||
).rejects.toThrow('Agent failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {});
|
||||
// addToConfig is called during the rollback (toggleOff)
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
|
||||
it('should remove from agent if config update fails when toggling on', async () => {
|
||||
const configError = new Error('Config failed');
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToConfig.mockRejectedValue(configError);
|
||||
|
||||
await expect(
|
||||
toggleExtension({
|
||||
toggle: 'toggleOn',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
addToConfig: mockAddToConfig,
|
||||
})
|
||||
).rejects.toThrow('Config failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {});
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {});
|
||||
});
|
||||
|
||||
it('should update config even if agent removal fails when toggling off', async () => {
|
||||
const agentError = new Error('Agent removal failed');
|
||||
mockRemoveFromAgent.mockRejectedValue(agentError);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
toggleExtension({
|
||||
toggle: 'toggleOff',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
addToConfig: mockAddToConfig,
|
||||
})
|
||||
).rejects.toThrow('Agent removal failed');
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {});
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteExtension', () => {
|
||||
it('should delete extension successfully', async () => {
|
||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
|
||||
await deleteExtension({
|
||||
name: 'test-extension',
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
});
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', { isDelete: true });
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
|
||||
it('should remove from config even if agent removal fails', async () => {
|
||||
const agentError = new Error('Agent removal failed');
|
||||
mockRemoveFromAgent.mockRejectedValue(agentError);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
deleteExtension({
|
||||
name: 'test-extension',
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
})
|
||||
).rejects.toThrow('Agent removal failed');
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', { isDelete: true });
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
|
||||
it('should throw config error if both agent and config fail', async () => {
|
||||
const agentError = new Error('Agent removal failed');
|
||||
const configError = new Error('Config removal failed');
|
||||
mockRemoveFromAgent.mockRejectedValue(agentError);
|
||||
mockRemoveFromConfig.mockRejectedValue(configError);
|
||||
|
||||
await expect(
|
||||
deleteExtension({
|
||||
name: 'test-extension',
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
})
|
||||
).rejects.toThrow('Config removal failed');
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', { isDelete: true });
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
nameToKey,
|
||||
getDefaultFormData,
|
||||
extensionToFormData,
|
||||
createExtensionConfig,
|
||||
splitCmdAndArgs,
|
||||
combineCmdAndArgs,
|
||||
extractExtensionConfig,
|
||||
replaceWithShims,
|
||||
removeShims,
|
||||
extractCommand,
|
||||
extractExtensionName,
|
||||
DEFAULT_EXTENSION_TIMEOUT,
|
||||
} from './utils';
|
||||
import type { FixedExtensionEntry } from '../../ConfigContext';
|
||||
|
||||
// Mock window.electron
|
||||
const mockElectron = {
|
||||
getBinaryPath: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: mockElectron,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('Extension Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('nameToKey', () => {
|
||||
it('should convert name to lowercase key format', () => {
|
||||
expect(nameToKey('My Extension')).toBe('myextension');
|
||||
expect(nameToKey('Test-Extension_Name')).toBe('test-extension_name');
|
||||
expect(nameToKey('UPPERCASE')).toBe('uppercase');
|
||||
});
|
||||
|
||||
it('should remove spaces', () => {
|
||||
expect(nameToKey('Extension With Spaces')).toBe('extensionwithspaces');
|
||||
expect(nameToKey(' Multiple Spaces ')).toBe('multiplespaces');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultFormData', () => {
|
||||
it('should return default form data structure', () => {
|
||||
const defaultData = getDefaultFormData();
|
||||
|
||||
expect(defaultData).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'stdio',
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensionToFormData', () => {
|
||||
it('should convert stdio extension to form data', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'stdio',
|
||||
name: 'test-extension',
|
||||
description: 'Test description',
|
||||
cmd: 'python',
|
||||
args: ['script.py', '--flag'],
|
||||
enabled: true,
|
||||
timeout: 600,
|
||||
env_keys: ['API_KEY', 'SECRET'],
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData).toEqual({
|
||||
name: 'test-extension',
|
||||
description: 'Test description',
|
||||
type: 'stdio',
|
||||
cmd: 'python script.py --flag',
|
||||
endpoint: undefined,
|
||||
enabled: true,
|
||||
timeout: 600,
|
||||
envVars: [
|
||||
{ key: 'API_KEY', value: '••••••••', isEdited: false },
|
||||
{ key: 'SECRET', value: '••••••••', isEdited: false },
|
||||
],
|
||||
headers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert sse extension to form data', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'sse',
|
||||
name: 'sse-extension',
|
||||
description: 'SSE description',
|
||||
uri: 'http://localhost:8080/events',
|
||||
enabled: false,
|
||||
env_keys: ['TOKEN'],
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData).toEqual({
|
||||
name: 'sse-extension',
|
||||
description: 'SSE description',
|
||||
type: 'sse',
|
||||
cmd: undefined,
|
||||
endpoint: 'http://localhost:8080/events',
|
||||
enabled: false,
|
||||
timeout: undefined,
|
||||
envVars: [{ key: 'TOKEN', value: '••••••••', isEdited: false }],
|
||||
headers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert streamable_http extension to form data', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'streamable_http',
|
||||
name: 'http-extension',
|
||||
description: 'HTTP description',
|
||||
uri: 'http://api.example.com',
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
env_keys: ['API_KEY'],
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData).toEqual({
|
||||
name: 'http-extension',
|
||||
description: 'HTTP description',
|
||||
type: 'streamable_http',
|
||||
cmd: undefined,
|
||||
endpoint: 'http://api.example.com',
|
||||
enabled: true,
|
||||
timeout: undefined,
|
||||
envVars: [{ key: 'API_KEY', value: '••••••••', isEdited: false }],
|
||||
headers: [
|
||||
{ key: 'Authorization', value: 'Bearer token', isEdited: false },
|
||||
{ key: 'Content-Type', value: 'application/json', isEdited: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle legacy envs field', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'stdio',
|
||||
name: 'legacy-extension',
|
||||
cmd: 'node',
|
||||
args: ['app.js'],
|
||||
enabled: true,
|
||||
envs: {
|
||||
OLD_KEY: 'old_value',
|
||||
LEGACY_TOKEN: 'legacy_token',
|
||||
},
|
||||
env_keys: ['NEW_KEY'],
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData.envVars).toEqual([
|
||||
{ key: 'OLD_KEY', value: 'old_value', isEdited: true },
|
||||
{ key: 'LEGACY_TOKEN', value: 'legacy_token', isEdited: true },
|
||||
{ key: 'NEW_KEY', value: '••••••••', isEdited: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle builtin extension', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData).toEqual({
|
||||
name: 'developer',
|
||||
description: '',
|
||||
type: 'builtin',
|
||||
cmd: undefined,
|
||||
endpoint: undefined,
|
||||
enabled: true,
|
||||
timeout: undefined,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createExtensionConfig', () => {
|
||||
it('should create stdio extension config', () => {
|
||||
const formData = {
|
||||
name: 'test-stdio',
|
||||
description: 'Test stdio extension',
|
||||
type: 'stdio' as const,
|
||||
cmd: 'python script.py --arg1 --arg2',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [
|
||||
{ key: 'API_KEY', value: 'secret123', isEdited: true },
|
||||
{ key: '', value: '', isEdited: false }, // Should be filtered out
|
||||
],
|
||||
headers: [],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'stdio',
|
||||
name: 'test-stdio',
|
||||
description: 'Test stdio extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py', '--arg1', '--arg2'],
|
||||
timeout: 300,
|
||||
env_keys: ['API_KEY'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create sse extension config', () => {
|
||||
const formData = {
|
||||
name: 'test-sse',
|
||||
description: 'Test SSE extension',
|
||||
type: 'sse' as const,
|
||||
cmd: '',
|
||||
endpoint: 'http://localhost:8080/events',
|
||||
enabled: true,
|
||||
timeout: 600,
|
||||
envVars: [{ key: 'TOKEN', value: 'abc123', isEdited: true }],
|
||||
headers: [],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'sse',
|
||||
name: 'test-sse',
|
||||
description: 'Test SSE extension',
|
||||
timeout: 600,
|
||||
uri: 'http://localhost:8080/events',
|
||||
env_keys: ['TOKEN'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create streamable_http extension config', () => {
|
||||
const formData = {
|
||||
name: 'test-http',
|
||||
description: 'Test HTTP extension',
|
||||
type: 'streamable_http' as const,
|
||||
cmd: '',
|
||||
endpoint: 'http://api.example.com',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [{ key: 'API_KEY', value: 'key123', isEdited: true }],
|
||||
headers: [
|
||||
{ key: 'Authorization', value: 'Bearer token', isEdited: true },
|
||||
{ key: '', value: '', isEdited: false }, // Should be filtered out
|
||||
],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'streamable_http',
|
||||
name: 'test-http',
|
||||
description: 'Test HTTP extension',
|
||||
timeout: 300,
|
||||
uri: 'http://api.example.com',
|
||||
env_keys: ['API_KEY'],
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create builtin extension config', () => {
|
||||
const formData = {
|
||||
name: 'developer',
|
||||
description: '',
|
||||
type: 'builtin' as const,
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
timeout: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCmdAndArgs', () => {
|
||||
it('should split command and arguments correctly', () => {
|
||||
expect(splitCmdAndArgs('python script.py --flag value')).toEqual({
|
||||
cmd: 'python',
|
||||
args: ['script.py', '--flag', 'value'],
|
||||
});
|
||||
|
||||
expect(splitCmdAndArgs('node')).toEqual({
|
||||
cmd: 'node',
|
||||
args: [],
|
||||
});
|
||||
|
||||
expect(splitCmdAndArgs('')).toEqual({
|
||||
cmd: '',
|
||||
args: [],
|
||||
});
|
||||
|
||||
expect(splitCmdAndArgs(' multiple spaces between args ')).toEqual({
|
||||
cmd: 'multiple',
|
||||
args: ['spaces', 'between', 'args'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineCmdAndArgs', () => {
|
||||
it('should combine command and arguments correctly', () => {
|
||||
expect(combineCmdAndArgs('python', ['script.py', '--flag', 'value'])).toBe(
|
||||
'python script.py --flag value'
|
||||
);
|
||||
|
||||
expect(combineCmdAndArgs('node', [])).toBe('node');
|
||||
|
||||
expect(combineCmdAndArgs('', ['arg1', 'arg2'])).toBe(' arg1 arg2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractExtensionConfig', () => {
|
||||
it('should extract extension config from fixed entry', () => {
|
||||
const fixedEntry: FixedExtensionEntry = {
|
||||
type: 'stdio',
|
||||
name: 'test-extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
};
|
||||
|
||||
const config = extractExtensionConfig(fixedEntry);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'stdio',
|
||||
name: 'test-extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceWithShims', () => {
|
||||
beforeEach(() => {
|
||||
mockElectron.getBinaryPath.mockImplementation((binary: string) => {
|
||||
const paths: Record<string, string> = {
|
||||
goosed: '/path/to/goosed',
|
||||
jbang: '/path/to/jbang',
|
||||
npx: '/path/to/npx',
|
||||
uvx: '/path/to/uvx',
|
||||
};
|
||||
return Promise.resolve(paths[binary] || binary);
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace known commands with shim paths', async () => {
|
||||
expect(await replaceWithShims('goosed')).toBe('/path/to/goosed');
|
||||
expect(await replaceWithShims('jbang')).toBe('/path/to/jbang');
|
||||
expect(await replaceWithShims('npx')).toBe('/path/to/npx');
|
||||
expect(await replaceWithShims('uvx')).toBe('/path/to/uvx');
|
||||
});
|
||||
|
||||
it('should leave unknown commands unchanged', async () => {
|
||||
expect(await replaceWithShims('python')).toBe('python');
|
||||
expect(await replaceWithShims('node')).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeShims', () => {
|
||||
it('should remove shim paths and return command name', () => {
|
||||
expect(removeShims('/path/to/goosed')).toBe('goosed');
|
||||
expect(removeShims('/usr/local/bin/jbang')).toBe('jbang');
|
||||
expect(removeShims('/Applications/Docker.app/Contents/Resources/bin/docker')).toBe('docker');
|
||||
expect(removeShims('/path/to/npx.cmd')).toBe('npx.cmd');
|
||||
});
|
||||
|
||||
it('should handle paths with trailing slashes', () => {
|
||||
// The removeShims function only works if the path ends with the shim pattern
|
||||
// Trailing slashes prevent the pattern from matching
|
||||
expect(removeShims('/path/to/goosed/')).toBe('/path/to/goosed/');
|
||||
expect(removeShims('/path/to/uvx//')).toBe('/path/to/uvx//');
|
||||
});
|
||||
|
||||
it('should leave non-shim commands unchanged', () => {
|
||||
expect(removeShims('python')).toBe('python');
|
||||
expect(removeShims('node')).toBe('node');
|
||||
expect(removeShims('/usr/bin/python3')).toBe('/usr/bin/python3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCommand', () => {
|
||||
it('should extract command from extension link', () => {
|
||||
const link = 'goose://extension/add?name=Test&cmd=python&arg=script.py&arg=--flag';
|
||||
expect(extractCommand(link)).toBe('python script.py --flag');
|
||||
});
|
||||
|
||||
it('should handle encoded arguments', () => {
|
||||
const link = 'goose://extension/add?cmd=echo&arg=hello%20world&arg=--test%3Dvalue';
|
||||
expect(extractCommand(link)).toBe('echo hello world --test=value');
|
||||
});
|
||||
|
||||
it('should handle missing command', () => {
|
||||
const link = 'goose://extension/add?name=Test';
|
||||
expect(extractCommand(link)).toBe('Unknown Command');
|
||||
});
|
||||
|
||||
it('should handle command without arguments', () => {
|
||||
const link = 'goose://extension/add?cmd=python';
|
||||
expect(extractCommand(link)).toBe('python');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractExtensionName', () => {
|
||||
it('should extract extension name from link', () => {
|
||||
const link = 'goose://extension/add?name=Test%20Extension&cmd=python';
|
||||
expect(extractExtensionName(link)).toBe('Test Extension');
|
||||
});
|
||||
|
||||
it('should handle missing name', () => {
|
||||
const link = 'goose://extension/add?cmd=python';
|
||||
expect(extractExtensionName(link)).toBe('Unknown Extension');
|
||||
});
|
||||
|
||||
it('should decode URL encoded names', () => {
|
||||
const link = 'goose://extension/add?name=My%20Special%20Extension%21';
|
||||
expect(extractExtensionName(link)).toBe('My Special Extension!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_EXTENSION_TIMEOUT', () => {
|
||||
it('should have correct default timeout value', () => {
|
||||
expect(DEFAULT_EXTENSION_TIMEOUT).toBe(300);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user