/** * A browser tab shown as a Tern browser picture-in-picture: every helper of * the tab API drives the PiP's native web view through Tern's browser op * protocol (`wire.ts`). Pages are reached through `eval` (page world for user * code and page instrumentation, the isolated world for omp's kit), trusted * `input` events at element centres, `capture`, `pdf` and the Tern-level ops; * what the page reports on its own arrives through `events` polling. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { isRecord, logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { JsRuntime } from "../../../eval/js/shared/runtime"; import { formatScreenshot, resizeImage } from "../../../utils/image-resize"; import { resolveToCwd } from "../../path-utils"; import { ToolAbortError, throwIfAborted } from "../../tool-errors"; import { type BrowserA11yOptions, type BrowserA11yResult, buildA11yPageScript, formatA11ySummary, normalizeA11yResult, } from "../a11y/audit"; import { type AriaSnapshotOptions, parseAriaRefSelector } from "../aria/aria-snapshot"; import type { BrowserCaptureResult, BrowserConsoleEntry, BrowserConsoleLevel, BrowserConsoleOptions, BrowserErrorEntry, BrowserErrorOptions, } from "../console-capture"; import type { DialogPolicy, DialogState } from "../dialogs"; import type { BrowserDownload } from "../downloads"; import type { BrowserEmulateOptions, ClipboardActionResult, ClipboardReadResult } from "../emulation"; import type { BrowserFrameInfo } from "../frames"; import type { InProcessRunContext, InProcessRunTab } from "../in-process-run"; import { loadedKnownDevices, loadPuppeteer } from "../launch"; import { pushStateInPage } from "../navigation"; import type { HarContentPolicy, NetworkPattern, NetworkRequestDetail, NetworkRequestRecord, NetworkRequestsOptions, NetworkResponseBody, NetworkRouteDescription, NetworkRouteOptions, } from "../network"; import { normalizeAllowedDomains } from "../network"; import { resolveOpTimeouts, resolveWaitTimeout, ZERO_MATCH_FAIL_FAST_MS } from "../op-timeouts"; import { DEFAULT_STYLE_PROPERTIES } from "../queries"; import { extractReadableFromHtml, type ReadableExtractOptions, type ReadableFormat } from "../readable"; import { enableReact, REACT_HOOK_INIT_SOURCE, type ReactEnableResult, type ReactPageEnvelope, type ReactPageHost, requireReactHookResult, } from "../react/devtools-hook"; import { type ReactRendersAction, type ReactRendersResult, reactRendersSource } from "../react/renders"; import { type ReactSuspenseBoundary, type ReactSuspenseOptions, reactSuspenseSource } from "../react/suspense"; import { type ReactInspectResult, reactInspectResult, reactInspectSource, type ReactTreeNode, type ReactTreeOptions, reactTreeSource, } from "../react/tree"; import { collectVitals, VITALS_INIT_SOURCE, type VitalsOptions, type VitalsResult } from "../react/vitals"; import { installCursorOverlay, RecordingController, removeCursorOverlay, type RecordingFrameSource, type RecordingFrameSourceStartParams, type RecordingOptions, type RecordingStartResult, type RecordingStatus, type RecordingStopResult, } from "../recording"; import { createPngDiff, type DiffScreenshotOptions, type DiffScreenshotResult, formatScreenshotLegend, type PdfOptions, pngPixelChangeRatio, type ScreenshotAnnotationTarget, type ScreenshotChangeResult, type ScreenshotHistory, type ScreenshotOptions, screenshotArea, screenshotQuality, screenshotScope, screenshotThreshold, } from "../screenshot"; import { type AriaSnapshotBaseline, type AriaSnapshotDiffResult, ariaSnapshotBaselineKey, diffAriaSnapshot, postProcessAriaSnapshot, } from "../snapshot-plus"; import { assertStorageKind, type BrowserCookie, type ClearCookiesOptions, type CookieQueryOptions, clearStorageInPage, type LoadStateResult, normalizeCookieArguments, readOriginStorageInPage, readStorageInPage, readStorageStateFile, restoreOriginStorageInPage, type StorageKind, type StorageStateOrigin, storageEntries, storageStatePath, storageValue, writeStorageInPage, } from "../storage-state"; import { assertTabPressArgs } from "../tab-arguments"; import type { Observation, ReadyInfo, SessionSnapshot } from "../tab-protocol"; import { readFilePayloads } from "../upload-files"; import { type WebMcpEventsOptions, type WebMcpEventsResult, type WebMcpInvokeOptions, type WebMcpInvokeResult, type WebMcpListOptions, type WebMcpListResult, } from "../webmcp"; import { keyDownStep, keyUpStep, pressSteps, type TernInputStep, type TernModifier, ternKey, typeSteps } from "./keys"; import { TernNetworkLog, type TernRequestEntry } from "./network-log"; import { TERN_CAPTURE_GLOBAL, TERN_CAPTURE_INSTALLER, type TernPageEmulation, ternCaptureScript } from "./page-capture"; import { TERN_KIT_SOURCE, type TernAnnotation, type TernFrameInfo, type TernGeometry, type TernKitApi, type TernTarget, type TernTargetAction, } from "./page-kit"; import { parseTernSelector, type TernSelector } from "./selectors"; import type { TernBlockEntry } from "./blocks"; import { TernError, type TernSocketClient } from "./wire"; type WaitUntil = "load" | "domcontentloaded" | "networkidle0" | "networkidle2"; type DragTarget = string | { readonly x: number; readonly y: number }; type MouseButtonName = "left" | "right" | "middle" | "back" | "forward"; type World = "page" | "isolated"; /** Where an operation runs: the main frame (`null`) or a registered frame path. */ type FramePath = string | null; interface BoundingBox { x: number; y: number; width: number; height: number; } interface ViewportOptions { width: number; height: number; deviceScaleFactor?: number; } /** Options of {@link TernTab.open}. */ export interface TernOpenOptions { /** Managed tab name (names the saved-state file). */ name: string; /** Pane that owns the PiP (`TERN_PANE`). */ pane: number; /** First real navigation after configuration. */ url?: string; /** Lifecycle the first navigation awaits. */ waitUntil?: WaitUntil; /** Page layout size. */ viewport: ViewportOptions; /** Budget of the whole open. */ timeoutMs: number; /** Abandons the open. */ signal?: AbortSignal; /** Automatic dialog policy. */ dialogs?: DialogPolicy; /** Hostname allowlist. */ allowedDomains?: string[]; /** Document-start scripts. */ initScripts?: string[]; /** Download directory. */ downloadsPath?: string; /** User agent override. */ userAgent?: string; /** Accept invalid TLS certificates. */ ignoreHttpsErrors?: boolean; } /** Options of {@link TernTab.adopt}: like an open, but every page setting only when given. */ export interface TernAdoptOptions extends Omit { /** The user's browser block to drive. */ block: TernBlockEntry; /** Page layout size for an adopted PiP; a docked block's page keeps the block's size. */ viewport?: ViewportOptions; } /** A Tern event (one entry of the `events` op). */ interface TernEvent { seq: number; type: string; [key: string]: unknown; } interface PendingDialog { type: string; message: string; defaultValue?: string; } interface InitScript { id: string; source: string; } type CaptureEntry = BrowserConsoleEntry | BrowserErrorEntry; const EVENT_LOG_LIMIT = 4_000; const CONSOLE_LIMIT = 500; const NAVIGATION_IDLE_MS = 500; const KIT_CALL = "async function (method, args) { const kit = globalThis.__ompTernKit; if (!kit) return { missing: true }; return { value: await kit[method](...args) }; }"; const HANDLE_ATTRIBUTE = "data-omp-tern-handle"; const NO_TERN_WEBMCP = "WKWebView has no native WebMCP; open the tab with `app.tern: false` to use Chromium"; /** The tab-helper name for errors: `tab.pdf()`. */ function helper(name: string): string { return `tab.${name}()`; } /** A ToolError for a helper the Tern backend cannot provide. */ function unsupported(name: string, reason: string): ToolError { return new ToolError(`${helper(name)} is not supported on the Tern browser backend: ${reason}`); } /** The message of a page-side exception answered by Tern (`js` errors), without Tern's prefix. */ function pageErrorText(error: TernError): string { return error.message.replace(/^Tern browser \w+ failed \(js\): /, ""); } /** * The page function running a user source string: an expression's value, or * — when the source is a statement list rather than an expression — the * statements as a function body. The choice is made by compiling (never * running) both candidates here, so the code runs exactly once in the page; * source that compiles as neither goes as an expression and the page reports * its syntax error. */ export function userSourceFunction(source: string): string { const expression = `return (\n${source}\n);`; try { new Function(expression); return `function () {\n${expression}\n}`; } catch {} try { new Function(source); return `function () {\n${source}\n}`; } catch {} return `function () {\n${expression}\n}`; } /** The first line of a page-side exception, without the `Error: ` prefix: agent-readable kit failures. */ function kitErrorText(error: TernError): string { return (pageErrorText(error).split("\n", 1)[0] ?? "").replace(/^Error: /, ""); } function numberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } function stringOr(value: unknown, fallback: string): string { return typeof value === "string" ? value : fallback; } /** Whether a cookie applies to `url` (domain, path and scheme). */ function cookieMatchesUrl(cookie: BrowserCookie, url: URL): boolean { const host = url.hostname.toLowerCase(); const domain = cookie.domain.toLowerCase().replace(/^\./, ""); const domainMatch = cookie.domain.startsWith(".") ? host === domain || host.endsWith(`.${domain}`) : host === domain; if (!domainMatch) return false; if (cookie.secure && url.protocol !== "https:") return false; const cookiePath = cookie.path || "/"; return ( url.pathname === cookiePath || url.pathname.startsWith(cookiePath.endsWith("/") ? cookiePath : `${cookiePath}/`) ); } /** * `args` without trailing `undefined`s. JSON turns an array's `undefined` into `null`, so an omitted * optional argument would reach the page function as `null` instead of `undefined`. */ function withoutTrailingUndefined(args: unknown[]): unknown[] { let end = args.length; while (end > 0 && args[end - 1] === undefined) end--; return end === args.length ? args : args.slice(0, end); } /** Whether `requested` is the address `current` already shows (`https://a.test` is `https://a.test/`). */ function sameAddress(requested: string, current: string): boolean { if (requested === current) return true; try { return new URL(requested).href === new URL(current).href; } catch { return false; } } /** A Tern cookie as the tab API reports it. */ function cookieFromTern(value: unknown): BrowserCookie | null { if (!isRecord(value) || typeof value.name !== "string" || typeof value.value !== "string") return null; const sameSite = value.sameSite === "Strict" || value.sameSite === "None" ? value.sameSite : "Lax"; return { name: value.name, value: value.value, domain: stringOr(value.domain, ""), path: stringOr(value.path, "/"), expires: numberOr(value.expires, -1), httpOnly: value.httpOnly === true, secure: value.secure === true, sameSite, }; } /** A captured console message's level (unknown levels read as `log`). */ function consoleLevel(value: unknown): BrowserConsoleLevel { return value === "info" || value === "warn" || value === "error" || value === "debug" ? value : "log"; } /** * The Tern backend's tab: one PiP (`block`) and the state its helpers keep — * the event cursor, console and request logs, dialog/download/frame state, * emulation, scripts, pointer and modifier state. */ export class TernTab implements InProcessRunTab { /** Guest file-name prefix of Tern runs. */ readonly runLabel = "tern-run"; /** The PiP's block id (daemon-native). */ readonly block: number; readonly #client: TernSocketClient; readonly #name: string; /** The user's block this tab adopted (released, never closed); undefined for omp's own PiP. */ readonly adopted: TernBlockEntry | undefined; #url = "about:blank"; #title = ""; #shown: boolean | undefined; #viewport: ViewportOptions; #cursor = 0; readonly #events: TernEvent[] = []; #pulling: Promise | undefined; #runContext: InProcessRunContext | undefined; #runtime: JsRuntime | undefined; #pageFacade: TernPageFacade | undefined; #browserFacade: TernBrowserFacade | undefined; // Console capture. readonly #console: CaptureEntry[] = []; #consoleSeq = 1; #consoleDropped = 0; // Page state from events. #pendingDialog: PendingDialog | undefined; readonly #frameUrls = new Map(); readonly #downloads = new Map(); readonly #completedDownloads: BrowserDownload[] = []; readonly #unclaimedDownloads: BrowserDownload[] = []; #downloadDir: string | undefined; // Network, scripts and emulation. readonly #network = new TernNetworkLog(); readonly #initScripts: InitScript[] = []; #nextInitScript = 1; #allowedDomains: string[] = []; readonly #emulation: BrowserEmulateOptions = {}; #pageEmulation: TernPageEmulation = {}; #deviceUserAgent: string | undefined; #reactHook = false; #cursorOverlay = false; // Input state. #pointer: { x: number; y: number } | undefined; readonly #buttons = new Set<"left" | "right" | "middle">(); readonly #mods: TernModifier[] = []; // Screenshots, snapshots, recording. readonly #screenshotHistory = new Map(); readonly #ariaBaselines = new Map(); readonly #recording = new RecordingController(); constructor(opts: { client: TernSocketClient; block: number; name: string; viewport: ViewportOptions; adopted?: TernBlockEntry; }) { this.#client = opts.client; this.block = opts.block; this.#name = opts.name; this.#viewport = { ...opts.viewport }; this.adopted = opts.adopted; } /** * Open a PiP over pane `opts.pane` at `about:blank`, wait for that load to * report, configure the PiP (dialog policy, allowlist, agent, TLS, downloads, * scripts) and only then make the first real navigation. The PiP closes * again when configuration fails, and when the open was abandoned * (timeout/abort) but Tern answered late. */ static async open(client: TernSocketClient, opts: TernOpenOptions): Promise { const startedAt = Date.now(); const remainingMs = (): number => Math.max(1, opts.timeoutMs - (Date.now() - startedAt)); const opened = await client.request( { op: "open", owner: opts.pane, url: "about:blank", width: Math.round(opts.viewport.width), height: Math.round(opts.viewport.height), }, { timeoutMs: opts.timeoutMs, signal: opts.signal, onLateAnswer: late => { if (!isRecord(late) || typeof late.block !== "number") return; void client.request({ op: "close", block: late.block }, { timeoutMs: 5_000 }).catch(() => undefined); }, }, ); if (!isRecord(opened) || typeof opened.block !== "number") { throw new ToolError("Tern answered the browser open without a block id"); } const tab = new TernTab({ client, block: opened.block, name: opts.name, viewport: opts.viewport }); try { await tab.#configure(opts, remainingMs()); // The PiP already shows about:blank, and a Tern web view never reloads an unchanged address. if (opts.url && opts.url !== "about:blank") { await tab.goto(opts.url, { waitUntil: opts.waitUntil ?? "load", timeoutMs: remainingMs() }); } } catch (error) { await tab.close({ timeoutMs: 5_000 }).catch(() => undefined); throw error; } return tab; } /** * Drive the user's existing browser block `opts.block` (docked or PiP) * without opening anything: skip the events it reported before, apply only * the page settings the caller gave (plus omp's document-start scripts, * installed into the current document too), and navigate only when `url` * is given. A failure releases the block again; it is never closed. */ static async adopt(client: TernSocketClient, opts: TernAdoptOptions): Promise { const startedAt = Date.now(); const remainingMs = (): number => Math.max(1, opts.timeoutMs - (Date.now() - startedAt)); const tab = new TernTab({ client, block: opts.block.block, name: opts.name, viewport: opts.viewport ?? { width: 0, height: 0 }, adopted: opts.block, }); try { const answer = await tab.#op("events", { after: 0 }, remainingMs()); if (isRecord(answer) && typeof answer.next === "number") tab.#cursor = answer.next; // The address the block shows: a `goto` to it must become a reload (see `gotoResponse`). await tab.#refreshState(); if (opts.dialogs !== undefined) await tab.#op("dialogs", { policy: opts.dialogs }); await tab.#applySettings(opts); if (opts.viewport && opts.block.owner !== null) await tab.setViewport(opts.viewport); await tab.#applyCapture(); if (opts.url) { await tab.goto(opts.url, { waitUntil: opts.waitUntil ?? "load", timeoutMs: remainingMs() }); } } catch (error) { await tab.close({ timeoutMs: 5_000 }).catch(() => undefined); throw error; } return tab; } /** The `page` facade `tab.run` code sees. */ get page(): TernPageFacade { this.#pageFacade ??= new TernPageFacade(this); return this.#pageFacade; } /** The `browser` facade `tab.run` code sees. */ get browser(): TernBrowserFacade { this.#browserFacade ??= new TernBrowserFacade(this); return this.#browserFacade; } /** The tab's JavaScript runtime for `tab.run`. */ ensureRuntime(session: SessionSnapshot): JsRuntime { this.#runtime ??= new JsRuntime({ initialCwd: session.cwd, sessionId: `tern-tab-${this.block}` }); return this.#runtime; } /** Publish the active run. */ setRunContext(context: InProcessRunContext): void { this.#runContext = context; } /** Forget the finished run. */ clearRunContext(): void { this.#runContext = undefined; } /** Page facts the supervisor records for the tab. */ async readyInfo(): Promise { await this.#refreshState(); return { url: this.#url, title: this.#title, viewport: { ...this.#viewport }, targetId: String(this.block), }; } /** * Whether the block was on screen at the last `state` (undefined before one, * or from a Tern that does not say). A PiP shows only while the tab holding * its owner pane is the one the user looks at. */ get shown(): boolean | undefined { return this.#shown; } /** * Close omp's PiP (its page goes), or release an adopted block: Tern stops * driving it and resets what omp set, the user's page stays. A Tern without * `release` (answers `invalid`) only gets omp's scripts cleared, best-effort. * A block already gone is no error. */ async close(opts: { timeoutMs: number }): Promise { await this.#recording.close().catch(error => { logger.warn("Failed to finalize a Tern browser recording during close", { error: error instanceof Error ? error.message : String(error), }); }); try { await this.#client.request( { op: this.adopted ? "release" : "close", block: this.block }, { timeoutMs: opts.timeoutMs }, ); } catch (error) { if (error instanceof TernError && (error.kind === "not_found" || error.kind === "closed")) return; if (!(this.adopted && error instanceof TernError && error.kind === "invalid")) throw error; await this.#client .request({ op: "scripts", block: this.block, scripts: [] }, { timeoutMs: opts.timeoutMs }) .catch(() => undefined); } } // ─── Protocol plumbing ──────────────────────────────────────────────── get #signal(): AbortSignal | undefined { return this.#runContext?.signal; } get #cellMs(): number { return this.#runContext?.timeoutMs ?? 30_000; } /** Send a block op; aborts with the run. */ async #op(op: string, fields: Record = {}, timeoutMs?: number): Promise { const signal = this.#signal; throwIfAborted(signal); return await this.#client.request( { op, block: this.block, ...fields }, { timeoutMs: timeoutMs ?? resolveOpTimeouts(this.#cellMs).budgetBound, signal }, ); } /** Evaluate a function source; resolves its JSON value (`undefined` for none). */ async #eval(source: string, args: unknown[], world: World, frame: FramePath, timeoutMs?: number): Promise { const answer = await this.#op( "eval", { function: source, args: withoutTrailingUndefined(args), world, ...(frame ? { frame } : {}) }, timeoutMs, ); return isRecord(answer) ? answer.value : undefined; } /** Call a kit method in `frame`'s isolated world, installing the kit when the document lacks it. */ async #kit(method: keyof TernKitApi, args: unknown[], frame: FramePath = null): Promise { for (let attempt = 0; attempt < 2; attempt++) { let result: unknown; try { result = await this.#eval(KIT_CALL, [method, args], "isolated", frame); } catch (error) { if (error instanceof TernError && error.kind === "js") throw new ToolError(kitErrorText(error)); throw error; } if (isRecord(result) && result.missing === true) { await this.#eval(`function () {\n${TERN_KIT_SOURCE}\n}`, [], "isolated", frame); continue; } return (isRecord(result) ? result.value : undefined) as T; } throw new ToolError("The Tern page kit could not be installed in this document"); } /** Evaluate user code in the page world, surfacing page exceptions as ToolErrors. */ async #evalUser(label: string, fn: unknown, args: unknown[], frame: FramePath): Promise { try { if (typeof fn === "function") return await this.#eval(fn.toString(), args, "page", frame); if (typeof fn !== "string") throw new ToolError(`${label} expects a function or a source string`); return await this.#eval(userSourceFunction(fn), [], "page", frame); } catch (error) { if (error instanceof TernError && error.kind === "js") { throw new ToolError(`${label} threw a JavaScript exception:\n${pageErrorText(error)}`); } throw error; } } /** Evaluate an expression source (an IIFE string) in the main page world. */ async #evalExpression(source: string, signal?: AbortSignal): Promise { throwIfAborted(signal); return await this.#eval(`function () {\nreturn ${source};\n}`, [], "page", null); } /** Send trusted input steps. */ async #input(steps: TernInputStep[]): Promise { if (steps.length === 0) return; await this.#op("input", { events: steps }); } /** Pull new Tern events into the tab's state (single-flight). */ async #pull(): Promise { if (this.#pulling) return await this.#pulling; const pulling = (async () => { const answer = await this.#op("events", { after: this.#cursor }, 10_000); if (!isRecord(answer) || !Array.isArray(answer.events)) return; for (const raw of answer.events) { if (!isRecord(raw) || typeof raw.seq !== "number" || typeof raw.type !== "string") continue; if (raw.seq <= this.#cursor) continue; this.#cursor = raw.seq; const event: TernEvent = { ...raw, seq: raw.seq, type: raw.type }; this.#events.push(event); this.#ingest(event); } if (this.#events.length > EVENT_LOG_LIMIT) this.#events.splice(0, this.#events.length - EVENT_LOG_LIMIT); if (typeof answer.dropped === "number" && answer.dropped > 0) { logger.debug("Tern dropped browser events before omp read them", { dropped: answer.dropped }); } })(); this.#pulling = pulling; try { await pulling; } finally { this.#pulling = undefined; } } /** Apply one event to the tab's state. */ #ingest(event: TernEvent): void { switch (event.type) { case "url": case "committed": if (typeof event.url === "string") this.#url = event.url; if (event.type === "committed") { this.#pendingDialog = undefined; this.#frameUrls.clear(); } return; case "title": if (typeof event.title === "string") this.#title = event.title; return; case "frame": if (typeof event.name === "string" && typeof event.url === "string") this.#frameUrls.set(event.name, event.url); return; case "dialog": if (event.handled === null || event.handled === undefined) { this.#pendingDialog = { type: stringOr(event.kind, "alert"), message: stringOr(event.message, ""), ...(event.kind === "prompt" ? { defaultValue: stringOr(event.default, "") } : {}), }; } return; case "download": this.#ingestDownload(event); return; case "response": this.#network.ingestNavigationResponse(event); return; case "blocked": if (typeof event.url === "string") this.#network.ingestBlocked(event.url); return; case "message": this.#ingestMessage(event); return; } } #ingestMessage(event: TernEvent): void { if (event.world !== "page" || typeof event.body !== "string") return; let message: unknown; try { message = JSON.parse(event.body); } catch { return; } if (!isRecord(message) || message.omp !== "tern") return; if (typeof message.dropped === "number") this.#consoleDropped += message.dropped; const ts = numberOr(message.ts, Date.now()); const location = typeof message.location === "string" ? message.location : undefined; switch (message.kind) { case "console": this.#pushConsole({ seq: 0, ts, type: "console", level: consoleLevel(message.level), text: stringOr(message.text, ""), location, args: Array.isArray(message.args) ? message.args : [], }); return; case "pageerror": this.#pushConsole({ seq: 0, ts, type: "pageerror", level: "error", text: stringOr(message.text, ""), location, stack: typeof message.stack === "string" ? message.stack : undefined, }); return; case "requestfailed": this.#network.ingestPageMessage(message); this.#pushConsole({ seq: 0, ts, type: "requestfailed", level: "error", text: `${stringOr(message.error, "request failed")}: ${stringOr(message.url, "")}`, location: typeof message.url === "string" ? message.url : undefined, }); return; case "request": case "response": this.#network.ingestPageMessage(message); return; } } #pushConsole(entry: CaptureEntry): void { entry.seq = this.#consoleSeq++; this.#console.push(entry); while (this.#console.length > CONSOLE_LIMIT) { this.#console.shift(); this.#consoleDropped++; } } #ingestDownload(event: TernEvent): void { if (typeof event.id !== "number") return; const known = this.#downloads.get(event.id); const entry = { url: stringOr(event.url, known?.url ?? ""), path: typeof event.path === "string" ? event.path : known?.path, state: stringOr(event.state, "started"), error: typeof event.error === "string" ? event.error : undefined, }; this.#downloads.set(event.id, entry); if (entry.state !== "finished" || !entry.path) return; let bytes = 0; try { bytes = fs.statSync(entry.path).size; } catch {} const download: BrowserDownload = { path: entry.path, suggestedFilename: path.basename(entry.path), url: entry.url, bytes, }; this.#completedDownloads.push(download); this.#unclaimedDownloads.push(download); } /** Events newer than `since` (already pulled). */ #eventsSince(since: number): TernEvent[] { return this.#events.filter(event => event.seq > since); } /** * Poll `check` until it yields a value (not `undefined`), backing off * 25→100 ms; `pull` also refreshes Tern events before each check. */ async #poll( label: string, timeoutMs: number, check: () => T | undefined | Promise, opts: { pull: boolean } = { pull: false }, ): Promise { const deadline = Date.now() + timeoutMs; let delay = 25; for (;;) { if (opts.pull) await this.#pull(); const value = await check(); if (value !== undefined) return value; const left = deadline - Date.now(); if (left <= 0) throw new ToolError(`${label} timed out after ${timeoutMs}ms`); await untilAborted(this.#signal, () => Bun.sleep(Math.min(delay, left))); delay = Math.min(100, Math.round(delay * 1.5)); } } /** Refresh url/title (and the known viewport) from Tern's `state`. */ async #refreshState(): Promise> { const state = await this.#op("state", {}, resolveOpTimeouts(this.#cellMs).quickOpMs); if (!isRecord(state)) return {}; if (typeof state.url === "string") this.#url = state.url; if (typeof state.title === "string") this.#title = state.title; if (typeof state.shown === "boolean") this.#shown = state.shown; if (typeof state.width === "number" && typeof state.height === "number") { this.#viewport = { ...this.#viewport, width: state.width, height: state.height }; } return state; } // ─── Configuration and scripts ──────────────────────────────────────── async #configure(opts: TernOpenOptions, timeoutMs: number): Promise { // Tern answers `open` once the page takes calls, before its about:blank load reports. That load's // late `committed`/`loaded` events would otherwise settle the first navigation before it loads. await this.#poll( "The Tern page's initial about:blank load", timeoutMs, () => (this.#events.some(event => event.type === "loaded" || event.type === "failed") ? true : undefined), { pull: true }, ); await this.#op("dialogs", { policy: opts.dialogs ?? "default" }); await this.#applySettings(opts); await this.#syncScripts(); } /** The page settings the caller gave: allowlist, agent, TLS, downloads, init scripts (sent with the next script sync). */ async #applySettings(opts: Omit): Promise { if (opts.allowedDomains?.length) { this.#allowedDomains = normalizeAllowedDomains(opts.allowedDomains); await this.#op("allow", { hosts: this.#allowedDomains }); } if (opts.userAgent !== undefined) { this.#emulation.userAgent = opts.userAgent; await this.#op("agent", { value: opts.userAgent }); } if (opts.ignoreHttpsErrors) await this.#op("insecure", { value: true }); if (opts.downloadsPath !== undefined) await this.#enableDownloads(opts.downloadsPath); for (const source of opts.initScripts ?? []) { this.#initScripts.push({ id: `tern-init-${this.#nextInitScript++}`, source }); } } /** Every document-start script this tab needs, in order. */ #scripts(): Array<{ source: string; world: World; frames: "main" | "all"; at: "start" | "end" }> { return [ { source: ternCaptureScript({ routes: this.#network.rules(), emulation: this.#pageEmulation }), world: "page", frames: "all", at: "start", }, { source: `${VITALS_INIT_SOURCE};`, world: "page", frames: "main", at: "start" }, ...(this.#reactHook ? [ { source: `${REACT_HOOK_INIT_SOURCE};`, world: "page" as const, frames: "main" as const, at: "start" as const, }, ] : []), ...(this.#cursorOverlay ? [ { source: `(${installCursorOverlay.toString()})();`, world: "page" as const, frames: "main" as const, at: "start" as const, }, ] : []), ...this.#initScripts.map(script => ({ source: script.source, world: "page" as const, frames: "all" as const, at: "start" as const, })), { source: TERN_KIT_SOURCE, world: "isolated", frames: "all", at: "start" }, ]; } /** Send the script list (future documents). */ async #syncScripts(): Promise { await this.#op("scripts", { scripts: this.#scripts() }); } /** Re-apply the capture configuration: future documents and every frame of the current one. */ async #applyCapture(): Promise { await this.#syncScripts(); const config = { routes: this.#network.rules(), emulation: this.#pageEmulation }; const children = await this.#kit("frames", []).catch(() => []); for (const frame of [null, ...children.map(child => child.path)]) { await this.#eval(TERN_CAPTURE_INSTALLER, [config], "page", frame).catch(error => { logger.debug("Tern capture update skipped for a frame", { frame, error: error instanceof Error ? error.message : String(error), }); }); } } // ─── Navigation ─────────────────────────────────────────────────────── /** * Wait for a navigation started after `since`: `load` = Tern's `loaded` * event, `domcontentloaded` = committed and parsed, `networkidle*` = loaded * and at most 0/2 page fetch/XHR in flight for 500 ms (page requests only). * A load failure or allowlist block throws. When a navigation reports no * events (same-document, cached), a settled `state` ends the wait. */ async #awaitNavigation( label: string, since: number, waitUntil: WaitUntil, timeoutMs: number, opts: { requireChange: boolean; startUrl: string }, ): Promise { let iteration = 0; let idleSince: number | undefined; let settledByState = false; await this.#poll( label, timeoutMs, async () => { iteration++; const events = this.#eventsSince(since); const failed = events.find(event => event.type === "failed"); if (failed) throw new ToolError(`${label} failed: ${stringOr(failed.message, "load failed")}`); const blocked = events.find(event => event.type === "blocked"); if (blocked) { throw new ToolError(`${label} was blocked by allowed_domains: ${stringOr(blocked.url, "")}`); } const committed = events.some(event => event.type === "committed"); const changed = committed || events.some(event => event.type === "url"); let loaded = events.some(event => event.type === "loaded"); if (!loaded && iteration % 4 === 0) { const state = await this.#refreshState(); if (state.loading === false && (changed || (!opts.requireChange && this.#url !== opts.startUrl))) { loaded = true; settledByState = true; } } if (opts.requireChange && !changed && !settledByState) return undefined; if (waitUntil === "domcontentloaded") { if (loaded) return true; if (!committed) return undefined; const ready = await this.#eval("function () { return document.readyState; }", [], "page", null).catch( () => "loading", ); return ready === "loading" ? undefined : true; } if (!loaded) return undefined; if (waitUntil === "load") return true; const inflight = numberOr( await this.#eval( `function () { const capture = globalThis[${JSON.stringify(TERN_CAPTURE_GLOBAL)}]; return capture ? capture.inflight : 0; }`, [], "page", null, ).catch(() => 0), 0, ); if (inflight > (waitUntil === "networkidle0" ? 0 : 2)) { idleSince = undefined; return undefined; } idleSince ??= Date.now(); return Date.now() - idleSince >= NAVIGATION_IDLE_MS ? true : undefined; }, { pull: true }, ); const mainResponses = this.#network .settledAfter(0) .filter(record => record.resourceType === "document" && record.status !== undefined); const response = mainResponses.at(-1) ?? null; await this.#refreshState().catch(() => undefined); return response; } async #navigate( label: string, start: () => Promise, waitUntil: WaitUntil | undefined, timeoutMs: number, ): Promise { await this.#pull(); const since = this.#cursor; const responseCursor = this.#network.cursor; const startUrl = this.#url; await start(); const response = await this.#awaitNavigation(label, since, waitUntil ?? "load", timeoutMs, { requireChange: false, startUrl, }); return response && response.seq >= responseCursor ? response : null; } /** Navigate and wait for `waitUntil` (default `load`). */ async goto(url: string, opts?: { waitUntil?: WaitUntil; timeoutMs?: number }): Promise { await this.gotoResponse(url, opts); } /** {@link goto}, resolving the main document's response (null when none was reported). */ async gotoResponse(url: string, opts?: { waitUntil?: WaitUntil; timeoutMs?: number }): Promise { const timeoutMs = opts?.timeoutMs ?? resolveOpTimeouts(this.#cellMs).budgetBound; // A Tern web view never reloads an unchanged address (Terns before the fix answer the // `goto` and report no navigation, so the wait would never end). Reloading is what a goto // to the shown address means anyway, and what Tern's own address field does: `nav reload` // always reports a fresh load. Settling by `state` instead would race a Tern that does // reload: `loading` can still read false before the reload starts. Decided after // `#navigate`'s event pull, so the shown address is current. const entry = await this.#navigate( `tab.goto(${JSON.stringify(url)})`, () => sameAddress(url, this.#url) ? this.#op("nav", { go: "reload" }, timeoutMs) : this.#op("goto", { url }, timeoutMs), opts?.waitUntil, timeoutMs, ); return entry ? new TernResponse(entry, this) : null; } async #history(go: "back" | "forward" | "reload", opts?: { waitUntil?: WaitUntil }): Promise { const timeoutMs = resolveOpTimeouts(this.#cellMs).budgetBound; if (go !== "reload") { const state = await this.#refreshState(); if (state[go] === false) return this.#url; } await this.#navigate(`tab.${go}()`, () => this.#op("nav", { go }), opts?.waitUntil, timeoutMs); return this.#url; } /** Go back one history entry (no-op at the start of history). */ async back(opts?: { waitUntil?: WaitUntil }): Promise { return await this.#history("back", opts); } /** Go forward one history entry (no-op at the end of history). */ async forward(opts?: { waitUntil?: WaitUntil }): Promise { return await this.#history("forward", opts); } /** Reload the document. */ async reload(opts?: { waitUntil?: WaitUntil }): Promise { return await this.#history("reload", opts); } /** Client-side navigation (`history.pushState`). */ async pushState(url: string): Promise { const result = await this.#evalUser("tab.pushState()", pushStateInPage, [url], null); if (typeof result === "string") this.#url = result; return this.#url; } /** Wait for the next navigation (start it before the action that navigates). */ async waitForNavigation(opts?: { waitUntil?: WaitUntil; timeout?: number }): Promise { const timeoutMs = resolveWaitTimeout(this.#cellMs, opts?.timeout ?? resolveOpTimeouts(this.#cellMs).budgetBound); await this.#pull(); const since = this.#cursor; const responseCursor = this.#network.cursor; const entry = await this.#awaitNavigation( "tab.waitForNavigation()", since, opts?.waitUntil ?? "load", timeoutMs, { requireChange: true, startUrl: this.#url, }, ); return entry && entry.seq >= responseCursor ? new TernResponse(entry, this) : null; } /** Wait until the URL contains `pattern` (string) or matches it (RegExp). */ async waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise { const timeoutMs = resolveWaitTimeout(this.#cellMs, opts?.timeout); let iteration = 0; return await this.#poll( `tab.waitForUrl(${String(pattern)})`, timeoutMs, async () => { if (++iteration % 4 === 0) await this.#refreshState(); if (typeof pattern === "string") return this.#url.includes(pattern) ? this.#url : undefined; pattern.lastIndex = 0; return pattern.test(this.#url) ? this.#url : undefined; }, { pull: true }, ); } /** The current URL (as last reported). */ url(): string { return this.#url; } /** The title as last reported (title events, state, observe), without asking the page. */ get lastTitle(): string { return this.#title; } /** The document title. */ async title(): Promise { await this.#refreshState(); return this.#title; } /** The page layout size. */ viewport(): ReadyInfo["viewport"] { return { ...this.#viewport }; } /** Resize the PiP's page (`viewport` op); `deviceScaleFactor` scales captures. */ async setViewport(viewport: ViewportOptions): Promise { await this.#op("viewport", { width: Math.round(viewport.width), height: Math.round(viewport.height) }); this.#viewport = { ...viewport }; } // ─── Selectors and targets ──────────────────────────────────────────── #spec(selector: string | TernSelector): TernSelector { return typeof selector === "string" ? parseTernSelector(selector) : selector; } /** Viewport offset of `frame`'s content box in the main frame. */ async #frameOffset(frame: FramePath): Promise<{ x: number; y: number }> { if (!frame) return { x: 0, y: 0 }; const indices = frame.split("."); let x = 0; let y = 0; for (let depth = 0; depth < indices.length; depth++) { const parent = depth === 0 ? null : indices.slice(0, depth).join("."); const origin = await this.#kit<{ x: number; y: number } | null>( "frameOrigin", [Number(indices[depth])], parent, ); if (!origin) throw new ToolError(`Frame ${frame} is no longer in the page`); x += origin.x; y += origin.y; } return { x, y }; } /** * The point to act on for `action`: waits for the element to be actionable * (visible, enabled, editable, stable, not covered) up to the action * ceiling, failing fast after 2 s of zero matches. */ async #target( label: string, selector: string | TernSelector, action: TernTargetAction, frame: FramePath, explicitTimeout?: number, ): Promise { const spec = this.#spec(selector); const timeoutMs = explicitTimeout === undefined ? resolveOpTimeouts(this.#cellMs).actionOpMs : resolveWaitTimeout(this.#cellMs, explicitTimeout); const started = Date.now(); let delay = 30; let last: TernTarget; for (;;) { last = await this.#kit("target", [spec, action], frame); if (last.ok) { const offset = await this.#frameOffset(frame); return { x: last.x + offset.x, y: last.y + offset.y, width: last.width, height: last.height }; } const elapsed = Date.now() - started; if (elapsed >= timeoutMs) break; if (last.reason === "missing" && explicitTimeout === undefined && elapsed >= ZERO_MATCH_FAIL_FAST_MS) break; await untilAborted(this.#signal, () => Bun.sleep(delay)); delay = Math.min(250, Math.round(delay * 1.5)); } const why = last.reason === "missing" ? "no element matches (wrong page or selector?)" : last.reason === "covered" ? `the element is covered by ${last.detail ?? "another element"}` : last.reason === "hidden" ? "the element is not visible" : last.reason === "disabled" ? "the element is disabled" : last.reason === "notEditable" ? "the element is not editable" : last.reason === "offViewport" ? "the element is outside the viewport" : "the element keeps moving"; throw new ToolError(`${label} timed out after ${Date.now() - started}ms: ${why} (${last.count} match(es))`); } #mouseStep( action: "move" | "down" | "up", point: { x: number; y: number }, button: "left" | "right" | "middle", clicks?: number, ): TernInputStep { return { type: "mouse", action, x: point.x, y: point.y, button, ...(clicks === undefined ? {} : { clicks }), mods: [...this.#mods], }; } /** Trusted clicks at `point` (`count` clicks, the last one with click count `count`). */ async #clickAt(point: { x: number; y: number }, button: "left" | "right" | "middle", count: number): Promise { const steps: TernInputStep[] = [this.#mouseStep("move", point, button, 0)]; for (let click = 1; click <= count; click++) { steps.push(this.#mouseStep("down", point, button, click), this.#mouseStep("up", point, button, click)); } this.#pointer = { ...point }; await this.#input(steps); } async #clickSelector( label: string, selector: string | TernSelector, frame: FramePath, count: number, button: MouseButtonName = "left", ): Promise { const pressed = this.#button(button); const box = await this.#target(label, selector, count === 2 ? "dblclick" : "click", frame); await this.#clickAt(box, pressed, count); } // ─── Interaction ────────────────────────────────────────────────────── /** Click the element's centre with a trusted mouse click, optionally with another button or click count. */ async click(selector: string, options?: { button?: MouseButtonName; count?: number }): Promise { await this.clickIn(selector, null, options); } /** {@link click} inside `frame`, optionally with another button or click count. */ async clickIn( selector: string | TernSelector, frame: FramePath, options?: { button?: MouseButtonName; count?: number }, ): Promise { const count = Math.max(1, Math.floor(options?.count ?? 1)); await this.#clickSelector(`tab.click(${describe(selector)})`, selector, frame, count, options?.button); } /** Double-click the element's centre. */ async dblclick(selector: string | TernSelector, frame: FramePath = null): Promise { await this.#clickSelector(`tab.dblclick(${describe(selector)})`, selector, frame, 2); } /** Move the pointer over the element's centre. */ async hover(selector: string | TernSelector, frame: FramePath = null): Promise { const box = await this.#target(`tab.hover(${describe(selector)})`, selector, "hover", frame); this.#pointer = { x: box.x, y: box.y }; await this.#input([this.#mouseStep("move", box, "left", 0)]); } /** Focus the element. */ async focus(selector: string | TernSelector, frame: FramePath = null): Promise { await this.#target(`tab.focus(${describe(selector)})`, selector, "focus", frame); await this.#kit("focus", [this.#spec(selector)], frame); } async #setChecked(selector: string | TernSelector, desired: boolean, frame: FramePath): Promise { const label = `tab.${desired ? "check" : "uncheck"}(${describe(selector)})`; const spec = this.#spec(selector); const box = await this.#target(label, spec, "check", frame); const before = await this.#kit<{ checked: boolean }>("checkState", [spec], frame); if (before.checked === desired) return; await this.#clickAt(box, "left", 1); const after = await this.#kit<{ checked: boolean }>("checkState", [spec], frame); if (after.checked !== desired) { throw new ToolError(`${label} clicked the element but it is still ${desired ? "unchecked" : "checked"}`); } } /** Check a checkbox, radio or ARIA switch (clicks only when needed). */ async check(selector: string | TernSelector, frame: FramePath = null): Promise { await this.#setChecked(selector, true, frame); } /** Uncheck a checkbox or ARIA switch (clicks only when needed). */ async uncheck(selector: string | TernSelector, frame: FramePath = null): Promise { await this.#setChecked(selector, false, frame); } /** Type `text` at the end of the element's content with trusted key events. */ async type(selector: string | TernSelector, text: string, frame: FramePath = null): Promise { const spec = this.#spec(selector); await this.#target(`tab.type(${describe(selector)})`, spec, "type", frame); await this.#kit("caretToEnd", [spec], frame); await this.#input(typeSteps(String(text), this.#mods)); } /** Replace the element's value (trusted text input for text controls). */ async fill(selector: string | TernSelector, value: string, frame: FramePath = null): Promise { const spec = this.#spec(selector); const text = String(value); await this.#target(`tab.fill(${describe(selector)})`, spec, "fill", frame); const prepared = await this.#kit<{ mode: "insert" | "done" }>("prepareFill", [spec, text], frame); if (prepared.mode === "done") return; await this.#input(text.length > 0 ? [{ type: "text", text }] : pressSteps("Backspace", this.#mods)); } /** Press a key (or `Mod+Key` combo), optionally after focusing an element. */ async press(key: string, opts?: { selector?: string }): Promise { await this.pressIn(key, opts, null); } /** {@link press} inside `frame`. */ async pressIn(key: string, opts: { selector?: string | TernSelector } | undefined, frame: FramePath): Promise { assertTabPressArgs(key, opts); if (opts?.selector) await this.focus(opts.selector, frame); await this.#input(pressSteps(key, this.#mods)); } /** Hold a key down (modifiers apply to later input). */ async keyDown(key: string): Promise { const resolved = ternKey(key); await this.#input([keyDownStep(resolved, this.#mods)]); if (resolved.modifier && !this.#mods.includes(resolved.modifier)) this.#mods.push(resolved.modifier); } /** Release a held key. */ async keyUp(key: string): Promise { const resolved = ternKey(key); if (resolved.modifier) { const index = this.#mods.indexOf(resolved.modifier); if (index >= 0) this.#mods.splice(index, 1); } await this.#input([keyUpStep(resolved, this.#mods)]); } #button(name: MouseButtonName | undefined): "left" | "right" | "middle" { const button = name ?? "left"; if (button === "back" || button === "forward") { throw unsupported("mouseDown", `the ${button} mouse button cannot be synthesised (left, right, middle only)`); } return button; } async #viewportCentre(): Promise<{ x: number; y: number }> { const geometry = await this.#kit("geometry", []); return { x: geometry.innerWidth / 2, y: geometry.innerHeight / 2 }; } /** Move the pointer (interpolated over `steps`); a held button drags. */ async mouseMove(x: number, y: number, opts?: { steps?: number }): Promise { if (!Number.isFinite(x) || !Number.isFinite(y)) throw new ToolError("mouseMove coordinates must be finite numbers"); const from = this.#pointer ?? { x, y }; const steps = Math.max(1, Math.floor(opts?.steps ?? 1)); const held = [...this.#buttons][0] ?? "left"; const events: TernInputStep[] = []; for (let step = 1; step <= steps; step++) { const point = { x: from.x + ((x - from.x) * step) / steps, y: from.y + ((y - from.y) * step) / steps }; events.push(this.#mouseStep("move", point, held, 0)); } this.#pointer = { x, y }; await this.#input(events); } /** Press a mouse button at the pointer. */ async mouseDown(opts?: { button?: MouseButtonName; clickCount?: number }): Promise { const button = this.#button(opts?.button); const point = this.#pointer ?? (await this.#viewportCentre()); this.#buttons.add(button); await this.#input([this.#mouseStep("down", point, button, opts?.clickCount ?? 1)]); } /** Release a mouse button at the pointer. */ async mouseUp(opts?: { button?: MouseButtonName; clickCount?: number }): Promise { const button = this.#button(opts?.button); const point = this.#pointer ?? (await this.#viewportCentre()); this.#buttons.delete(button); await this.#input([this.#mouseStep("up", point, button, opts?.clickCount ?? 1)]); } /** Click at viewport coordinates. */ async clickAt(x: number, y: number, opts?: { button?: MouseButtonName; clickCount?: number }): Promise { if (!Number.isFinite(x) || !Number.isFinite(y)) throw new ToolError("clickAt coordinates must be finite numbers"); await this.#clickAt({ x, y }, this.#button(opts?.button), Math.max(1, Math.floor(opts?.clickCount ?? 1))); } /** A trusted wheel event at the pointer (the viewport centre before any pointer move). */ async wheel(deltaX: number, deltaY: number): Promise { if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) throw new ToolError("wheel deltas must be finite numbers"); const point = this.#pointer ?? (await this.#viewportCentre()); await this.#input([{ type: "wheel", x: point.x, y: point.y, dx: deltaX, dy: deltaY, mods: [...this.#mods] }]); } /** Scroll the page (trusted wheel at the viewport centre) or an element (`scrollBy`). */ async scroll(dx: number, dy: number, opts?: { selector?: string }): Promise { if (opts?.selector) { const spec = this.#spec(opts.selector); await this.#target(`tab.scroll(${describe(opts.selector)})`, spec, "point", null); await this.#kit("scrollBy", [spec, dx, dy]); return; } const centre = await this.#viewportCentre(); await this.#input([{ type: "wheel", x: centre.x, y: centre.y, dx, dy, mods: [...this.#mods] }]); } async #dragPoint(target: DragTarget, role: string): Promise<{ x: number; y: number }> { if (typeof target === "string") { const box = await this.#target(`tab.drag() ${role} ${JSON.stringify(target)}`, target, "drag", null); return { x: box.x, y: box.y }; } if (target && Number.isFinite(target.x) && Number.isFinite(target.y)) return { x: target.x, y: target.y }; throw new ToolError("Drag target must be a selector string or { x: number, y: number } point"); } /** Trusted drag from one selector/point to another. */ async drag(from: DragTarget, to: DragTarget): Promise { const start = await this.#dragPoint(from, "from"); const end = await this.#dragPoint(to, "to"); const steps: TernInputStep[] = [ this.#mouseStep("move", start, "left", 0), this.#mouseStep("down", start, "left", 1), ]; for (let step = 1; step <= 10; step++) { const point = { x: start.x + ((end.x - start.x) * step) / 10, y: start.y + ((end.y - start.y) * step) / 10 }; steps.push(this.#mouseStep("move", point, "left", 0)); } steps.push(this.#mouseStep("up", end, "left", 1)); this.#pointer = { ...end }; await this.#input(steps); } /** Outline the element for `duration` ms (default 2000). */ async highlight(selector: string | TernSelector, opts?: { duration?: number }): Promise { const duration = opts?.duration ?? 2_000; if (!Number.isFinite(duration) || duration < 0) throw new ToolError("highlight duration must be a non-negative number"); const spec = this.#spec(selector); await this.#target(`tab.highlight(${describe(selector)})`, spec, "point", null); const id = `omp-highlight-${crypto.randomUUID()}`; await this.#kit("highlight", [spec, id]); try { await untilAborted(this.#signal, () => Bun.sleep(duration)); } finally { await this.#kit("removeOverlay", [id]).catch(() => undefined); } } /** Scroll the element into view. */ async scrollIntoView(selector: string | TernSelector): Promise { const spec = this.#spec(selector); await this.#target(`tab.scrollIntoView(${describe(selector)})`, spec, "point", null); await this.#kit("scrollIntoView", [spec]); } /** Select `