6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径 - 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径 - 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现 - 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道 - App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约 - Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核 - 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据 - 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
243 lines
7.7 KiB
TypeScript
243 lines
7.7 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;
|
|
}),
|
|
}));
|
|
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,
|
|
normalizeDiagnosticText,
|
|
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'),
|
|
'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',
|
|
});
|
|
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('保留 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();
|
|
});
|
|
});
|