mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-03 01:22:17 +03:00
Improve frontend e2e tests (#22958)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add mock data * add helpers * page objects * updated specs * remove PENDING_REWARITE * formatting
This commit is contained in:
+298
-21
@@ -1,34 +1,311 @@
|
||||
/**
|
||||
* Chat page tests -- MEDIUM tier.
|
||||
*
|
||||
* Tests chat interface rendering, input area, and example prompt buttons.
|
||||
* Starting state, NDJSON streaming contract (not SSE), assistant
|
||||
* bubble grows as chunks arrive, error path, and mobile viewport.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
test.describe("Chat Page @medium", () => {
|
||||
test("chat page renders without crash", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/chat");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
});
|
||||
/**
|
||||
* Install a window.fetch override on the page so that POSTs to
|
||||
* chat/completion resolve with a real ReadableStream that emits the
|
||||
* given chunks over time. This is the only way to validate
|
||||
* chunk-by-chunk rendering through Playwright — page.route() does not
|
||||
* support streaming responses.
|
||||
*
|
||||
* Must be called BEFORE frigateApp.goto(). The override also exposes
|
||||
* `__chatRequests` on window so tests can assert the outgoing body.
|
||||
*/
|
||||
async function installChatStreamOverride(
|
||||
app: FrigateApp,
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
opts: { chunkDelayMs?: number; status?: number } = {},
|
||||
) {
|
||||
const { chunkDelayMs = 40, status = 200 } = opts;
|
||||
await app.page.addInitScript(
|
||||
({ chunks, chunkDelayMs, status }) => {
|
||||
(window as unknown as { __chatRequests: unknown[] }).__chatRequests = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: (input as Request).url;
|
||||
if (url.includes("chat/completion")) {
|
||||
const body =
|
||||
init?.body instanceof String || typeof init?.body === "string"
|
||||
? JSON.parse(init!.body as string)
|
||||
: null;
|
||||
(
|
||||
window as unknown as { __chatRequests: unknown[] }
|
||||
).__chatRequests.push({ url, body });
|
||||
if (status !== 200) {
|
||||
return new Response(JSON.stringify({ error: "boom" }), {
|
||||
status,
|
||||
});
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
await new Promise((r) => setTimeout(r, chunkDelayMs));
|
||||
controller.enqueue(
|
||||
encoder.encode(JSON.stringify(chunk) + "\n"),
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
return origFetch.call(window, input as RequestInfo, init);
|
||||
};
|
||||
},
|
||||
{ chunks, chunkDelayMs, status },
|
||||
);
|
||||
}
|
||||
|
||||
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
|
||||
test.describe("Chat — starting state @medium", () => {
|
||||
test("empty message list renders ChatStartingState with title and input", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/chat");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
const interactive = frigateApp.page.locator("input, textarea, button");
|
||||
const count = await interactive.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 1 }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible();
|
||||
// Four quick-reply buttons from starting_requests.*
|
||||
const quickReplies = frigateApp.page.locator(
|
||||
"button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')",
|
||||
);
|
||||
await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test("chat input accepts text", async ({ frigateApp }) => {
|
||||
test.describe("Chat — streaming @medium", () => {
|
||||
test("submission POSTs to chat/completion with stream: true", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: "Hel" },
|
||||
{ type: "content", delta: "lo" },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
const input = frigateApp.page.locator("input, textarea").first();
|
||||
if (await input.isVisible().catch(() => false)) {
|
||||
await input.fill("What cameras detected a person today?");
|
||||
const value = await input.inputValue();
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("hello chat");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
frigateApp.page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __chatRequests: unknown[] })
|
||||
.__chatRequests?.length ?? 0,
|
||||
),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const request = await frigateApp.page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__chatRequests: Array<{
|
||||
url: string;
|
||||
body: { stream: boolean; messages: Array<{ content: string }> };
|
||||
}>;
|
||||
}
|
||||
).__chatRequests[0],
|
||||
);
|
||||
expect(request.body.stream).toBe(true);
|
||||
expect(
|
||||
request.body.messages[request.body.messages.length - 1].content,
|
||||
).toBe("hello chat");
|
||||
});
|
||||
|
||||
test("NDJSON content chunks accumulate in the assistant bubble", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installChatStreamOverride(
|
||||
frigateApp,
|
||||
[
|
||||
{ type: "content", delta: "Hel" },
|
||||
{ type: "content", delta: "lo, " },
|
||||
{ type: "content", delta: "world!" },
|
||||
],
|
||||
{ chunkDelayMs: 50 },
|
||||
);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("greet me");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(frigateApp.page.getByText(/Hello, world!/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => {
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{
|
||||
type: "tool_calls",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
name: "search_objects",
|
||||
arguments: { label: "person" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "content", delta: "Searching for people." },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("find people");
|
||||
await input.press("Enter");
|
||||
|
||||
// ToolCallsGroup normalizes "search_objects" → "Search Objects" via
|
||||
// normalizeName(). Match the rendered display label instead.
|
||||
await expect(frigateApp.page.getByText(/search objects/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByText(/searching for people/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — stop @medium", () => {
|
||||
test("Stop button aborts an in-flight stream and freezes the partial message", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A long chunk sequence with big delays gives us time to hit Stop.
|
||||
await installChatStreamOverride(
|
||||
frigateApp,
|
||||
[
|
||||
{ type: "content", delta: "First chunk. " },
|
||||
{ type: "content", delta: "Second chunk. " },
|
||||
{ type: "content", delta: "Third chunk. " },
|
||||
],
|
||||
{ chunkDelayMs: 300 },
|
||||
);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("slow response please");
|
||||
await input.press("Enter");
|
||||
|
||||
// Wait for the first chunk to render
|
||||
await expect(frigateApp.page.getByText(/First chunk\./)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The Stop button is a destructive rounded button shown while isLoading.
|
||||
// It contains only an FaStop SVG icon (no visible text). Find it by the
|
||||
// destructive variant class or fall back to aria-label.
|
||||
const stopBtn = frigateApp.page
|
||||
.locator("button.bg-destructive, button[class*='destructive']")
|
||||
.first();
|
||||
await stopBtn.click({ timeout: 3_000 }).catch(async () => {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /stop|cancel/i })
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Third chunk should never appear.
|
||||
await expect(frigateApp.page.getByText(/Third chunk\./)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — error @medium", () => {
|
||||
test("non-OK response renders an error banner", async ({ frigateApp }) => {
|
||||
await installChatStreamOverride(frigateApp, [], { status: 500 });
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("trigger error");
|
||||
await input.press("Enter");
|
||||
// The error banner is a role="alert" paragraph; target by role so we
|
||||
// don't collide with the user-message bubble that contains "trigger
|
||||
// error" (which would match /error/ in strict mode).
|
||||
await expect(
|
||||
frigateApp.page.getByRole("alert").filter({
|
||||
hasText: /boom|something went wrong/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — attachment chip @medium", () => {
|
||||
test("attaching an event renders a ChatAttachmentChip", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The chat starts with an empty message list (ChatStartingState).
|
||||
// After sending a message, ChatEntry with the paperclip button appears.
|
||||
// We use the stream override so the first message completes quickly.
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: "Done." },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
|
||||
// Send a first message to transition out of ChatStartingState so the
|
||||
// full ChatEntry (with the paperclip) is visible.
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("hello");
|
||||
await input.press("Enter");
|
||||
// Wait for the assistant response to complete so isLoading becomes false
|
||||
// and the paperclip button is re-enabled.
|
||||
await expect(frigateApp.page.getByText(/Done\./i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The paperclip button has aria-label from t("attachment_picker_placeholder")
|
||||
// = "Attach an event".
|
||||
const paperclip = frigateApp.page
|
||||
.getByRole("button", { name: /attach an event/i })
|
||||
.first();
|
||||
await expect(paperclip).toBeVisible({ timeout: 5_000 });
|
||||
await paperclip.click();
|
||||
|
||||
// The popover shows a paste input with placeholder "Or paste event ID".
|
||||
const idInput = frigateApp.page
|
||||
.locator('input[placeholder*="event" i], input[aria-label*="attach" i]')
|
||||
.first();
|
||||
await expect(idInput).toBeVisible({ timeout: 3_000 });
|
||||
await idInput.fill("test-event-1");
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^attach$/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The ChatAttachmentChip renders in the composer area. It shows an
|
||||
// activity indicator while loading event data (event_ids API not mocked),
|
||||
// so assert on the chip container being present in the composer.
|
||||
await expect(
|
||||
frigateApp.page.locator(
|
||||
"[class*='inline-flex'][class*='rounded-lg'][class*='border']",
|
||||
),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("chat input is focusable at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.focus();
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user