diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index f029ec937..f380ab5d6 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,7 +11,6 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; -import { ClientActionError } from '../services/clientActionError'; import { loginClientWithPassword, loginClientWithPhoneCode, @@ -21,6 +20,7 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; +import { ClientAuthErrorWrapper } from '../services/clientAuthErrorWrapper'; import { type ClientServerPreset, type ClientServerSelection, @@ -35,7 +35,6 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; -import type { ClientAuthError } from '../services/generated/ClientAuthError'; import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; import type { PasswordMissing } from '../services/generated/PasswordMissing'; @@ -278,8 +277,8 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - if (!(error instanceof ClientActionError)) throw error; - const failure = error.cause as ClientAuthError; + if (!(error instanceof ClientAuthErrorWrapper)) throw error; + const failure = error.error; switch (failure.type) { // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 case 'serverAddressRejected': { @@ -420,7 +419,7 @@ export function AuthenticatedClient({ /** * 认证失败的分流判据直接写在每个 catch 里: * - * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientActionError`,`cause` 就是判别联合, + * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合, * `failure.type` 是唯一分流键; * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; @@ -450,8 +449,8 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (!(error instanceof ClientActionError)) throw error; - const failure = error.cause as ClientAuthError; + if (!(error instanceof ClientAuthErrorWrapper)) throw error; + const failure = error.error; switch (failure.type) { case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; @@ -591,8 +590,8 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (!(error instanceof ClientActionError)) throw error; - const failure = error.cause as ClientAuthError; + if (!(error instanceof ClientAuthErrorWrapper)) throw error; + const failure = error.error; switch (failure.type) { case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; diff --git a/apps/ai-game-creator-shell/src/services/clientActionError.ts b/apps/ai-game-creator-shell/src/services/clientActionError.ts deleted file mode 100644 index 2e19e38f6..000000000 --- a/apps/ai-game-creator-shell/src/services/clientActionError.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。 - * - * 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。 - */ -export type ClientErrorReportContext = { - source: string; - action?: string; - page?: string; -}; - -/** - * 调用方判定「这是真故障 / 认不出」时的统一载体:`message` 是可展示文案,`context` 决定错误池的 - * 指纹维度,`cause` 保留原始拒绝值(Rust 的结构化 `ClientAuthError`、裸字符串或 `Error`)。 - * - * 分流只看类型化的变体,**不要用文案判断**。`captureClientError` 用 `instanceof` 解包 - * `context` 与 `cause`,所以它与显式调用 `captureClientError(error, context)` 的指纹、展示字段一致; - * 需要"带上文继续抛出"的调用方也可以直接 `throw` 它。 - */ -export class ClientActionError extends Error { - readonly context: ClientErrorReportContext; - - constructor( - message: string, - context: ClientErrorReportContext, - cause?: unknown, - ) { - super(message, { cause }); - this.name = 'ClientActionError'; - this.context = context; - } -} diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c3f0e3c5a..1f93ce84b 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { ClientActionError } from './clientActionError'; +import { ClientAuthErrorWrapper } from './clientAuthErrorWrapper'; import type { ClientAuthError } from './generated/ClientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; @@ -37,11 +37,12 @@ function requireInvoke() { } /** - * 认证命令的统一入口:把 Tauri 的拒绝装进已有的 `ClientActionError`。 + * 认证命令的统一入口:把 Tauri 的拒绝原样装进已有的 `ClientAuthErrorWrapper`。 * * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的 - * `ClientAuthError` 判别联合,原样放进 `cause`;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 - * `switch` 的 `default` 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 + * `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的 + * `default` 分支仍会把它抛出去上报。包装本身不读变体字段(`message` 之类不保证每个变体都有)、 + * 不注入上下文、不兜底文案。 */ async function invokeClientAuth( command: string, @@ -55,13 +56,8 @@ async function invokeClientAuth( ? await invoke(command) : await invoke(command, args); } catch (error) { - // 拒绝值就是判别联合;`message` 是每个变体都有的展示文案,分流只按 `type`。 - const failure = error as ClientAuthError; - throw new ClientActionError( - failure.message, - { source: 'auth', action: command }, - failure, - ); + // 薄包装:原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。 + throw new ClientAuthErrorWrapper(error as ClientAuthError); } } @@ -80,7 +76,7 @@ type RustAuthRefreshView = * 恢复登录态。 * * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败, - * 由 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三态投影。 + * 由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三态投影。 */ export async function readClientAuthState( expectedApiBaseUrl?: string, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts new file mode 100644 index 000000000..d72e7483e --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts @@ -0,0 +1,30 @@ +import type { ClientAuthError } from './generated/ClientAuthError'; + +/** + * 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。 + * + * 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。 + */ +export type ClientErrorReportContext = { + source: string; + action?: string; + page?: string; +}; + +/** + * 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError` + * 判别联合)。 + * + * 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体上的 `message`(变体不 + * 保证都有这个字段),不注入 `source` / `action`,`Error.message` 留空。分流只看类型化的变体, + * **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。 + */ +export class ClientAuthErrorWrapper extends Error { + readonly error: ClientAuthError; + + constructor(error: ClientAuthError) { + super(); + this.name = 'ClientAuthErrorWrapper'; + this.error = error; + } +} diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 080bb2e4a..09c1fb0b1 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -1,9 +1,9 @@ import { invoke } from '@tauri-apps/api/core'; import { - ClientActionError, + ClientAuthErrorWrapper, type ClientErrorReportContext, -} from './clientActionError'; +} from './clientAuthErrorWrapper'; import { ackErrorReports, getPendingErrorReports, @@ -80,24 +80,25 @@ export async function captureClientError( error: unknown, context: Partial = {}, ) { - // 调用方"带上文重抛"时用 ClientActionError 承载上下文与原始拒绝值;显式传参作为兜底。 - const actionError = error instanceof ClientActionError ? error : null; - const errorValue = error instanceof Error ? error : new Error(String(error)); + // ClientAuthErrorWrapper 只把原始拒绝值装成 JS Error:真的 message / stack 在 `error` 字段里, + // 载体自身是空文案。结构化 Rust 拒绝没有 JS Error 语义,落回默认文案。 + const actionError = error instanceof ClientAuthErrorWrapper ? error : null; + // 类型上 `error` 是 ClientAuthError;Tauri 在映射外抛 Error 属于它的缺陷,这里按 unknown 兜底取文案/栈。 + const carried: unknown = actionError?.error; + const errorValue = + carried instanceof Error + ? carried + : error instanceof Error + ? error + : new Error(String(error)); const message = errorValue.message || '未知客户端错误'; - // 原始失败的栈信息比包装点更有诊断价值(包装点只是 catch 的位置)。 - const stackSource = - actionError?.cause instanceof Error && actionError.cause.stack - ? actionError.cause - : errorValue; - const stack = stackSource.stack - ? stackSource.stack.slice(0, 8_000) - : undefined; + const stack = errorValue.stack?.slice(0, 8_000); return reportClientError({ - source: actionError?.context.source ?? context.source ?? 'client', + source: context.source ?? 'client', message, stack, - action: actionError?.context.action ?? context.action, - page: actionError?.context.page ?? context.page, + action: context.action, + page: context.page, }).catch(() => undefined); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 76d997050..fc58a3350 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,5 +1,6 @@ import { afterEach } from 'vitest'; +import { ClientAuthErrorWrapper } from '../../src/services/clientAuthErrorWrapper'; import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp'; import { currentPlatformSessionGeneration, @@ -294,7 +295,10 @@ export function registerAuthTests() { // 先等系统变体真的经 unhandledrejection 抛出,再断言它没落到登录页文案。 await waitFor(() => expect(reasons).toHaveLength(1)); - expect(reasons[0]).toMatchObject({ + // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 + expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); + expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ + type: 'authNetworkUnavailable', message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', }); expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 435d56e37..c631d955f 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -122,9 +122,8 @@ describe('认证失败的上报判据', () => { await waitFor(() => expect(reportCalls()).toHaveLength(1)); expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'auth', - action: 'login_client_with_password', - message, + source: 'unhandledrejection', + message: '未知客户端错误', }); expect(screen.queryByText(message)).toBeNull(); } finally { @@ -147,8 +146,7 @@ describe('认证失败的上报判据', () => { await waitFor(() => expect(reportCalls()).toHaveLength(1)); expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'auth', - action: 'login_client_with_password', + source: 'unhandledrejection', message: 'IPC 桥接不可用', }); } finally { diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index cde523737..51e0d77b4 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -2,14 +2,15 @@ /** * 认证命令失败的 JS 侧载体。 * - * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`: - * `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。 - * 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。 + * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 + * 字段(`message` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 + * `error.error.type`。另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体, + * `expectNever(error)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; -import { ClientActionError } from '../src/services/clientActionError'; import { loginClientWithPassword } from '../src/services/clientAuth'; +import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; @@ -88,11 +89,9 @@ function installInvoke(handler: () => Promise) { } describe('认证命令失败的 JS 侧载体', () => { - it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => { - const rejection = { - type: 'phoneNumberInvalid', - message: '请输入正确的手机号', - }; + it('结构化拒绝原样装进 error 字段,不读变体字段', async () => { + // 故意不带 message:包装层绝不能假设每个变体都有这个字段。 + const rejection = { type: 'phoneNumberInvalid', status: 400 }; installInvoke(async () => { throw rejection; }); @@ -103,13 +102,9 @@ describe('认证命令失败的 JS 侧载体', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientActionError); - expect((failure as ClientActionError).cause).toEqual(rejection); - expect((failure as ClientActionError).context).toEqual({ - source: 'auth', - action: 'login_client_with_password', - }); - expect((failure as Error).message).toBe('请输入正确的手机号'); + expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); + expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); + expect((failure as Error).message).toBe(''); }); it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { @@ -136,30 +131,9 @@ describe('认证命令失败的 JS 侧载体', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientActionError); - expect((failure as Error).message).toBe('IPC 桥接异常'); - expect((failure as ClientActionError).cause).toBe(rejection); - expect((failure as ClientActionError).context).toEqual({ - source: 'auth', - action: 'login_client_with_password', - }); - }); - - it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => { - installInvoke(async () => { - throw { type: 'unexpectedRejection', status: 409 }; - }); - - const failure = await loginClientWithPassword( - '13800000000', - 'secret', - 'https://dev.genarrative.world', - ).catch((error: unknown) => error); - + expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); expect((failure as Error).message).toBe(''); - expect((failure as ClientActionError).cause).toMatchObject({ - type: 'unexpectedRejection', - }); + expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index cfeadcab1..9fcea5cca 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -158,14 +158,13 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('结构化失败不降级成投影值,而是以带 cause 的 ClientActionError 拒绝', async () => { +test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { const rejection = { type: 'authNetworkUnavailable', message: '无法连接登录服务', }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ - cause: rejection, - context: { source: 'auth', action: 'refresh_client_auth_session' }, + error: rejection, }); }); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 304e2e03e..a19cd3184 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -71,7 +71,7 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { invoke } from '@tauri-apps/api/core'; -import { ClientActionError } from '../src/services/clientActionError'; +import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, @@ -209,23 +209,21 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([event]); }); - it('解包调用方补的上报上下文与原始错误', async () => { - const original = new Error('无法连接登录服务,请确认网络后重试'); + it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { + const original = { + type: 'authNetworkUnavailable', + message: '无法连接登录服务,请确认网络后重试', + }; - await captureClientError( - new ClientActionError( - '无法连接登录服务,请确认网络后重试', - { source: 'auth', action: 'login' }, - original, - ), - // 显式入参是兜底:ClientActionError 自带的上下文优先。 - { source: 'unhandledrejection' }, - ); + await captureClientError(new ClientAuthErrorWrapper(original), { + source: 'unhandledrejection', + action: 'login', + }); expect(invoke).toHaveBeenCalledWith('report_client_error', { - source: 'auth', - message: '无法连接登录服务,请确认网络后重试', - stack: original.stack, + source: 'unhandledrejection', + message: '未知客户端错误', + stack: expect.any(String), action: 'login', page: undefined, });