mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-17 20:50:06 -04:00
932 lines
30 KiB
TypeScript
932 lines
30 KiB
TypeScript
import { test, expect } from "../fixtures/frigate-test";
|
|
|
|
test.describe("Export Page - Overview @high", () => {
|
|
test("renders uncategorized exports and case cards from mock data", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/export");
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("Front Door - Person Alert"),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText("Garage - In Progress"),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText("Package Theft Investigation"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("search filters uncategorized exports", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/export");
|
|
|
|
const searchInput = frigateApp.page.getByPlaceholder(/search/i).first();
|
|
await searchInput.fill("Front Door");
|
|
|
|
await expect(
|
|
frigateApp.page.getByText("Front Door - Person Alert"),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText("Backyard - Car Detection"),
|
|
).toBeHidden();
|
|
await expect(
|
|
frigateApp.page.getByText("Garage - In Progress"),
|
|
).toBeHidden();
|
|
});
|
|
|
|
test("new case button opens the create case dialog", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/export");
|
|
|
|
await frigateApp.page.getByRole("button", { name: "New Case" }).click();
|
|
|
|
await expect(
|
|
frigateApp.page.getByRole("dialog").filter({ hasText: "Create Case" }),
|
|
).toBeVisible();
|
|
await expect(frigateApp.page.getByPlaceholder("Case name")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Export Page - Case Detail @high", () => {
|
|
test("opening a case shows its detail view and associated export", 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();
|
|
await expect(
|
|
frigateApp.page.getByRole("button", { name: "Add Export" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByRole("button", { name: "Edit Case" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByRole("button", { name: "Delete Case" }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("edit case opens a prefilled dialog", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/export");
|
|
|
|
await frigateApp.page
|
|
.getByText("Package Theft Investigation")
|
|
.first()
|
|
.click();
|
|
await frigateApp.page.getByRole("button", { name: "Edit Case" }).click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("dialog")
|
|
.filter({ hasText: "Edit Case" });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.locator("input")).toHaveValue(
|
|
"Package Theft Investigation",
|
|
);
|
|
await expect(dialog.locator("textarea")).toHaveValue(
|
|
"Review of suspicious activity near the front porch",
|
|
);
|
|
});
|
|
|
|
test("add export shows completed uncategorized exports for assignment", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.goto("/export");
|
|
|
|
await frigateApp.page
|
|
.getByText("Package Theft Investigation")
|
|
.first()
|
|
.click();
|
|
await frigateApp.page.getByRole("button", { name: "Add Export" }).click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("dialog")
|
|
.filter({ hasText: "Add Export to Package Theft Investigation" });
|
|
await expect(dialog).toBeVisible();
|
|
// Completed, uncategorized exports are selectable
|
|
await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible();
|
|
// In-progress exports are intentionally hidden by AssignExportDialog
|
|
// (see Exports.tsx filteredExports) — they can't be assigned until
|
|
// they finish, so they should not show in the picker.
|
|
await expect(dialog.getByText("Garage - In Progress")).toBeHidden();
|
|
});
|
|
|
|
test("delete case opens a confirmation dialog", async ({ frigateApp }) => {
|
|
await frigateApp.goto("/export");
|
|
|
|
await frigateApp.page
|
|
.getByText("Package Theft Investigation")
|
|
.first()
|
|
.click();
|
|
await frigateApp.page.getByRole("button", { name: "Delete Case" }).click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("alertdialog")
|
|
.filter({ hasText: "Delete Case" });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.getByText(/Package Theft Investigation/)).toBeVisible();
|
|
});
|
|
|
|
test("delete case can also delete its exports", async ({ frigateApp }) => {
|
|
let deleteRequestUrl: string | null = null;
|
|
let deleteCaseCompleted = false;
|
|
|
|
const initialCases = [
|
|
{
|
|
id: "case-001",
|
|
name: "Package Theft Investigation",
|
|
description: "Review of suspicious activity near the front porch",
|
|
created_at: 1775407931.3863528,
|
|
updated_at: 1775483531.3863528,
|
|
},
|
|
];
|
|
|
|
const initialExports = [
|
|
{
|
|
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,
|
|
},
|
|
];
|
|
|
|
await frigateApp.page.route(/\/api\/cases(?:$|\?|\/)/, async (route) => {
|
|
const request = route.request();
|
|
|
|
if (request.method() === "DELETE") {
|
|
deleteRequestUrl = request.url();
|
|
deleteCaseCompleted = true;
|
|
return route.fulfill({ json: { success: true } });
|
|
}
|
|
|
|
if (request.method() === "GET") {
|
|
return route.fulfill({
|
|
json: deleteCaseCompleted ? [] : initialCases,
|
|
});
|
|
}
|
|
|
|
return route.fallback();
|
|
});
|
|
|
|
await frigateApp.page.route("**/api/exports**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
|
|
return route.fulfill({
|
|
json: deleteCaseCompleted
|
|
? initialExports.filter((exp) => exp.export_case_id !== "case-001")
|
|
: initialExports,
|
|
});
|
|
});
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await frigateApp.page
|
|
.getByText("Package Theft Investigation")
|
|
.first()
|
|
.click();
|
|
await frigateApp.page.getByRole("button", { name: "Delete Case" }).click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("alertdialog")
|
|
.filter({ hasText: "Delete Case" });
|
|
await expect(dialog).toBeVisible();
|
|
|
|
const deleteExportsSwitch = dialog.getByRole("switch", {
|
|
name: "Also delete exports",
|
|
});
|
|
await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "false");
|
|
await expect(
|
|
dialog.getByText(
|
|
"Exports will remain available as uncategorized exports.",
|
|
),
|
|
).toBeVisible();
|
|
|
|
await deleteExportsSwitch.click();
|
|
|
|
await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "true");
|
|
await expect(
|
|
dialog.getByText("All exports in this case will be permanently deleted."),
|
|
).toBeVisible();
|
|
|
|
await dialog.getByRole("button", { name: /^delete$/i }).click();
|
|
|
|
await expect
|
|
.poll(() => deleteRequestUrl)
|
|
.toContain("/api/cases/case-001?delete_exports=true");
|
|
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
frigateApp.page.getByRole("heading", {
|
|
name: "Package Theft Investigation",
|
|
}),
|
|
).toBeHidden();
|
|
await expect(
|
|
frigateApp.page.getByText("Backyard - Car Detection"),
|
|
).toBeHidden();
|
|
await expect(
|
|
frigateApp.page.getByText("Front Door - Person Alert"),
|
|
).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Export Page - Empty State @high", () => {
|
|
test("renders the empty state when there are no exports or cases", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.page.route("**/api/export**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await frigateApp.page.route("**/api/exports**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await frigateApp.page.route("**/api/cases", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await frigateApp.page.route("**/api/cases**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await expect(frigateApp.page.getByText("No exports found")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Export Page - Mobile @high @mobile", () => {
|
|
test("mobile can open an export preview dialog", async ({ frigateApp }) => {
|
|
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await frigateApp.page
|
|
.getByText("Front Door - Person Alert")
|
|
.first()
|
|
.click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("dialog")
|
|
.filter({ hasText: "Front Door - Person Alert" });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.locator("video")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Multi-Review Export @high", () => {
|
|
// Two alert reviews close enough to "now" to fall within the
|
|
// default last-24-hours review window. Using numeric timestamps
|
|
// because the TS ReviewSegment type expects numbers even though
|
|
// the backend pydantic model serializes datetime as ISO strings —
|
|
// the app reads these as numbers for display math.
|
|
const now = Date.now() / 1000;
|
|
const mockReviews = [
|
|
{
|
|
id: "mex-review-001",
|
|
camera: "front_door",
|
|
start_time: now - 600,
|
|
end_time: now - 580,
|
|
has_been_reviewed: false,
|
|
severity: "alert",
|
|
thumb_path: "/clips/front_door/mex-review-001-thumb.jpg",
|
|
data: {
|
|
audio: [],
|
|
detections: ["person-001"],
|
|
objects: ["person"],
|
|
sub_labels: [],
|
|
significant_motion_areas: [],
|
|
zones: ["front_yard"],
|
|
},
|
|
},
|
|
{
|
|
id: "mex-review-002",
|
|
camera: "backyard",
|
|
start_time: now - 1200,
|
|
end_time: now - 1170,
|
|
has_been_reviewed: false,
|
|
severity: "alert",
|
|
thumb_path: "/clips/backyard/mex-review-002-thumb.jpg",
|
|
data: {
|
|
audio: [],
|
|
detections: ["car-002"],
|
|
objects: ["car"],
|
|
sub_labels: [],
|
|
significant_motion_areas: [],
|
|
zones: ["driveway"],
|
|
},
|
|
},
|
|
];
|
|
|
|
// 51 alert reviews, all front_door, spaced 5 minutes apart. Used by the
|
|
// over-limit test to trigger Ctrl+A select-all and verify the Export
|
|
// button is hidden at 51 selected.
|
|
const oversizedReviews = Array.from({ length: 51 }, (_, i) => ({
|
|
id: `mex-oversized-${i.toString().padStart(3, "0")}`,
|
|
camera: "front_door",
|
|
start_time: now - 60 * 60 - i * 300,
|
|
end_time: now - 60 * 60 - i * 300 + 20,
|
|
has_been_reviewed: false,
|
|
severity: "alert",
|
|
thumb_path: `/clips/front_door/mex-oversized-${i}-thumb.jpg`,
|
|
data: {
|
|
audio: [],
|
|
detections: [`person-${i}`],
|
|
objects: ["person"],
|
|
sub_labels: [],
|
|
significant_motion_areas: [],
|
|
zones: ["front_yard"],
|
|
},
|
|
}));
|
|
|
|
const mockSummary = {
|
|
last24Hours: {
|
|
reviewed_alert: 0,
|
|
reviewed_detection: 0,
|
|
total_alert: 2,
|
|
total_detection: 0,
|
|
},
|
|
};
|
|
|
|
async function routeReviews(
|
|
page: import("@playwright/test").Page,
|
|
reviews: unknown[],
|
|
) {
|
|
// Intercept the actual `/api/review` endpoint (singular — the
|
|
// default api-mocker only registers `/api/reviews**` (plural)
|
|
// which does not match the real request URL).
|
|
await page.route(/\/api\/review(\?|$)/, (route) =>
|
|
route.fulfill({ json: reviews }),
|
|
);
|
|
await page.route(/\/api\/review\/summary/, (route) =>
|
|
route.fulfill({ json: mockSummary }),
|
|
);
|
|
}
|
|
|
|
test.beforeEach(async ({ frigateApp }) => {
|
|
await routeReviews(frigateApp.page, mockReviews);
|
|
// Empty cases list by default so the dialog defaults to "new case".
|
|
// Individual tests override this to populate existing cases.
|
|
await frigateApp.page.route("**/api/cases", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
});
|
|
|
|
async function selectTwoReviews(frigateApp: {
|
|
page: import("@playwright/test").Page;
|
|
}) {
|
|
// Every review card has className `review-item` on its wrapper
|
|
// (see EventView.tsx). Cards also have data-start attributes that
|
|
// we can key off if needed.
|
|
const reviewItems = frigateApp.page.locator(".review-item");
|
|
await reviewItems.first().waitFor({ state: "visible", timeout: 10_000 });
|
|
|
|
// Meta-click the first two items to enter multi-select mode.
|
|
// PreviewThumbnailPlayer reads e.metaKey to decide multi-select.
|
|
await reviewItems.nth(0).click({ modifiers: ["Meta"] });
|
|
await reviewItems.nth(1).click();
|
|
}
|
|
|
|
test("selecting two reviews reveals the export button", async ({
|
|
frigateApp,
|
|
}) => {
|
|
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
|
|
|
await frigateApp.goto("/review");
|
|
|
|
await selectTwoReviews(frigateApp);
|
|
|
|
// Action group replaces the filter bar once items are selected
|
|
await expect(frigateApp.page.getByText(/2.*selected/i)).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
|
|
const exportButton = frigateApp.page.getByRole("button", {
|
|
name: /export/i,
|
|
});
|
|
await expect(exportButton).toBeVisible();
|
|
});
|
|
|
|
test("clicking export opens the multi-review dialog with correct title", async ({
|
|
frigateApp,
|
|
}) => {
|
|
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
|
|
|
await frigateApp.goto("/review");
|
|
|
|
await selectTwoReviews(frigateApp);
|
|
|
|
await frigateApp.page
|
|
.getByRole("button", { name: /export/i })
|
|
.first()
|
|
.click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("dialog")
|
|
.filter({ hasText: /Export 2 reviews/i });
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
// The dialog uses a Select trigger for case selection (admins). The
|
|
// default "None" value is shown on the trigger.
|
|
await expect(dialog.locator("button[role='combobox']")).toBeVisible();
|
|
await expect(dialog.getByText(/None/)).toBeVisible();
|
|
});
|
|
|
|
test("starting an export posts the expected payload and navigates to the case", async ({
|
|
frigateApp,
|
|
}) => {
|
|
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
|
|
|
let capturedPayload: unknown = null;
|
|
await frigateApp.page.route("**/api/exports/batch", async (route) => {
|
|
capturedPayload = route.request().postDataJSON();
|
|
await route.fulfill({
|
|
status: 202,
|
|
json: {
|
|
export_case_id: "new-case-xyz",
|
|
export_ids: ["front_door_a", "backyard_b"],
|
|
results: [
|
|
{
|
|
camera: "front_door",
|
|
export_id: "front_door_a",
|
|
success: true,
|
|
status: "queued",
|
|
error: null,
|
|
item_index: 0,
|
|
},
|
|
{
|
|
camera: "backyard",
|
|
export_id: "backyard_b",
|
|
success: true,
|
|
status: "queued",
|
|
error: null,
|
|
item_index: 1,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
await frigateApp.goto("/review");
|
|
await selectTwoReviews(frigateApp);
|
|
await frigateApp.page
|
|
.getByRole("button", { name: /export/i })
|
|
.first()
|
|
.click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("dialog")
|
|
.filter({ hasText: /Export 2 reviews/i });
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Select "Create new case" from the case dropdown (default is "None")
|
|
await dialog.locator("button[role='combobox']").click();
|
|
await frigateApp.page
|
|
.getByRole("option", { name: /Create new case/i })
|
|
.click();
|
|
|
|
const nameInput = dialog.locator("input").first();
|
|
await nameInput.fill("E2E Incident");
|
|
|
|
await dialog.getByRole("button", { name: /export 2 reviews/i }).click();
|
|
|
|
// Wait for the POST to fire
|
|
await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull();
|
|
|
|
const payload = capturedPayload as {
|
|
items: Array<{
|
|
camera: string;
|
|
start_time: number;
|
|
end_time: number;
|
|
image_path?: string;
|
|
client_item_id?: string;
|
|
}>;
|
|
new_case_name?: string;
|
|
export_case_id?: string;
|
|
};
|
|
expect(payload.items).toHaveLength(2);
|
|
expect(payload.new_case_name).toBe("E2E Incident");
|
|
// When creating a new case, we must NOT also send export_case_id —
|
|
// the two fields are mutually exclusive on the backend.
|
|
expect(payload.export_case_id).toBeUndefined();
|
|
expect(payload.items.map((i) => i.camera).sort()).toEqual([
|
|
"backyard",
|
|
"front_door",
|
|
]);
|
|
// Each item must preserve REVIEW_PADDING (4s) on the edges —
|
|
// i.e. the padded window is 8s longer than the original review.
|
|
// The mock reviews above have 20s and 30s raw durations, so the
|
|
// expected padded durations are 28s and 38s.
|
|
const paddedDurations = payload.items
|
|
.map((i) => i.end_time - i.start_time)
|
|
.sort((a, b) => a - b);
|
|
expect(paddedDurations).toEqual([28, 38]);
|
|
// Thumbnails should be passed through per item
|
|
for (const item of payload.items) {
|
|
expect(item.image_path).toMatch(/mex-review-\d+-thumb\.jpg$/);
|
|
}
|
|
expect(payload.items.map((item) => item.client_item_id)).toEqual([
|
|
"mex-review-001",
|
|
"mex-review-002",
|
|
]);
|
|
|
|
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
|
|
timeout: 5_000,
|
|
});
|
|
});
|
|
|
|
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
|
|
frigateApp,
|
|
}) => {
|
|
test.skip(!frigateApp.isMobile, "Mobile-only Drawer assertion");
|
|
|
|
await frigateApp.goto("/review");
|
|
await selectTwoReviews(frigateApp);
|
|
|
|
await frigateApp.page
|
|
.getByRole("button", { name: /export/i })
|
|
.first()
|
|
.click();
|
|
|
|
// On mobile the component renders a shadcn Drawer, which uses
|
|
// role="dialog" but sets data-vaul-drawer. Desktop renders a
|
|
// shadcn Dialog with role="dialog" but no data-vaul-drawer.
|
|
// The title and submit button both contain "Export 2 reviews", so
|
|
// assert each element distinctly: the title is a heading and the
|
|
// submit button has role="button".
|
|
const drawer = frigateApp.page.locator("[data-vaul-drawer]");
|
|
await expect(drawer).toBeVisible({ timeout: 5_000 });
|
|
await expect(
|
|
drawer.getByRole("heading", { name: /Export 2 reviews/i }),
|
|
).toBeVisible();
|
|
await expect(
|
|
drawer.getByRole("button", { name: /export 2 reviews/i }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("hides export button when more than 50 reviews are selected", async ({
|
|
frigateApp,
|
|
}) => {
|
|
test.skip(frigateApp.isMobile, "Desktop select-all keyboard flow");
|
|
|
|
// Override the default 2-review mock with 51 reviews before
|
|
// navigation. Playwright matches routes last-registered-first so
|
|
// this takes precedence over the beforeEach.
|
|
await routeReviews(frigateApp.page, oversizedReviews);
|
|
|
|
await frigateApp.goto("/review");
|
|
|
|
// Wait for any review item to render before firing the shortcut
|
|
await frigateApp.page
|
|
.locator(".review-item")
|
|
.first()
|
|
.waitFor({ state: "visible", timeout: 10_000 });
|
|
|
|
// Ctrl+A triggers onSelectAllReviews (see EventView.tsx useKeyboardListener)
|
|
await frigateApp.page.keyboard.press("Control+a");
|
|
|
|
// The action group should show "51 selected" but no Export button.
|
|
// Mark-as-reviewed is still there so the action bar is rendered.
|
|
// Scope the "Mark as reviewed" lookup to its exact aria-label because
|
|
// the page can render other "mark as reviewed" controls elsewhere
|
|
// (e.g. on individual cards) that would trip strict-mode matching.
|
|
await expect(frigateApp.page.getByText(/51.*selected/i)).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
await expect(
|
|
frigateApp.page.getByRole("button", { name: "Mark as reviewed" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByRole("button", { name: /^export$/i }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("attaching to an existing case sends export_case_id without new_case_name", async ({
|
|
frigateApp,
|
|
}) => {
|
|
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
|
|
|
// Seed one existing case so the dialog can offer the "existing" branch.
|
|
// The fixture mocks the user as admin (adminProfile()), so useIsAdmin()
|
|
// is true and the dialog renders the "Existing case" radio.
|
|
await frigateApp.page.route("**/api/cases", (route) =>
|
|
route.fulfill({
|
|
json: [
|
|
{
|
|
id: "existing-case-abc",
|
|
name: "Incident #42",
|
|
description: "",
|
|
created_at: now - 3600,
|
|
updated_at: now - 3600,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
let capturedPayload: unknown = null;
|
|
await frigateApp.page.route("**/api/exports/batch", async (route) => {
|
|
capturedPayload = route.request().postDataJSON();
|
|
await route.fulfill({
|
|
status: 202,
|
|
json: {
|
|
export_case_id: "existing-case-abc",
|
|
export_ids: ["front_door_a", "backyard_b"],
|
|
results: [
|
|
{
|
|
camera: "front_door",
|
|
export_id: "front_door_a",
|
|
success: true,
|
|
status: "queued",
|
|
error: null,
|
|
item_index: 0,
|
|
},
|
|
{
|
|
camera: "backyard",
|
|
export_id: "backyard_b",
|
|
success: true,
|
|
status: "queued",
|
|
error: null,
|
|
item_index: 1,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
await frigateApp.goto("/review");
|
|
await selectTwoReviews(frigateApp);
|
|
|
|
await frigateApp.page
|
|
.getByRole("button", { name: /export/i })
|
|
.first()
|
|
.click();
|
|
|
|
const dialog = frigateApp.page
|
|
.getByRole("dialog")
|
|
.filter({ hasText: /Export 2 reviews/i });
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Open the Case Select dropdown and pick the seeded case directly.
|
|
// The dialog now uses a single Select listing existing cases above
|
|
// the "Create new case" option — no radio toggle needed.
|
|
const selectTrigger = dialog.locator("button[role='combobox']").first();
|
|
await selectTrigger.waitFor({ state: "visible", timeout: 5_000 });
|
|
await selectTrigger.click();
|
|
|
|
// The dropdown portal renders outside the dialog
|
|
await frigateApp.page.getByRole("option", { name: /Incident #42/ }).click();
|
|
|
|
await dialog.getByRole("button", { name: /export 2 reviews/i }).click();
|
|
|
|
await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull();
|
|
|
|
const payload = capturedPayload as {
|
|
items: unknown[];
|
|
new_case_name?: string;
|
|
new_case_description?: string;
|
|
export_case_id?: string;
|
|
};
|
|
expect(payload.export_case_id).toBe("existing-case-abc");
|
|
expect(payload.new_case_name).toBeUndefined();
|
|
expect(payload.new_case_description).toBeUndefined();
|
|
expect(payload.items).toHaveLength(2);
|
|
|
|
// Navigate should hit /export. useSearchEffect consumes the caseId
|
|
// query param and strips it once the case is found in the cases list,
|
|
// so we assert on the path, not the query string.
|
|
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
|
|
timeout: 5_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe("Export Page - Active Job Progress @medium", () => {
|
|
test("encoding job renders percent label and progress bar", async ({
|
|
frigateApp,
|
|
}) => {
|
|
// Override the default empty mock with an encoding job. Per-test
|
|
// page.route registrations win over those set by the api-mocker.
|
|
await frigateApp.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({
|
|
json: [
|
|
{
|
|
id: "job-encoding",
|
|
job_type: "export",
|
|
status: "running",
|
|
camera: "front_door",
|
|
name: "Encoding Sample",
|
|
export_case_id: null,
|
|
request_start_time: 1775407931,
|
|
request_end_time: 1775408531,
|
|
start_time: 1775407932,
|
|
end_time: null,
|
|
error_message: null,
|
|
results: null,
|
|
current_step: "encoding",
|
|
progress_percent: 42,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await expect(frigateApp.page.getByText("Encoding Sample")).toBeVisible();
|
|
// Step label and percent are rendered together as text near the
|
|
// progress bar (separated by a middle dot), not in a corner badge.
|
|
await expect(frigateApp.page.getByText(/Encoding\s*·\s*42%/)).toBeVisible();
|
|
});
|
|
|
|
test("queued job shows queued badge", async ({ frigateApp }) => {
|
|
await frigateApp.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({
|
|
json: [
|
|
{
|
|
id: "job-queued",
|
|
job_type: "export",
|
|
status: "queued",
|
|
camera: "front_door",
|
|
name: "Queued Sample",
|
|
export_case_id: null,
|
|
request_start_time: 1775407931,
|
|
request_end_time: 1775408531,
|
|
start_time: null,
|
|
end_time: null,
|
|
error_message: null,
|
|
results: null,
|
|
current_step: "queued",
|
|
progress_percent: 0,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await expect(frigateApp.page.getByText("Queued Sample")).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText("Queued", { exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("active job hides matching in_progress export row", async ({
|
|
frigateApp,
|
|
}) => {
|
|
// The backend inserts the Export row with in_progress=True before
|
|
// FFmpeg starts encoding, so the same id appears in BOTH /jobs/export
|
|
// and /exports during the run. The page must show the rich progress
|
|
// card from the active jobs feed and suppress the binary-spinner
|
|
// ExportCard from the exports feed; otherwise the older binary
|
|
// spinner replaces the percent label as soon as SWR re-polls.
|
|
await frigateApp.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({
|
|
json: [
|
|
{
|
|
id: "shared-id",
|
|
job_type: "export",
|
|
status: "running",
|
|
camera: "front_door",
|
|
name: "Shared Id Encoding",
|
|
export_case_id: null,
|
|
request_start_time: 1775407931,
|
|
request_end_time: 1775408531,
|
|
start_time: 1775407932,
|
|
end_time: null,
|
|
error_message: null,
|
|
results: null,
|
|
current_step: "encoding",
|
|
progress_percent: 67,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await frigateApp.page.route("**/api/exports**", (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
return route.fulfill({
|
|
json: [
|
|
{
|
|
id: "shared-id",
|
|
camera: "front_door",
|
|
name: "Shared Id Encoding",
|
|
date: 1775407931,
|
|
video_path: "/exports/shared-id.mp4",
|
|
thumb_path: "/exports/shared-id-thumb.jpg",
|
|
in_progress: true,
|
|
export_case_id: null,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
// The progress label must be present — proving the rich card won.
|
|
await expect(frigateApp.page.getByText(/Encoding\s*·\s*67%/)).toBeVisible();
|
|
|
|
// And only ONE card should be visible for that id, not two.
|
|
const titles = frigateApp.page.getByText("Shared Id Encoding");
|
|
await expect(titles).toHaveCount(1);
|
|
});
|
|
|
|
test("stream copy job shows copying label", async ({ frigateApp }) => {
|
|
// Default (non-custom) exports use `-c copy`, which is a remux, not
|
|
// a real encode. The step label should read "Copying" so users
|
|
// aren't misled into thinking re-encoding is happening.
|
|
await frigateApp.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({
|
|
json: [
|
|
{
|
|
id: "job-copying",
|
|
job_type: "export",
|
|
status: "running",
|
|
camera: "front_door",
|
|
name: "Copy Sample",
|
|
export_case_id: null,
|
|
request_start_time: 1775407931,
|
|
request_end_time: 1775408531,
|
|
start_time: 1775407932,
|
|
end_time: null,
|
|
error_message: null,
|
|
results: null,
|
|
current_step: "copying",
|
|
progress_percent: 80,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await expect(frigateApp.page.getByText("Copy Sample")).toBeVisible();
|
|
await expect(frigateApp.page.getByText(/Copying\s*·\s*80%/)).toBeVisible();
|
|
});
|
|
|
|
test("encoding retry job shows retry label", async ({ frigateApp }) => {
|
|
await frigateApp.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({
|
|
json: [
|
|
{
|
|
id: "job-retry",
|
|
job_type: "export",
|
|
status: "running",
|
|
camera: "front_door",
|
|
name: "Retry Sample",
|
|
export_case_id: null,
|
|
request_start_time: 1775407931,
|
|
request_end_time: 1775408531,
|
|
start_time: 1775407932,
|
|
end_time: null,
|
|
error_message: null,
|
|
results: null,
|
|
current_step: "encoding_retry",
|
|
progress_percent: 12,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await frigateApp.goto("/export");
|
|
|
|
await expect(frigateApp.page.getByText("Retry Sample")).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByText(/Encoding \(retry\)\s*·\s*12%/),
|
|
).toBeVisible();
|
|
});
|
|
});
|