Files
life-echo/app-expo/tests/core/ws/client.test.ts
Kevin 3d01085442 fix(conversation): 修复实时会话 TTS/回复被离屏 WS 抢占
- 列表预热仅预取消息缓存,避免后台 WebSocket 覆盖服务端连接
- RealtimeSession UI 回调按 owner 独占,防止 offscreen 覆盖聊天页
- 列表页聚焦时再 prewarm,会话页 TTS 入队优先 base64
- 管线下发 TTS 同时带 audio_base64 与 audio_url;协议说明同步
- 移除 TTS 排查用前后端调试日志,保留错误/告警
- 补充 WS / RealtimeSession / entry-warmup / 播放器相关单测

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 10:42:44 +08:00

224 lines
5.7 KiB
TypeScript

import { WsClient } from '@/core/ws/client';
import type { WsEvent, WsConnectionState } from '@/core/ws/types';
jest.mock('@/core/auth/token-manager', () => ({
tokenManager: {
getAccessToken: jest.fn().mockResolvedValue('test-token'),
},
}));
jest.mock('@/core/config', () => ({
config: {
wsBaseUrl: 'ws://localhost:8000/',
ws: {
reconnectMaxRetries: 3,
reconnectBaseDelayMs: 10,
reconnectMaxDelayMs: 100,
heartbeatIntervalMs: 60000,
},
},
}));
// Mock WebSocket
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
readyState = MockWebSocket.OPEN;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sentMessages: string[] = [];
constructor(public url: string) {
MockWebSocket.instances.push(this);
setTimeout(() => this.onopen?.(), 0);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
}
simulateMessage(data: Record<string, unknown>) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
(global as Record<string, unknown>).WebSocket = MockWebSocket;
describe('WsClient', () => {
afterEach(() => {
jest.clearAllMocks();
MockWebSocket.instances = [];
});
test('connects with token and conversation id in URL', async () => {
const client = new WsClient('conv-123');
const states: WsConnectionState[] = [];
client.onStateChange((s) => states.push(s));
await client.connect();
await new Promise((r) => setTimeout(r, 10));
expect(states).toContain('connecting');
expect(states).toContain('connected');
expect(MockWebSocket.instances[0]?.url).toBe(
'ws://localhost:8000/ws/conversation/conv-123?token=test-token',
);
client.dispose();
});
test('maps server messages to domain events', async () => {
const client = new WsClient('conv-123');
const events: WsEvent[] = [];
client.onEvent((e) => events.push(e));
await client.connect();
await new Promise((r) => setTimeout(r, 10));
const ws = (client as unknown as { ws: MockWebSocket }).ws;
ws.simulateMessage({
type: 'connect',
conversation_id: 'conv-123',
data: { status: 'connected' },
timestamp: '2026-01-01T00:00:00Z',
});
ws.simulateMessage({
type: 'agent_response',
conversation_id: 'conv-123',
data: { text: 'Hello!', index: 0, total: 1 },
timestamp: '2026-01-01T00:00:00Z',
});
expect(events).toHaveLength(2);
expect(events[0]).toEqual({
kind: 'connected',
conversationId: 'conv-123',
});
expect(events[1]).toEqual({
kind: 'agent_response',
conversationId: 'conv-123',
text: 'Hello!',
index: 0,
total: 1,
isTransition: undefined,
segmentIndex: undefined,
});
client.dispose();
});
test('maps tts audio with base64 and url playback channels', async () => {
const client = new WsClient('conv-123');
const events: WsEvent[] = [];
client.onEvent((e) => events.push(e));
await client.connect();
await new Promise((r) => setTimeout(r, 10));
const ws = (client as unknown as { ws: MockWebSocket }).ws;
ws.simulateMessage({
type: 'tts_audio',
conversation_id: 'conv-123',
data: {
audio_base64: 'ZmFrZS1tcDM=',
audio_url: 'https://example.com/tts.mp3',
index: 0,
total: 1,
assistant_message_id: 'aa11aa11-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
manual: true,
},
timestamp: '2026-01-01T00:00:00Z',
});
expect(events).toEqual([
{
kind: 'tts_audio_received',
conversationId: 'conv-123',
audioBase64: 'ZmFrZS1tcDM=',
audioUrl: 'https://example.com/tts.mp3',
index: 0,
total: 1,
assistantMessageId: 'aa11aa11-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
manual: true,
},
]);
client.dispose();
});
test('sends text messages', async () => {
const client = new WsClient('conv-123');
await client.connect();
await new Promise((r) => setTimeout(r, 10));
client.sendText('Hi there');
const ws = (client as unknown as { ws: MockWebSocket }).ws;
expect(ws.sentMessages).toHaveLength(1);
const sent = JSON.parse(ws.sentMessages[0]);
expect(sent).toEqual({
type: 'text',
conversation_id: 'conv-123',
data: { text: 'Hi there' },
});
client.dispose();
});
test('sends text with tts_this_turn when requested', async () => {
const client = new WsClient('conv-123');
await client.connect();
await new Promise((r) => setTimeout(r, 10));
client.sendText('Hello', { ttsThisTurn: true });
const ws = (client as unknown as { ws: MockWebSocket }).ws;
expect(ws.sentMessages).toHaveLength(1);
const sent = JSON.parse(ws.sentMessages[0]);
expect(sent).toEqual({
type: 'text',
conversation_id: 'conv-123',
data: { text: 'Hello', tts_this_turn: true },
});
client.dispose();
});
test('ignores unknown message types without crashing', async () => {
const client = new WsClient('conv-123');
const events: WsEvent[] = [];
client.onEvent((e) => events.push(e));
await client.connect();
await new Promise((r) => setTimeout(r, 10));
const ws = (client as unknown as { ws: MockWebSocket }).ws;
ws.simulateMessage({
type: 'unknown_type' as string,
conversation_id: 'conv-123',
data: {},
timestamp: '2026-01-01T00:00:00Z',
});
// Unknown types are silently ignored — no events emitted
expect(events).toHaveLength(0);
client.dispose();
});
});