rewrite export.spec.ts

This commit is contained in:
Josh Hawkins 2026-04-08 16:51:27 -05:00
parent 741c5abdc3
commit b0c772352a
5 changed files with 224 additions and 49 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

@ -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 = [

View File

@ -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();
});
});

View File

@ -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<Export[]>(() => {
const caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id,
(e) => e.export_case_id == selectedCase.id,
);
if (!search) {

View File

@ -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 = {