Files
frigate/web/e2e/specs/history-debug-replay.spec.ts
T

208 lines
6.3 KiB
TypeScript
Raw Normal View History

/**
* Debug Replay range selection from History -- HIGH tier.
*
* Covers the "Select from Timeline" flow that Debug Replay shares with
* Export. The other half of the same report, a loading spinner latched
* after Cancel, needs real media: the vod mock serves an empty playlist.
*/
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
// the selection is seeded around the playback position, so land near the
// live edge
const playbackTime = Math.floor(Date.now() / 1000) - 300;
async function openRecordingView(frigateApp: FrigateApp) {
// The recording view pulls these while the timeline renders; the preview
// server 500s on them, which the error collector would flag.
await frigateApp.page.route("**/api/*/recordings**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
route.fulfill({ json: [] }),
);
// inert here; the 0.19 recording view fetches coverage and needs an
// object, so this has to follow the broad recordings route to win
await frigateApp.page.route("**/api/*/recordings/coverage**", (route) =>
route.fulfill({
json: {
spans: [
{
start_time: playbackTime - 3600,
end_time: playbackTime + 600,
streams: ["main"],
},
],
codecs_compatible: true,
streams: {
main: {
video_codec: "h264",
audio_rate: null,
audio_codec: null,
has_audio: false,
bitrate: 2_000_000,
},
},
},
}),
);
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
}
// desktop reaches Debug Replay through the Actions menu, mobile through
// the settings drawer; both render the same form
async function openDebugReplayForm(frigateApp: FrigateApp) {
if (frigateApp.isMobile) {
await frigateApp.page
.getByRole("button", { name: /filters/i })
.first()
.click({ timeout: 15_000 });
await frigateApp.page
.getByRole("button", { name: /^debug replay$/i })
.click();
} else {
await frigateApp.page
.getByRole("button", { name: /actions/i })
.click({ timeout: 15_000 });
await frigateApp.page
.getByRole("menuitem", { name: /debug replay/i })
.click();
}
const form = frigateApp.page.getByRole("dialog");
await expect(form).toBeVisible({ timeout: 5_000 });
return form;
}
async function selectRangeFromTimeline(frigateApp: FrigateApp) {
const form = await openDebugReplayForm(frigateApp);
await form.getByText("From Timeline").click();
await form.getByRole("button", { name: "Select", exact: true }).click();
await expect(form).toBeHidden({ timeout: 5_000 });
await expect(frigateApp.page.locator(".export-start")).toHaveText(
/\d{1,2}:\d{2}/,
{ timeout: 5_000 },
);
}
// moving the playhead between the two selections is what makes the second
// range differ from the first
async function reselectRange(frigateApp: FrigateApp) {
await selectRangeFromTimeline(frigateApp);
await frigateApp.page
.getByRole("button", { name: /^cancel$/i })
.click({ timeout: 5_000 });
await expect(frigateApp.page.locator(".export-start")).toHaveCount(0);
const segments = frigateApp.page.locator(".segment[data-segment-id]");
const count = await segments.count();
await segments.nth(Math.min(20, count - 1)).click({ force: true });
await selectRangeFromTimeline(frigateApp);
}
// the loop flipped this label between the two ranges ~25 times a second
async function countHandleLabelChanges(frigateApp: FrigateApp) {
return frigateApp.page.evaluate(async () => {
const handle = document.querySelector(".export-start");
if (!handle) {
return -1;
}
let changes = 0;
let last = handle.textContent;
const observer = new MutationObserver(() => {
if (handle.textContent !== last) {
changes += 1;
last = handle.textContent;
}
});
observer.observe(handle, {
subtree: true,
childList: true,
characterData: true,
});
await new Promise((resolve) => setTimeout(resolve, 1500));
observer.disconnect();
return changes;
});
}
test.describe("Debug Replay from History @high", () => {
test("a reselected range lands once and stays put", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const pageErrors: string[] = [];
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
await frigateApp.installDefaults();
await openRecordingView(frigateApp);
await reselectRange(frigateApp);
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
expect(
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
).toHaveLength(0);
});
test("dragging a handle after a reselect moves it", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults();
await openRecordingView(frigateApp);
await reselectRange(frigateApp);
const start = frigateApp.page.locator(".export-start");
const before = (await start.textContent()) ?? "";
const box = await start.boundingBox();
if (!box) {
throw new Error("export start handle has no bounding box");
}
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await frigateApp.page.mouse.move(x, y);
await frigateApp.page.mouse.down();
await frigateApp.page.mouse.move(x, y - 90, { steps: 12 });
await frigateApp.page.mouse.up();
await expect(start).not.toHaveText(before, { timeout: 5_000 });
});
});
test.describe("Debug Replay from History — mobile @high @mobile", () => {
test("a reselected range lands once and stays put", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
const pageErrors: string[] = [];
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
await frigateApp.installDefaults();
await openRecordingView(frigateApp);
await reselectRange(frigateApp);
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
expect(
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
).toHaveLength(0);
});
});