/** * High-level per-tab helpers for the `browser` tool (gjc `browser` parity, via * Playwright instead of puppeteer — jeo-code already ships `playwright` for its own * test suite; this reuses it rather than adding a second automation dependency). * * `observe()` mirrors gjc's accessibility-snapshot-first philosophy: it returns a * bounded list of interactive elements (role/name/value) instead of a raw DOM dump * or a screenshot, and tags each one with a `data-jeo-observe-id` attribute so a * later `act`/`run` call can address it by stable numeric `id` instead of a fragile * CSS selector or pixel coordinates. Ids are stable until the next `observe()`/ * navigation, exactly like the real tool's contract. */ import type { Page } from "playwright"; // Module-scoped (NOT global) DOM shims: these `page.evaluate()` callbacks run // INSIDE the browser page, not in this Bun/Node process, so they need `document`/ // `HTMLElement` in scope for the callback body to type-check — but a real // `/// ` pulls DOM's global `ReadableStream`/`Headers` // declarations into the WHOLE program's type-checking and breaks unrelated // Bun/Node-side files (job-registry.ts, the anthropic provider, ...) that rely on // the Node/Bun versions of those types. `declare const`/`type` inside a module // file are scoped to this file only, so they carry no such risk. declare const document: any; type HTMLElement = any; type HTMLInputElement = any; export interface ObservedElement { id: number; role: string; name: string; value: string; tag: string; } const OBSERVE_ATTR = "data-jeo-observe-id"; const INTERACTIVE_SELECTOR = 'a, button, input, select, textarea, [role], [onclick], [tabindex]'; const MAX_OBSERVED = 300; export type ExtractFormat = "markdown" | "text" | "html"; export interface Tab { page: Page; observe(): Promise; id(n: number): import("playwright").Locator; goto(url: string, opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle" }): Promise; click(selectorOrId: string | number): Promise; type(selectorOrId: string | number, text: string): Promise; fill(selector: string, value: string): Promise; press(key: string, opts?: { selector?: string }): Promise; scroll(dx: number, dy: number): Promise; select(selector: string, ...values: string[]): Promise; waitFor(selectorOrMs: string | number): Promise; back(): Promise; extract(format?: ExtractFormat): Promise; screenshot(opts?: { selector?: string; fullPage?: boolean }): Promise; evaluate(fn: (...args: any[]) => T, ...args: any[]): Promise; } function resolveLocator(page: Page, selectorOrId: string | number) { return typeof selectorOrId === "number" ? page.locator(`[${OBSERVE_ATTR}="${selectorOrId}"]`) : page.locator(selectorOrId).first(); } export function createTab(page: Page): Tab { return { page, async observe(): Promise { return page.evaluate( ({ sel, attr, max }) => { const nodes = Array.from(document.querySelectorAll(sel)).slice(0, max) as HTMLElement[]; return nodes.map((el, i) => { el.setAttribute(attr, String(i)); const tag = el.tagName.toLowerCase(); const explicitRole = el.getAttribute("role"); const role = explicitRole || (tag === "a" ? "link" : tag === "button" ? "button" : tag === "input" ? "textbox" : tag === "select" ? "combobox" : tag === "textarea" ? "textbox" : tag); const name = el.getAttribute("aria-label") || (el as HTMLInputElement).placeholder || el.textContent?.trim().replace(/\s+/g, " ").slice(0, 200) || ""; const value = (el as HTMLInputElement).value ?? ""; return { id: i, role, name, value, tag }; }); }, { sel: INTERACTIVE_SELECTOR, attr: OBSERVE_ATTR, max: MAX_OBSERVED }, ); }, id(n: number) { return page.locator(`[${OBSERVE_ATTR}="${n}"]`); }, async goto(url, opts) { await page.goto(url, { waitUntil: opts?.waitUntil ?? "load" }); }, async click(selectorOrId) { await resolveLocator(page, selectorOrId).click(); }, async type(selectorOrId, text) { const loc = resolveLocator(page, selectorOrId); await loc.fill(""); await loc.pressSequentially(text); }, async fill(selector, value) { await page.locator(selector).first().fill(value); }, async press(key, opts) { if (opts?.selector) await page.locator(opts.selector).first().press(key); else await page.keyboard.press(key); }, async scroll(dx, dy) { await page.mouse.wheel(dx, dy); }, async select(selector, ...values) { return page.locator(selector).first().selectOption(values); }, async waitFor(selectorOrMs) { if (typeof selectorOrMs === "number") await page.waitForTimeout(selectorOrMs); else await page.waitForSelector(selectorOrMs); }, async back() { await page.goBack({ waitUntil: "load" }); }, async extract(format: ExtractFormat = "markdown") { if (format === "html") return page.content(); // No readability port for v1 — markdown/text both collapse to the page's // rendered plain text (still far more compact/relevant than raw HTML). return page.evaluate(() => document.body?.innerText ?? ""); }, async screenshot(opts) { if (opts?.selector) return page.locator(opts.selector).first().screenshot(); return page.screenshot({ fullPage: !!opts?.fullPage }); }, async evaluate(fn, ...args) { return page.evaluate(fn as any, ...(args as any)); }, }; }