diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index f39b5ae62..94141d8ac 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -18,6 +18,7 @@ import { } from '@tabler/icons-react'; import { groupApi } from '../../services/groupApi'; import { fetchJson } from '../../services/api'; +import { getErrorMessage } from '../../services/apiError'; import { compareCursor, type GroupActivity, @@ -634,8 +635,8 @@ export default function GroupsPage() { await queryClient.invalidateQueries({ queryKey: ['group-sessions', targetGroupId] }); setExpandedGroups((current) => new Set(current).add(targetGroupId)); navigate(`/groups/${targetGroupId}/${session.id}`); - } catch (error: any) { - toast.error(error?.message ?? t('groups.createSessionFailed', '创建会话失败')); + } catch (error: unknown) { + toast.error(getErrorMessage(error, t('groups.createSessionFailed', '创建会话失败'))); } }; diff --git a/frontend/src/services/apiError.ts b/frontend/src/services/apiError.ts index 8dcdd8370..a9cc3ce95 100644 --- a/frontend/src/services/apiError.ts +++ b/frontend/src/services/apiError.ts @@ -99,17 +99,17 @@ function validationMessage(items: unknown[]): string | undefined { return messages.length ? messages.join('; ') : undefined; } -function messageFromValue(value: unknown): string | undefined { +function messageFromValue(value: unknown, depth = 0): string | undefined { + if (depth > 4) return undefined; if (typeof value === 'string') return optionalString(value); if (Array.isArray(value)) return validationMessage(value); if (!isRecord(value)) return value == null ? undefined : String(value); - const direct = optionalString(value.message) ?? optionalString(value.detail); + const direct = messageFromValue(value.message, depth + 1) + ?? messageFromValue(value.detail, depth + 1); if (direct) return direct; - if (isRecord(value.error)) { - const nested = optionalString(value.error.message) ?? optionalString(value.error.detail); - if (nested) return nested; - } + const nested = messageFromValue(value.error, depth + 1); + if (nested) return nested; return stableStringify(value); } @@ -180,14 +180,18 @@ export async function parseHttpErrorResponse(response: Response): Promise> = {}, ): AppError { if (error instanceof AppError) return error; - const message = error instanceof Error - ? error.message - : messageFromValue(error) ?? 'Unknown error'; + const message = getErrorMessage(error, 'Unknown error'); return new AppError({ message, code: context.code ?? 'unknown_error', diff --git a/frontend/tests/apiError.test.mjs b/frontend/tests/apiError.test.mjs index 69e47689e..cfdc7407d 100644 --- a/frontend/tests/apiError.test.mjs +++ b/frontend/tests/apiError.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test'; import { ApiError, AppError, + getErrorMessage, normalizeUnknownError, parseHttpError, parseHttpErrorResponse, @@ -104,6 +105,18 @@ test('plain text and empty responses receive useful messages', () => { assert.equal(parseHttpError({ status: 404, statusText: 'Not Found', bodyText: '' }).message, 'HTTP 404 Not Found'); }); +test('user-facing errors unwrap nested messages and reject object coercion', () => { + assert.equal( + getErrorMessage({ message: { error: { message: 'Session backend unavailable' } } }, 'Could not create session'), + 'Session backend unavailable', + ); + + const malformed = new Error('placeholder'); + Object.defineProperty(malformed, 'message', { value: { detail: 'Session creation denied' } }); + assert.equal(getErrorMessage(malformed, 'Could not create session'), 'Session creation denied'); + assert.equal(getErrorMessage(new Error('[object Object]'), 'Could not create session'), 'Could not create session'); +}); + test('unknown thrown values normalize to typed AppError instances', () => { const native = normalizeUnknownError(new Error('connection reset'), { code: 'network_error', diff --git a/frontend/tests/groupInteractionContract.test.mjs b/frontend/tests/groupInteractionContract.test.mjs index be9a1556c..21819a2cb 100644 --- a/frontend/tests/groupInteractionContract.test.mjs +++ b/frontend/tests/groupInteractionContract.test.mjs @@ -31,6 +31,14 @@ const groupStyles = readFileSync( 'utf8', ); +test('session creation displays a normalized string error instead of object coercion', () => { + assert.match(groupsPage, /import \{ getErrorMessage \} from '\.\.\/\.\.\/services\/apiError'/); + assert.match( + groupsPage, + /const createSession = async[\s\S]*?catch \(error: unknown\) \{[\s\S]*?toast\.error\(getErrorMessage\(error, t\('groups\.createSessionFailed'/, + ); +}); + test('new group sessions may use the backend default title while group names stay required', () => { assert.match(promptModal, /allowEmpty\?: boolean/); assert.match(promptModal, /allowEmpty \|\| Boolean\(value\.trim\(\)\)/);