Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions frontend/src/pages/groups/GroupsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', '创建会话失败')));
}
};

Expand Down
22 changes: 13 additions & 9 deletions frontend/src/services/apiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -180,14 +180,18 @@ export async function parseHttpErrorResponse(response: Response): Promise<ApiErr
});
}

export function getErrorMessage(error: unknown, fallback: string): string {
const value = error instanceof Error ? error.message : error;
const message = messageFromValue(value);
return message && message !== '[object Object]' ? message : fallback;
}

export function normalizeUnknownError(
error: unknown,
context: Partial<Omit<AppErrorContext, 'message'>> = {},
): 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',
Expand Down
13 changes: 13 additions & 0 deletions frontend/tests/apiError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import test from 'node:test';
import {
ApiError,
AppError,
getErrorMessage,
normalizeUnknownError,
parseHttpError,
parseHttpErrorResponse,
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions frontend/tests/groupInteractionContract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\(\)\)/);
Expand Down