diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 52f10d64b4..e1a191fe0b 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -113,11 +113,12 @@ export class ApiMocker { route.fulfill({ json: [] }), ); - // Sub-labels and attributes (for explore filters) - await this.page.route("**/api/sub_labels", (route) => + // 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) => + await this.page.route("**/api/labels**", (route) => route.fulfill({ json: ["person", "car"] }), ); await this.page.route("**/api/*/attributes", (route) => diff --git a/web/e2e/helpers/clipboard.ts b/web/e2e/helpers/clipboard.ts new file mode 100644 index 0000000000..9099073b32 --- /dev/null +++ b/web/e2e/helpers/clipboard.ts @@ -0,0 +1,25 @@ +/** + * Clipboard read helper for e2e tests. + * + * Clipboard API requires a browser permission in headless mode. + * grantClipboardPermissions() must be called before any readClipboard() + * attempt. Used by logs.spec.ts (Copy button) and config-editor.spec.ts + * (Copy button). + */ + +import type { BrowserContext, Page } from "@playwright/test"; + +/** + * Grant clipboard-read + clipboard-write permissions on the context. + * Call in beforeEach or at the top of a test before the Copy action. + */ +export async function grantClipboardPermissions( + context: BrowserContext, +): Promise { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); +} + +/** Read the current clipboard contents via the page's navigator.clipboard. */ +export async function readClipboard(page: Page): Promise { + return page.evaluate(async () => await navigator.clipboard.readText()); +} diff --git a/web/e2e/helpers/monaco.ts b/web/e2e/helpers/monaco.ts new file mode 100644 index 0000000000..f9079db922 --- /dev/null +++ b/web/e2e/helpers/monaco.ts @@ -0,0 +1,61 @@ +/** + * Monaco editor DOM helpers for e2e tests. + * + * Monaco is imported as a module-local object in the app and is NOT + * exposed on window; we drive + read through the rendered DOM and + * keyboard instead. Used by config-editor.spec.ts only. + */ + +import { expect, type Page } from "@playwright/test"; + +/** + * Returns the current visible text of the first Monaco editor on the + * page. Monaco virtualizes long files — this reads only the rendered + * lines. For short configs (our mocks) that's the full content. + */ +export async function getMonacoVisibleText(page: Page): Promise { + return page + .locator(".monaco-editor .view-lines") + .first() + .innerText(); +} + +/** + * Focus the editor and replace its full content with `value` via + * keyboard. Uses Ctrl+A (Cmd+A on macOS Playwright is equivalent) + * + Delete + type. Works cross-platform because Playwright normalizes. + */ +export async function replaceMonacoValue( + page: Page, + value: string, +): Promise { + const editor = page.locator(".monaco-editor").first(); + await editor.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Delete"); + // Use `type` with zero delay — Monaco handles each key. + await page.keyboard.type(value, { delay: 0 }); +} + +/** + * Returns true when the editor shows at least one error-severity + * marker. Monaco renders error underlines as `.squiggly-error` in + * the `.view-overlays` layer. + */ +export async function hasErrorMarkers(page: Page): Promise { + const count = await page.locator(".monaco-editor .squiggly-error").count(); + return count > 0; +} + +/** + * Poll until an error marker appears. Monaco schedules marker updates + * asynchronously after content changes (debounce + schema validation). + */ +export async function waitForErrorMarker( + page: Page, + timeoutMs: number = 10_000, +): Promise { + await expect + .poll(() => hasErrorMarkers(page), { timeout: timeoutMs }) + .toBe(true); +} diff --git a/web/e2e/helpers/ws-frames.ts b/web/e2e/helpers/ws-frames.ts new file mode 100644 index 0000000000..d46376d871 --- /dev/null +++ b/web/e2e/helpers/ws-frames.ts @@ -0,0 +1,65 @@ +/** + * WebSocket frame capture helper. + * + * The ws-mocker intercepts the /ws route, so Playwright's page-level + * `websocket` event never fires. This helper patches client-side + * WebSocket.prototype.send before any app code runs and mirrors every + * sent frame into a window-level array the test can read back. + * + * Used by live.spec.ts (feature toggles, PTZ preset commands) and + * config-editor.spec.ts (restart command via useRestart). + */ + +import { expect, type Page } from "@playwright/test"; + +export type CapturedFrame = string; + +declare global { + interface Window { + __sentWsFrames: CapturedFrame[]; + } +} + +/** + * Patch WebSocket.prototype.send to capture every outbound frame into + * window.__sentWsFrames. Must be called BEFORE page.goto(). + */ +export async function installWsFrameCapture(page: Page): Promise { + await page.addInitScript(() => { + window.__sentWsFrames = []; + const origSend = WebSocket.prototype.send; + WebSocket.prototype.send = function (data) { + try { + window.__sentWsFrames.push( + typeof data === "string" ? data : "(binary)", + ); + } catch { + // ignore — best-effort tracing + } + return origSend.call(this, data); + }; + }); +} + +/** Read all captured frames at call time. */ +export async function readWsFrames(page: Page): Promise { + return page.evaluate(() => window.__sentWsFrames ?? []); +} + +/** + * Poll until at least one captured frame matches the predicate. + * Throws via expect if the frame never arrives within timeout. + */ +export async function waitForWsFrame( + page: Page, + matcher: (frame: CapturedFrame) => boolean, + opts: { timeout?: number; message?: string } = {}, +): Promise { + const { timeout = 2_000, message } = opts; + await expect + .poll(async () => (await readWsFrames(page)).some(matcher), { + timeout, + message, + }) + .toBe(true); +} diff --git a/web/e2e/helpers/ws-mocker.ts b/web/e2e/helpers/ws-mocker.ts index 6b29b76396..03db9f7410 100644 --- a/web/e2e/helpers/ws-mocker.ts +++ b/web/e2e/helpers/ws-mocker.ts @@ -79,7 +79,20 @@ export class WsMocker { this.send("model_state", JSON.stringify({})); } if (data.topic === "embeddingsReindexProgress") { - this.send("embeddings_reindex_progress", JSON.stringify(null)); + // Send a completed reindex state so Explore renders when + // semantic_search.enabled is true. A null payload leaves the page + // in a permanent loading spinner because !reindexState is truthy. + this.send( + "embeddings_reindex_progress", + JSON.stringify({ + status: "completed", + processed_objects: 0, + total_objects: 0, + thumbnails: 0, + descriptions: 0, + time_remaining: null, + }), + ); } if (data.topic === "birdseyeLayout") { this.send("birdseye_layout", JSON.stringify(null));