Phase 2: providers + chat + tasks + IPC rewire
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* chatHandlers IPC streaming tests
|
||||
*
|
||||
* Post-Phase 2: chatHandlers uses ChatService.sendMessage, not OpenCodeManager.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
|
||||
@@ -10,7 +16,7 @@ const mainWindowMock = {
|
||||
};
|
||||
|
||||
const chatEngineInstances: Array<Record<string, any>> = [];
|
||||
const openCodeManagerInstances: Array<Record<string, any>> = [];
|
||||
const chatServiceInstances: Array<Record<string, any>> = [];
|
||||
const secureKeyStoreInstances: Array<Record<string, any>> = [];
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -52,42 +58,6 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/OpenCodeManager', () => ({
|
||||
OpenCodeManager: class {
|
||||
constructor() {
|
||||
const instance = {
|
||||
setApiKey: vi.fn(),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
validateApiKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||
getApiKey: vi.fn(() => 'abc12345'),
|
||||
getAvailableModels: vi.fn(async () => []),
|
||||
sendMessage: vi.fn(async (_conversationId: string, _message: string, options: any) => {
|
||||
options?.onDelta?.('stream-delta');
|
||||
options?.onToolCall?.({ name: 'search_posts', args: { query: 'q' } });
|
||||
options?.onToolResult?.({ name: 'search_posts', result: { ok: true } });
|
||||
options?.onTokenUsage?.({
|
||||
inputTokens: 100, outputTokens: 50,
|
||||
cacheReadTokens: 80, cacheWriteTokens: 20, totalTokens: 250,
|
||||
cumulativeInputTokens: 100, cumulativeOutputTokens: 50,
|
||||
cumulativeCacheReadTokens: 80, cumulativeCacheWriteTokens: 20,
|
||||
cumulativeTotalTokens: 250,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'assistant reply',
|
||||
};
|
||||
}),
|
||||
abortMessage: vi.fn(async () => ({ success: true })),
|
||||
analyzeTaxonomy: vi.fn(async () => ({ success: true })),
|
||||
analyzeMediaImage: vi.fn(async () => ({ success: true })),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
openCodeManagerInstances.push(instance);
|
||||
return instance;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
||||
SecureKeyStore: class {
|
||||
constructor() {
|
||||
@@ -104,12 +74,67 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ai/providers', () => ({
|
||||
ProviderRegistry: class {
|
||||
constructor() { /* no-op */ }
|
||||
setOpencodeKey = vi.fn();
|
||||
getOpencodeKey = vi.fn(() => 'abc12345');
|
||||
setMistralKey = vi.fn();
|
||||
getMistralKey = vi.fn(() => '');
|
||||
isReady = vi.fn(() => true);
|
||||
isProviderKeySet = vi.fn(() => true);
|
||||
getProviderStatus = vi.fn(() => ({ opencode: true, mistral: false }));
|
||||
resolveModel = vi.fn();
|
||||
getAvailableModels = vi.fn(async () => []);
|
||||
validateOpencodeKey = vi.fn(async () => ({ isValid: true, models: [] }));
|
||||
validateMistralKey = vi.fn(async () => ({ isValid: true, models: [] }));
|
||||
invalidateModelCache = vi.fn();
|
||||
getModelCatalogEngine = vi.fn(() => ({ refresh: vi.fn(async () => ({})), getAll: vi.fn(async () => []) }));
|
||||
},
|
||||
detectProvider: vi.fn(() => 'anthropic'),
|
||||
createOpenCodeGateway: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ai/chat', () => ({
|
||||
ChatService: class {
|
||||
constructor() {
|
||||
const instance = {
|
||||
sendMessage: vi.fn(async (_conversationId: string, _message: string, callbacks: any) => {
|
||||
callbacks?.onDelta?.('stream-delta');
|
||||
callbacks?.onToolCall?.({ name: 'search_posts', args: { query: 'q' } });
|
||||
callbacks?.onToolResult?.({ name: 'search_posts', result: { ok: true } });
|
||||
callbacks?.onTokenUsage?.({
|
||||
inputTokens: 100, outputTokens: 50,
|
||||
cacheReadTokens: 80, cacheWriteTokens: 20, totalTokens: 250,
|
||||
cumulativeInputTokens: 100, cumulativeOutputTokens: 50,
|
||||
cumulativeCacheReadTokens: 80, cumulativeCacheWriteTokens: 20,
|
||||
cumulativeTotalTokens: 250,
|
||||
});
|
||||
return { success: true, message: 'assistant reply' };
|
||||
}),
|
||||
abortMessage: vi.fn(async () => ({ success: true })),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
chatServiceInstances.push(instance);
|
||||
return instance;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ai/tasks', () => ({
|
||||
OneShotTasks: class {
|
||||
constructor() { /* no-op */ }
|
||||
analyzeTaxonomy = vi.fn(async () => ({ success: true }));
|
||||
analyzeMediaImage = vi.fn(async () => ({ success: true }));
|
||||
},
|
||||
}));
|
||||
|
||||
describe('chatHandlers', () => {
|
||||
beforeEach(() => {
|
||||
registeredHandlers.clear();
|
||||
webContentsSend.mockReset();
|
||||
chatEngineInstances.length = 0;
|
||||
openCodeManagerInstances.length = 0;
|
||||
chatServiceInstances.length = 0;
|
||||
secureKeyStoreInstances.length = 0;
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -141,13 +166,11 @@ describe('chatHandlers', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
|
||||
expect(manager.sendMessage).toHaveBeenCalledWith(
|
||||
const service = chatServiceInstances[0];
|
||||
expect(service.sendMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
'hello assistant',
|
||||
expect.objectContaining({
|
||||
metadata: { surface: 'sidebar' },
|
||||
onDelta: expect.any(Function),
|
||||
onToolCall: expect.any(Function),
|
||||
onToolResult: expect.any(Function),
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* Tests that API keys are stored/retrieved via SecureKeyStore (encrypted)
|
||||
* and that old plain-text keys are cleaned up on startup.
|
||||
*
|
||||
* Post-Phase 2: chatHandlers uses ProviderRegistry + ChatService, not OpenCodeManager.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -17,7 +19,7 @@ const mainWindowMock = {
|
||||
};
|
||||
|
||||
const chatEngineInstances: Array<Record<string, any>> = [];
|
||||
const openCodeManagerInstances: Array<Record<string, any>> = [];
|
||||
const providerRegistryInstances: Array<Record<string, any>> = [];
|
||||
const secureKeyStoreInstances: Array<Record<string, any>> = [];
|
||||
|
||||
// Per-test overrides for SecureKeyStore mock behavior
|
||||
@@ -88,25 +90,47 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/OpenCodeManager', () => ({
|
||||
OpenCodeManager: class {
|
||||
vi.mock('../../src/main/engine/ai/providers', () => ({
|
||||
ProviderRegistry: class {
|
||||
constructor() {
|
||||
const instance = {
|
||||
setApiKey: vi.fn(),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
validateApiKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||
getApiKey: vi.fn(() => 'abc12345'),
|
||||
setOpencodeKey: vi.fn(),
|
||||
getOpencodeKey: vi.fn(() => 'abc12345'),
|
||||
setMistralKey: vi.fn(),
|
||||
getMistralKey: vi.fn(() => ''),
|
||||
isReady: vi.fn(() => true),
|
||||
isProviderKeySet: vi.fn(() => true),
|
||||
getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false })),
|
||||
resolveModel: vi.fn(),
|
||||
getAvailableModels: vi.fn(async () => []),
|
||||
sendMessage: vi.fn(async () => ({ success: true, message: 'reply' })),
|
||||
abortMessage: vi.fn(async () => ({ success: true })),
|
||||
analyzeTaxonomy: vi.fn(async () => ({ success: true })),
|
||||
analyzeMediaImage: vi.fn(async () => ({ success: true })),
|
||||
stop: vi.fn(async () => undefined),
|
||||
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||
validateMistralKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||
invalidateModelCache: vi.fn(),
|
||||
getModelCatalogEngine: vi.fn(() => ({ refresh: vi.fn(async () => ({})), getAll: vi.fn(async () => []) })),
|
||||
};
|
||||
openCodeManagerInstances.push(instance);
|
||||
providerRegistryInstances.push(instance);
|
||||
return instance;
|
||||
}
|
||||
},
|
||||
detectProvider: vi.fn(() => 'anthropic'),
|
||||
createOpenCodeGateway: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ai/chat', () => ({
|
||||
ChatService: class {
|
||||
constructor() { /* no-op */ }
|
||||
sendMessage = vi.fn(async () => ({ success: true, message: 'reply' }));
|
||||
abortMessage = vi.fn(async () => ({ success: true }));
|
||||
stop = vi.fn(async () => undefined);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ai/tasks', () => ({
|
||||
OneShotTasks: class {
|
||||
constructor() { /* no-op */ }
|
||||
analyzeTaxonomy = vi.fn(async () => ({ success: true }));
|
||||
analyzeMediaImage = vi.fn(async () => ({ success: true }));
|
||||
},
|
||||
}));
|
||||
|
||||
describe('chatHandlers keychain integration', () => {
|
||||
@@ -114,7 +138,7 @@ describe('chatHandlers keychain integration', () => {
|
||||
registeredHandlers.clear();
|
||||
webContentsSend.mockReset();
|
||||
chatEngineInstances.length = 0;
|
||||
openCodeManagerInstances.length = 0;
|
||||
providerRegistryInstances.length = 0;
|
||||
secureKeyStoreInstances.length = 0;
|
||||
secureKeyStoreRetrieveResult = 'encrypted-stored-key';
|
||||
secureKeyStoreStoreError = null;
|
||||
@@ -141,8 +165,8 @@ describe('chatHandlers keychain integration', () => {
|
||||
const keyStore = secureKeyStoreInstances[0];
|
||||
expect(keyStore.retrieve).toHaveBeenCalledWith('opencode_api_key');
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||
const registry = providerRegistryInstances[0];
|
||||
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||
});
|
||||
|
||||
it('cleans up old plain-text key on init', async () => {
|
||||
@@ -173,8 +197,8 @@ describe('chatHandlers keychain integration', () => {
|
||||
const keyStore = secureKeyStoreInstances[0];
|
||||
expect(keyStore.store).toHaveBeenCalledWith('opencode_api_key', 'sk-new-secret-key');
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
expect(manager.setApiKey).toHaveBeenCalledWith('sk-new-secret-key');
|
||||
const registry = providerRegistryInstances[0];
|
||||
expect(registry.setOpencodeKey).toHaveBeenCalledWith('sk-new-secret-key');
|
||||
});
|
||||
|
||||
it('does not use plain-text getSetting for API key', async () => {
|
||||
@@ -218,9 +242,9 @@ describe('chatHandlers keychain integration', () => {
|
||||
const result = await handler!(undefined);
|
||||
expect(result.ready).toBe(true);
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
// setApiKey should NOT have been called since there's no stored key
|
||||
expect(manager.setApiKey).not.toHaveBeenCalled();
|
||||
const registry = providerRegistryInstances[0];
|
||||
// setOpencodeKey should NOT have been called since there's no stored key
|
||||
expect(registry.setOpencodeKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still initializes when retrieve() throws on init', async () => {
|
||||
@@ -236,8 +260,8 @@ describe('chatHandlers keychain integration', () => {
|
||||
// Init should complete even if key retrieval fails
|
||||
expect(result.ready).toBe(true);
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
expect(manager.setApiKey).not.toHaveBeenCalled();
|
||||
const registry = providerRegistryInstances[0];
|
||||
expect(registry.setOpencodeKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still initializes and loads key when cleanupPlainTextKey() throws on init', async () => {
|
||||
@@ -254,8 +278,8 @@ describe('chatHandlers keychain integration', () => {
|
||||
expect(result.ready).toBe(true);
|
||||
|
||||
// The encrypted key should still be loaded despite cleanup failure
|
||||
const manager = openCodeManagerInstances[0];
|
||||
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||
const registry = providerRegistryInstances[0];
|
||||
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||
});
|
||||
|
||||
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
|
||||
@@ -270,13 +294,13 @@ describe('chatHandlers keychain integration', () => {
|
||||
const checkHandler = registeredHandlers.get('chat:checkReady');
|
||||
await checkHandler!(undefined);
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
// After init, the manager has the key from SecureKeyStore
|
||||
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||
manager.setApiKey.mockClear();
|
||||
const registry = providerRegistryInstances[0];
|
||||
// After init, the registry has the key from SecureKeyStore
|
||||
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||
registry.setOpencodeKey.mockClear();
|
||||
|
||||
// getApiKey returns the current in-memory key (to be restored on rollback)
|
||||
manager.getApiKey.mockReturnValue('encrypted-stored-key');
|
||||
// getOpencodeKey returns the current in-memory key (to be restored on rollback)
|
||||
registry.getOpencodeKey.mockReturnValue('encrypted-stored-key');
|
||||
|
||||
const handler = registeredHandlers.get('chat:setApiKey');
|
||||
const result = await handler!(undefined, 'sk-new-key');
|
||||
@@ -284,10 +308,10 @@ describe('chatHandlers keychain integration', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('encryption unavailable');
|
||||
|
||||
// setApiKey should have been called twice:
|
||||
// setOpencodeKey should have been called twice:
|
||||
// 1) with the new key (optimistic), 2) with the old key (rollback)
|
||||
expect(manager.setApiKey).toHaveBeenCalledTimes(2);
|
||||
expect(manager.setApiKey).toHaveBeenNthCalledWith(1, 'sk-new-key');
|
||||
expect(manager.setApiKey).toHaveBeenNthCalledWith(2, 'encrypted-stored-key');
|
||||
expect(registry.setOpencodeKey).toHaveBeenCalledTimes(2);
|
||||
expect(registry.setOpencodeKey).toHaveBeenNthCalledWith(1, 'sk-new-key');
|
||||
expect(registry.setOpencodeKey).toHaveBeenNthCalledWith(2, 'encrypted-stored-key');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user