From 495e695fca137c9756ecde24ed974d621372647d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:50:27 -0500 Subject: [PATCH] move export spec rewrite and bugfix to separate branch --- web/e2e/fixtures/mock-data/exports.json | 2 +- web/e2e/scripts/lint-specs.mjs | 1 + web/e2e/specs/export.spec.ts | 258 ++++-------------------- web/src/pages/Exports.tsx | 4 +- web/src/types/export.ts | 2 +- 5 files changed, 48 insertions(+), 219 deletions(-) diff --git a/web/e2e/fixtures/mock-data/exports.json b/web/e2e/fixtures/mock-data/exports.json index 0c1ae83ad9..9af04f45a8 100644 --- a/web/e2e/fixtures/mock-data/exports.json +++ b/web/e2e/fixtures/mock-data/exports.json @@ -1 +1 @@ -[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}] +[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}] \ No newline at end of file diff --git a/web/e2e/scripts/lint-specs.mjs b/web/e2e/scripts/lint-specs.mjs index 4282f58da3..4724e99bb9 100644 --- a/web/e2e/scripts/lint-specs.mjs +++ b/web/e2e/scripts/lint-specs.mjs @@ -36,6 +36,7 @@ const PENDING_REWRITE = new Set([ "classification.spec.ts", "config-editor.spec.ts", "explore.spec.ts", + "export.spec.ts", "face-library.spec.ts", "live.spec.ts", "logs.spec.ts", diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 7a2670ae38..07454231ab 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -1,246 +1,74 @@ /** - * Export page tests. + * Export page tests -- HIGH tier. * - * Covers rendering, search, delete confirmation, empty/loading/error - * states, and mobile-specific interactions (video pane, case detail). + * Tests export card rendering with mock data, search filtering, + * and delete confirmation dialog. */ import { test, expect } from "../fixtures/frigate-test"; -import { mockEmpty, mockError, mockDelay } from "../helpers/mock-overrides"; -test.describe("Export Page — Happy Path @high", () => { - test("renders unassigned exports in the main list", async ({ +test.describe("Export Page - Cards @high", () => { + test("export page renders export cards from mock data", async ({ frigateApp, }) => { await frigateApp.goto("/export"); - - // export-001 (Front Door) and export-003 (Garage) are unassigned — visible - // in the main list. export-002 (Backyard) belongs to case-001 and appears - // only inside the case detail view, not in the top-level list. + await frigateApp.page.waitForTimeout(2000); + // Should show export names from our mock data await expect( frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - await expect( - frigateApp.page.getByText("Garage - In Progress"), - ).toBeVisible(); - }); - - test("renders the case card from mock data", async ({ frigateApp }) => { - await frigateApp.goto("/export"); - await expect( - frigateApp.page.getByText("Package Theft Investigation"), - ).toBeVisible(); - }); - - test("clicking a case opens its detail view", async ({ frigateApp }) => { - await frigateApp.goto("/export"); - await frigateApp.page - .getByText("Package Theft Investigation") - .first() - .click(); - - await expect( - frigateApp.page.getByRole("heading", { - name: "Package Theft Investigation", - }), - ).toBeVisible(); + ).toBeVisible({ timeout: 10_000 }); await expect( frigateApp.page.getByText("Backyard - Car Detection"), ).toBeVisible(); }); -}); -test.describe("Export Page — Search @high", () => { - test("search filter narrows the list to matching exports", async ({ - frigateApp, - }) => { + test("export page shows in-progress indicator", async ({ frigateApp }) => { await frigateApp.goto("/export"); - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - - const search = frigateApp.page.getByPlaceholder(/search/i).first(); - await search.fill("Front Door"); - - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - // Garage (unassigned) and Package Theft Investigation (case) are filtered out - await expect( - frigateApp.page.getByText("Garage - In Progress"), - ).toBeHidden(); - await expect( - frigateApp.page.getByText("Package Theft Investigation"), - ).toBeHidden(); + await frigateApp.page.waitForTimeout(2000); + // "Garage - In Progress" export should be visible + await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible( + { timeout: 10_000 }, + ); }); - test("clearing the search restores all exports", async ({ frigateApp }) => { + test("export page shows case grouping", async ({ frigateApp }) => { await frigateApp.goto("/export"); - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - - const search = frigateApp.page.getByPlaceholder(/search/i).first(); - await search.fill("Front Door"); - await expect( - frigateApp.page.getByText("Garage - In Progress"), - ).toBeHidden(); - - await search.fill(""); - await expect( - frigateApp.page.getByText("Garage - In Progress"), - ).toBeVisible(); + await frigateApp.page.waitForTimeout(3000); + // Cases may render differently depending on API response shape + const pageText = await frigateApp.page.textContent("#pageRoot"); + expect(pageText?.length).toBeGreaterThan(0); }); }); -test.describe("Export Page — Delete @high", () => { - test("clicking delete on a card opens the confirmation dialog", async ({ - frigateApp, - }) => { +test.describe("Export Page - Search @high", () => { + test("search input filters export list", async ({ frigateApp }) => { await frigateApp.goto("/export"); - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - - // Find the card containing "Front Door - Person Alert" and open its menu. - // The menu trigger is an "Edit name" BlurredIconButton rendered inside - // each card. Since all three cards share this label, we scope the - // button query to the card that contains the matching text. - const cardMenuTrigger = frigateApp.page - .getByRole("button", { name: "Edit name" }) - .first(); - await cardMenuTrigger.click(); - - // Click the Delete export menu item - await frigateApp.page - .getByRole("menuitem", { name: "Delete export" }) - .click(); - - // Confirmation alert dialog visible with the Delete Export button - // (capital E — distinct from the lowercase-e menu item) - await expect( - frigateApp.page.getByRole("button", { name: "Delete Export" }), - ).toBeVisible(); - }); - - test("cancelling the delete dialog leaves the card in place", async ({ - frigateApp, - }) => { - await frigateApp.goto("/export"); - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - - await frigateApp.page - .getByRole("button", { name: "Edit name" }) - .first() - .click(); - await frigateApp.page - .getByRole("menuitem", { name: "Delete export" }) - .click(); - - await frigateApp.page.getByRole("button", { name: /^cancel$/i }).click(); - - await expect( - frigateApp.page.getByRole("button", { name: "Delete Export" }), - ).toBeHidden(); - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - }); -}); - -test.describe("Export Page — States @high", () => { - test("empty state renders when there are no exports or cases", async ({ - page, - frigateApp, - }) => { - await mockEmpty(page, "**/api/exports**"); - await mockEmpty(page, "**/api/cases**"); - await frigateApp.goto("/export"); - - await expect(frigateApp.page.getByText("No exports found")).toBeVisible(); - }); - - test("loading state — empty list resolves after delay", async ({ - page, - frigateApp, - }) => { - await mockDelay(page, "**/api/exports**", 500, []); - await mockDelay(page, "**/api/cases**", 500, []); - await frigateApp.goto("/export"); - + await frigateApp.page.waitForTimeout(2000); + const searchInput = frigateApp.page.locator( + '#pageRoot input[type="text"], #pageRoot input', + ); + if ( + (await searchInput.count()) > 0 && + (await searchInput.first().isVisible()) + ) { + // Type a search term that matches one export + await searchInput.first().fill("Front Door"); + await frigateApp.page.waitForTimeout(500); + // "Front Door - Person Alert" should still be visible + await expect( + frigateApp.page.getByText("Front Door - Person Alert"), + ).toBeVisible(); + } await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - await expect(frigateApp.page.getByText("No exports found")).toBeVisible({ - timeout: 5_000, - }); }); }); -test.describe("Export Page — Error State @high", () => { - // The exports endpoint returning 500 will be logged by SWR. Use a single - // alternation regex per Playwright's isFixtureTuple collision. - test.use({ - expectedErrors: [/500.*\/api\/exports|Failed to load resource.*500/], - }); - - test("API error does not crash the page", async ({ page, frigateApp }) => { - await mockError(page, "**/api/exports**"); +test.describe("Export Page - Controls @high", () => { + test("export page filter controls are present", async ({ frigateApp }) => { await frigateApp.goto("/export"); - - // Page must still mount — the search input is rendered before data loads - await expect( - frigateApp.page.getByPlaceholder(/search/i).first(), - ).toBeVisible(); - }); -}); - -test.describe("Export Page — Mobile @high @mobile", () => { - test("mobile viewport renders the export list and opens video pane", async ({ - frigateApp, - }) => { - test.skip(!frigateApp.isMobile, "Mobile-only assertion"); - - await frigateApp.goto("/export"); - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - - // Click the export card to open the video dialog. This is the exact - // code path that was silently broken for weeks before this spec existed. - await frigateApp.page - .getByText("Front Door - Person Alert") - .first() - .click(); - - // The dialog mounts with the export's name as title - await expect( - frigateApp.page - .getByRole("dialog") - .filter({ hasText: "Front Door - Person Alert" }), - ).toBeVisible(); - - // The video element inside the dialog is present - await expect( - frigateApp.page.locator('[role="dialog"] video'), - ).toBeVisible(); - }); - - test("mobile viewport renders cases and opens case detail", async ({ - frigateApp, - }) => { - test.skip(!frigateApp.isMobile, "Mobile-only assertion"); - - await frigateApp.goto("/export"); - await frigateApp.page - .getByText("Package Theft Investigation") - .first() - .click(); - - await expect( - frigateApp.page.getByRole("heading", { - name: "Package Theft Investigation", - }), - ).toBeVisible(); + await frigateApp.page.waitForTimeout(1000); + const buttons = frigateApp.page.locator("#pageRoot button"); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); }); }); diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index ed23a6bb40..065e0c9008 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -71,7 +71,7 @@ function Exports() { const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const grouped: { [caseId: string]: Export[] } = {}; (rawExports ?? []).forEach((exp) => { - const caseId = exp.export_case_id || "none"; + const caseId = exp.export_case || "none"; if (!grouped[caseId]) { grouped[caseId] = []; } @@ -477,7 +477,7 @@ function CaseView({ }: CaseViewProps) { const filteredExports = useMemo(() => { const caseExports = (exports || []).filter( - (e) => e.export_case_id == selectedCase.id, + (e) => e.export_case == selectedCase.id, ); if (!search) { diff --git a/web/src/types/export.ts b/web/src/types/export.ts index 9c0223c2f5..c606855f2a 100644 --- a/web/src/types/export.ts +++ b/web/src/types/export.ts @@ -6,7 +6,7 @@ export type Export = { video_path: string; thumb_path: string; in_progress: boolean; - export_case_id?: string; + export_case?: string; }; export type ExportCase = {