/** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest'; type FakeErrorEvent = { eventId: string; fingerprint: string; source: string; message: string; stack?: string; occurredAt: string; count: number; }; const fakeRustQueue = vi.hoisted(() => { const events = new Map(); let sequence = 0; return { events, get sequence() { return sequence; }, next() { sequence += 1; return sequence; }, reset() { events.clear(); sequence = 0; }, }; }); vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(async (command: string, args?: Record) => { if (command === 'report_client_error') { const fingerprint = `${args?.source ?? 'client'}|${args?.action ?? ''}|${args?.page ?? ''}|${args?.message ?? ''}`; const existing = fakeRustQueue.events.get(fingerprint); if (existing) { existing.count += 1; return existing; } const event = { eventId: `rust-error-${fakeRustQueue.next()}`, fingerprint, source: String(args?.source ?? 'client'), message: String(args?.message ?? ''), stack: typeof args?.stack === 'string' ? args.stack : undefined, occurredAt: '1', count: 1, }; if (fakeRustQueue.events.size >= 100) { const oldest = fakeRustQueue.events.keys().next().value; if (oldest) fakeRustQueue.events.delete(oldest); } fakeRustQueue.events.set(fingerprint, event); return event; } if (command === 'get_pending_error_reports') return [...fakeRustQueue.events.values()]; if (command === 'ack_error_reports') { for (const event of fakeRustQueue.events.values()) { const eventIds = Array.isArray(args?.eventIds) ? args.eventIds : []; if (eventIds.includes(event.eventId)) fakeRustQueue.events.delete(event.fingerprint); } return undefined; } return undefined; }), })); vi.mock('../src/services/clientAuth', () => ({ getStoredAuthAccessToken: vi.fn(() => 'test-token'), })); vi.mock('../src/services/clientHttp', () => ({ fetchClientHttp: vi.fn(), getClientServerBaseUrl: vi.fn(() => 'https://example.test'), })); import { invoke } from '@tauri-apps/api/core'; import { fetchClientHttp } from '../src/services/clientHttp'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, captureClientError, getPendingClientErrorEvents, getStableErrorReportSubmissionId, installWebviewLogBridge, markClientErrorEventsSubmitted, resetClientErrorEventsForTests, shouldCaptureClientError, submitErrorReportBatch, subscribeClientErrorEvents, } from '../src/services/errorReporting'; describe('客户端错误报告池', () => { afterEach(() => { fakeRustQueue.reset(); resetClientErrorEventsForTests(); vi.clearAllMocks(); }); it('按 fingerprint 合并重复错误并累计次数', async () => { const first = await captureClientError(new Error('重复错误'), { source: 'test', }); const second = await captureClientError(new Error('重复错误'), { source: 'test', }); expect(second.eventId).toBe(first.eventId); expect(await getPendingClientErrorEvents()).toHaveLength(1); expect((await getPendingClientErrorEvents())[0]?.count).toBe(2); }); it('使用统一上下文采集 Agent Runtime 错误', async () => { await captureAgentRuntimeError( new Error('kind=codex-app-server-other fingerprint=dynamic chars=12'), 'project-supervisor', ); expect(invoke).toHaveBeenCalledWith('report_client_error', { source: 'agent-runtime', message: 'kind=codex-app-server-other fingerprint=dynamic chars=12', stack: expect.any(String), action: 'agent-runtime', page: 'project-supervisor', }); }); it('限制当前进程错误池最多保留 100 条', async () => { for (let index = 0; index < 101; index += 1) { await captureClientError(new Error(`错误 ${index}`), { source: 'test' }); } expect(await getPendingClientErrorEvents()).toHaveLength(100); }); it('提交成功后只清除本次提交的事件', async () => { const first = await captureClientError(new Error('第一个错误'), { source: 'test', }); const second = await captureClientError(new Error('第二个错误'), { source: 'test', }); vi.mocked(fetchClientHttp).mockResolvedValue( new Response(JSON.stringify({ data: { batchId: 'batch-1' } }), { status: 200, }), ); await submitErrorReportBatch({ events: [first], logs: [] }); await markClientErrorEventsSubmitted([first]); expect(await getPendingClientErrorEvents()).toEqual([second]); }); it('同一批事件复用稳定 submissionId', async () => { const event = await captureClientError(new Error('可重试错误'), { source: 'test', }); const firstId = getStableErrorReportSubmissionId([event]); const secondId = getStableErrorReportSubmissionId([event]); expect(secondId).toBe(firstId); }); it('本地 ack 失败时自动重试且不向调用方抛错', async () => { vi.useFakeTimers(); const event = await captureClientError(new Error('ack 重试错误'), { source: 'test', }); vi.mocked(invoke) .mockRejectedValueOnce(new Error('暂时不可用')) .mockResolvedValueOnce(undefined); const pending = ackClientErrorEventsWithRetry([event]); await vi.advanceTimersByTimeAsync(500); await pending; expect(invoke).toHaveBeenCalledWith('ack_error_reports', { eventIds: [event.eventId], }); expect( vi .mocked(invoke) .mock.calls.filter(([command]) => command === 'ack_error_reports'), ).toHaveLength(2); vi.useRealTimers(); }); it('未登录时拒绝提交且不发请求', async () => { const auth = await import('../src/services/clientAuth'); vi.mocked(auth.getStoredAuthAccessToken).mockReturnValue(''); await expect( submitErrorReportBatch({ events: [], logs: [] }), ).rejects.toThrow('请先登录'); expect(fetchClientHttp).not.toHaveBeenCalled(); }); it('只采集网络错误、408 和 5xx', () => { expect(shouldCaptureClientError({ status: 400 })).toBe(false); expect(shouldCaptureClientError({ status: 401 })).toBe(false); expect(shouldCaptureClientError({ status: 429 })).toBe(false); expect(shouldCaptureClientError({ status: 408 })).toBe(true); expect(shouldCaptureClientError({ status: 503 })).toBe(true); expect(shouldCaptureClientError({ networkError: true })).toBe(true); }); it('将 WebView console 输出写入普通文本日志 command', async () => { const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}); const uninstall = installWebviewLogBridge(); console.info('hello', { count: 1 }); await Promise.resolve(); expect(invoke).toHaveBeenCalledWith('append_application_log', { level: 'info', source: 'webview', message: 'hello {"count":1}', }); uninstall(); consoleInfo.mockRestore(); }); it('浏览器预览中订阅错误事件不会产生未处理拒绝', async () => { delete window.__TAURI__; const unsubscribe = subscribeClientErrorEvents(vi.fn()); unsubscribe(); await Promise.resolve(); }); });