Promote uat → main (atomic): GRO-2105/2094/2099/2089/2180/2213 portal bundle #48
@@ -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 `<div id="root">` 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
|
||||
|
||||
|
||||
@@ -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 `<div id="root">`.
|
||||
*
|
||||
* 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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
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 (
|
||||
<div
|
||||
data-testid="error-boundary"
|
||||
style={{
|
||||
padding: "2rem",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
color: "#7f1d1d",
|
||||
background: "#fef2f2",
|
||||
minHeight: "100vh",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
|
||||
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
|
||||
The app failed to render. The full error is shown below — please share this
|
||||
output when reporting the bug.
|
||||
</p>
|
||||
<pre
|
||||
data-testid="error-boundary-message"
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
background: "#fff",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
padding: "0.75rem 1rem",
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{err.name}: {err.message}
|
||||
{"\n\n"}
|
||||
{err.stack ?? "(no stack)"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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 <div data-testid="good-child">ok</div>;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary (GRO-2094)", () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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(
|
||||
<ErrorBoundary>
|
||||
<GoodChild />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
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(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+37
-3
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user