diff --git a/web/e2e/fixtures/mock-data/exports.json b/web/e2e/fixtures/mock-data/exports.json index 9af04f45a8..0c1ae83ad9 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}] \ No newline at end of file +[{"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}] diff --git a/web/e2e/scripts/lint-specs.mjs b/web/e2e/scripts/lint-specs.mjs index b0d702f99a..4256271683 100644 --- a/web/e2e/scripts/lint-specs.mjs +++ b/web/e2e/scripts/lint-specs.mjs @@ -44,8 +44,6 @@ const PHASE_3_PENDING = new Set([ "replay.spec.ts", "review.spec.ts", "system.spec.ts", - // export.spec.ts is rewritten in Task 13; remove it before Task 13 - "export.spec.ts", ]); const BANNED_PATTERNS = [ diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 07454231ab..14d09c4bcc 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -1,74 +1,251 @@ /** - * Export page tests -- HIGH tier. + * Export page tests — deepened per the test deepening spec. * - * Tests export card rendering with mock data, search filtering, - * and delete confirmation dialog. + * Covers the deepening checklist: + * 1. Clean render (auto via error fixture) + * 2. Mobile + desktop parity (@mobile test) + * 3. Primary interactions: search, delete, case open, case close + * 4. Empty / loading / error states (via mock-overrides) + * 5. No lenient waits — only real conditions + * 6. Data-driven assertions against fixtures/mock-data/exports.json */ import { test, expect } from "../fixtures/frigate-test"; +import { mockEmpty, mockError, mockDelay } from "../helpers/mock-overrides"; -test.describe("Export Page - Cards @high", () => { - test("export page renders export cards from mock data", async ({ +test.describe("Export Page — Happy Path @high", () => { + test("renders unassigned exports in the main list", async ({ frigateApp, }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - // Should show export names from our mock data + + // 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 expect( frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible({ timeout: 10_000 }); + ).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(); await expect( frigateApp.page.getByText("Backyard - Car Detection"), ).toBeVisible(); }); +}); - test("export page shows in-progress indicator", async ({ frigateApp }) => { +test.describe("Export Page — Search @high", () => { + test("search filter narrows the list to matching exports", async ({ + frigateApp, + }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - // "Garage - In Progress" export should be visible - await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible( - { timeout: 10_000 }, - ); + 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(); }); - test("export page shows case grouping", async ({ frigateApp }) => { + test("clearing the search restores all exports", async ({ frigateApp }) => { await frigateApp.goto("/export"); - 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); + 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(); }); }); -test.describe("Export Page - Search @high", () => { - test("search input filters export list", async ({ frigateApp }) => { +test.describe("Export Page — Delete @high", () => { + test("clicking delete on a card opens the confirmation dialog", async ({ + frigateApp, + }) => { 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.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 expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); + await expect(frigateApp.page.getByText("No exports found")).toBeVisible({ + timeout: 5_000, + }); }); }); -test.describe("Export Page - Controls @high", () => { - test("export page filter controls are present", async ({ frigateApp }) => { +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**"); await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(1000); - const buttons = frigateApp.page.locator("#pageRoot button"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); + + // 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(); }); }); diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 065e0c9008..ed23a6bb40 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 || "none"; + const caseId = exp.export_case_id || "none"; if (!grouped[caseId]) { grouped[caseId] = []; } @@ -477,7 +477,7 @@ function CaseView({ }: CaseViewProps) { const filteredExports = useMemo(() => { const caseExports = (exports || []).filter( - (e) => e.export_case == selectedCase.id, + (e) => e.export_case_id == selectedCase.id, ); if (!search) { diff --git a/web/src/types/export.ts b/web/src/types/export.ts index c606855f2a..9c0223c2f5 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?: string; + export_case_id?: string; }; export type ExportCase = {