Skip to content

Commit 0f5ab25

Browse files
authored
Merge pull request #172 from bcgov/AI-1261
AI-1261 Frontend Error Page
2 parents c672f40 + 465c03b commit 0f5ab25

12 files changed

Lines changed: 594 additions & 29 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Test, TestingModule } from "@nestjs/testing";
2+
import { AppLoggerService } from "./app-logger.service";
3+
import { ClientErrorController } from "./client-error.controller";
4+
import { ClientErrorDto } from "./dto/client-error.dto";
5+
6+
describe("ClientErrorController", () => {
7+
let controller: ClientErrorController;
8+
let logger: AppLoggerService;
9+
10+
const mockLogger = {
11+
error: jest.fn(),
12+
};
13+
14+
beforeEach(async () => {
15+
jest.clearAllMocks();
16+
17+
const module: TestingModule = await Test.createTestingModule({
18+
controllers: [ClientErrorController],
19+
providers: [
20+
{
21+
provide: AppLoggerService,
22+
useValue: mockLogger,
23+
},
24+
],
25+
}).compile();
26+
27+
controller = module.get<ClientErrorController>(ClientErrorController);
28+
logger = module.get<AppLoggerService>(AppLoggerService);
29+
});
30+
31+
describe("reportClientError", () => {
32+
it("should log the error message and return received: true", () => {
33+
const dto: ClientErrorDto = { message: "Something broke" };
34+
35+
const result = controller.reportClientError(dto);
36+
37+
expect(result).toEqual({ received: true });
38+
expect(logger.error).toHaveBeenCalledWith(
39+
"Client-side error reported",
40+
expect.objectContaining({ errorMessage: "Something broke" }),
41+
);
42+
});
43+
44+
it("should include all optional fields in log context when provided", () => {
45+
const dto: ClientErrorDto = {
46+
message: "Render error",
47+
componentStack: " at MyComponent\n at App",
48+
errorStack: "Error: Render error\n at MyComponent (index.js:10:5)",
49+
url: "https://example.com/queue",
50+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
51+
};
52+
53+
controller.reportClientError(dto);
54+
55+
expect(logger.error).toHaveBeenCalledWith(
56+
"Client-side error reported",
57+
expect.objectContaining({
58+
errorMessage: "Render error",
59+
componentStack: dto.componentStack,
60+
errorStack: dto.errorStack,
61+
url: dto.url,
62+
userAgent: dto.userAgent,
63+
}),
64+
);
65+
});
66+
67+
it("should omit optional fields from log context when not provided", () => {
68+
const dto: ClientErrorDto = { message: "Error without extras" };
69+
70+
controller.reportClientError(dto);
71+
72+
expect(logger.error).toHaveBeenCalledWith(
73+
"Client-side error reported",
74+
expect.not.objectContaining({
75+
componentStack: expect.anything(),
76+
errorStack: expect.anything(),
77+
url: expect.anything(),
78+
userAgent: expect.anything(),
79+
}),
80+
);
81+
});
82+
});
83+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
2+
import {
3+
ApiOkResponse,
4+
ApiOperation,
5+
ApiTags,
6+
ApiUnauthorizedResponse,
7+
} from "@nestjs/swagger";
8+
import { Identity } from "@/auth/identity.decorator";
9+
import { AppLoggerService } from "./app-logger.service";
10+
import { ClientErrorDto, ClientErrorResponseDto } from "./dto/client-error.dto";
11+
12+
/**
13+
* Receives client-side errors reported by the frontend ErrorBoundary and
14+
* writes them to the structured application log so they appear in Loki.
15+
*/
16+
@ApiTags("Logging")
17+
@Controller("api/client-errors")
18+
export class ClientErrorController {
19+
constructor(private readonly logger: AppLoggerService) {}
20+
21+
/**
22+
* Report a client-side error caught by the frontend ErrorBoundary.
23+
*/
24+
@Post()
25+
@HttpCode(HttpStatus.OK)
26+
@Identity()
27+
@ApiOperation({
28+
summary: "Report a client-side error",
29+
description:
30+
"Accepts an error caught by the React ErrorBoundary and logs it server-side so it is captured by the structured logging pipeline.",
31+
})
32+
@ApiOkResponse({
33+
description: "Error received and logged",
34+
type: ClientErrorResponseDto,
35+
})
36+
@ApiUnauthorizedResponse({ description: "Not authenticated" })
37+
reportClientError(@Body() dto: ClientErrorDto): ClientErrorResponseDto {
38+
this.logger.error("Client-side error reported", {
39+
errorMessage: dto.message,
40+
...(dto.componentStack && { componentStack: dto.componentStack }),
41+
...(dto.errorStack && { errorStack: dto.errorStack }),
42+
...(dto.url && { url: dto.url }),
43+
...(dto.userAgent && { userAgent: dto.userAgent }),
44+
});
45+
46+
return { received: true };
47+
}
48+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsOptional, IsString } from "class-validator";
3+
4+
/**
5+
* DTO representing a client-side error reported by the frontend ErrorBoundary.
6+
*/
7+
export class ClientErrorDto {
8+
@ApiProperty({ description: "Error message from the thrown Error object" })
9+
@IsString()
10+
message!: string;
11+
12+
@ApiPropertyOptional({
13+
description: "React component stack trace from ErrorInfo",
14+
})
15+
@IsOptional()
16+
@IsString()
17+
componentStack?: string;
18+
19+
@ApiPropertyOptional({
20+
description: "JavaScript error stack trace from the Error object",
21+
})
22+
@IsOptional()
23+
@IsString()
24+
errorStack?: string;
25+
26+
@ApiPropertyOptional({
27+
description: "Browser URL where the error occurred (window.location.href)",
28+
})
29+
@IsOptional()
30+
@IsString()
31+
url?: string;
32+
33+
@ApiPropertyOptional({
34+
description: "Browser user agent string (navigator.userAgent)",
35+
})
36+
@IsOptional()
37+
@IsString()
38+
userAgent?: string;
39+
}
40+
41+
/**
42+
* Response DTO returned after a client error is accepted.
43+
*/
44+
export class ClientErrorResponseDto {
45+
@ApiProperty({ description: "Indicates the error was received successfully" })
46+
received!: boolean;
47+
}

apps/backend-services/src/logging/logging.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { Global, Module, type NestModule } from "@nestjs/common";
22
import { APP_INTERCEPTOR } from "@nestjs/core";
33
import { MetricsModule } from "@/metrics/metrics.module";
44
import { AppLoggerService } from "./app-logger.service";
5+
import { ClientErrorController } from "./client-error.controller";
56
import { LoggingMiddleware } from "./logging.middleware";
67
import { RequestLoggingInterceptor } from "./request-logging.interceptor";
78

89
@Global()
910
@Module({
1011
imports: [MetricsModule],
12+
controllers: [ClientErrorController],
1113
providers: [
1214
AppLoggerService,
1315
LoggingMiddleware,

apps/frontend/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MembershipPageGuard, NoGroupGuard } from "./auth/NoGroupGuard";
33
import { useAuth } from "./auth/useAuth";
44
import { Stack, Text, Title } from "./ui";
55
import "./App.css";
6-
import { Login } from "./components";
6+
import { Login, RouterErrorPage } from "./components";
77
import { ReviewQueuePage } from "./features/annotation/hitl/pages/ReviewQueuePage";
88
import { ReviewWorkspacePage } from "./features/annotation/hitl/pages/ReviewWorkspacePage";
99
import { LabelingWorkspacePage } from "./features/annotation/template-models/pages/LabelingWorkspacePage";
@@ -49,6 +49,7 @@ const router = createBrowserRouter([
4949
<RootLayout />
5050
</NoGroupGuard>
5151
),
52+
errorElement: <RouterErrorPage />,
5253
children: [
5354
{ index: true, element: <UploadPage /> },
5455
{ path: "documents", element: <DocumentsPage /> },
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { MantineProvider } from "@mantine/core";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
import { type ReactNode } from "react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { apiService } from "../data/services/api.service";
6+
import { ErrorBoundary } from "./ErrorBoundary";
7+
8+
vi.mock("../data/services/api.service", () => ({
9+
apiService: {
10+
post: vi.fn().mockResolvedValue({ success: true, data: null }),
11+
},
12+
}));
13+
14+
const mockPost = vi.mocked(apiService.post);
15+
16+
const Wrapper = ({ children }: { children: ReactNode }) => (
17+
<MantineProvider>{children}</MantineProvider>
18+
);
19+
20+
/**
21+
* Creates an isolated ThrowingChild component with its own closure-scoped
22+
* `shouldThrow` flag. Each test gets a fresh instance so there is no
23+
* shared mutable state between tests, making order-independent execution safe.
24+
*/
25+
const createThrowingChild = () => {
26+
let shouldThrow = false;
27+
const ThrowingChild = () => {
28+
if (shouldThrow) throw new Error("Test render error");
29+
return <div>Safe content</div>;
30+
};
31+
return {
32+
ThrowingChild,
33+
setThrow: (value: boolean) => {
34+
shouldThrow = value;
35+
},
36+
};
37+
};
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
// Suppress React's console.error output for intentional error boundary tests
42+
vi.spyOn(console, "error").mockImplementation(() => undefined);
43+
});
44+
45+
describe("ErrorBoundary", () => {
46+
it("renders children when no error is thrown", () => {
47+
const { ThrowingChild } = createThrowingChild();
48+
49+
render(
50+
<Wrapper>
51+
<ErrorBoundary>
52+
<ThrowingChild />
53+
</ErrorBoundary>
54+
</Wrapper>,
55+
);
56+
57+
expect(screen.getByText("Safe content")).toBeInTheDocument();
58+
});
59+
60+
it("shows the fallback UI when a child throws", () => {
61+
const { ThrowingChild, setThrow } = createThrowingChild();
62+
setThrow(true);
63+
64+
render(
65+
<Wrapper>
66+
<ErrorBoundary>
67+
<ThrowingChild />
68+
</ErrorBoundary>
69+
</Wrapper>,
70+
);
71+
72+
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
73+
expect(
74+
screen.getByRole("button", { name: "Try again" }),
75+
).toBeInTheDocument();
76+
});
77+
78+
it("reports the error to the backend on catch", () => {
79+
const { ThrowingChild, setThrow } = createThrowingChild();
80+
setThrow(true);
81+
82+
render(
83+
<Wrapper>
84+
<ErrorBoundary>
85+
<ThrowingChild />
86+
</ErrorBoundary>
87+
</Wrapper>,
88+
);
89+
90+
expect(mockPost).toHaveBeenCalledWith(
91+
"client-errors",
92+
expect.objectContaining({ message: "Test render error" }),
93+
);
94+
});
95+
96+
it("resets and shows children again when Try again is clicked and error is resolved", () => {
97+
const { ThrowingChild, setThrow } = createThrowingChild();
98+
setThrow(true);
99+
100+
render(
101+
<Wrapper>
102+
<ErrorBoundary>
103+
<ThrowingChild />
104+
</ErrorBoundary>
105+
</Wrapper>,
106+
);
107+
108+
setThrow(false);
109+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
110+
111+
expect(screen.getByText("Safe content")).toBeInTheDocument();
112+
});
113+
114+
it("shows Go to home page button on the final retry attempt", () => {
115+
const { ThrowingChild, setThrow } = createThrowingChild();
116+
setThrow(true);
117+
118+
render(
119+
<Wrapper>
120+
<ErrorBoundary>
121+
<ThrowingChild />
122+
</ErrorBoundary>
123+
</Wrapper>,
124+
);
125+
126+
// Click through until the last attempt (MAX_RETRIES - 1 = 2 retries,
127+
// error persists so the boundary re-catches each time)
128+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
129+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
130+
131+
expect(
132+
screen.getByRole("button", { name: "Go to home page" }),
133+
).toBeInTheDocument();
134+
});
135+
136+
it("redirects to home when Go to home page is clicked", () => {
137+
const originalLocation = window.location;
138+
Object.defineProperty(window, "location", {
139+
value: { href: "" },
140+
writable: true,
141+
});
142+
143+
const { ThrowingChild, setThrow } = createThrowingChild();
144+
setThrow(true);
145+
146+
render(
147+
<Wrapper>
148+
<ErrorBoundary>
149+
<ThrowingChild />
150+
</ErrorBoundary>
151+
</Wrapper>,
152+
);
153+
154+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
155+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
156+
fireEvent.click(screen.getByRole("button", { name: "Go to home page" }));
157+
158+
expect(window.location.href).toBe("/");
159+
160+
Object.defineProperty(window, "location", {
161+
value: originalLocation,
162+
writable: true,
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)