mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-15 07:03:33 -04:00
* add error boundary on frontend errors, show a page-level recovery panel and a separate compact strip for the sidebar/status/bottombar so failures there don't take down the rest of the page * tweaks * fix test
215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|