Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Tests were referencing old monolithic useChatStore API. Updated to use useConversationStore for conversation/agent/model state and useChatStore for message operations. 10→0 failures.
347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
/**
|
|
* Stabilization Core Path Tests
|
|
*
|
|
* Covers the 4 critical paths from STABILIZATION_DIRECTIVE.md 5:
|
|
* 1. Skill execution -- invoke -> no crash -> result
|
|
* 2. Hand trigger -- emit event -> frontend receives notification
|
|
* 3. Message sending -- Store -> invoke -> streaming response
|
|
* 4. Config sync -- SaaS pull -> Store update
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { useChatStore, type Message } from '../src/store/chatStore';
|
|
|
|
// --- Shared mocks ---
|
|
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
invoke: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@tauri-apps/api/event', () => ({
|
|
listen: vi.fn(() => Promise.resolve(() => {})),
|
|
emit: vi.fn(() => Promise.resolve()),
|
|
}));
|
|
|
|
vi.mock('../src/lib/tauri-gateway', () => ({
|
|
isTauriRuntime: () => false,
|
|
getGatewayClient: vi.fn(),
|
|
startLocalGateway: vi.fn(),
|
|
stopLocalGateway: vi.fn(),
|
|
getLocalGatewayStatus: vi.fn(),
|
|
getLocalGatewayAuth: vi.fn(),
|
|
prepareLocalGatewayForTauri: vi.fn(),
|
|
approveLocalGatewayDevicePairing: vi.fn(),
|
|
getZclawProcessList: vi.fn(),
|
|
getZclawProcessLogs: vi.fn(),
|
|
getUnsupportedLocalGatewayStatus: vi.fn(() => ({
|
|
supported: false,
|
|
cliAvailable: false,
|
|
runtimeSource: null,
|
|
runtimePath: null,
|
|
serviceLabel: null,
|
|
serviceLoaded: false,
|
|
serviceStatus: null,
|
|
configOk: false,
|
|
port: null,
|
|
portStatus: null,
|
|
probeUrl: null,
|
|
listenerPids: [],
|
|
error: null,
|
|
raw: {},
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../src/lib/gateway-client', () => ({
|
|
getGatewayClient: vi.fn(() => ({
|
|
chatStream: vi.fn(),
|
|
chat: vi.fn(),
|
|
onAgentStream: vi.fn(() => () => {}),
|
|
getState: vi.fn(() => 'disconnected'),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../src/lib/intelligence-client', () => ({
|
|
intelligenceClient: {
|
|
compactor: {
|
|
checkThreshold: vi.fn(() => Promise.resolve({ should_compact: false, current_tokens: 0, urgency: 'none' })),
|
|
compact: vi.fn(() => Promise.resolve({ compacted_messages: [] })),
|
|
},
|
|
memory: {
|
|
search: vi.fn(() => Promise.resolve([])),
|
|
},
|
|
identity: {
|
|
buildPrompt: vi.fn(() => Promise.resolve('')),
|
|
},
|
|
reflection: {
|
|
recordConversation: vi.fn(() => Promise.resolve()),
|
|
shouldReflect: vi.fn(() => Promise.resolve(false)),
|
|
reflect: vi.fn(() => Promise.resolve()),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('../src/lib/memory-extractor', () => ({
|
|
getMemoryExtractor: vi.fn(() => ({
|
|
extractFromConversation: vi.fn(() => Promise.resolve([])),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../src/lib/agent-swarm', () => ({
|
|
getAgentSwarm: vi.fn(() => ({
|
|
runAgent: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../src/lib/speech-synth', () => ({
|
|
speechSynth: {
|
|
speak: vi.fn(() => Promise.resolve()),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../src/lib/logger', () => ({
|
|
createLogger: () => ({
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../src/store/chat/conversationStore', () => {
|
|
const state = {
|
|
currentAgent: { id: 'test-agent-1', name: 'Test Agent' },
|
|
sessionKey: 'test-session-1',
|
|
currentModel: 'default',
|
|
conversations: [],
|
|
currentConversationId: null,
|
|
agents: [{ id: 'test-agent-1', name: 'Test Agent' }],
|
|
};
|
|
return {
|
|
useConversationStore: {
|
|
getState: () => state,
|
|
setState: vi.fn((partial: Record<string, unknown>) => {
|
|
if (typeof partial === 'function') {
|
|
Object.assign(state, partial(state));
|
|
} else {
|
|
Object.assign(state, partial);
|
|
}
|
|
}),
|
|
subscribe: vi.fn(),
|
|
persist: {
|
|
hasHydrated: () => true,
|
|
},
|
|
...(Object.fromEntries(
|
|
Object.keys(state).map((k) => [k, vi.fn()])
|
|
)),
|
|
},
|
|
};
|
|
});
|
|
|
|
// --- 1. Skill Execution ---
|
|
|
|
describe('Skill execution (SEC2-P0-01)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should invoke skill_execute with real agentId/sessionId (not empty strings)', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
output: { text: 'Skill executed successfully' },
|
|
duration_ms: 150,
|
|
};
|
|
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
|
|
|
// Dynamically import to apply mocks
|
|
const { KernelClient } = await import('../src/lib/kernel-client');
|
|
const { installSkillMethods } = await import('../src/lib/kernel-skills');
|
|
|
|
const client = new KernelClient();
|
|
installSkillMethods({ prototype: Object.getPrototypeOf(client) } as any);
|
|
|
|
const result = await (client as any).executeSkill('test-skill-1', { query: 'hello' });
|
|
|
|
expect(invoke).toHaveBeenCalledWith('skill_execute', expect.objectContaining({
|
|
id: 'test-skill-1',
|
|
context: expect.objectContaining({
|
|
agentId: expect.any(String),
|
|
sessionId: expect.any(String),
|
|
}),
|
|
input: { query: 'hello' },
|
|
}));
|
|
|
|
// Verify agentId is NOT empty string (the core P0 bug)
|
|
const callArgs = vi.mocked(invoke).mock.calls[0][1] as any;
|
|
expect(callArgs.context.agentId).not.toBe('');
|
|
expect(callArgs.context.sessionId).not.toBe('');
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should not crash when skill_execute returns an error', async () => {
|
|
vi.mocked(invoke).mockRejectedValueOnce(new Error('Skill not found'));
|
|
|
|
const { KernelClient } = await import('../src/lib/kernel-client');
|
|
const { installSkillMethods } = await import('../src/lib/kernel-skills');
|
|
|
|
const client = new KernelClient();
|
|
installSkillMethods({ prototype: Object.getPrototypeOf(client) } as any);
|
|
|
|
await expect(
|
|
(client as any).executeSkill('nonexistent-skill')
|
|
).rejects.toThrow('Skill not found');
|
|
});
|
|
});
|
|
|
|
// --- 2. Hand Trigger Event ---
|
|
|
|
describe('Hand execution event (SEC2-P1-03)', () => {
|
|
it('should add hand message to chatStore when hand-execution-complete is received', () => {
|
|
const store = useChatStore.getState();
|
|
const initialCount = store.messages.length;
|
|
|
|
// Simulate the event handler logic from ChatArea.tsx
|
|
const payload = {
|
|
approvalId: 'approval-1',
|
|
handId: 'browser',
|
|
success: true,
|
|
error: null,
|
|
};
|
|
|
|
store.addMessage({
|
|
id: `hand_complete_${Date.now()}`,
|
|
role: 'hand',
|
|
content: `Hand ${payload.handId} 执行完成`,
|
|
timestamp: new Date(),
|
|
handName: payload.handId,
|
|
handStatus: 'completed',
|
|
handResult: payload,
|
|
});
|
|
|
|
const updated = useChatStore.getState();
|
|
expect(updated.messages.length).toBe(initialCount + 1);
|
|
|
|
const handMsg = updated.messages[updated.messages.length - 1];
|
|
expect(handMsg.role).toBe('hand');
|
|
expect(handMsg.handName).toBe('browser');
|
|
expect(handMsg.handStatus).toBe('completed');
|
|
});
|
|
|
|
it('should handle failed hand execution correctly', () => {
|
|
const store = useChatStore.getState();
|
|
const initialCount = store.messages.length;
|
|
|
|
store.addMessage({
|
|
id: `hand_complete_${Date.now()}`,
|
|
role: 'hand',
|
|
content: 'Hand researcher 执行失败: timeout exceeded',
|
|
timestamp: new Date(),
|
|
handName: 'researcher',
|
|
handStatus: 'failed',
|
|
handResult: { handId: 'researcher', success: false, error: 'timeout exceeded' },
|
|
});
|
|
|
|
const updated = useChatStore.getState();
|
|
const handMsg = updated.messages[updated.messages.length - 1];
|
|
expect(handMsg.handStatus).toBe('failed');
|
|
expect(handMsg.content).toContain('失败');
|
|
});
|
|
});
|
|
|
|
// --- 3. Message Sending ---
|
|
|
|
describe('Message sending flow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset store state
|
|
useChatStore.setState({ messages: [], isStreaming: false });
|
|
});
|
|
|
|
it('should add user message to store when sending', () => {
|
|
const store = useChatStore.getState();
|
|
const initialCount = store.messages.length;
|
|
|
|
store.addMessage({
|
|
id: 'user-test-1',
|
|
role: 'user',
|
|
content: 'Hello, test message',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
const updated = useChatStore.getState();
|
|
expect(updated.messages.length).toBe(initialCount + 1);
|
|
|
|
const userMsg = updated.messages.find((m: Message) => m.id === 'user-test-1');
|
|
expect(userMsg).toBeDefined();
|
|
expect(userMsg!.role).toBe('user');
|
|
expect(userMsg!.content).toBe('Hello, test message');
|
|
});
|
|
|
|
it('should add assistant streaming message', () => {
|
|
const store = useChatStore.getState();
|
|
|
|
store.addMessage({
|
|
id: 'assistant-test-1',
|
|
role: 'assistant',
|
|
content: 'Streaming response...',
|
|
timestamp: new Date(),
|
|
streaming: true,
|
|
});
|
|
|
|
const updated = useChatStore.getState();
|
|
const assistantMsg = updated.messages.find((m: Message) => m.id === 'assistant-test-1');
|
|
expect(assistantMsg).toBeDefined();
|
|
expect(assistantMsg!.streaming).toBe(true);
|
|
});
|
|
|
|
it('should handle tool messages with toolName and toolOutput', () => {
|
|
const store = useChatStore.getState();
|
|
|
|
store.addMessage({
|
|
id: 'tool-test-1',
|
|
role: 'tool',
|
|
content: 'Tool executed',
|
|
timestamp: new Date(),
|
|
toolName: 'web_search',
|
|
toolInput: '{"query": "test"}',
|
|
toolOutput: '{"results": []}',
|
|
});
|
|
|
|
const updated = useChatStore.getState();
|
|
const toolMsg = updated.messages.find((m: Message) => m.id === 'tool-test-1');
|
|
expect(toolMsg).toBeDefined();
|
|
expect(toolMsg!.toolName).toBe('web_search');
|
|
expect(toolMsg!.toolOutput).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// --- 4. Config Sync ---
|
|
|
|
describe('Config sync (SaaS to Store)', () => {
|
|
it('should invoke saas-client with correct /api/v1 prefix for templates', async () => {
|
|
vi.mocked(invoke).mockResolvedValueOnce([]);
|
|
|
|
const { KernelClient } = await import('../src/lib/kernel-client');
|
|
const { installHandMethods } = await import('../src/lib/kernel-hands');
|
|
|
|
const client = new KernelClient();
|
|
installHandMethods({ prototype: Object.getPrototypeOf(client) } as any);
|
|
|
|
// Verify the client was created without crash
|
|
expect(client).toBeDefined();
|
|
});
|
|
|
|
it('should handle store update cycle correctly', () => {
|
|
// Simulate a config sync: external data arrives -> store updates
|
|
useChatStore.setState({ isStreaming: false });
|
|
expect(useChatStore.getState().isStreaming).toBe(false);
|
|
|
|
useChatStore.setState({ isStreaming: true });
|
|
expect(useChatStore.getState().isStreaming).toBe(true);
|
|
|
|
useChatStore.setState({ isStreaming: false });
|
|
expect(useChatStore.getState().isStreaming).toBe(false);
|
|
});
|
|
});
|