mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-17 20:50:06 -04:00
* backend * frontend + i18n * tests + api spec * tweak backend to use Job infrastructure for exports * frontend tweaks and Job infrastructure * tests * tweaks - add ability to remove from case - change location of counts in case card * add stale export reaper on startup * fix toaster close button color * improve add dialog * formatting * hide max_concurrent from camera config export settings * remove border * refactor batch endpoint for multiple review items * frontend * tests and fastapi spec * fix deletion of in-progress exports in a case * tweaks - hide cases when filtering cameras that have no exports from those cameras - remove description from case card - use textarea instead of input for case description in add new case dialog * add auth exceptions for exports * add e2e test for deleting cases with exports * refactor delete and case endpoints allow bulk deleting and reassigning * frontend - bulk selection like Review - gate admin-only actions - consolidate dialogs - spacing/padding tweaks * i18n and tests * update openapi spec * tweaks - add None to case selection list - allow new case creation from single cam export dialog * fix codeql * fix i18n * remove unused * fix frontend tests
284 lines
8.5 KiB
TypeScript
284 lines
8.5 KiB
TypeScript
/**
|
|
* REST API mock using Playwright's page.route().
|
|
*
|
|
* Intercepts all /api/* requests and returns factory-generated responses.
|
|
* Must be installed BEFORE page.goto() to prevent auth redirects.
|
|
*/
|
|
|
|
import type { Page } from "@playwright/test";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
BASE_CONFIG,
|
|
type DeepPartial,
|
|
configFactory,
|
|
} from "../fixtures/mock-data/config";
|
|
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
|
|
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const MOCK_DATA_DIR = resolve(__dirname, "../fixtures/mock-data");
|
|
|
|
function loadMockJson(filename: string): unknown {
|
|
return JSON.parse(readFileSync(resolve(MOCK_DATA_DIR, filename), "utf-8"));
|
|
}
|
|
|
|
// 1x1 transparent PNG
|
|
const PLACEHOLDER_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
|
|
export interface ApiMockOverrides {
|
|
config?: DeepPartial<typeof BASE_CONFIG>;
|
|
profile?: UserProfile;
|
|
stats?: DeepPartial<typeof BASE_STATS>;
|
|
reviews?: unknown[];
|
|
events?: unknown[];
|
|
exports?: unknown[];
|
|
cases?: unknown[];
|
|
faces?: Record<string, unknown>;
|
|
configRaw?: string;
|
|
configSchema?: Record<string, unknown>;
|
|
}
|
|
|
|
export class ApiMocker {
|
|
private page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
async install(overrides?: ApiMockOverrides) {
|
|
const config = configFactory(overrides?.config);
|
|
const profile = overrides?.profile ?? adminProfile();
|
|
const stats = statsFactory(overrides?.stats);
|
|
const reviews =
|
|
overrides?.reviews ?? (loadMockJson("reviews.json") as unknown[]);
|
|
const events =
|
|
overrides?.events ?? (loadMockJson("events.json") as unknown[]);
|
|
const exports =
|
|
overrides?.exports ?? (loadMockJson("exports.json") as unknown[]);
|
|
const cases = overrides?.cases ?? (loadMockJson("cases.json") as unknown[]);
|
|
const reviewSummary = loadMockJson("review-summary.json");
|
|
|
|
// Config endpoint
|
|
await this.page.route("**/api/config", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({ json: config });
|
|
}
|
|
return route.fulfill({ json: { success: true } });
|
|
});
|
|
|
|
// Profile endpoint (AuthProvider fetches /profile directly via axios,
|
|
// which resolves to /api/profile due to axios.defaults.baseURL)
|
|
await this.page.route("**/profile", (route) =>
|
|
route.fulfill({ json: profile }),
|
|
);
|
|
|
|
// Stats endpoint
|
|
await this.page.route("**/api/stats", (route) =>
|
|
route.fulfill({ json: stats }),
|
|
);
|
|
|
|
// Reviews. The real backend exposes /review (singular) for the main
|
|
// list and /review/summary for the summary — the previous plural glob
|
|
// (**/api/reviews**) never matched either endpoint, so review-dependent
|
|
// tests silently ran without data. The POST mutations at /reviews/viewed
|
|
// and /reviews/delete (plural) still fall through to the generic
|
|
// mutation catch-all further down the file.
|
|
await this.page.route(/\/api\/review\/summary/, (route) =>
|
|
route.fulfill({ json: reviewSummary }),
|
|
);
|
|
await this.page.route(/\/api\/review(\?|$)/, (route) =>
|
|
route.fulfill({ json: reviews }),
|
|
);
|
|
|
|
// Export jobs. The Exports page polls this every 2s while any export
|
|
// is in_progress; without a mock route it falls through to the preview
|
|
// server which returns 500 and makes the page flap between loading and
|
|
// rendered state, breaking tests that navigate to /export.
|
|
await this.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
// Recordings summary
|
|
await this.page.route("**/api/recordings/summary**", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Previews (needed for review page event cards)
|
|
await this.page.route("**/api/preview/**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
// Sub-labels and attributes (for explore filters)
|
|
await this.page.route("**/api/sub_labels", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await this.page.route("**/api/labels", (route) =>
|
|
route.fulfill({ json: ["person", "car"] }),
|
|
);
|
|
await this.page.route("**/api/*/attributes", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await this.page.route("**/api/recognized_license_plates", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
// Events / search
|
|
await this.page.route("**/api/events**", (route) =>
|
|
route.fulfill({ json: events }),
|
|
);
|
|
|
|
// Exports
|
|
await this.page.route("**/api/export**", (route) =>
|
|
route.fulfill({ json: exports }),
|
|
);
|
|
|
|
// Cases
|
|
await this.page.route("**/api/cases", (route) =>
|
|
route.fulfill({ json: cases }),
|
|
);
|
|
|
|
// Faces
|
|
await this.page.route("**/api/faces", (route) =>
|
|
route.fulfill({ json: overrides?.faces ?? {} }),
|
|
);
|
|
|
|
// Logs
|
|
await this.page.route("**/api/logs/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "text/plain",
|
|
body: "[2026-04-06 10:00:00] INFO: Frigate started\n[2026-04-06 10:00:01] INFO: Cameras loaded\n",
|
|
}),
|
|
);
|
|
|
|
// Config raw
|
|
await this.page.route("**/api/config/raw", (route) =>
|
|
route.fulfill({
|
|
contentType: "text/plain",
|
|
body:
|
|
overrides?.configRaw ??
|
|
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n",
|
|
}),
|
|
);
|
|
|
|
// Config schema
|
|
await this.page.route("**/api/config/schema.json", (route) =>
|
|
route.fulfill({
|
|
json: overrides?.configSchema ?? { type: "object", properties: {} },
|
|
}),
|
|
);
|
|
|
|
// Config set (mutation)
|
|
await this.page.route("**/api/config/set", (route) =>
|
|
route.fulfill({ json: { success: true, require_restart: false } }),
|
|
);
|
|
|
|
// Go2RTC streams
|
|
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Profiles
|
|
await this.page.route("**/api/profiles**", (route) =>
|
|
route.fulfill({
|
|
json: { profiles: [], active_profile: null, last_activated: {} },
|
|
}),
|
|
);
|
|
|
|
// Motion search
|
|
await this.page.route("**/api/motion_search**", (route) =>
|
|
route.fulfill({ json: { job_id: "test-job" } }),
|
|
);
|
|
|
|
// Region grid
|
|
await this.page.route("**/api/*/region_grid", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Debug replay
|
|
await this.page.route("**/api/debug_replay/**", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Generic mutation catch-all for remaining endpoints.
|
|
// Uses route.fallback() to defer to more specific routes registered above.
|
|
// Playwright matches routes in reverse registration order (last wins),
|
|
// so this catch-all must use fallback() to let specific routes take precedence.
|
|
await this.page.route("**/api/**", (route) => {
|
|
const method = route.request().method();
|
|
if (
|
|
method === "POST" ||
|
|
method === "PUT" ||
|
|
method === "PATCH" ||
|
|
method === "DELETE"
|
|
) {
|
|
return route.fulfill({ json: { success: true } });
|
|
}
|
|
// Fall through to more specific routes for GET requests
|
|
return route.fallback();
|
|
});
|
|
}
|
|
}
|
|
|
|
export class MediaMocker {
|
|
private page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
async install() {
|
|
// Camera snapshots
|
|
await this.page.route("**/api/*/latest.jpg**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// Clips and thumbnails
|
|
await this.page.route("**/clips/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// Event thumbnails
|
|
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// Event snapshots
|
|
await this.page.route("**/api/events/*/snapshot.jpg**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// VOD / recordings
|
|
await this.page.route("**/vod/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/vnd.apple.mpegurl",
|
|
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
|
|
}),
|
|
);
|
|
|
|
// Live streams
|
|
await this.page.route("**/live/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/vnd.apple.mpegurl",
|
|
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
|
|
}),
|
|
);
|
|
}
|
|
}
|