add helpers

This commit is contained in:
Josh Hawkins 2026-04-21 16:40:26 -05:00
parent 8b3e21703d
commit fbcf329a71
5 changed files with 169 additions and 4 deletions

View File

@ -113,11 +113,12 @@ export class ApiMocker {
route.fulfill({ json: [] }), route.fulfill({ json: [] }),
); );
// Sub-labels and attributes (for explore filters) // Sub-labels and attributes (for explore filters).
await this.page.route("**/api/sub_labels", (route) => // Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
await this.page.route("**/api/sub_labels**", (route) =>
route.fulfill({ json: [] }), route.fulfill({ json: [] }),
); );
await this.page.route("**/api/labels", (route) => await this.page.route("**/api/labels**", (route) =>
route.fulfill({ json: ["person", "car"] }), route.fulfill({ json: ["person", "car"] }),
); );
await this.page.route("**/api/*/attributes", (route) => await this.page.route("**/api/*/attributes", (route) =>

View File

@ -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<void> {
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<string> {
return page.evaluate(async () => await navigator.clipboard.readText());
}

61
web/e2e/helpers/monaco.ts Normal file
View File

@ -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<string> {
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<void> {
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<boolean> {
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<void> {
await expect
.poll(() => hasErrorMarkers(page), { timeout: timeoutMs })
.toBe(true);
}

View File

@ -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<void> {
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<CapturedFrame[]> {
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<void> {
const { timeout = 2_000, message } = opts;
await expect
.poll(async () => (await readWsFrames(page)).some(matcher), {
timeout,
message,
})
.toBe(true);
}

View File

@ -79,7 +79,20 @@ export class WsMocker {
this.send("model_state", JSON.stringify({})); this.send("model_state", JSON.stringify({}));
} }
if (data.topic === "embeddingsReindexProgress") { 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") { if (data.topic === "birdseyeLayout") {
this.send("birdseye_layout", JSON.stringify(null)); this.send("birdseye_layout", JSON.stringify(null));