Files
frigate/web/e2e/specs/chat.spec.ts
T
Josh Hawkins 962d36323b Improve frontend e2e tests (#22958)
* add mock data

* add helpers

* page objects

* updated specs

* remove PENDING_REWARITE

* formatting
2026-04-21 16:32:18 -06:00

312 lines
11 KiB
TypeScript

/**
* Chat page tests -- MEDIUM tier.
*
* Starting state, NDJSON streaming contract (not SSE), assistant
* bubble grows as chunks arrive, error path, and mobile viewport.
*/
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
/**
* 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.describe("Chat — starting state @medium", () => {
test("empty message list renders ChatStartingState with title and input", async ({
frigateApp,
}) => {
await frigateApp.goto("/chat");
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.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");
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();
});
});