From ce54c729e82f5f718b2905020d426ec32304a837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 20:49:48 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=94=B9=E4=B8=BA=20JS=20=E4=BE=A7=E8=BD=BD?= =?UTF-8?q?=E4=BD=93=E5=B9=B6=E5=8F=AA=E6=8C=89=E5=8F=98=E4=BD=93=E5=88=86?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ClientAuthFailure:payload 为 ts-rs 判别联合,context 固定 source=auth + 命令名,cause 为原始拒绝值 - clientAuth 的六个命令统一走 invokeClientAuth:结构化拒绝转成 ClientAuthFailure,非结构化拒绝原样抛出,不兜底文案 - 删除 clientAuthError.ts 的形状读取与 getClientAuthErrorMessage 文案回落层 - AuthenticatedClient 的两个 catch 内联 19 个变体的 switch:业务 / 会话变体原样展示载荷 message,系统变体原样抛出经 unhandledrejection 入池,default: expectNever 保证漏接变体编译失败 - hydrateAuth 失败先离开 loading 态;系统变体不再伪装成 unavailable 投影 - ClientAuthState 收窄为 authenticated / unauthenticated,ClientAuthRefreshResult 收窄为 refreshed / unauthenticated / stale - platformSession 续期只保留 refreshed / unauthenticated / stale 三条路径 - check-config 的 App 调用扫描登记 invokeClientAuth,继续静态证明命令名可达 - 测试:新增 clientAuthFailure 载体与编译期穷尽用例、jsdom unhandledrejection 桥;删除 clientAuthError.test.ts;clientAuthHost / authFailureReporting / authSurface 跟改新口径 - 共享记忆记录 check-config 扫描形态新增 invokeClientAuth --- .../scripts/check-config.mjs | 1 + .../src/app/AuthenticatedClient.tsx | 318 +++++++++++++++--- .../src/services/clientAuth.ts | 136 ++++---- .../src/services/clientAuthError.ts | 23 -- .../src/services/clientAuthFailure.ts | 30 ++ .../src/services/platformSession.ts | 10 +- .../tests/appSurface/auth.suite.ts | 111 +++--- .../tests/authFailureReporting.test.tsx | 63 +++- .../tests/clientAuthError.test.ts | 77 ----- .../tests/clientAuthFailure.test.ts | 164 +++++++++ .../tests/clientAuthHost.test.ts | 40 +-- .../tests/unhandledRejectionBridge.ts | 27 ++ .../shared-memory/decision-log.md | 2 +- 13 files changed, 672 insertions(+), 330 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/clientAuthError.ts create mode 100644 apps/ai-game-creator-shell/src/services/clientAuthFailure.ts delete mode 100644 apps/ai-game-creator-shell/tests/clientAuthError.test.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts create mode 100644 apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 1b964573f..5a2b4bf24 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -432,6 +432,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([ 'directInvoke', 'invokeInput', 'invokeAuthenticatedInput', + 'invokeClientAuth', 'invokeDiagnostic', ]); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index a77f00f30..af9ab45f6 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,9 +11,7 @@ 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 { - getClientAuthErrorMessage, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, @@ -22,7 +20,7 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; -import { isClientAuthError } from '../services/clientAuthError'; +import { ClientAuthFailure } from '../services/clientAuthFailure'; import { type ClientServerPreset, type ClientServerSelection, @@ -37,6 +35,18 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; +import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; +import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; +import type { PasswordMissing } from '../services/generated/PasswordMissing'; +import type { PermissionDenied } from '../services/generated/PermissionDenied'; +import type { PhoneLoginInputRejected } from '../services/generated/PhoneLoginInputRejected'; +import type { PhoneNumberInvalid } from '../services/generated/PhoneNumberInvalid'; +import type { PhoneOrPasswordMismatch } from '../services/generated/PhoneOrPasswordMismatch'; +import type { SendCodeInputRejected } from '../services/generated/SendCodeInputRejected'; +import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; +import type { SessionAuthorityRejected } from '../services/generated/SessionAuthorityRejected'; +import type { SmsCodeInvalidOrExpired } from '../services/generated/SmsCodeInvalidOrExpired'; +import type { SmsCodeThrottled } from '../services/generated/SmsCodeThrottled'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -67,6 +77,14 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; // 30s startup deadline. Keep the UI fence slightly above that worst case. const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; +/** + * 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。 + * 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。 + */ +function expectNever(value: never): void { + void value; +} + function withAuthCheckTimeout( promise: Promise, timeoutMs: number, @@ -237,12 +255,6 @@ export function AuthenticatedClient({ '检查登录状态超时,请检查服务器地址和网络后重试', ); if (!isActiveRun()) return; - if (state.status === 'unavailable') { - setAuthCheckError(state.message); - setLoginStatus(state.message); - setAuthStatus('unauthenticated'); - return; - } if (state.status !== 'authenticated') { setAuthStatus('unauthenticated'); return; @@ -263,13 +275,89 @@ export function AuthenticatedClient({ setAuthStatus('authenticated'); } catch (error) { if (!isActiveRun()) return; - const message = getClientAuthErrorMessage( - error, - '登录服务暂时不可用,请稍后重试', - ); - setAuthCheckError(message); - setLoginStatus(message); + // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 + case 'serverAddressRejected': { + const payload = error.payload as ServerAddressRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'passwordMissing': { + const payload = error.payload as PasswordMissing; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'loginCodeMissing': { + const payload = error.payload as LoginCodeMissing; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'passwordEntryInputRejected': { + const payload = error.payload as PasswordEntryInputRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'phoneOrPasswordMismatch': { + const payload = error.payload as PhoneOrPasswordMismatch; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'sendCodeInputRejected': { + const payload = error.payload as SendCodeInputRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'smsCodeThrottled': { + const payload = error.payload as SmsCodeThrottled; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'phoneLoginInputRejected': { + const payload = error.payload as PhoneLoginInputRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'smsCodeInvalidOrExpired': { + const payload = error.payload as SmsCodeInvalidOrExpired; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + // 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。 + case 'sessionAuthorityRejected': + case 'permissionDenied': + break; + // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkUnavailable': + case 'authServiceUnavailable': + case 'unexpectedRejection': + case 'authResponseMalformed': + case 'clientSessionPersistFailed': + case 'runtimeSessionInstallFailed': + case 'authClientInitFailed': + throw error; + default: { + expectNever(error.payload); + throw error; + } + } } } void hydrateAuth(); @@ -328,47 +416,15 @@ export function AuthenticatedClient({ }, [codeCooldownSeconds]); /** - * 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。 + * 认证失败的分流判据直接写在每个 catch 里: * - * 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、 - * 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池—— - * `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。 + * - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键; + * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; + * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; + * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 + * + * 不把判据抽成函数:判定必须发生在 catch 里。 */ - function presentAuthFailure( - error: unknown, - fallback: string, - action: string, - ) { - const structured = isClientAuthError(error); - if (structured) { - switch (structured.type) { - // 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理): - // 这些都给用户提示,不进错误池。 - case 'serverAddressRejected': - case 'phoneNumberInvalid': - case 'passwordMissing': - case 'loginCodeMissing': - case 'passwordEntryInputRejected': - case 'phoneOrPasswordMismatch': - case 'sendCodeInputRejected': - case 'smsCodeThrottled': - case 'phoneLoginInputRejected': - case 'smsCodeInvalidOrExpired': - case 'sessionAuthorityRejected': - case 'permissionDenied': { - setLoginStatus(structured.message.trim() || fallback); - return; - } - default: - break; - } - } - const message = getClientAuthErrorMessage(error, fallback); - setLoginStatus(message); - void captureClientError( - new ClientActionError(message, { source: 'auth', action }, error), - ); - } async function handleSendCode() { if (codeBusy || codeCooldownSeconds > 0) { @@ -391,7 +447,82 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - presentAuthFailure(error, '发送验证码失败', 'send-login-code'); + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + case 'serverAddressRejected': { + const payload = error.payload as ServerAddressRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setLoginStatus(payload.message); + break; + } + case 'passwordMissing': { + const payload = error.payload as PasswordMissing; + setLoginStatus(payload.message); + break; + } + case 'loginCodeMissing': { + const payload = error.payload as LoginCodeMissing; + setLoginStatus(payload.message); + break; + } + case 'passwordEntryInputRejected': { + const payload = error.payload as PasswordEntryInputRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneOrPasswordMismatch': { + const payload = error.payload as PhoneOrPasswordMismatch; + setLoginStatus(payload.message); + break; + } + case 'sendCodeInputRejected': { + const payload = error.payload as SendCodeInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeThrottled': { + const payload = error.payload as SmsCodeThrottled; + setLoginStatus(payload.message); + break; + } + case 'phoneLoginInputRejected': { + const payload = error.payload as PhoneLoginInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeInvalidOrExpired': { + const payload = error.payload as SmsCodeInvalidOrExpired; + setLoginStatus(payload.message); + break; + } + case 'sessionAuthorityRejected': { + const payload = error.payload as SessionAuthorityRejected; + setLoginStatus(payload.message); + break; + } + case 'permissionDenied': { + const payload = error.payload as PermissionDenied; + setLoginStatus(payload.message); + break; + } + // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkUnavailable': + case 'authServiceUnavailable': + case 'unexpectedRejection': + case 'authResponseMalformed': + case 'clientSessionPersistFailed': + case 'runtimeSessionInstallFailed': + case 'authClientInitFailed': + throw error; + default: { + expectNever(error.payload); + throw error; + } + } } finally { setCodeBusy(false); } @@ -456,7 +587,82 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - presentAuthFailure(error, '登录失败', 'login'); + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + case 'serverAddressRejected': { + const payload = error.payload as ServerAddressRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setLoginStatus(payload.message); + break; + } + case 'passwordMissing': { + const payload = error.payload as PasswordMissing; + setLoginStatus(payload.message); + break; + } + case 'loginCodeMissing': { + const payload = error.payload as LoginCodeMissing; + setLoginStatus(payload.message); + break; + } + case 'passwordEntryInputRejected': { + const payload = error.payload as PasswordEntryInputRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneOrPasswordMismatch': { + const payload = error.payload as PhoneOrPasswordMismatch; + setLoginStatus(payload.message); + break; + } + case 'sendCodeInputRejected': { + const payload = error.payload as SendCodeInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeThrottled': { + const payload = error.payload as SmsCodeThrottled; + setLoginStatus(payload.message); + break; + } + case 'phoneLoginInputRejected': { + const payload = error.payload as PhoneLoginInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeInvalidOrExpired': { + const payload = error.payload as SmsCodeInvalidOrExpired; + setLoginStatus(payload.message); + break; + } + case 'sessionAuthorityRejected': { + const payload = error.payload as SessionAuthorityRejected; + setLoginStatus(payload.message); + break; + } + case 'permissionDenied': { + const payload = error.payload as PermissionDenied; + setLoginStatus(payload.message); + break; + } + // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkUnavailable': + case 'authServiceUnavailable': + case 'unexpectedRejection': + case 'authResponseMalformed': + case 'clientSessionPersistFailed': + case 'runtimeSessionInstallFailed': + case 'authClientInitFailed': + throw error; + default: { + expectNever(error.payload); + throw error; + } + } } finally { setLoginBusy(false); } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 8b76fd3fb..de7369bcb 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,7 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { isClientAuthError } from './clientAuthError'; +import { ClientAuthFailure } from './clientAuthFailure'; +import type { ClientAuthError } from './generated/ClientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -8,14 +9,12 @@ export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed'; export type ClientAuthState = | { status: 'authenticated'; user: AuthUser; apiBaseUrl: string } - | { status: 'unauthenticated' } - | { status: 'unavailable'; message: string }; + | { status: 'unauthenticated' }; export type ClientAuthRefreshResult = | { status: 'refreshed'; user: AuthUser } | { status: 'unauthenticated' } - | { status: 'stale' } - | { status: 'failed'; message: string; authoritative: boolean }; + | { status: 'stale' }; export type ClientLoginCodeResult = { cooldownSeconds: number; @@ -38,55 +37,67 @@ function requireInvoke() { } /** - * 从任意拒绝值里取一条可显示文案。 + * 认证命令的统一入口:把 Rust 结构化拒绝转成 JS 侧的 `ClientAuthFailure`, + * 非结构化拒绝(Tauri / JS 运行时自己的错误)原样抛出。 * - * 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串 - * 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**—— - * `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。 - * - * 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。 + * 只判 `type` 是不是字符串(这是 ts-rs 判别联合的稳定键),**不校验字段名、不读文案判断、 + * 不兜底文案**。要不要上报、给不给用户提示,由调用方在 catch 里按 `payload.type` 决定。 */ -export function getClientAuthErrorMessage(error: unknown, fallback: string) { - const structured = isClientAuthError(error); - if (structured) return structured.message.trim() || fallback; - if (error instanceof Error && error.message.trim()) return error.message; - if (typeof error === 'string') return error.trim() || fallback; - return fallback; +async function invokeClientAuth( + command: string, + args?: Record, +): Promise { + try { + const invoke = requireInvoke(); + // 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。 + return args === undefined + ? await invoke(command) + : await invoke(command, args); + } catch (error) { + const rejection = error as { type?: unknown; message?: unknown }; + if (typeof rejection?.type !== 'string') throw error; + throw new ClientAuthFailure( + typeof rejection.message === 'string' ? rejection.message : '', + error as ClientAuthError, + command, + error, + ); + } } -type RustAuthStateView = { - status?: string; - user?: AuthUser | null; - apiBaseUrl?: string | null; - errorMessage?: string | null; -}; +/** Rust 认证态投影,与 `ClientAuthStateView` 一一对应。 */ +type RustAuthStateView = + | { status: 'authenticated'; user: AuthUser; apiBaseUrl: string } + | { status: 'unauthenticated' }; + +/** Rust 续期结果投影,与 `ClientAuthRefreshView` 一一对应。 */ +type RustAuthRefreshView = + | { status: 'refreshed'; user: AuthUser } + | { status: 'unauthenticated' } + | { status: 'stale' }; /** * 恢复登录态。 * - * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;这里只把结果投影成 - * `authenticated` / `unauthenticated` / `unavailable` 三态,供登录页决定展示分支。 + * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败, + * 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。 */ export async function readClientAuthState( expectedApiBaseUrl?: string, ): Promise { - const invoke = requireInvoke(); - const view = await invoke('read_client_auth_state', { - expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null, - }); - if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + const view = await invokeClientAuth( + 'read_client_auth_state', + { + expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null, + }, + ); + if (view.status === 'authenticated') { return { status: 'authenticated', user: view.user, apiBaseUrl: view.apiBaseUrl, }; } - if (view?.status === 'unavailable') { - return { - status: 'unavailable', - message: view.errorMessage?.trim() || '登录服务暂时不可用,请稍后重试', - }; - } return { status: 'unauthenticated' }; } @@ -94,8 +105,7 @@ export async function sendClientPhoneLoginCode( phone: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - const result = await invoke<{ + const result = await invokeClientAuth<{ cooldownSeconds?: number; expiresInSeconds?: number; }>('send_client_phone_login_code', { @@ -113,8 +123,7 @@ export async function loginClientWithPassword( password: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - return invoke('login_client_with_password', { + return invokeClientAuth('login_client_with_password', { apiBaseUrl, phone: normalizeAuthPhoneInput(phone), password: password.trim(), @@ -126,8 +135,7 @@ export async function loginClientWithPhoneCode( code: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - return invoke('login_client_with_phone_code', { + return invokeClientAuth('login_client_with_phone_code', { apiBaseUrl, phone: normalizeAuthPhoneInput(phone), code: code.trim(), @@ -136,43 +144,23 @@ export async function loginClientWithPhoneCode( /** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */ export async function logoutClientAuthSession(): Promise { - const invoke = requireInvoke(); - await invoke('logout_client_session'); + await invokeClientAuth('logout_client_session'); } export async function refreshClientAuthSession( expectedUserId?: string, ): Promise { - const invoke = requireInvoke(); - const view = await invoke<{ - status?: string; - user?: AuthUser | null; - authoritative?: boolean; - errorMessage?: string | null; - }>('refresh_client_auth_session', { - expectedUserId: expectedUserId?.trim() || null, - }); - switch (view?.status) { - case 'refreshed': - if (!view.user) { - return { - status: 'failed', - message: '刷新登录状态失败', - authoritative: false, - }; - } - return { status: 'refreshed', user: view.user }; - case 'unauthenticated': - return { status: 'unauthenticated' }; - case 'stale': - return { status: 'stale' }; - default: - return { - status: 'failed', - message: view?.errorMessage?.trim() || '刷新登录状态失败', - authoritative: view?.authoritative === true, - }; + const view = await invokeClientAuth( + 'refresh_client_auth_session', + { expectedUserId: expectedUserId?.trim() || null }, + ); + if (view.status === 'refreshed') { + return { status: 'refreshed', user: view.user }; } + if (view.status === 'unauthenticated') { + return { status: 'unauthenticated' }; + } + return { status: 'stale' }; } /** 订阅 Rust 认证态事件,返回幂等释放函数。 */ @@ -183,7 +171,7 @@ export function subscribeClientAuthState( CLIENT_AUTH_STATE_CHANGED_EVENT, (event) => { const view = event.payload; - if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + if (view.status === 'authenticated') { listener({ status: 'authenticated', user: view.user, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts deleted file mode 100644 index 6029e91ee..000000000 --- a/apps/ai-game-creator-shell/src/services/clientAuthError.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ClientAuthError } from './generated/ClientAuthError'; - -export type { ClientAuthError }; - -/** - * `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取: - * `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。 - * - * 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`), - * 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。 - * - * **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填 - * `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让 - * "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案 - * ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 - */ -export function isClientAuthError(value: unknown): ClientAuthError | null { - if (!value || typeof value !== 'object') return null; - const candidate = value as { type?: unknown; message?: unknown }; - if (typeof candidate.type !== 'string' || !candidate.type) return null; - if (typeof candidate.message !== 'string') return null; - return value as ClientAuthError; -} diff --git a/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts b/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts new file mode 100644 index 000000000..4cd4386f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts @@ -0,0 +1,30 @@ +import { ClientActionError } from './clientActionError'; +import type { ClientAuthError } from './generated/ClientAuthError'; + +/** + * 认证命令失败在 JS 侧的载体。 + * + * Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被 + * 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类: + * + * - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键; + * - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹; + * - `cause` 是原始拒绝值,保留给上报链路取栈与排障。 + * + * 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方 + * 在 catch 里按 `payload.type` 判。 + */ +export class ClientAuthFailure extends ClientActionError { + readonly payload: ClientAuthError; + + constructor( + message: string, + payload: ClientAuthError, + command: string, + cause: unknown, + ) { + super(message, { source: 'auth', action: command }, cause); + this.name = 'ClientAuthFailure'; + this.payload = payload; + } +} diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 372aa7ca9..4e8886b51 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -168,14 +168,8 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { authoritative: true, }; } - if (result.status === 'stale') { - return { status: 'stale' }; - } - return { - status: 'failed', - error: new Error(result.message), - authoritative: result.authoritative, - }; + // `stale`:续期期间身份已变化,调用方不得重放旧身份请求。 + return { status: 'stale' }; })().then((result) => { notifyPlatformSessionRefresh(result); return result; 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 0628c31ef..b8c37b473 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -5,6 +5,7 @@ import { currentPlatformSessionGeneration, resetPlatformSessionForTests, } from '../../src/services/platformSession'; +import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge'; import { act, AuthenticatedClient, @@ -99,26 +100,40 @@ export function registerAuthTests() { delete window.__TAURI__; }); - it('leaves startup loading with an actionable retry after auth check timeout', async () => { - renderAuthSurface(async (command: string) => { - if (command === 'read_client_auth_state') { - return await new Promise(() => {}); - } - return null; - }); + it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => { + const uninstall = installUnhandledRejectionBridge(); + const reasons: unknown[] = []; + const onRejection = (event: Event) => { + reasons.push((event as Event & { reason?: unknown }).reason); + }; + window.addEventListener('unhandledrejection', onRejection); + try { + renderAuthSurface(async (command: string) => { + if (command === 'read_client_auth_state') { + return await new Promise(() => {}); + } + return null; + }); - expect( - await screen.findByText( - '检查登录状态超时,请检查服务器地址和网络后重试', - undefined, - { - timeout: 20_000, - }, - ), - ).not.toBeNull(); - expect( - screen.getByRole('button', { name: '重试登录状态检查' }), - ).toHaveProperty('disabled', false); + await waitFor( + () => + expect( + reasons.some( + (reason) => + reason instanceof Error && + reason.message.includes('检查登录状态超时'), + ), + ).toBe(true), + { timeout: 20_000 }, + ); + expect( + screen.queryByRole('button', { name: '重试登录状态检查' }), + ).toBeNull(); + await screen.findByRole('main', { name: '登录' }); + } finally { + window.removeEventListener('unhandledrejection', onRejection); + uninstall(); + } }, 30_000); it('renders the unauthenticated client with the shared light platform theme and product image', async () => { @@ -254,47 +269,51 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); }); - it('keeps transport details out of the login failure copy', async () => { - const invoke = vi.fn(async (command: string) => { - if (command === 'read_client_auth_state') { - return { status: 'unauthenticated' }; - } - if (command === 'login_client_with_phone_code') { - throw { - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - }; - } - return null; - }); - renderAuthSurface(invoke); - await loginWithCode(); + it('keeps transport details out of the login copy by throwing system variants through', async () => { + const uninstall = installUnhandledRejectionBridge(); + try { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + throw { + type: 'authNetworkUnavailable', + message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + }; + } + return null; + }); + renderAuthSurface(invoke); + await loginWithCode(); - expect( - await screen.findByText( - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ), - ).not.toBeNull(); - expect(document.body.textContent).not.toContain('ECONNREFUSED'); + await waitFor(() => + expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(), + ); + expect(document.body.textContent).not.toContain('ECONNREFUSED'); + await screen.findByRole('main', { name: '登录' }); + } finally { + uninstall(); + } }); - it('keeps a retry when the startup check reports the service unavailable', async () => { + it('keeps a retry when the startup check is rejected by a business variant', async () => { let reads = 0; const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { reads += 1; return reads === 1 - ? { - status: 'unavailable', - errorMessage: '无法连接登录服务', - } + ? Promise.reject({ + type: 'serverAddressRejected', + message: '服务器地址无效', + }) : { status: 'unauthenticated' }; } return null; }); renderAuthSurface(invoke); - expect(await screen.findByText('无法连接登录服务')).not.toBeNull(); + expect(await screen.findByText('服务器地址无效')).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' })); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 5255f6c47..3ff8a3cec 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -28,6 +28,7 @@ import { invoke } from '@tauri-apps/api/core'; import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; import { resetPlatformSessionForTests } from '../src/services/platformSession'; +import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; type AuthInvoke = ( command: string, @@ -105,24 +106,52 @@ describe('认证失败的上报判据', () => { expect(reportCalls()).toHaveLength(0); }); - it('系统变体(网络不可达)带 auth/login 上下文上报一次', async () => { + it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => { const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; - await submitPasswordLogin(async (command) => { - if (command === 'read_client_auth_state') { - return { status: 'unauthenticated' }; - } - if (command === 'login_client_with_password') { - throw { type: 'authNetworkUnavailable', message }; - } - return null; - }); + const uninstall = installUnhandledRejectionBridge(); + try { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { type: 'authNetworkUnavailable', message }; + } + return null; + }); - expect(await screen.findByText(message)).not.toBeNull(); - await waitFor(() => expect(reportCalls()).toHaveLength(1)); - expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'auth', - action: 'login', - message, - }); + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'auth', + action: 'login_client_with_password', + message, + }); + expect(screen.queryByText(message)).toBeNull(); + } finally { + uninstall(); + } + }); + + it('非结构化拒绝原样抛出,由 unhandledrejection 兜底上报', async () => { + const uninstall = installUnhandledRejectionBridge(); + try { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw new Error('IPC 桥接不可用'); + } + return null; + }); + + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'unhandledrejection', + message: 'IPC 桥接不可用', + }); + } finally { + uninstall(); + } }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts deleted file mode 100644 index 584a63cd2..000000000 --- a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isClientAuthError } from '../src/services/clientAuthError'; -import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; -import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; -import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; - -/** - * 编译期用例:每个变体在 `type` 上判别后都能落到一个**具名载荷类型**, - * 等价于 Java 的 `catch (PhoneNumberInvalid e)`;类型不匹配时 `tsc` 会直接报错。 - */ -function handleByNamedType(error: ClientAuthError) { - switch (error.type) { - case 'phoneNumberInvalid': { - const payload: PhoneNumberInvalid = error; - return payload.message; - } - case 'authServiceUnavailable': { - const payload: AuthServiceUnavailable = error; - return `${payload.status}:${payload.message}`; - } - default: - return ''; - } -} - -describe('AGC 认证命令错误的形状读取', () => { - it('判别后按具名载荷类型分支处理', () => { - expect( - handleByNamedType({ - type: 'phoneNumberInvalid', - message: '请输入正确的手机号', - }), - ).toBe('请输入正确的手机号'); - expect( - handleByNamedType({ - type: 'authServiceUnavailable', - status: 503, - message: '登录服务暂时不可用', - }), - ).toBe('503:登录服务暂时不可用'); - }); - - it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { - expect( - isClientAuthError({ - type: 'phoneOrPasswordMismatch', - message: '手机号或密码错误', - }), - ).toEqual({ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }); - expect(isClientAuthError('手机号或密码错误')).toBeNull(); - expect(isClientAuthError(new Error('手机号或密码错误'))).toBeNull(); - expect( - isClientAuthError({ type: '', message: '手机号或密码错误' }), - ).toBeNull(); - expect(isClientAuthError({ type: 'phoneNumberInvalid' })).toBeNull(); - expect(isClientAuthError(null)).toBeNull(); - }); - - it('未知变体也算形状合法,由调用方按 default 交给错误池', () => { - expect( - isClientAuthError({ type: 'brandNewRejection', message: '新变体' }), - ).toEqual({ type: 'brandNewRejection', message: '新变体' }); - }); - - it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => { - expect( - isClientAuthError({ - type: 'authServiceUnavailable', - message: '登录服务暂时不可用', - }), - ).toEqual({ - type: 'authServiceUnavailable', - message: '登录服务暂时不可用', - }); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts b/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts new file mode 100644 index 000000000..4f87031b9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +/** + * 认证命令失败的 JS 侧载体。 + * + * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝 + * 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷 + * 类型":漏列任何变体,`expectNever(error)` 都编译不过。 + */ +import { describe, expect, it, vi } from 'vitest'; + +import { ClientActionError } from '../src/services/clientActionError'; +import { loginClientWithPassword } from '../src/services/clientAuth'; +import { ClientAuthFailure } from '../src/services/clientAuthFailure'; +import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; +import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; +import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; +import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; +import type { LoginCodeMissing } from '../src/services/generated/LoginCodeMissing'; +import type { PasswordEntryInputRejected } from '../src/services/generated/PasswordEntryInputRejected'; +import type { PasswordMissing } from '../src/services/generated/PasswordMissing'; +import type { PermissionDenied } from '../src/services/generated/PermissionDenied'; +import type { PhoneLoginInputRejected } from '../src/services/generated/PhoneLoginInputRejected'; +import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; +import type { PhoneOrPasswordMismatch } from '../src/services/generated/PhoneOrPasswordMismatch'; +import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; +import type { SendCodeInputRejected } from '../src/services/generated/SendCodeInputRejected'; +import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; +import type { SessionAuthorityRejected } from '../src/services/generated/SessionAuthorityRejected'; +import type { SmsCodeInvalidOrExpired } from '../src/services/generated/SmsCodeInvalidOrExpired'; +import type { SmsCodeThrottled } from '../src/services/generated/SmsCodeThrottled'; +import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; + +function expectNever(value: never): void { + void value; +} + +/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */ +function variantMessage(error: ClientAuthError): string { + switch (error.type) { + case 'serverAddressRejected': + return (error as ServerAddressRejected).message; + case 'phoneNumberInvalid': + return (error as PhoneNumberInvalid).message; + case 'passwordMissing': + return (error as PasswordMissing).message; + case 'loginCodeMissing': + return (error as LoginCodeMissing).message; + case 'passwordEntryInputRejected': + return (error as PasswordEntryInputRejected).message; + case 'phoneOrPasswordMismatch': + return (error as PhoneOrPasswordMismatch).message; + case 'sendCodeInputRejected': + return (error as SendCodeInputRejected).message; + case 'smsCodeThrottled': + return (error as SmsCodeThrottled).message; + case 'phoneLoginInputRejected': + return (error as PhoneLoginInputRejected).message; + case 'smsCodeInvalidOrExpired': + return (error as SmsCodeInvalidOrExpired).message; + case 'sessionAuthorityRejected': + return (error as SessionAuthorityRejected).message; + case 'permissionDenied': + return (error as PermissionDenied).message; + case 'authNetworkUnavailable': + return (error as AuthNetworkUnavailable).message; + case 'authServiceUnavailable': + return (error as AuthServiceUnavailable).message; + case 'unexpectedRejection': + return (error as UnexpectedRejection).message; + case 'authResponseMalformed': + return (error as AuthResponseMalformed).message; + case 'clientSessionPersistFailed': + return (error as ClientSessionPersistFailed).message; + case 'runtimeSessionInstallFailed': + return (error as RuntimeSessionInstallFailed).message; + case 'authClientInitFailed': + return (error as AuthClientInitFailed).message; + default: { + expectNever(error); + return ''; + } + } +} + +function installInvoke(handler: () => Promise) { + window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; +} + +describe('ClientAuthFailure', () => { + it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => { + const rejection = { + type: 'phoneNumberInvalid', + message: '请输入正确的手机号', + }; + installInvoke(async () => { + throw rejection; + }); + + const failure = await loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ClientAuthFailure); + expect(failure).toBeInstanceOf(ClientActionError); + expect((failure as ClientAuthFailure).payload).toEqual(rejection); + expect((failure as ClientAuthFailure).context).toEqual({ + source: 'auth', + action: 'login_client_with_password', + }); + expect((failure as ClientAuthFailure).cause).toBe(rejection); + expect((failure as Error).message).toBe('请输入正确的手机号'); + }); + + it('非结构化拒绝原样抛出,不包装也不改写', async () => { + const rejection = new Error('需要在 Tauri App 内登录'); + installInvoke(async () => { + throw rejection; + }); + + await expect( + loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ), + ).rejects.toBe(rejection); + }); + + 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 as Error).message).toBe(''); + expect((failure as ClientAuthFailure).payload.type).toBe( + 'unexpectedRejection', + ); + }); +}); + +describe('变体分流', () => { + it('判别后按具名载荷类型取值', () => { + expect( + variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }), + ).toBe('格式不对'); + expect( + variantMessage({ + type: 'authServiceUnavailable', + status: 503, + message: '服务暂不可用', + }), + ).toBe('服务暂不可用'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index e6cef4552..d4092efcc 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -10,7 +10,6 @@ import { beforeEach, expect, test, vi } from 'vitest'; const invoke = vi.fn(); import { - getClientAuthErrorMessage, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, @@ -47,7 +46,7 @@ test('手机号归一化只保留可提交的纯号码', () => { expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000'); }); -test('恢复登录态映射为 authenticated / unauthenticated / unavailable', async () => { +test('恢复登录态映射为 authenticated / unauthenticated', async () => { invoke.mockResolvedValueOnce({ status: 'authenticated', user: testUser, @@ -71,15 +70,6 @@ test('恢复登录态映射为 authenticated / unauthenticated / unavailable', a expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { expectedApiBaseUrl: null, }); - - invoke.mockResolvedValueOnce({ - status: 'unavailable', - errorMessage: '无法连接登录服务', - }); - await expect(readClientAuthState()).resolves.toEqual({ - status: 'unavailable', - message: '无法连接登录服务', - }); }); test('登录只提交结构化参数,凭据由 Rust 持有', async () => { @@ -135,7 +125,7 @@ test('登录只提交结构化参数,凭据由 Rust 持有', async () => { expect(serialized).not.toContain('cookie'); }); -test('续期结果区分成功、失效、身份变化与暂时失败', async () => { +test('续期结果区分成功、失效与身份变化', async () => { invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser }); await expect(refreshClientAuthSession('user-1')).resolves.toEqual({ status: 'refreshed', @@ -151,17 +141,6 @@ test('续期结果区分成功、失效、身份变化与暂时失败', async () await expect(refreshClientAuthSession()).resolves.toEqual({ status: 'stale', }); - - invoke.mockResolvedValueOnce({ - status: 'failed', - authoritative: false, - errorMessage: '无法连接登录服务', - }); - await expect(refreshClientAuthSession()).resolves.toEqual({ - status: 'failed', - message: '无法连接登录服务', - authoritative: false, - }); }); test('没有原生宿主时登录能力明确失败关闭', async () => { @@ -179,9 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('错误文案优先使用服务端原因,缺失时回落到调用方文案', () => { - expect( - getClientAuthErrorMessage(new Error('手机号或密码错误'), '登录失败'), - ).toBe('手机号或密码错误'); - expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); +test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => { + const rejection = { + type: 'authNetworkUnavailable', + message: '无法连接登录服务', + }; + invoke.mockRejectedValueOnce(rejection); + await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ + payload: rejection, + context: { source: 'auth', action: 'refresh_client_auth_session' }, + }); }); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts new file mode 100644 index 000000000..01f440388 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -0,0 +1,27 @@ +/** + * 把 Node 的 `unhandledRejection` 桥接成 jsdom `window` 的 `unhandledrejection` 事件。 + * + * 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发 + * `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发 + * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node + * 监听器,再合成一个带 `reason` 的 window 事件。 + * + * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 + * `afterEach` 里 `uninstall()`。 + */ +export function installUnhandledRejectionBridge() { + const originalListeners = process.listeners('unhandledRejection'); + process.removeAllListeners('unhandledRejection'); + const handleRejection = (reason: unknown) => { + const event = new Event('unhandledrejection'); + Object.defineProperty(event, 'reason', { value: reason }); + window.dispatchEvent(event); + }; + process.on('unhandledRejection', handleRejection); + return () => { + process.removeListener('unhandledRejection', handleRejection); + for (const listener of originalListeners) { + process.on('unhandledRejection', listener); + } + }; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 63db45b0f..642b64e7b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8106,7 +8106,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-22 AGC Tauri 命令调用可达性失败关闭 -- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。 +- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput`、认证命令的 `invokeClientAuth` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。 - allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。 - 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。