feat(logging): capture frontend errors to disk with structured redaction

Frontend half of the logging overhaul.

Capture:
- Add a global error boundary plus window error/unhandledrejection
  handlers that persist renderer errors via tauri-plugin-log (new
  @tauri-apps/plugin-log dependency + log:default capability), so a
  white-screen crash leaves a trace.

Redaction (two layers over every logged value):
- Structured serializer: redact by sensitive property name (arrays and
  nested values included) and by opaque value shape.
- Text layer, the universal final pass: redact URL credentials/query,
  auth schemes, known secret shapes, and named-key array/object values.
  The container rule closes prefix+JSON, double-encoded, and POJO-error
  bypasses regardless of which entrypoint the structured data came from.
- Render Errors as redacted message + native stack across V8 and WebKit
  (macOS/Linux) without depending on a single stack format.
- Bound all inputs and drop oversized JSON to keep the UI responsive.

i18n:
- zh / en / ja / zh-TW for the new log settings and error text.
This commit is contained in:
Jason
2026-07-16 10:21:00 +08:00
parent 22d2872c33
commit 6274705848
12 changed files with 853 additions and 46 deletions
+1
View File
@@ -70,6 +70,7 @@
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.8.0", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-log": "2.8.0",
"@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0", "@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0",
+10
View File
@@ -98,6 +98,9 @@ importers:
'@tauri-apps/plugin-dialog': '@tauri-apps/plugin-dialog':
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.4.0 version: 2.4.0
'@tauri-apps/plugin-log':
specifier: 2.8.0
version: 2.8.0
'@tauri-apps/plugin-process': '@tauri-apps/plugin-process':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.3.0 version: 2.3.0
@@ -1562,6 +1565,9 @@ packages:
'@tauri-apps/plugin-dialog@2.4.0': '@tauri-apps/plugin-dialog@2.4.0':
resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==} resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==}
'@tauri-apps/plugin-log@2.8.0':
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
'@tauri-apps/plugin-process@2.3.0': '@tauri-apps/plugin-process@2.3.0':
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
@@ -4348,6 +4354,10 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.8.0 '@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-log@2.8.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-process@2.3.0': '@tauri-apps/plugin-process@2.3.0':
dependencies: dependencies:
'@tauri-apps/api': 2.8.0 '@tauri-apps/api': 2.8.0
+3 -4
View File
@@ -2,13 +2,12 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "enables the default permissions", "description": "enables the default permissions",
"windows": [ "windows": ["main"],
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"updater:default", "updater:default",
"log:default",
"core:window:allow-set-skip-taskbar", "core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-minimize", "core:window:allow-minimize",
@@ -19,4 +18,4 @@
"process:allow-restart", "process:allow-restart",
"dialog:default" "dialog:default"
] ]
} }
+66
View File
@@ -0,0 +1,66 @@
import React from "react";
import { RefreshCw, TriangleAlert } from "lucide-react";
import i18n from "@/i18n";
import { reportFrontendError } from "@/lib/frontendLogger";
import { Button } from "@/components/ui/button";
interface FrontendErrorBoundaryState {
hasError: boolean;
}
export class FrontendErrorBoundary extends React.Component<
React.PropsWithChildren,
FrontendErrorBoundaryState
> {
state: FrontendErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): FrontendErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
reportFrontendError(
"react.error_boundary",
error,
info.componentStack ?? undefined,
);
}
render(): React.ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
<section
role="alert"
className="w-full max-w-md space-y-5 rounded-lg border border-border bg-card p-6 shadow-sm"
>
<div className="flex items-start gap-3">
<TriangleAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="space-y-1.5">
<h1 className="text-base font-semibold">
{i18n.t("errors.frontendCrashTitle", {
defaultValue: "界面遇到了问题",
})}
</h1>
<p className="text-sm leading-6 text-muted-foreground">
{i18n.t("errors.frontendCrashMessage", {
defaultValue:
"已尝试将错误信息写入应用诊断日志。请重新加载界面;如果问题持续,请在提交 Issue 时附上日志。",
})}
</p>
</div>
</div>
<Button className="w-full" onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 size-4" />
{i18n.t("errors.reloadInterface", {
defaultValue: "重新加载界面",
})}
</Button>
</section>
</main>
);
}
}
+10 -7
View File
@@ -397,10 +397,10 @@
"cacheInjectionDescription": "Automatically inject standard 5-minute cache breakpoints at key positions to reduce duplicate token billing" "cacheInjectionDescription": "Automatically inject standard 5-minute cache breakpoints at key positions to reduce duplicate token billing"
}, },
"logConfig": { "logConfig": {
"title": "Log Management", "title": "Application Diagnostic Logs",
"description": "Control log output level", "description": "Control the output level for application diagnostic logs",
"enabled": "Enable Logging", "enabled": "Enable Diagnostic Logs",
"enabledDescription": "Master switch, all logging will be disabled when turned off", "enabledDescription": "Controls cc-switch.log; request usage records and crash.log are unaffected",
"level": "Log Level", "level": "Log Level",
"levelDescription": "Set the minimum log level to output", "levelDescription": "Set the minimum log level to output",
"levels": { "levels": {
@@ -1701,7 +1701,10 @@
"errors": { "errors": {
"usage_query_failed": "Usage query failed", "usage_query_failed": "Usage query failed",
"configLoadFailedTitle": "Configuration Load Failed", "configLoadFailedTitle": "Configuration Load Failed",
"configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this." "configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this.",
"frontendCrashTitle": "Something went wrong in the interface",
"frontendCrashMessage": "An attempt was made to write the error to the application diagnostic log. Reload the interface, and attach the log when opening an issue if the problem continues.",
"reloadInterface": "Reload interface"
}, },
"presetSelector": { "presetSelector": {
"title": "Select Configuration Type", "title": "Select Configuration Type",
@@ -2533,8 +2536,8 @@
"description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)" "description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
}, },
"enableLogging": { "enableLogging": {
"label": "Enable Logging", "label": "Record Request Usage",
"description": "Log all routing requests for troubleshooting" "description": "Write routing request usage and status to the local statistics database"
}, },
"streamingFirstByteTimeout": { "streamingFirstByteTimeout": {
"label": "Streaming First Byte Timeout (sec)", "label": "Streaming First Byte Timeout (sec)",
+10 -7
View File
@@ -397,10 +397,10 @@
"cacheInjectionDescription": "リクエストの重要な位置に標準の 5 分間 Cache ブレークポイントを自動注入し、重複トークンの課金を削減" "cacheInjectionDescription": "リクエストの重要な位置に標準の 5 分間 Cache ブレークポイントを自動注入し、重複トークンの課金を削減"
}, },
"logConfig": { "logConfig": {
"title": "ログ管理", "title": "アプリ診断ログ",
"description": "ログ出力レベルを制御", "description": "アプリ診断ログ出力レベルを制御",
"enabled": "ログを有効化", "enabled": "診断ログを有効化",
"enabledDescription": "マスタースイッチ、オフにするとすべてのログが無効になります", "enabledDescription": "cc-switch.log を制御します。リクエスト使用量記録と crash.log には影響しません",
"level": "ログレベル", "level": "ログレベル",
"levelDescription": "出力する最小ログレベルを設定", "levelDescription": "出力する最小ログレベルを設定",
"levels": { "levels": {
@@ -1701,7 +1701,10 @@
"errors": { "errors": {
"usage_query_failed": "利用状況の取得に失敗しました", "usage_query_failed": "利用状況の取得に失敗しました",
"configLoadFailedTitle": "設定の読み込みに失敗しました", "configLoadFailedTitle": "設定の読み込みに失敗しました",
"configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。" "configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。",
"frontendCrashTitle": "画面で問題が発生しました",
"frontendCrashMessage": "エラー情報をアプリ診断ログに保存しようとしました。画面を再読み込みし、問題が続く場合は Issue にログを添付してください。",
"reloadInterface": "画面を再読み込み"
}, },
"presetSelector": { "presetSelector": {
"title": "設定タイプを選択", "title": "設定タイプを選択",
@@ -2533,8 +2536,8 @@
"description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)" "description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
}, },
"enableLogging": { "enableLogging": {
"label": "ログ記録を有効化", "label": "リクエスト使用量を記録",
"description": "トラブルシューティングのためにすべてのルーティングリクエストを記録" "description": "ルーティングリクエストの使用量と状態をローカル統計データベースに保存"
}, },
"streamingFirstByteTimeout": { "streamingFirstByteTimeout": {
"label": "ストリーミング初回バイトタイムアウト(秒)", "label": "ストリーミング初回バイトタイムアウト(秒)",
+10 -7
View File
@@ -397,10 +397,10 @@
"cacheInjectionDescription": "自動在請求關鍵位置注入標準 5 分鐘 Cache 斷點,減少重複 token 計費" "cacheInjectionDescription": "自動在請求關鍵位置注入標準 5 分鐘 Cache 斷點,減少重複 token 計費"
}, },
"logConfig": { "logConfig": {
"title": "日誌管理", "title": "應用程式診斷日誌",
"description": "控制日誌輸出層級", "description": "控制應用程式診斷日誌輸出層級",
"enabled": "啟用日誌", "enabled": "啟用應用程式診斷日誌",
"enabledDescription": "總開關,關閉後所有日誌將被停用", "enabledDescription": "控制 cc-switch.log;不影響請求用量記錄和 crash.log",
"level": "日誌層級", "level": "日誌層級",
"levelDescription": "設定輸出的最低日誌層級", "levelDescription": "設定輸出的最低日誌層級",
"levels": { "levels": {
@@ -1673,7 +1673,10 @@
"errors": { "errors": {
"usage_query_failed": "用量查詢失敗", "usage_query_failed": "用量查詢失敗",
"configLoadFailedTitle": "設定載入失敗", "configLoadFailedTitle": "設定載入失敗",
"configLoadFailedMessage": "無法讀取設定檔:\n{{path}}\n\n錯誤詳情:\n{{detail}}\n\n請手動檢查 JSON 是否有效,或從同目錄的備份檔案(如 config.json.bak)還原。\n\n應用程式將退出以便您進行修復。" "configLoadFailedMessage": "無法讀取設定檔:\n{{path}}\n\n錯誤詳情:\n{{detail}}\n\n請手動檢查 JSON 是否有效,或從同目錄的備份檔案(如 config.json.bak)還原。\n\n應用程式將退出以便您進行修復。",
"frontendCrashTitle": "介面遇到了問題",
"frontendCrashMessage": "已嘗試將錯誤資訊寫入應用程式診斷日誌。請重新載入介面;如果問題持續,請在提交 Issue 時附上日誌。",
"reloadInterface": "重新載入介面"
}, },
"presetSelector": { "presetSelector": {
"title": "選擇設定類型", "title": "選擇設定類型",
@@ -2505,8 +2508,8 @@
"description": "單個請求的最大等待時間(0 表示不限制,或設定 10 ~ 600 秒)" "description": "單個請求的最大等待時間(0 表示不限制,或設定 10 ~ 600 秒)"
}, },
"enableLogging": { "enableLogging": {
"label": "啟用日誌紀錄", "label": "記錄請求用量",
"description": "紀錄所有路由請求,便於排查問題" "description": "將路由請求的用量與狀態寫入本機統計資料庫"
}, },
"streamingFirstByteTimeout": { "streamingFirstByteTimeout": {
"label": "串流首位元組逾時(秒)", "label": "串流首位元組逾時(秒)",
+10 -7
View File
@@ -397,10 +397,10 @@
"cacheInjectionDescription": "自动在请求关键位置注入标准 5 分钟 Cache 断点,减少重复 token 计费" "cacheInjectionDescription": "自动在请求关键位置注入标准 5 分钟 Cache 断点,减少重复 token 计费"
}, },
"logConfig": { "logConfig": {
"title": "日志管理", "title": "应用诊断日志",
"description": "控制日志输出级别", "description": "控制应用诊断日志输出级别",
"enabled": "启用日志", "enabled": "启用应用诊断日志",
"enabledDescription": "总开关,关闭后所有日志将被禁用", "enabledDescription": "控制 cc-switch.log;不影响请求用量记录和 crash.log",
"level": "日志级别", "level": "日志级别",
"levelDescription": "设置输出的最低日志级别", "levelDescription": "设置输出的最低日志级别",
"levels": { "levels": {
@@ -1701,7 +1701,10 @@
"errors": { "errors": {
"usage_query_failed": "用量查询失败", "usage_query_failed": "用量查询失败",
"configLoadFailedTitle": "配置加载失败", "configLoadFailedTitle": "配置加载失败",
"configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。" "configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。",
"frontendCrashTitle": "界面遇到了问题",
"frontendCrashMessage": "已尝试将错误信息写入应用诊断日志。请重新加载界面;如果问题持续,请在提交 Issue 时附上日志。",
"reloadInterface": "重新加载界面"
}, },
"presetSelector": { "presetSelector": {
"title": "选择配置类型", "title": "选择配置类型",
@@ -2533,8 +2536,8 @@
"description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)" "description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
}, },
"enableLogging": { "enableLogging": {
"label": "启用日志记录", "label": "记录请求用量",
"description": "记录所有路由请求,便于排查问题" "description": "将路由请求的用量与状态写入本地统计数据库"
}, },
"streamingFirstByteTimeout": { "streamingFirstByteTimeout": {
"label": "流式首字超时(秒)", "label": "流式首字超时(秒)",
+345
View File
@@ -0,0 +1,345 @@
import { error as writeErrorLog } from "@tauri-apps/plugin-log";
const MAX_LOG_MESSAGE_LENGTH = 12_000;
const MAX_RAW_LOG_INPUT_LENGTH = 16_000;
const MAX_SERIALIZED_STRING_LENGTH = 2_000;
const MAX_SERIALIZED_ENTRIES = 32;
const MAX_SERIALIZED_TOTAL_VALUES = 64;
const MAX_SERIALIZATION_DEPTH = 4;
const QUERY_VALUE_PATTERN = /([?&][A-Za-z0-9_.~-]+)=([^&#\s"'<>]*)/g;
const URL_CREDENTIAL_PATTERN = /(https?:\/\/)[^/@\s]+@/gi;
const QUOTED_NAMED_SECRET_PATTERN =
/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|auth|password|passwd|pwd|secret|cookie)\s*["']?\s*[:=]\s*)(["'])(.*?)\2/gi;
const NAMED_SECRET_PATTERN =
/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|auth|password|passwd|pwd|secret|cookie)\s*["']?\s*[:=]\s*["']?)([^\s"',}]+)/gi;
// 敏感键的值是数组/对象(`"tokens":[...]`、`"auth":{...}`)时,标量正则够不着里面的元素。
// 文本层是所有入口(Error/string/对象/嵌套/前缀+JSON)最终汇聚的唯一出口,故在这里
// 兜底:命中敏感键名后把紧跟的 `[..]`/`{..}` 整体替换。`\b` 防止匹配到 monkey 之类的后缀,
// `(?:\\?["'])?` 同时兼容裸引号与转义引号(双重编码 JSON 里的 `\"tokens\"`)。
const NAMED_SECRET_CONTAINER_PATTERN =
/((?:\\?["'])?\b(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|session[_-]?id|authorization|credential|password|passwd|bearer|cookie|secret|token|auth|pwd|key)s?(?:\\?["'])?\s*[:=]\s*)(\[[^\]]*\]|\{[^{}]*\})/gi;
const SENSITIVE_HEADER_LINE_PATTERN =
/(^|[\r\n])([ \t]*(?:(?:proxy-)?authorization|cookie|set-cookie|x-api-key|api-key)\s*[:=]\s*)[^\r\n]+/gim;
const AUTH_SCHEME_PATTERN =
/\b(Bearer|Basic|Token|ApiKey|Digest|Negotiate|AWS4-HMAC-SHA256)\s+[^\s"',}\]]+/gi;
const SECRET_VALUE_IN_TEXT_PATTERN =
/(^|[^A-Za-z0-9]|\\[nrt])(?:sk-[A-Za-z0-9._~+\/-]{6,}|AIza[A-Za-z0-9_-]{8,}|github_pat_[A-Za-z0-9_]{6,}|gh[pousr]_[A-Za-z0-9_]{6,}|xox[baprs]-[A-Za-z0-9-]{6,}|ya29\.[A-Za-z0-9._-]{6,}|(?:AKIA|ASIA)[A-Z0-9]{12,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/gim;
const SECRET_VALUE_WITHIN_STRING_PATTERN =
/(?:sk-[A-Za-z0-9._~+\/-]{6,}|AIza[A-Za-z0-9_-]{8,}|github_pat_[A-Za-z0-9_]{6,}|gh[pousr]_[A-Za-z0-9_]{6,}|xox[baprs]-[A-Za-z0-9-]{6,}|ya29\.[A-Za-z0-9._-]{6,}|(?:AKIA|ASIA)[A-Z0-9]{12,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
function truncateForProcessing(input: string, limit: number): string {
if (input.length <= limit) {
return input;
}
return `${input.slice(0, limit)}\n[input truncated]`;
}
export function redactFrontendLogText(input: string): string {
return input
.replace(QUERY_VALUE_PATTERN, "$1=[REDACTED]")
.replace(URL_CREDENTIAL_PATTERN, "$1[REDACTED]@")
.replace(SENSITIVE_HEADER_LINE_PATTERN, "$1$2[REDACTED]")
.replace(AUTH_SCHEME_PATTERN, "$1 [REDACTED]")
.replace(SECRET_VALUE_IN_TEXT_PATTERN, "$1[REDACTED]")
.replace(NAMED_SECRET_CONTAINER_PATTERN, "$1[REDACTED]")
.replace(QUOTED_NAMED_SECRET_PATTERN, "$1$2[REDACTED]$2")
.replace(NAMED_SECRET_PATTERN, "$1[REDACTED]");
}
function looksLikeSecretValue(value: string): boolean {
const trimmed = value.trim();
if (SECRET_VALUE_WITHIN_STRING_PATTERN.test(trimmed)) {
return true;
}
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(trimmed)) {
return true;
}
// Unknown opaque credentials: keep this only in the structured serializer,
// where a false positive costs diagnostics but cannot alter application data.
return (
trimmed.length >= 32 &&
/^[A-Za-z0-9._~+/=-]+$/.test(trimmed) &&
/[A-Za-z]/.test(trimmed) &&
/\d/.test(trimmed)
);
}
// 结构化对象里,命中这些属性名即认定其整个值(标量/数组/对象)为敏感并整体隐藏。
// 存单数形式,查表前去掉尾部 s,让复数(tokens/apiKeys/credentials)自动覆盖,
// 不必逐个枚举——正则文本层只能匹配 `"name":"value"` 标量,抓不到数组/嵌套。
const SENSITIVE_KEY_NAMES = new Set([
"key",
"apikey",
"accesskey",
"secretkey",
"privatekey",
"clientsecret",
"token",
"authtoken",
"accesstoken",
"refreshtoken",
"idtoken",
"sessiontoken",
"sessionid",
"authorization",
"auth",
"bearer",
"password",
"passwd",
"pwd",
"secret",
"credential",
"cookie",
]);
function isSensitiveKey(key: string): boolean {
const normalized = key
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.replace(/s$/, "");
return SENSITIVE_KEY_NAMES.has(normalized);
}
function normalizeForSerialization(
value: unknown,
depth: number,
ancestors: WeakSet<object>,
budget: { remaining: number },
): unknown {
if (budget.remaining <= 0) {
return "[Serialization budget exhausted]";
}
budget.remaining -= 1;
if (typeof value === "string") {
// 值一级脱敏:对“看起来像密钥”的不透明串整体隐藏。命名字段(apiKey/token/...)
// 由上层 isSensitiveKey 按属性名整体隐藏;文本里的裸密钥再由 redactFrontendLogText 兜底。
if (looksLikeSecretValue(value)) {
return "[REDACTED]";
}
return truncateForProcessing(value, MAX_SERIALIZED_STRING_LENGTH);
}
if (
value == null ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (typeof value === "bigint") {
return `${value}n`;
}
if (typeof value === "symbol") {
return String(value);
}
if (typeof value === "function") {
return `[Function ${value.name || "anonymous"}]`;
}
if (typeof value !== "object") {
return String(value);
}
if (ancestors.has(value)) {
return "[Circular]";
}
if (depth >= MAX_SERIALIZATION_DEPTH) {
return "[Object: max depth reached]";
}
ancestors.add(value);
try {
if (Array.isArray(value)) {
const items: unknown[] = [];
for (const item of value.slice(0, MAX_SERIALIZED_ENTRIES)) {
if (budget.remaining <= 0) {
break;
}
items.push(
normalizeForSerialization(item, depth + 1, ancestors, budget),
);
}
if (value.length > items.length) {
items.push(`[${value.length - items.length} more items]`);
}
return items;
}
const output: Record<string, unknown> = {};
const keys = Object.keys(value);
for (const key of keys.slice(0, MAX_SERIALIZED_ENTRIES)) {
if (budget.remaining <= 0) {
output["[truncated]"] = "Serialization budget exhausted";
break;
}
try {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor?.get) {
output[key] = "[Getter omitted]";
continue;
}
if (isSensitiveKey(key)) {
// 敏感属性名 → 整个值(含数组/对象)一律隐藏,不递归、不猜形状。
output[key] = "[REDACTED]";
continue;
}
output[key] = normalizeForSerialization(
descriptor?.value,
depth + 1,
ancestors,
budget,
);
} catch {
output[key] = "[Property access failed]";
}
}
if (keys.length > MAX_SERIALIZED_ENTRIES) {
output["[truncated]"] =
`${keys.length - MAX_SERIALIZED_ENTRIES} more properties`;
}
return output;
} finally {
ancestors.delete(value);
}
}
function serializeStructured(value: unknown): string | null {
try {
const serialized = JSON.stringify(
normalizeForSerialization(value, 0, new WeakSet(), {
remaining: MAX_SERIALIZED_TOTAL_VALUES,
}),
);
return serialized ?? null;
} catch {
return null;
}
}
// 结构化数据可能以“字符串形态的 JSON”混进来(Promise.reject(JSON.stringify(...))、
// throw new Error(JSON.stringify(...)))。不还原结构就只剩文本正则,够不着数组/嵌套字段。
// 命中 JSON 结构则 parse 后走属性级脱敏;非 JSON 返回 null 交调用方按普通文本处理。
function redactStructuredString(text: string): string | null {
const trimmed = text.trim();
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) {
return null;
}
if (text.length > MAX_RAW_LOG_INPUT_LENGTH) {
// 合法的超大 JSON 一旦截断必成非法 JSON,会退回够不着数组字段的文本正则并泄漏;
// 也不冒险 parse 多 MiB 输入阻塞 UI。检出 JSON-like 且超限即整体丢弃。
return "[oversized structured error omitted]";
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
// 标量 JSON(如 "42")无字段可脱,交给文本层。
if (parsed === null || typeof parsed !== "object") {
return null;
}
return serializeStructured(parsed) ?? "[Unserializable structured error]";
}
// 把 Error 渲染成“脱敏 message + 原生调用栈”,与浏览器引擎的 stack 格式无关:
// - V8/Chromium(Windows WebView2)stack 首行内嵌 message → 全局字面量替换成脱敏版;
// - WebKit/JSC(macOS/Linux WKWebView)、SpiderMonkeystack 是纯栈帧、不含 message → 补脱敏头。
// 不识别 ` at ` / `@` 等引擎特有格式(枚举不完,正是它把 WebKit 栈整段丢了),只按
// “message 是否出现在 stack 里”分流,从而各平台都保留原生调用栈,且绝不残留未脱敏 message。
function renderRedactedError(error: Error, structuredMessage: string): string {
const head = `${error.name}: ${structuredMessage}`;
const stack = error.stack;
if (!stack) {
return head;
}
if (error.message && stack.includes(error.message)) {
// V8message 内嵌在 stack —— 全局字面量替换(split/join 换掉所有出现),栈帧原样保留。
return stack.split(error.message).join(structuredMessage);
}
// WebKit/Firefoxstack 不含 message —— 纯栈帧前补一个脱敏 message 头。
return `${head}\n${stack}`;
}
function describeError(error: unknown): string {
if (error instanceof Error) {
// throw new Error(JSON.stringify(payload)) 很常见:凭据会藏进 message,而 V8 的
// stack 首行就是原始 message。先对 message 按 JSON 结构化脱敏,命中则渲染成
// “脱敏 message + 原生栈”,避免 stack 把未脱敏的 message 直接吐出去。
const structuredMessage = redactStructuredString(error.message);
if (structuredMessage !== null) {
return truncateForProcessing(
renderRedactedError(error, structuredMessage),
MAX_RAW_LOG_INPUT_LENGTH,
);
}
return truncateForProcessing(
error.stack || `${error.name}: ${error.message}`,
MAX_RAW_LOG_INPUT_LENGTH,
);
}
if (typeof error === "string") {
const structured = redactStructuredString(error);
if (structured !== null) {
return structured;
}
return truncateForProcessing(error, MAX_RAW_LOG_INPUT_LENGTH);
}
if (error == null) {
return String(error);
}
const structured = serializeStructured(error);
if (structured === null) {
return "[Unserializable thrown value]";
}
return truncateForProcessing(structured, MAX_RAW_LOG_INPUT_LENGTH);
}
export function reportFrontendError(
context: string,
error: unknown,
details?: string,
): void {
// 先限制每段原始输入,再执行全局正则,避免异常携带多 MiB 文本时阻塞 UI。
const raw = truncateForProcessing(
[
`[frontend] ${truncateForProcessing(context, MAX_RAW_LOG_INPUT_LENGTH)}`,
describeError(error),
details
? truncateForProcessing(details, MAX_RAW_LOG_INPUT_LENGTH).trim()
: undefined,
]
.filter(Boolean)
.join("\n"),
MAX_RAW_LOG_INPUT_LENGTH,
);
const redacted = redactFrontendLogText(raw);
const message =
redacted.length > MAX_LOG_MESSAGE_LENGTH
? `${redacted.slice(0, MAX_LOG_MESSAGE_LENGTH)}\n[truncated]`
: redacted;
// Web 开发/测试环境没有 Tauri invoke,日志上报失败不应再触发
// console.error 或未处理 Promise,否则会形成错误循环。
void writeErrorLog(message, { file: "frontend" }).catch(() => undefined);
}
export function installGlobalErrorHandlers(
target: Window = window,
): () => void {
const handleError = (event: ErrorEvent) => {
const location = event.filename
? `${event.filename}:${event.lineno}:${event.colno}`
: undefined;
reportFrontendError("window.error", event.error ?? event.message, location);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
reportFrontendError("unhandledrejection", event.reason);
};
target.addEventListener("error", handleError);
target.addEventListener("unhandledrejection", handleUnhandledRejection);
return () => {
target.removeEventListener("error", handleError);
target.removeEventListener("unhandledrejection", handleUnhandledRejection);
};
}
+25 -14
View File
@@ -14,6 +14,13 @@ import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { exit } from "@tauri-apps/plugin-process"; import { exit } from "@tauri-apps/plugin-process";
import { FrontendErrorBoundary } from "./components/FrontendErrorBoundary";
import {
installGlobalErrorHandlers,
reportFrontendError,
} from "./lib/frontendLogger";
installGlobalErrorHandlers();
// 根据平台添加 body class,便于平台特定样式 // 根据平台添加 body class,便于平台特定样式
try { try {
@@ -70,7 +77,7 @@ try {
}); });
} catch (e) { } catch (e) {
// 忽略事件订阅异常(例如在非 Tauri 环境下) // 忽略事件订阅异常(例如在非 Tauri 环境下)
console.error("订阅 configLoadError 事件失败", e); reportFrontendError("config_load_error_listener", e);
} }
async function bootstrap() { async function bootstrap() {
@@ -83,10 +90,12 @@ async function bootstrap() {
// 数据库版本过新:渲染应用内「升级应用」恢复界面,不进入正常 App // 数据库版本过新:渲染应用内「升级应用」恢复界面,不进入正常 App
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme"> <FrontendErrorBoundary>
<DatabaseUpgrade payload={initError} /> <ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
<Toaster /> <DatabaseUpgrade payload={initError} />
</ThemeProvider> <Toaster />
</ThemeProvider>
</FrontendErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
return; return;
@@ -98,19 +107,21 @@ async function bootstrap() {
} }
} catch (e) { } catch (e) {
// 忽略拉取错误,继续渲染 // 忽略拉取错误,继续渲染
console.error("拉取初始化错误失败", e); reportFrontendError("get_init_error", e);
} }
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <FrontendErrorBoundary>
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme"> <QueryClientProvider client={queryClient}>
<UpdateProvider> <ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
<App /> <UpdateProvider>
<Toaster /> <App />
</UpdateProvider> <Toaster />
</ThemeProvider> </UpdateProvider>
</QueryClientProvider> </ThemeProvider>
</QueryClientProvider>
</FrontendErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
} }
@@ -0,0 +1,36 @@
const reportFrontendError = vi.hoisted(() => vi.fn());
vi.mock("@/lib/frontendLogger", () => ({
reportFrontendError,
}));
import { render, screen } from "@testing-library/react";
import { FrontendErrorBoundary } from "@/components/FrontendErrorBoundary";
function ThrowingChild(): React.ReactNode {
throw new Error("sensitive render failure");
}
describe("FrontendErrorBoundary", () => {
it("reports render failures and replaces the broken tree", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
render(
<FrontendErrorBoundary>
<ThrowingChild />
</FrontendErrorBoundary>,
);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(reportFrontendError).toHaveBeenCalledWith(
"react.error_boundary",
expect.objectContaining({ message: "sensitive render failure" }),
expect.any(String),
);
consoleError.mockRestore();
});
});
+327
View File
@@ -0,0 +1,327 @@
const writeErrorLog = vi.hoisted(() =>
vi.fn<(message: string, options: { file: string }) => Promise<void>>(() =>
Promise.resolve(),
),
);
vi.mock("@tauri-apps/plugin-log", () => ({
error: writeErrorLog,
}));
import {
installGlobalErrorHandlers,
redactFrontendLogText,
reportFrontendError,
} from "@/lib/frontendLogger";
describe("frontendLogger", () => {
beforeEach(() => {
writeErrorLog.mockClear();
});
it("redacts URL parameters and named credentials", () => {
const redacted = redactFrontendLogText(
"https://example.test/path?apiKey=query-secret&name=alice\n" +
'api_key: "config secret with spaces"\n' +
"Authorization: Bearer bearer-secret\n" +
"Authorization: Basic dXNlcjpwYXNz\n" +
"Authorization: Token token-secret\n" +
" Cookie: session=cookie-secret; preference=private\n" +
"ApiKey standalone-secret\n" +
"https://user:password@example.test/private",
);
expect(redacted).not.toContain("query-secret");
expect(redacted).not.toContain("alice");
expect(redacted).not.toContain("config secret with spaces");
expect(redacted).not.toContain("secret with spaces");
expect(redacted).not.toContain("bearer-secret");
expect(redacted).not.toContain("dXNlcjpwYXNz");
expect(redacted).not.toContain("token-secret");
expect(redacted).not.toContain("standalone-secret");
expect(redacted).not.toContain("cookie-secret");
expect(redacted).not.toContain("preference=private");
expect(redacted).not.toContain("user:password");
expect(redacted).toContain("apiKey=[REDACTED]");
expect(redacted).toContain('api_key: "[REDACTED]"');
const escapedMultiline = redactFrontendLogText(
'{"detail":"line1\\nsk-ant-api03-escaped-secret"}',
);
expect(escapedMultiline).not.toContain("sk-ant-api03-escaped-secret");
});
it("writes bounded, redacted errors through the Tauri log plugin", () => {
const secret = "sensitive-token";
const oversizedDetails = `${"x".repeat(2_000_000)} token=${secret}`;
reportFrontendError(
"window.error",
new Error(`failed at https://example.test/?key=${secret}`),
oversizedDetails,
);
expect(writeErrorLog).toHaveBeenCalledOnce();
const [message, options] = writeErrorLog.mock.calls[0];
expect(message).not.toContain(secret);
expect(message).toContain("[truncated]");
expect(message.length).toBeLessThanOrEqual(12_020);
expect(options).toEqual({ file: "frontend" });
});
it("serializes object-shaped rejection reasons without exposing secrets", () => {
// 两层脱敏契约:
// - 属性名一级:命中敏感名(含复数/数组/嵌套)整个值一律隐藏;
// - 值一级:任意位置的“不透明密钥形状”按形状隐藏;
// - 文本一级:序列化后 `"name":"value"` 的裸密钥由正则兜底。
reportFrontendError("unhandledrejection", {
code: 500,
message: "auth failed", // 良性文本保留(值含 "auth" 但非 name:value)
key: "short-secret", // 裸 key 标量(非不透明) → 属性名层
token: "object-secret", // 标量命名 → 属性名层
tokens: ["k-9f3a7c2b1e"], // 复数+数组(短值) → 属性名层(去尾 s + 整体隐藏)
auth: ["opaque-credential"], // 数组 → 属性名层
credential: "AIzaRealCredential123",
nested: { detail: "ghp_abcdef123456" }, // 非敏感名,靠不透明形状
values: ["eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2lnbmF0dXJl"], // 数组内不透明形状
multiline: "line1\nsk-ant-api03-multiline-secret", // 串内不透明形状
session: { activeTab: "providers", scrollPos: 120 }, // 良性状态原样保留
});
const [message] = writeErrorLog.mock.calls[0];
expect(message).toContain('"code":500');
expect(message).toContain('"message":"auth failed"');
expect(message).not.toContain("short-secret");
expect(message).not.toContain("object-secret");
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).not.toContain("opaque-credential");
expect(message).not.toContain("AIzaRealCredential123");
expect(message).not.toContain("ghp_abcdef123456");
expect(message).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(message).not.toContain("sk-ant-api03-multiline-secret");
expect(message).toContain('"key":"[REDACTED]"');
expect(message).toContain('"token":"[REDACTED]"');
expect(message).toContain('"tokens":"[REDACTED]"');
expect(message).toContain('"auth":"[REDACTED]"');
expect(message).toContain('"credential":"[REDACTED]"');
expect(message).toContain(
'"session":{"activeTab":"providers","scrollPos":120}',
);
});
it("applies property-level redaction to stringified-JSON rejection reasons", () => {
// 字符串形态的 JSON 不能只靠文本正则——数组/复数/裸标量字段会漏。
reportFrontendError(
"unhandledrejection",
'{"tokens":["k-9f3a7c2b1e"],"auth":["opaque-credential"],"key":"short-secret","keepMe":"visible"}',
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).not.toContain("opaque-credential");
expect(message).not.toContain("short-secret");
expect(message).toContain('"tokens":"[REDACTED]"');
expect(message).toContain('"auth":"[REDACTED]"');
expect(message).toContain('"key":"[REDACTED]"');
expect(message).toContain('"keepMe":"visible"');
});
it("keeps non-JSON error strings as readable text", () => {
reportFrontendError("window.error", "plain failure at step 3");
const [message] = writeErrorLog.mock.calls[0];
expect(message).toContain("plain failure at step 3");
});
it("applies property-level redaction to JSON wrapped in an Error message", () => {
// throw new Error(JSON.stringify(payload)) 会把凭据藏进 message
// 而 error.stack 第一行原样吐出 message —— 必须先对 message 结构化脱敏。
reportFrontendError(
"unhandledrejection",
new Error(
'{"tokens":["k-9f3a7c2b1e"],"key":"short-secret","keepMe":"visible"}',
),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).not.toContain("short-secret");
expect(message).toContain('"tokens":"[REDACTED]"');
expect(message).toContain('"key":"[REDACTED]"');
expect(message).toContain('"keepMe":"visible"');
});
it("omits oversized JSON error strings instead of leaking truncated fields", () => {
// 合法但超长的 JSON 若先截断再 parse,必成非法 JSON 而退回文本层,数组字段泄漏。
const padding = "x".repeat(20_000);
reportFrontendError(
"unhandledrejection",
`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`,
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain("[oversized structured error omitted]");
});
it("omits oversized JSON wrapped in an Error message", () => {
const padding = "x".repeat(20_000);
reportFrontendError(
"unhandledrejection",
new Error(`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain("[oversized structured error omitted]");
});
it("redacts array credentials in prefix+JSON rejection strings", () => {
// 前缀 + JSON`redactStructuredString` 的 startsWith 门被前缀挡掉,退回文本层。
// 文本层的容器正则必须在这个统一出口兜住 `"tokens":[...]`。
reportFrontendError(
"unhandledrejection",
'Load failed: {"tokens":["ak_live_7f3d9b21c8e4"]}',
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
expect(message).toContain("Load failed");
});
it("redacts array credentials in prefix+JSON wrapped in an Error", () => {
reportFrontendError(
"unhandledrejection",
new Error(
"Provider provisioning failed: " +
JSON.stringify({ apiKeys: ["ak_live_7f3d9b21c8e4"] }),
),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
});
it("redacts credentials in double-encoded nested JSON (escaped quotes)", () => {
reportFrontendError(
"unhandledrejection",
new Error(
JSON.stringify({
status: 400,
body: '{"keys":["abcd1234efgh5678ij"]}',
}),
),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("abcd1234efgh5678ij");
});
it("redacts array credentials in a POJO (non-Error) rejection", () => {
reportFrontendError("unhandledrejection", {
name: "HttpError",
message: '{"tokens":["opaqueTokenValue12345"]}',
code: 400,
});
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("opaqueTokenValue12345");
});
it("redacts container values only under sensitive keys", () => {
// 容器正则只对敏感键生效:普通数组/对象(items/config)与后缀含 key 的词(monkey)不误伤。
const redacted = redactFrontendLogText(
'{"tokens":["k-secret-1"],"monkey":["visible-a"],"items":["visible-b"],"config":{"theme":"dark"}}',
);
expect(redacted).not.toContain("k-secret-1");
expect(redacted).toContain("visible-a");
expect(redacted).toContain("visible-b");
expect(redacted).toContain('"theme":"dark"');
});
it("preserves native WebKit-style stack frames for JSON-wrapped errors", () => {
// macOS/Linux 的 WKWebView 用 `fn@file:line:col` 格式,且 stack 不含 message。
// 旧的 `/^\s+at\s/` 过滤会把整段栈丢掉;新实现须补脱敏头并保留原生栈。
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
Object.defineProperty(err, "stack", {
value:
"handleClick@tauri://localhost/assets/index.js:42:15\n" +
"dispatch@tauri://localhost/assets/index.js:99:3",
});
reportFrontendError("unhandledrejection", err);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain('"tokens":"[REDACTED]"'); // 脱敏 message 头
expect(message).toContain("handleClick@tauri://localhost"); // 原生栈帧保留
expect(message).toContain("dispatch@tauri://localhost");
});
it("replaces every occurrence of the raw message in a V8-style stack", () => {
// message 若在 stack 里出现多次(eval/匿名帧回显),字面量替换必须全部换掉,零残留。
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
Object.defineProperty(err, "stack", {
value:
'Error: {"tokens":["k-9f3a7c2b1e"]}\n' +
' at eval (eval at <anonymous>, {"tokens":["k-9f3a7c2b1e"]}:1:1)\n' +
" at run (index.js:10:5)",
});
reportFrontendError("unhandledrejection", err);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain("at run (index.js:10:5)"); // 栈帧保留
});
it("redacts standalone secret shapes in ordinary error text", () => {
reportFrontendError(
"window.error",
new Error("request failed with sk-ant-api03-real-secret"),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("sk-ant-api03-real-secret");
expect(message).toContain("[REDACTED]");
});
it("handles circular rejection objects", () => {
const reason: Record<string, unknown> = { message: "failed" };
reason.self = reason;
expect(() =>
reportFrontendError("unhandledrejection", reason),
).not.toThrow();
expect(writeErrorLog.mock.calls[0][0]).toContain("[Circular]");
});
it("captures global errors and unhandled rejections and can uninstall", () => {
const target = new EventTarget() as unknown as Window;
const uninstall = installGlobalErrorHandlers(target);
const errorEvent = new Event("error") as ErrorEvent;
Object.defineProperties(errorEvent, {
error: { value: new Error("render failed") },
filename: { value: "app.js" },
lineno: { value: 10 },
colno: { value: 4 },
});
target.dispatchEvent(errorEvent);
const rejectionEvent = new Event(
"unhandledrejection",
) as PromiseRejectionEvent;
Object.defineProperty(rejectionEvent, "reason", {
value: new Error("request failed"),
});
target.dispatchEvent(rejectionEvent);
expect(writeErrorLog).toHaveBeenCalledTimes(2);
uninstall();
target.dispatchEvent(errorEvent);
expect(writeErrorLog).toHaveBeenCalledTimes(2);
});
});