diff --git a/web/e2e/specs/error-boundary.spec.ts b/web/e2e/specs/error-boundary.spec.ts new file mode 100644 index 0000000000..f68c1115fe --- /dev/null +++ b/web/e2e/specs/error-boundary.spec.ts @@ -0,0 +1,214 @@ +/** + * App shell error boundaries. + * + * Both failures are forced through the mock layer rather than through test + * hooks in the app. A non-array payload reaches a component that treats it as + * a list and throws on the first render; aborting a page's asset request + * reproduces what an open tab sees after Frigate is updated underneath it. + */ + +import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; +import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard"; + +/** Exports builds its list with `rawExports.filter(...)`. */ +async function breakExportsPage(app: FrigateApp) { + await app.page.route("**/api/exports**", (route) => + route.fulfill({ json: { unexpected: true } }), + ); + await app.goto("/export"); + await expect(app.page.getByTestId("error-panel")).toBeVisible({ + timeout: 10_000, + }); +} + +/** + * `useStats` runs `Object.entries(stats.detectors)` and only the status bar + * and bottom bar call it, so null detectors throw in one chrome component. + */ +async function breakStatusbar(app: FrigateApp) { + await app.installDefaults({ stats: { detectors: null } }); + await app.goto("/"); +} + +/** GeneralSettings and the status bar both read profiles, so both throw. */ +async function breakAllDesktopChrome(app: FrigateApp) { + await app.page.route("**/api/profiles**", (route) => + route.fulfill({ + json: { profiles: { broken: true }, active_profile: "default" }, + }), + ); + await app.goto("/"); +} + +test.describe("Error boundaries - page failure @high", () => { + // React mirrors the caught error to console.error, and the panel shows the + // TypeError message on purpose. + test.use({ + expectedErrors: [ + /is not a function|An error occurred in the|The above error occurred/, + ], + }); + + test("a thrown page renders a recovery panel, not a blank screen", async ({ + frigateApp, + }) => { + await breakExportsPage(frigateApp); + + const panel = frigateApp.page.getByTestId("error-panel"); + await expect(panel.getByText("This page stopped working")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible(); + await expect(panel.getByTestId("error-panel-message")).toContainText( + "is not a function", + ); + + // Contained to the route: the panel sits in the page container and the + // chrome boundary never trips, so navigation stays usable. + await expect( + frigateApp.page.locator("#pageRoot [data-testid='error-panel']"), + ).toBeVisible(); + await expect(frigateApp.page.getByTestId("error-strip")).toHaveCount(0); + await expect(frigateApp.page.locator('a[href="/"]').first()).toBeVisible(); + }); + + test("Copy details puts a triageable report on the clipboard", async ({ + frigateApp, + context, + }) => { + await grantClipboardPermissions(context); + await breakExportsPage(frigateApp); + + await frigateApp.page + .getByTestId("error-panel") + .getByRole("button", { name: "Copy details" }) + .click(); + + await expect + .poll(() => readClipboard(frigateApp.page), { timeout: 5_000 }) + .toContain("Frigate UI crash report"); + + const report = await readClipboard(frigateApp.page); + expect(report).toContain("Version: 0.15.0-test"); + expect(report).toMatch(/^Page: http/m); + expect(report).toContain("is not a function"); + }); + + test("navigating away drops the panel", async ({ frigateApp }) => { + await breakExportsPage(frigateApp); + + await frigateApp.page.locator('a[href="/"]').first().click(); + await expect(frigateApp.page).toHaveURL(/\/$/); + await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0); + await expect( + frigateApp.page.locator("[data-camera='front_door']"), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("@mobile the panel leaves the bottom bar reachable", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only assertion"); + await breakExportsPage(frigateApp); + + await expect( + frigateApp.page + .getByTestId("error-panel") + .getByRole("button", { name: "Reload" }), + ).toBeVisible(); + await expect( + frigateApp.page.locator('a[href="/review"]').first(), + ).toBeVisible(); + }); +}); + +test.describe("Error boundaries - chrome failure @high", () => { + test.use({ + expectedErrors: [ + /is not a function|An error occurred in the|The above error occurred/, + ], + }); + + test("a thrown status bar leaves the sidebar usable", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "The status bar is desktop chrome"); + await breakStatusbar(frigateApp); + + const strip = frigateApp.page.getByTestId("error-strip"); + await expect(strip).toBeVisible({ timeout: 10_000 }); + await expect(strip).toHaveCount(1); + await expect(strip.getByRole("button", { name: "Reload" })).toBeVisible(); + + // Each chrome component owns a boundary, so the sidebar outlives the + // status bar and the user can still navigate out. + await expect(frigateApp.page.locator("aside")).toBeVisible(); + await expect( + frigateApp.page.locator('a[href="/review"]').first(), + ).toBeVisible(); + await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0); + }); + + test("chrome failures never reach the page content", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop chrome"); + await breakAllDesktopChrome(frigateApp); + + await expect( + frigateApp.page.getByTestId("error-strip").first(), + ).toBeVisible({ timeout: 10_000 }); + await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0); + await expect( + frigateApp.page.locator("[data-camera='front_door']"), + ).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Error boundaries - clipboard refused @high", () => { + test.use({ + expectedErrors: [ + /is not a function|An error occurred in the|The above error occurred/, + ], + }); + + test("a refused clipboard write reports the failure", async ({ + frigateApp, + }) => { + // copy-to-clipboard treats a false return from execCommand as a failure + // and falls back to a prompt, which Playwright dismisses on its own. + await frigateApp.page.addInitScript(() => { + document.execCommand = () => false; + }); + await breakExportsPage(frigateApp); + + await frigateApp.page + .getByTestId("error-panel") + .getByRole("button", { name: "Copy details" }) + .click(); + + await expect( + frigateApp.page.getByText("Could not copy details to clipboard"), + ).toBeVisible(); + }); +}); + +test.describe("Error boundaries - stale assets @high", () => { + test.use({ + expectedErrors: [ + /Failed to fetch dynamically imported module|Importing a module script failed|net::ERR_FAILED|An error occurred in the|The above error occurred/, + ], + }); + + test("a missing page chunk asks for a reload", async ({ frigateApp }) => { + await frigateApp.page.route( + /\/assets\/Exports-[^/]+\.js(\?.*)?$/, + (route) => route.abort("failed"), + ); + await frigateApp.goto("/"); + await frigateApp.page.locator('a[href="/export"]').first().click(); + + const panel = frigateApp.page.getByTestId("error-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + await expect(panel.getByText("Reload required")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible(); + }); +}); diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 202e5cddc2..934e730c50 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -164,7 +164,8 @@ "saveAll": "Save All", "savingAll": "Saving All…", "undoAll": "Undo All", - "retry": "Retry" + "retry": "Retry", + "reload": "Reload" }, "menu": { "system": "System", @@ -314,6 +315,15 @@ "title": "404", "desc": "Page not found" }, + "error": { + "title": "This page stopped working", + "desc": "The page ran into an unexpected error. Reloading usually clears it. If it keeps happening, copy the details and attach them to a discussion on GitHub.", + "staleTitle": "Reload required", + "staleDesc": "Part of the interface could not be loaded, which usually means Frigate was updated while this tab was open. Reload to pick up the new version.", + "partial": "Part of the interface stopped working.", + "copyDetails": "Copy details", + "copyFailed": "Could not copy details to clipboard" + }, "selectItem": "Select {{item}}", "readTheDocumentation": "Read the documentation", "information": { diff --git a/web/src/App.tsx b/web/src/App.tsx index f6d43188ff..893819393d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar"; import { isDesktop, isMobile } from "react-device-detect"; import Statusbar from "./components/Statusbar"; import Bottombar from "./components/navigation/Bottombar"; -import { Suspense, lazy, useContext, useEffect, useState } from "react"; +import { lazy, useContext, useEffect, useState } from "react"; import { Redirect } from "./components/navigation/Redirect"; import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; @@ -18,6 +18,7 @@ import { isRedirectingToLogin } from "@/api/auth-redirect"; import { AuthContext } from "@/context/auth-context"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { isSetupDismissed } from "@/utils/setupWizard"; +import { ChromeErrorBoundary, LazyPage } from "@/components/ErrorBoundaries"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -94,22 +95,22 @@ function DefaultAppView() { if (showWizard) { return (
+ {stale ? t("error.staleDesc") : t("error.desc")} +
+
+ {messageOf(failure.error)}
+
+