import { DEFAULT_TIMEOUT } from "@package/misc/constants"; import type { Locator, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { BaseComponent } from "./base.component"; /** * A CodeMirror-backed editor (`.client/codemirror/code-editor.tsx` or * `one-line-editor.tsx`). `root` is the editor container * (`data-testid="CodeEditor"` / `"OneLineEditor"`) or anything that * contains exactly one `.CodeMirror`. * * Reads and writes go through the editor's own `CodeMirror` instance API * instead of keystrokes, which is both faster and immune to autocomplete. */ export class CodeEditorComponent extends BaseComponent { /** The n-th `CodeEditor` on the page (or within `scope`). */ static nth(page: Page, index = 0, scope?: Locator): CodeEditorComponent { return new CodeEditorComponent( page, (scope ?? page).getByTestId("CodeEditor").nth(index), ); } private directCodeMirror?: Locator; /** * Wraps a locator that already points at the `.CodeMirror` element * itself (rather than a container holding one). */ static fromCodeMirror(page: Page, codeMirror: Locator): CodeEditorComponent { const editor = new CodeEditorComponent(page, codeMirror); editor.directCodeMirror = codeMirror; return editor; } get codeMirror(): Locator { return this.directCodeMirror ?? this.root.locator(".CodeMirror").first(); } async getValue(): Promise { return this.codeMirror.evaluate( (el) => (el as any).CodeMirror?.getValue() ?? "", ); } /** * Sets the value and confirms it holds for 200ms, retrying the whole * write if a background re-render (e.g. a `{{ }}` tag widget redraw or a * still-initializing document patch) reverts it. */ async setValue(text: string): Promise { const deadline = Date.now() + DEFAULT_TIMEOUT; let attempt = 0; do { attempt++; await expect(async () => { await this.codeMirror.evaluate( (el, value) => (el as any).CodeMirror?.setValue(value), text, ); expect(await this.getValue()).toBe(text); }).toPass({ timeout: DEFAULT_TIMEOUT }); if (await this.valueHolds(text, 200)) return; await new Promise((resolve) => setTimeout(resolve, Math.min(250 * attempt, 1000)), ); } while (Date.now() < deadline); throw new Error( `CodeMirror value "${text}" did not hold after ${attempt} attempts`, ); } private async valueHolds( expected: string, durationMs: number, ): Promise { const deadline = Date.now() + durationMs; while (Date.now() < deadline) { if ((await this.getValue()) !== expected) return false; await this.waitForRendererIdle(); } return true; } async clear(): Promise { await this.setValue(""); } async focus(): Promise { await this.codeMirror.evaluate((el) => (el as any).CodeMirror?.focus()); } /** The app sets `data-focused="on"` on the container on focus/blur. */ async hasFocus(): Promise { return (await this.root.getAttribute("data-focused")) === "on"; } async expectValue( expected: string | RegExp, timeout = DEFAULT_TIMEOUT, ): Promise { await expect(async () => { const value = await this.getValue(); if (typeof expected === "string") expect(value).toBe(expected); else expect(value).toMatch(expected); }).toPass({ timeout }); } /** Types through the keyboard; use only when the key events matter. */ async type(text: string): Promise { await this.codeMirror.click(); await this.page.keyboard.type(text); } } /** Single-line variant (`data-testid="OneLineEditor"`): url bar, kv cells. */ export class OneLineEditorComponent extends CodeEditorComponent { static nth(page: Page, index = 0, scope?: Locator): OneLineEditorComponent { return new OneLineEditorComponent( page, (scope ?? page).getByTestId("OneLineEditor").nth(index), ); } }