Skip to content
Merged
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
3 changes: 2 additions & 1 deletion frontend/viewer/src/lib/components/ui/sonner/sonner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,14 @@ I.e. If there's small, big, small, then sonner thinks the big one is small and p
classes: {
toast:
'gap-3 group toast data-[expanded="true"]:h-max! data-[expanded="true"]:max-h-max group-[.toaster]:bg-background! group-[.toaster]:border-border! group-[.toaster]:shadow-lg',
title: 'line-clamp-4',
description:
'group-[.toast]:text-muted-foreground! max-h-[30vh] overflow-y-auto whitespace-break-spaces touch-pan-y' /* pan-y means the browser should handle y-scrolling (and NOT x-scrolling, which is for swiping away) */,
actionButton: buttonVariants({
size: 'sm',
variant: 'default',
class:
'group-[.toast]:bg-primary! group-[.toast]:text-primary-foreground! h-9! min-h-9 px-3! group-[.toast[data-type="error"]]:i-mdi-content-copy [&.copied]:i-mdi-check! group-[.toast[data-promise="true"][data-type="loading"]]:hidden!',
'group-[.toast]:bg-primary! group-[.toast]:text-primary-foreground! h-9! min-h-9 px-3! group-[.toast[data-type="error"]]:i-mdi-content-copy group-[.toast[data-type="error"]]:[&.copied]:i-mdi-check group-[.toast[data-promise="true"][data-type="loading"]]:hidden!',
}),
cancelButton: buttonVariants({
size: 'sm',
Expand Down
80 changes: 80 additions & 0 deletions frontend/viewer/src/lib/errors/global-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {describe, expect, it} from 'vitest';

import {processErrorIntoDetails, unifyErrorEvent} from './global-errors';

describe('processErrorIntoDetails', () => {
it('splits a .NET error at the first stack frame', () => {
const message = [
'System.InvalidOperationException: Everything is broken.',
' at FwLiteShared.Services.Foo.Bar(String x)',
' at FwLiteShared.Services.Foo.Baz()',
].join('\n');

const {message: title, detail} = processErrorIntoDetails({message, error: null});

expect(title).toBe('System.InvalidOperationException: Everything is broken.');
expect(detail).toContain('at FwLiteShared.Services.Foo.Bar(String x)');
});

it('splits at the inner-exception marker that precedes the outer frames, keeping a wrapped error\'s title short', () => {
// MSAL wrapping an Android network failure: the whole " ---> " cascade comes before the first managed frame
const message = [
'Microsoft.Identity.Client.MsalServiceException: Failed to retrieve OIDC configuration. See inner exception.',
' ---> System.Net.Http.HttpRequestException: Connection failure',
' ---> Java.Net.UnknownHostException: Unable to resolve host',
' at Java.Interop.JniEnvironment.InstanceMethods.CallVoidMethod()',
].join('\n');

const {message: title, detail} = processErrorIntoDetails({message, error: null});

expect(title).toBe('Microsoft.Identity.Client.MsalServiceException: Failed to retrieve OIDC configuration. See inner exception.');
expect(detail).toContain('---> System.Net.Http.HttpRequestException: Connection failure');
});

it('keeps the whole message as the title when there is no .NET stack', () => {
const {message: title, detail} = processErrorIntoDetails({message: 'plain error', error: null});

expect(title).toBe('plain error');
expect(detail).toBeUndefined();
});

it('shows frames-only detail for a JS error, not repeating the title', () => {
const error = new Error('boom');

const {message: title, detail} = processErrorIntoDetails(unifyErrorEvent(error));

expect(title).toBe('Error: boom');
expect(detail).not.toContain('boom');
expect(detail).toContain('at ');
});
});

describe('unifyErrorEvent', () => {
it('uses Error.toString() (type kept, no "Uncaught" prefix) for an ErrorEvent with an error', () => {
const error = new TypeError('x is not a function');
const event = {message: 'Uncaught TypeError: x is not a function', error, filename: 'f', lineno: 1, colno: 2};

const unified = unifyErrorEvent(event as unknown as ErrorEvent);

expect(unified.message).toBe('TypeError: x is not a function');
expect(unified.error).toBe(error);
});

it('uses Error.toString() for a rejected Error reason', () => {
const error = new TypeError('x is not a function');

const unified = unifyErrorEvent({reason: error} as unknown as PromiseRejectionEvent);

expect(unified.message).toBe('TypeError: x is not a function');
expect(unified.error).toBe(error);
});

it('falls back to the event message for an ErrorEvent without an error object', () => {
const event = {message: 'Script error.', error: null, filename: '', lineno: 0, colno: 0};

const unified = unifyErrorEvent(event as unknown as ErrorEvent);

expect(unified.message).toBe('Script error.');
expect(unified.error).toBeNull();
});
});
35 changes: 27 additions & 8 deletions frontend/viewer/src/lib/errors/global-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ type UnifiedErrorEvent = {
at?: string;
}

function unifyErrorEvent(event: ErrorEvent | PromiseRejectionEvent | Error): UnifiedErrorEvent {
// message is normalized here to a display-ready title so consumers don't each re-derive one. For any Error we
// use toString() ("TypeError: x", not the bare "x") to keep the type, and drop the browser's "Uncaught" prefix.
export function unifyErrorEvent(event: ErrorEvent | PromiseRejectionEvent | Error): UnifiedErrorEvent {
if (event instanceof Error) {
return { message: event.message, error: event };
return { message: event.toString(), error: event };
} else if ('message' in event) {
return { message: event.message, error: event.error, at: `${event.filename}:${event.lineno}:${event.colno}` };
return { message: event.error instanceof Error ? event.error.toString() : event.message, error: event.error, at: `${event.filename}:${event.lineno}:${event.colno}` };
} else if (typeof event.reason === 'string') {
return { message: event.reason, error: null };
} else if (event.reason instanceof Error) {
return { message: event.reason.message, error: event.reason };
return { message: event.reason.toString(), error: event.reason };
} else {
return { message: 'Unknown error', error: event.reason };
}
Expand All @@ -37,20 +39,37 @@ function shouldIgnoreError(message: string): boolean {
return false;
}

/** Matches messages/stack traces of the format:
/** Splits a .NET error string into its leading message and the rest (stack/inner exceptions), at whichever
comes first: the first stack frame (" at ") or the first inner-exception marker (" ---> "). The latter matters
because .NET prints the whole inner-exception chain before the outer frames, so without it a deeply-wrapped
error (e.g. MSAL wrapping an Android network failure) dumps the entire cascade into the title.
System.InvalidOperationException: Everything is broken. Here's some ice cream.
at FwLiteShared.Services.ProjectServicesProvider.OpenCrdtProject(String projectName)
*/
const dotnetErrorRegex = /^([\s\S]+?) {3}at /m;
const dotnetErrorRegex = /^([\s\S]+?)(?: {3}at | ---> )/m;

function processErrorIntoDetails(event: UnifiedErrorEvent): {message: string, detail?: string} {
export function processErrorIntoDetails(event: UnifiedErrorEvent): {message: string, detail?: string} {
const message = event.message;
const match = dotnetErrorRegex.exec(message);
if (match) return {message: match[1].trim(), detail: message.substring(match[1].length).trim()};
else if (event.error instanceof Error) return {message: message, detail: event.error.stack};
// stackFrames drops the leading "Error: <message>" header so the message isn't shown in both slots.
else if (event.error instanceof Error) return {message, detail: stackFrames(event.error)};
else return {message};
}

function stackFrames(error: Error): string | undefined {
const stack = error.stack;
if (!stack) return undefined;

// the stack seems to sometimes start with the error message, so we drop it to avoid duplication in the UI
const header = error.toString();
if (stack.startsWith(header)) return stack.slice(header.length).trim();
// perhaps redundant, but cheap
else if (stack.startsWith(error.message)) return stack.slice(error.message.length).trim();

return stack;
}

let setup = false;
export function setupGlobalErrorHandlers() {
if (setup) return;
Expand Down
Loading