Files
frigate/web/e2e/helpers/api-mocker.ts
T
Josh HawkinsandNicolas Mowen fb8ab56c41 Add onboarding wizard for new installations (#24102)
* add onboarding wizard for new users

* resolve hwaccel per camera and clarify recording retention

The hwaccel step listed every preset Frigate ships, so an Intel box was offered Raspberry Pi and Rockchip decoding, and the codec specific presets (`preset-intel-qsv-h264` vs `-h265`) were offered as global values that break as soon as two cameras use different codecs. `/hardware/hwaccel` now returns the decoding families the probed hardware can actually use, each carrying a preset per codec, and the wizard resolves the family against the detect stream codec the camera wizard already probed: one global `ffmpeg.hwaccel_args` when every camera agrees, per-camera `cameras.<name>.ffmpeg.hwaccel_args` when they don't. The global stays on `auto` in that case so cameras added later still resolve at startup. A gen13+ Intel machine keeps its QuickSync recommendation with mixed h264 and h265 cameras instead of dropping to vaapi.

The recording step's "Days to retain recordings" only wrote alert and detection retention, and the storage estimate under it assumed continuous recording. It now asks what to record in plain language, writes `record.continuous.days` to match, shows the estimate only for continuous, and drops the spinner arrows on the number input.

* clean up

* add light/dark mode icon switcher

* use yml as default config file extension when not found

* i18n tweaks

* gate the setup wizard on cameras instead of a config key

* render setup wizard steps by key

* share the setup wizard e2e helpers and mock users

* add an account step to the setup wizard

* add setup wizard account step e2e coverage

* cover the account step's restart behavior

* button consistency

* fix test

* docs

* fixes
2026-09-12 07:30:04 -06:00

319 lines
9.8 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 { DETECTION_HARDWARE } from "../fixtures/mock-data/hardware";
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>;
hardware?: unknown[];
hwaccel?: {
recommended: string;
available?: { key: string; presets: Record<string, string> }[];
};
users?: { username: string; role: string }[];
}
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).
// Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
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 } }),
);
// Detection hardware discovery
await this.page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
);
// Hwaccel preset recommendation
await this.page.route("**/api/hardware/hwaccel**", (route) =>
route.fulfill({
json: {
recommended: "",
available: [],
...(overrides?.hwaccel ?? {}),
},
}),
);
// Users. GET lists them; POST/PUT (create, password) just succeed, so
// tests assert on the intercepted request body instead of a response.
await this.page.route("**/api/users**", (route) =>
route.request().method() === "GET"
? route.fulfill({
json: overrides?.users ?? [{ username: "admin", role: "admin" }],
})
: route.fulfill({ json: { message: "ok" } }),
);
// 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. The explore grid and detail dialog request .webp,
// everything else requests .jpg.
await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (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",
}),
);
}
}