Files
Genarrative/apps/ai-game-creator-shell/tests/errorReporting.test.ts
T
k88936 5013f4385f
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Successful in 2m3s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / Native shell tests (pull_request) Successful in 5m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m10s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m9s
AGC 前端错误分流基元:ClientActionError 与认证变体分类
- 新增 clientActionError.ts:ClientActionError 承载 source/action/page 上下文与 cause
- 新增 clientAuthError.ts:isClientAuthError 形状读取 + clientAuthErrorKind 的 input/session/fault 分类 + clientAuthErrorNotice 文案
- captureClientError 解包 ClientActionError 的上下文,并优先使用 cause 的栈信息
- getClientAuthErrorMessage 认识结构化拒绝,非 Error 形状不再字符串化成 [object Object]
- 新增 clientAuthError.test.ts,并在 errorReporting.test.ts 用解包用例替换 shouldCaptureClientError 用例
2026-10-01 15:27:31 +08:00

265 lines
8.4 KiB
TypeScript

/** @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<string, FakeErrorEvent>();
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<string, unknown>) => {
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;
}),
}));
import { invoke } from '@tauri-apps/api/core';
import { ClientActionError } from '../src/services/clientActionError';
import {
ackClientErrorEventsWithRetry,
captureAgentRuntimeError,
captureClientError,
getPendingClientErrorEvents,
installWebviewLogBridge,
markClientErrorEventsSubmitted,
normalizeDiagnosticText,
resetClientErrorEventsForTests,
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'),
'planning-agent-v2',
);
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: 'planning-agent-v2',
});
});
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',
});
await submitErrorReportBatch({ events: [first], logs: [] });
await markClientErrorEventsSubmitted([first]);
expect(await getPendingClientErrorEvents()).toEqual([second]);
});
it('提交只把脱敏事件交给 Rust,不在渲染层携带凭据或 submissionId', async () => {
const event = await captureClientError(new Error('可重试错误'), {
source: 'test',
});
vi.mocked(invoke).mockImplementationOnce(async (command: string) => {
if (command !== 'submit_error_report') {
throw new Error(`unexpected invoke ${command}`);
}
return { batchId: 'batch-1' };
});
await submitErrorReportBatch({
events: [event],
logs: [{ name: 'application.log', content: 'line' }],
userDescription: ' 说明 ',
});
expect(invoke).toHaveBeenCalledWith('submit_error_report', {
events: [event],
logs: [{ name: 'application.log', content: 'line' }],
userDescription: '说明',
});
const [, args] = vi.mocked(invoke).mock.calls.at(-1) ?? [];
const serialized = JSON.stringify(args);
expect(serialized).not.toContain('token');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('submissionId');
expect(serialized).not.toContain('http');
});
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('登录态缺失时把 Rust 的稳定分类原样透出,不清除本地事件', async () => {
const event = await captureClientError(new Error('未登录错误'), {
source: 'test',
});
vi.mocked(invoke).mockRejectedValueOnce(
new Error('authentication-required: 请先登录后再提交错误报告'),
);
await expect(
submitErrorReportBatch({ events: [event], logs: [] }),
).rejects.toThrow('请先登录');
expect(await getPendingClientErrorEvents()).toEqual([event]);
});
it('解包调用方补的上报上下文与原始错误', async () => {
const original = new Error('无法连接登录服务,请确认网络后重试');
await captureClientError(
new ClientActionError(
'无法连接登录服务,请确认网络后重试',
{ source: 'auth', action: 'login' },
original,
),
// 显式入参是兜底:ClientActionError 自带的上下文优先。
{ source: 'unhandledrejection' },
);
expect(invoke).toHaveBeenCalledWith('report_client_error', {
source: 'auth',
message: '无法连接登录服务,请确认网络后重试',
stack: original.stack,
action: 'login',
page: undefined,
});
});
it('保留 API 路由但隐藏 URL origin 与查询参数', () => {
const sanitized = normalizeDiagnosticText(
'请求超时:https://dev.genarrative.world/api/llm/models?token=secret#fragment',
);
expect(sanitized).toBe('请求超时:<origin>/api/llm/models');
expect(sanitized).not.toContain('genarrative.world');
expect(sanitized).not.toContain('secret');
});
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();
});
});