move export spec rewrite and bugfix to separate branch

This commit is contained in:
Josh Hawkins 2026-04-09 09:50:27 -05:00
parent 826e2ec91e
commit 495e695fca
5 changed files with 48 additions and 219 deletions

View File

@ -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}]

View File

@ -36,6 +36,7 @@ const PENDING_REWRITE = new Set([
"classification.spec.ts", "classification.spec.ts",
"config-editor.spec.ts", "config-editor.spec.ts",
"explore.spec.ts", "explore.spec.ts",
"export.spec.ts",
"face-library.spec.ts", "face-library.spec.ts",
"live.spec.ts", "live.spec.ts",
"logs.spec.ts", "logs.spec.ts",

View File

@ -1,246 +1,74 @@
/** /**
* Export page tests. * Export page tests -- HIGH tier.
* *
* Covers rendering, search, delete confirmation, empty/loading/error * Tests export card rendering with mock data, search filtering,
* states, and mobile-specific interactions (video pane, case detail). * and delete confirmation dialog.
*/ */
import { test, expect } from "../fixtures/frigate-test"; import { test, expect } from "../fixtures/frigate-test";
import { mockEmpty, mockError, mockDelay } from "../helpers/mock-overrides";
test.describe("Export Page — Happy Path @high", () => { test.describe("Export Page - Cards @high", () => {
test("renders unassigned exports in the main list", async ({ test("export page renders export cards from mock data", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.goto("/export"); await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// export-001 (Front Door) and export-003 (Garage) are unassigned — visible // Should show export names from our mock data
// 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( await expect(
frigateApp.page.getByText("Front Door - Person Alert"), frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible(); ).toBeVisible({ timeout: 10_000 });
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( await expect(
frigateApp.page.getByText("Backyard - Car Detection"), frigateApp.page.getByText("Backyard - Car Detection"),
).toBeVisible(); ).toBeVisible();
}); });
});
test.describe("Export Page — Search @high", () => { test("export page shows in-progress indicator", async ({ frigateApp }) => {
test("search filter narrows the list to matching exports", async ({
frigateApp,
}) => {
await frigateApp.goto("/export"); await frigateApp.goto("/export");
await expect( await frigateApp.page.waitForTimeout(2000);
frigateApp.page.getByText("Front Door - Person Alert"), // "Garage - In Progress" export should be visible
).toBeVisible(); await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
{ timeout: 10_000 },
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("clearing the search restores all exports", async ({ frigateApp }) => { test("export page shows case grouping", async ({ frigateApp }) => {
await frigateApp.goto("/export"); await frigateApp.goto("/export");
await expect( await frigateApp.page.waitForTimeout(3000);
frigateApp.page.getByText("Front Door - Person Alert"), // Cases may render differently depending on API response shape
).toBeVisible(); const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
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 — Delete @high", () => { test.describe("Export Page - Search @high", () => {
test("clicking delete on a card opens the confirmation dialog", async ({ test("search input filters export list", async ({ frigateApp }) => {
frigateApp,
}) => {
await frigateApp.goto("/export"); await frigateApp.goto("/export");
await expect( await frigateApp.page.waitForTimeout(2000);
frigateApp.page.getByText("Front Door - Person Alert"), const searchInput = frigateApp.page.locator(
).toBeVisible(); '#pageRoot input[type="text"], #pageRoot input',
);
// Find the card containing "Front Door - Person Alert" and open its menu. if (
// The menu trigger is an "Edit name" BlurredIconButton rendered inside (await searchInput.count()) > 0 &&
// each card. Since all three cards share this label, we scope the (await searchInput.first().isVisible())
// button query to the card that contains the matching text. ) {
const cardMenuTrigger = frigateApp.page // Type a search term that matches one export
.getByRole("button", { name: "Edit name" }) await searchInput.first().fill("Front Door");
.first(); await frigateApp.page.waitForTimeout(500);
await cardMenuTrigger.click(); // "Front Door - Person Alert" should still be visible
await expect(
// Click the Delete export menu item frigateApp.page.getByText("Front Door - Person Alert"),
await frigateApp.page ).toBeVisible();
.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.locator("#pageRoot")).toBeVisible();
await expect(frigateApp.page.getByText("No exports found")).toBeVisible({
timeout: 5_000,
});
}); });
}); });
test.describe("Export Page — Error State @high", () => { test.describe("Export Page - Controls @high", () => {
// The exports endpoint returning 500 will be logged by SWR. Use a single test("export page filter controls are present", async ({ frigateApp }) => {
// 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.goto("/export");
await frigateApp.page.waitForTimeout(1000);
// Page must still mount — the search input is rendered before data loads const buttons = frigateApp.page.locator("#pageRoot button");
await expect( const count = await buttons.count();
frigateApp.page.getByPlaceholder(/search/i).first(), expect(count).toBeGreaterThan(0);
).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();
}); });
}); });

View File

@ -71,7 +71,7 @@ function Exports() {
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
const grouped: { [caseId: string]: Export[] } = {}; const grouped: { [caseId: string]: Export[] } = {};
(rawExports ?? []).forEach((exp) => { (rawExports ?? []).forEach((exp) => {
const caseId = exp.export_case_id || "none"; const caseId = exp.export_case || "none";
if (!grouped[caseId]) { if (!grouped[caseId]) {
grouped[caseId] = []; grouped[caseId] = [];
} }
@ -477,7 +477,7 @@ function CaseView({
}: CaseViewProps) { }: CaseViewProps) {
const filteredExports = useMemo<Export[]>(() => { const filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter( const caseExports = (exports || []).filter(
(e) => e.export_case_id == selectedCase.id, (e) => e.export_case == selectedCase.id,
); );
if (!search) { if (!search) {

View File

@ -6,7 +6,7 @@ export type Export = {
video_path: string; video_path: string;
thumb_path: string; thumb_path: string;
in_progress: boolean; in_progress: boolean;
export_case_id?: string; export_case?: string;
}; };
export type ExportCase = { export type ExportCase = {