diff --git a/ui/desktop/src/components/settings/extensions/agent-api.test.ts b/ui/desktop/src/components/settings/extensions/agent-api.test.ts new file mode 100644 index 0000000000..ddd9a8e3b3 --- /dev/null +++ b/ui/desktop/src/components/settings/extensions/agent-api.test.ts @@ -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(); + }); + }); +}); diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts new file mode 100644 index 0000000000..2c187ed378 --- /dev/null +++ b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts @@ -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'); + }); + }); +}); diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts new file mode 100644 index 0000000000..2c8818b646 --- /dev/null +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -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 = { + 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); + }); + }); +});