接入 AGC 客户端错误采集与报告面板

在当前进程按指纹合并客户端错误并持久化应用诊断日志。

新增登录态批量提交、用户说明和可选日志上传面板。

将 /bug-report 快捷入口改为打开报告问题面板并更新端到端断言。
This commit is contained in:
2026-09-01 10:10:48 +08:00
parent 1f252ef86d
commit 5a4ac48575
12 changed files with 556 additions and 51 deletions
@@ -39,6 +39,7 @@ use shared_contracts::game_creation_app::{
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
use shared_contracts::error_reports::{ErrorReportEventInput, ErrorReportLogInput};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -1563,6 +1564,49 @@ static DIAGNOSTIC_LOG_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static STARTUP_PANIC_LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false);
#[tauri::command]
fn append_diagnostic_event(event: ErrorReportEventInput) -> Result<(), String> {
let config_dir = game_creator_runtime_config_dir()
.ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?;
let path = config_dir.join("diagnostics/application.log");
let mut event = event;
event.event_id = sanitize_diagnostic_message(&event.event_id, Some(&config_dir));
event.fingerprint = sanitize_diagnostic_message(&event.fingerprint, Some(&config_dir));
event.source = sanitize_diagnostic_message(&event.source, Some(&config_dir));
event.message = sanitize_diagnostic_message(&event.message, Some(&config_dir));
event.stack = event
.stack
.take()
.map(|value| sanitize_diagnostic_message(&value, Some(&config_dir)));
event.occurred_at = sanitize_diagnostic_message(&event.occurred_at, Some(&config_dir));
let line = serde_json::to_string(&event).map_err(|error| error.to_string())?;
append_bounded_diagnostic_line(&path, &line).map_err(|error| error.to_string())
}
#[tauri::command]
fn read_diagnostic_logs() -> Result<Vec<ErrorReportLogInput>, String> {
let Some(config_dir) = game_creator_runtime_config_dir() else {
return Ok(Vec::new());
};
let _guard = DIAGNOSTIC_LOG_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let directory = config_dir.join("diagnostics");
let mut files = Vec::new();
for name in ["application.log", "application.previous.log", "startup.log"] {
let path = directory.join(name);
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
files.push(ErrorReportLogInput {
name: name.to_string(),
content,
});
}
Ok(files)
}
fn diagnostic_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -2059,6 +2103,11 @@ fn main() {
show_startup_error_dialog(path);
}
})?;
let startup_log = game_creator_runtime_config_dir()
.map(|directory| directory.join("diagnostics/startup.log"));
if let Some(path) = startup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
}
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
}
@@ -2292,7 +2341,9 @@ fn main() {
commit_local_project_asset,
commit_local_project_asset_canvas_candidate,
get_local_game_project_revision,
get_local_game_manifest
get_local_game_manifest,
append_diagnostic_event,
read_diagnostic_logs
])
.build(tauri_context);
let app = match app {
@@ -9,6 +9,7 @@ import {
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ErrorReportDialog } from '../components/error-report/ErrorReportDialog';
import {
clearStoredAuthAccessToken,
getClientAuthErrorMessage,
@@ -29,6 +30,7 @@ import {
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import { captureClientError } from '../services/errorReporting';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
@@ -43,6 +45,7 @@ import {
type ClientRuntimeErrorBoundaryProps = {
children: ReactNode;
onLogout: () => void;
onReport?: () => void;
};
type ClientRuntimeErrorBoundaryState = {
@@ -66,6 +69,7 @@ export class ClientRuntimeErrorBoundary extends Component<
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error('AGC authenticated client render failed', error, errorInfo);
void captureClientError(error, { source: 'react-render' });
}
render() {
@@ -86,6 +90,11 @@ export class ClientRuntimeErrorBoundary extends Component<
<button type="button" onClick={this.props.onLogout}>
</button>
{this.props.onReport ? (
<button type="button" onClick={this.props.onReport}>
</button>
) : null}
</section>
</main>
);
@@ -109,6 +118,7 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const [errorReportOpen, setErrorReportOpen] = useState(false);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
@@ -117,6 +127,35 @@ export function AuthenticatedClient({
initialServerSelection.customBaseUrl,
);
useEffect(() => {
const handleError = (event: ErrorEvent) => {
void captureClientError(event.error ?? event.message, {
source: 'window.onerror',
});
};
const handleRejection = (event: PromiseRejectionEvent) => {
void captureClientError(event.reason, { source: 'unhandledrejection' });
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleRejection);
};
}, []);
useEffect(() => {
const openReport = () => {
void captureClientError(new Error('用户主动报告问题'), {
source: 'manual',
});
setErrorReportOpen(true);
};
window.addEventListener('agc-open-error-report', openReport);
return () =>
window.removeEventListener('agc-open-error-report', openReport);
}, []);
function persistServerSelection() {
try {
const next = setClientServerSelection({
@@ -126,6 +165,10 @@ export function AuthenticatedClient({
setServerSelection(next);
return next;
} catch (error) {
void captureClientError(error, {
source: 'auth-hydrate',
action: 'restore-session',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
return null;
}
@@ -304,6 +347,10 @@ export function AuthenticatedClient({
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
} catch (error) {
void captureClientError(error, {
source: 'auth',
action: 'send-login-code',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
} finally {
setCodeBusy(false);
@@ -362,6 +409,7 @@ export function AuthenticatedClient({
setCode('');
setPassword('');
} catch (error) {
void captureClientError(error, { source: 'auth', action: 'login' });
setLoginStatus(error instanceof Error ? error.message : String(error));
} finally {
setLoginBusy(false);
@@ -530,8 +578,22 @@ export function AuthenticatedClient({
}
return (
<ClientRuntimeErrorBoundary onLogout={logout}>
{children({ user: authUser, logout })}
</ClientRuntimeErrorBoundary>
<>
<ClientRuntimeErrorBoundary
onLogout={logout}
onReport={() => {
void captureClientError(new Error('用户主动报告问题'), {
source: 'manual',
});
setErrorReportOpen(true);
}}
>
{children({ user: authUser, logout })}
</ClientRuntimeErrorBoundary>
<ErrorReportDialog
open={errorReportOpen}
onClose={() => setErrorReportOpen(false)}
/>
</>
);
}
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useState } from 'react';
import {
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
markClientErrorEventsSubmitted,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
subscribeClientErrorEvents,
} from '../../services/errorReporting';
import { ThemedModal } from '../modal/ThemedModal';
type ErrorReportDialogProps = {
open: boolean;
onClose: () => void;
};
export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
const [logs, setLogs] = useState<DiagnosticLogFile[]>([]);
const [includeLogs, setIncludeLogs] = useState(true);
const [description, setDescription] = useState('');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('');
useEffect(
() =>
subscribeClientErrorEvents(() =>
setEvents(getPendingClientErrorEvents()),
),
[],
);
useEffect(() => {
if (!open) return;
setEvents(getPendingClientErrorEvents());
setStatus('');
void readApplicationDiagnosticLogs().then(setLogs);
}, [open]);
const submit = useCallback(async () => {
if (!events.length || busy) return;
setBusy(true);
setStatus('正在提交…');
try {
await submitErrorReportBatch({
events,
logs: includeLogs ? logs : [],
userDescription: description,
});
markClientErrorEventsSubmitted(events);
setStatus('已提交,感谢你的反馈');
window.setTimeout(onClose, 700);
} catch (error) {
setStatus(
error instanceof Error ? error.message : '提交失败,请稍后再试',
);
} finally {
setBusy(false);
}
}, [busy, description, events, includeLogs, logs, onClose]);
return (
<ThemedModal
open={open}
ariaLabel="报告问题"
onClose={onClose}
panelClassName="error-report-dialog"
>
<header className="error-report-dialog__header">
<div>
<h2></h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">
×
</button>
</header>
<div className="error-report-dialog__body">
<section>
<strong>{events.length}</strong>
{events.length ? (
<ul className="error-report-dialog__events">
{events.map((event) => (
<li key={event.fingerprint}>
<span>{event.message}</span>
<small>
{event.source} · {event.count}
</small>
</li>
))}
</ul>
) : (
<p></p>
)}
</section>
<label>
<textarea
maxLength={2000}
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="刚刚做了什么?"
/>
</label>
<label className="error-report-dialog__logs">
<input
type="checkbox"
checked={includeLogs}
onChange={(event) => setIncludeLogs(event.target.checked)}
/>
<span>{logs.length} </span>
</label>
<p className="error-report-dialog__consent">
</p>
{status ? <p role="status">{status}</p> : null}
</div>
<footer className="error-report-dialog__actions">
<button type="button" onClick={onClose} disabled={busy}>
</button>
<button
type="button"
onClick={() => void submit()}
disabled={busy || !events.length}
>
{busy ? '提交中…' : '提交报告'}
</button>
</footer>
</ThemedModal>
);
}
@@ -75,7 +75,7 @@ export function summarizeNextProjectActions(
addSuggestion('查看隐私与导出边界', '/privacy');
addSuggestion('查看首批试玩对象', '/audience');
addSuggestion('准备试玩邀请文案', '/invite');
addSuggestion('准备缺陷复现记录', '/bug-report');
addSuggestion('报告问题', '/bug-report');
addSuggestion('准备试玩问卷问题', '/survey');
addSuggestion('准备封面与缩略图检查', '/cover');
addSuggestion('准备宣传截图清单', '/screenshots');
@@ -123,7 +123,7 @@ export const chatCommandHelp = [
'/privacy:查看隐私与导出边界',
'/audience:查看首批试玩对象',
'/invite:准备试玩邀请文案',
'/bug-report准备缺陷复现记录',
'/bug-report报告问题',
'/survey:准备试玩问卷问题',
'/cover:准备封面与缩略图检查',
'/screenshots:准备宣传截图清单',
@@ -19,7 +19,6 @@ import {
summarizeProjectBalanceState,
summarizeProjectBlockers,
summarizeProjectBrief,
summarizeProjectBugReport,
summarizeProjectCommunityPost,
summarizeProjectCompatibilityNotes,
summarizeProjectContextSources,
@@ -507,19 +506,16 @@ export function handleProjectSummaryChatCommand({
}
if (prompt === '/bug-report') {
if (!requireChatProjectForUserAction()) {
return true;
}
const summary = summarizeProjectBugReport(manifest, agentRunTrace);
setMessages((current) => [
...current,
{
role: 'assistant',
text: summary.text,
draftCommand: summary.draftCommand,
draftCommandLabel: summary.draftCommandLabel,
text: '已打开“报告问题”面板,请填写遇到的问题。',
},
]);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('agc-open-error-report'));
}
return true;
}
@@ -9,6 +9,7 @@ import {
unwrapApiResponse,
} from '../../../../packages/shared/src';
import { fetchClientHttp } from './clientHttp';
import { captureClientError } from './errorReporting';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
@@ -77,13 +78,23 @@ export async function requestClientApi<T>(
credentials: 'same-origin',
headers,
});
} catch {
} catch (error) {
void captureClientError(error, {
source: 'api',
action: url.split('?')[0],
});
throw new ClientAuthRequestError(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
{ networkError: true },
);
}
if (!response.ok) {
void captureClientError(
new ClientAuthRequestError(`HTTP ${response.status}`, {
status: response.status,
}),
{ source: 'api', action: url.split('?')[0] },
);
throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage),
{ status: response.status },
@@ -116,13 +127,23 @@ export async function requestClientApiBytes(
credentials: 'same-origin',
headers,
});
} catch {
} catch (error) {
void captureClientError(error, {
source: 'api',
action: url.split('?')[0],
});
throw new ClientAuthRequestError(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
{ networkError: true },
);
}
if (!response.ok) {
void captureClientError(
new ClientAuthRequestError(`HTTP ${response.status}`, {
status: response.status,
}),
{ source: 'api', action: url.split('?')[0] },
);
throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage),
{ status: response.status },
@@ -0,0 +1,161 @@
import { invoke } from '@tauri-apps/api/core';
import { getStoredAuthAccessToken } from './clientAuth';
import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp';
export type ClientErrorEvent = {
eventId: string;
fingerprint: string;
source: string;
message: string;
stack?: string;
occurredAt: string;
count: number;
};
export type DiagnosticLogFile = { name: string; content: string };
const pending = new Map<string, ClientErrorEvent>();
const listeners = new Set<() => void>();
const MAX_EVENTS = 100;
function notify() {
listeners.forEach((listener) => listener());
}
function normalizeMessage(value: string) {
return value
.replace(/https?:\/\/\S+/giu, '<url>')
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>')
.replace(/\d{2,}/gu, '<n>')
.trim()
.slice(0, 512);
}
async function sha256(value: string) {
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
return normalizeMessage(value);
}
export async function captureClientError(
error: unknown,
context: { source?: string; action?: string; page?: string } = {},
) {
const errorValue = error instanceof Error ? error : new Error(String(error));
const message = normalizeMessage(errorValue.message || '未知客户端错误');
const stack = errorValue.stack?.slice(0, 8_000);
// 只用栈的首行参与指纹,避免同一错误因调用位置行号变化而无法合并。
const fingerprint = await sha256(
[context.source ?? 'client', message, stack?.split('\n')[0]]
.filter(Boolean)
.join('|'),
);
const existing = pending.get(fingerprint);
const now = new Date().toISOString();
if (existing) {
existing.count += 1;
existing.occurredAt = now;
notify();
return existing;
}
const event: ClientErrorEvent = {
eventId: `client-error-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
fingerprint,
source: context.source ?? 'client',
message,
stack,
occurredAt: now,
count: 1,
};
if (pending.size >= MAX_EVENTS) {
const oldest = pending.keys().next().value;
if (oldest) pending.delete(oldest);
}
pending.set(fingerprint, event);
try {
await invoke('append_diagnostic_event', { event });
} catch {
// 浏览器预览和未初始化 Tauri 时仍保留内存事件;不得阻断主流程。
}
notify();
return event;
}
export function getPendingClientErrorEvents() {
return Array.from(pending.values());
}
export function subscribeClientErrorEvents(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function readApplicationDiagnosticLogs(): Promise<
DiagnosticLogFile[]
> {
try {
return await invoke<DiagnosticLogFile[]>('read_diagnostic_logs');
} catch {
return [];
}
}
export async function submitErrorReportBatch(
payload: {
events: ClientErrorEvent[];
userDescription?: string;
logs: DiagnosticLogFile[];
},
apiBaseUrl = getClientServerBaseUrl(),
) {
const token = getStoredAuthAccessToken();
if (!token) throw new Error('请先登录后再提交错误报告');
const response = await fetchClientHttp(
'/api/error-reports',
{
method: 'POST',
credentials: 'same-origin',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
schemaVersion: 1,
submissionId:
globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}`,
events: payload.events,
userDescription: payload.userDescription?.trim() || null,
logs: payload.logs,
}),
},
{ serverBaseUrl: apiBaseUrl },
);
if (!response.ok) {
throw new Error(`错误报告提交失败(${response.status}`);
}
const result = (await response.json()) as { data?: unknown };
return result.data ?? result;
}
export function markClientErrorEventsSubmitted(events: ClientErrorEvent[]) {
for (const event of events) pending.delete(event.fingerprint);
notify();
}
/** 仅供单元测试隔离进程内错误池;生产流程不调用。 */
export function resetClientErrorEventsForTests() {
pending.clear();
notify();
}
+32
View File
@@ -671,6 +671,38 @@ textarea {
overflow-wrap: anywhere;
}
.error-report-dialog {
width: min(560px, calc(100vw - 2rem));
max-height: min(760px, calc(100vh - 2rem));
overflow: auto;
border-radius: 18px;
padding: 20px;
box-shadow: 0 24px 80px rgb(36 20 12 / 22%);
}
.error-report-dialog__header,
.error-report-dialog__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.error-report-dialog__header h2 { margin: 0; }
.error-report-dialog__header p,
.error-report-dialog__consent { margin: 4px 0 0; color: var(--platform-text-soft); font-size: 13px; }
.error-report-dialog__header > button { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
.error-report-dialog__body { display: grid; gap: 16px; margin: 20px 0; }
.error-report-dialog__events { display: grid; gap: 8px; padding: 0; margin: 8px 0 0; list-style: none; }
.error-report-dialog__events li { display: grid; gap: 3px; border: 1px solid var(--platform-border-subtle); border-radius: 10px; padding: 10px; }
.error-report-dialog__events small { color: var(--platform-text-soft); }
.error-report-dialog__body label { display: grid; gap: 6px; font-size: 13px; font-weight: 600; }
.error-report-dialog__body textarea { min-height: 100px; resize: vertical; border: 1px solid var(--platform-border-subtle); border-radius: 10px; padding: 10px; font: inherit; }
.error-report-dialog__logs { display: flex !important; grid-template-columns: auto 1fr; align-items: center; }
.error-report-dialog__actions { justify-content: flex-end; }
.error-report-dialog__actions button { border: 0; border-radius: 9px; padding: 9px 14px; cursor: pointer; }
.error-report-dialog__actions button:last-child { background: var(--platform-button-primary-fill); color: var(--platform-button-primary-text); }
@media (max-width: 480px) {
.client-auth-shell {
padding: 16px;
@@ -5072,7 +5072,7 @@ export function registerDeveloperToolsTests() {
expect(screen.getByText(/\/privacy:查看隐私与导出边界/)).not.toBeNull();
expect(screen.getByText(/\/audience:查看首批试玩对象/)).not.toBeNull();
expect(screen.getByText(/\/invite:准备试玩邀请文案/)).not.toBeNull();
expect(screen.getByText(/\/bug-report准备缺陷复现记录/)).not.toBeNull();
expect(screen.getByText(/\/bug-report报告问题/)).not.toBeNull();
expect(screen.getByText(/\/survey:准备试玩问卷问题/)).not.toBeNull();
expect(screen.getByText(/\/cover:准备封面与缩略图检查/)).not.toBeNull();
expect(screen.getByText(/\/screenshots:准备宣传截图清单/)).not.toBeNull();
@@ -46,7 +46,7 @@ export async function assertPlaytestAndReleaseShortcutFlow(
expect(screen.getByText(/查看隐私与导出边界:\/privacy/)).not.toBeNull();
expect(screen.getByText(/查看首批试玩对象:\/audience/)).not.toBeNull();
expect(screen.getByText(/准备试玩邀请文案:\/invite/)).not.toBeNull();
expect(screen.getByText(/准备缺陷复现记录\/bug-report/)).not.toBeNull();
expect(screen.getByText(/报告问题\/bug-report/)).not.toBeNull();
expect(screen.getByText(/准备试玩问卷问题:\/survey/)).not.toBeNull();
expect(screen.getByText(/准备封面与缩略图检查:\/cover/)).not.toBeNull();
expect(screen.getByText(/准备宣传截图清单:\/screenshots/)).not.toBeNull();
@@ -648,39 +648,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
).length,
};
submitChat('/bug-report');
const bugReportMessages = await screen.findAllByText(/缺陷记录:/);
const bugReportMessage = bugReportMessages[bugReportMessages.length - 1];
expect(bugReportMessage.textContent).toContain('项目:未命名游戏原型');
expect(bugReportMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
expect(bugReportMessage.textContent).toContain(
'复现入口:预览 未启动 · 建议 /run',
);
expect(bugReportMessage.textContent).toContain('最近试玩证据:暂无');
expect(bugReportMessage.textContent).toContain(
'记录模板:问题一句话;复现步骤 1/2/3;期望结果;实际结果;设备/输入方式;严重度 阻断/高/中/低;附件 截图/录屏/日志时间点',
);
expect(bugReportMessage.textContent).toContain(
'优先级口径:阻断无法进入首局;高影响胜负或重开;中影响理解或手感;低为包装和文字问题',
);
expect(bugReportMessage.textContent).toContain(
'转修复草稿:/agent-resume 缺陷修复:现象…;复现…;期望…;实际…',
);
expect(bugReportMessage.textContent).toContain(
'参考:/test-plan/feedback/review/logs',
);
expect(bugReportMessage.textContent).toContain(
'边界:只准备缺陷记录模板;不读取文件;不启动或打开预览;不导出试玩包;不写项目',
);
expect(bugReportMessage.textContent).toContain('建议:/run');
const bugReportMessageList = document.querySelector('.message-list');
expect(bugReportMessageList).not.toBeNull();
const bugReportDraftButtons = within(
bugReportMessageList as HTMLElement,
).getAllByRole('button', { name: '启动试玩' });
fireEvent.click(bugReportDraftButtons[bugReportDraftButtons.length - 1]);
expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run');
expect(
await screen.findByText('已打开“报告问题”面板,请填写遇到的问题。'),
).toBeTruthy();
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
@@ -0,0 +1,79 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn().mockResolvedValue(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 { fetchClientHttp } from '../src/services/clientHttp';
import {
captureClientError,
getPendingClientErrorEvents,
markClientErrorEventsSubmitted,
resetClientErrorEventsForTests,
submitErrorReportBatch,
} from '../src/services/errorReporting';
describe('客户端错误报告池', () => {
afterEach(() => {
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(getPendingClientErrorEvents()).toHaveLength(1);
expect(getPendingClientErrorEvents()[0]?.count).toBe(2);
});
it('限制当前进程错误池最多保留 100 条', async () => {
for (let index = 0; index < 101; index += 1) {
await captureClientError(new Error(`错误 ${index}`), { source: 'test' });
}
expect(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: [] });
markClientErrorEventsSubmitted([first]);
expect(getPendingClientErrorEvents()).toEqual([second]);
});
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();
});
});