diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index fb51993..5522090 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -54,6 +54,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page | | TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session | | TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). | +| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `
` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. | +| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. | ### 5.2 Authentication — VITE_API_URL Set diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx new file mode 100644 index 0000000..5e0b730 --- /dev/null +++ b/src/ErrorBoundary.tsx @@ -0,0 +1,77 @@ +import { Component } from "react"; +import type { ErrorInfo, ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Top-level ErrorBoundary — renders the error visibly so the actual exception + * appears in the DOM (and therefore in the Playwright snapshot) instead of + * React 18+ unmounting the entire tree to a blank `
`. + * + * Background: GRO-2094. The bundle was executing but never painting, with + * the failure swallowed. Surfacing the error here is the first step; the + * real fix is in the underlying component that threw. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Also surface to the console — this is what the test harness greps for. + // eslint-disable-next-line no-console + console.error("[ErrorBoundary] Uncaught render error:", error, info); + } + + render() { + if (this.state.error) { + const err = this.state.error; + return ( +
+

Something went wrong

+

+ The app failed to render. The full error is shown below — please share this + output when reporting the bug. +

+
+            {err.name}: {err.message}
+            {"\n\n"}
+            {err.stack ?? "(no stack)"}
+          
+
+ ); + } + return this.props.children; + } +} diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..6d6d4c9 --- /dev/null +++ b/src/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { ErrorBoundary } from "../ErrorBoundary"; + +function ThrowingChild(): never { + throw new Error("synthetic render-time failure for GRO-2094"); +} + +function GoodChild() { + return
ok
; +} + +describe("ErrorBoundary (GRO-2094)", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + // React 18+ logs caught render errors to console.error via React's own + // instrumentation; suppress it so test output is clean but capture it + // for an assertion below. + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + cleanup(); + }); + + it("renders children when nothing throws", () => { + render( + + + + ); + expect(screen.getByTestId("good-child")).toBeInTheDocument(); + expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument(); + }); + + it("renders the error visibly when a child throws during render", () => { + render( + + + + ); + + const fallback = screen.getByTestId("error-boundary"); + expect(fallback).toBeInTheDocument(); + const message = screen.getByTestId("error-boundary-message"); + // The actual exception is shown — no more silent blank root. + expect(message.textContent).toContain("synthetic render-time failure for GRO-2094"); + // The boundary also calls console.error so it shows up in the Playwright + // console log even if the DOM-rendered fallback is somehow missed. + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index 3920a8d..77ffcf1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,41 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App.js"; +import { ErrorBoundary } from "./ErrorBoundary.js"; import { installDevFetchInterceptor } from "./lib/devFetch.js"; import "./index.css"; +// -------------------------------------------------------------------- +// Global error capture (GRO-2094). +// +// Symptom: React root stays empty at /login — bundle parses, no console +// errors, no error boundary fallback. Some failure is being swallowed +// before it reaches React's commit phase. These listeners make sure any +// thrown error or unhandled promise rejection is at least visible in +// the console (and in the Playwright network/console log) instead of +// vanishing into the void. +// -------------------------------------------------------------------- +function reportGlobalError(kind: string, payload: unknown): void { + // eslint-disable-next-line no-console + console.error(`[${kind}]`, payload); +} + +window.addEventListener("error", (event) => { + reportGlobalError("window.error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error, + }); +}); + +window.addEventListener("unhandledrejection", (event) => { + reportGlobalError("unhandledrejection", { + reason: event.reason, + }); +}); + installDevFetchInterceptor(); const root = document.getElementById("root"); @@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found"); createRoot(root).render( - - - + + + + + );