import ariaBundle from "../aria/aria-snapshot.bundle.txt" with { type: "text" }; import type { AriaSnapshotPayload } from "../aria/aria-snapshot"; import { DEFAULT_STYLE_PROPERTIES, type QueryBox } from "../queries"; import { roleRules, SEMANTIC_QUERY_HANDLERS } from "../query-handlers"; import type { Observation } from "../tab-protocol"; import type { TernSelector } from "./selectors"; /** Action whose actionability `target()` checks; `point` only scrolls and requires visibility. */ export type TernTargetAction = | "click" | "dblclick" | "hover" | "check" | "type" | "fill" | "focus" | "drag" | "upload" | "point"; /** Why `target()` could not produce an actionable point. */ export type TernTargetFailure = | "missing" | "hidden" | "disabled" | "notEditable" | "unstable" | "covered" | "offViewport"; /** `target()` result: the point to press (viewport CSS px of the element's frame) or the failure reason. */ export type TernTarget = | { ok: true; x: number; y: number; width: number; height: number; count: number } | { ok: false; reason: TernTargetFailure; count: number; detail?: string }; /** Value type of each `read()` property. */ export interface TernReadValues { /** Trimmed `innerText ?? textContent`. */ text: string; /** `innerHTML`. */ html: string; /** `outerHTML`. */ outerHTML: string; /** Form value, or null when the element has none. */ value: string | null; /** Attribute named by `arg`, or null when absent. */ attr: string | null; /** Box in the frame's viewport, or null when zero-size. */ box: QueryBox | null; /** Computed values of the properties in `arg` (default: `DEFAULT_STYLE_PROPERTIES`). */ styles: Record; /** Non-zero box and not `display:none` / `visibility:hidden|collapse`. */ visible: boolean; /** Not `:disabled` and not `aria-disabled="true"`. */ enabled: boolean; /** Native `checked`, else `aria-checked="true"`. */ checked: boolean; } /** Property readable through `read()`. */ export type TernReadProp = keyof TernReadValues; /** File passed to `setFiles()`; `data` is base64. */ export interface TernFilePayload { name: string; type: string; data: string; } /** Options for `observe()`; `root` scopes the walk to one element. */ export interface TernObserveOptions { includeAll?: boolean; viewportOnly?: boolean; root?: TernSelector; compact?: boolean; } /** `observe()` result; ids populate the document's `id` selector registry. */ export type TernObservation = Observation; /** Numbered screenshot overlay installed by `annotate()`; remove it with `removeOverlay(token)`. */ export interface TernAnnotation { token: string; boxes: Array<{ id: number; x: number; y: number; width: number; height: number }>; } /** One descendant frame reported by `frames()`; `path` is dot-joined indices into `window.frames`. */ export interface TernFrameInfo { path: string; name: string | null; id: string | null; src: string | null; url: string | null; sameOrigin: boolean; } /** Viewport metrics of the kit's frame. */ export interface TernGeometry { innerWidth: number; innerHeight: number; dpr: number; scrollX: number; scrollY: number; scrollWidth: number; scrollHeight: number; } /** * Methods of `globalThis.__ompTernKit`, called by omp from the isolated world as * `kit[method](...args)`. Every argument and result is JSON; failures throw `Error`s with * agent-readable messages (a missing element: `No element matches `). */ export interface TernKitApi { /** Number of elements matching `sel`. */ count(sel: TernSelector): number; /** Read one property of the first match; null when nothing matches. */ read

(sel: TernSelector, prop: P, arg?: string | string[]): TernReadValues[P] | null; /** Scroll the first match into view and check actionability for `action`. */ target(sel: TernSelector, action: TernTargetAction): Promise; /** Focus the first match without scrolling. */ focus(sel: TernSelector): void; /** * Prepare a fill: text-like controls are focused with their content selected (`insert`: omp types * the value); selects and date/time/color/range inputs are set directly (`done`). */ prepareFill(sel: TernSelector, value: string): { mode: "insert" } | { mode: "done" }; /** Focus the first match and collapse the selection to the end of its content. */ caretToEnd(sel: TernSelector): void; /** Checked state of a checkbox, radio, or ARIA checkbox/radio/switch. */ checkState(sel: TernSelector): { checked: boolean }; /** Select ` option matches " + JSON.stringify(value)); setNativeValue(el, option.value); fireInputChange(el); return { mode: "done" }; } if (tag === "input") { const type = inputType(el); if (type === "file") throw new Error("Cannot fill a file input; use tab.uploadFile()"); if (SETTER_INPUT_TYPES.has(type)) { setNativeValue(el, value); fireInputChange(el); return { mode: "done" }; } if (UNTYPABLE_INPUT_TYPES.has(type)) throw new Error("Cannot fill "); } if (tag === "input" || tag === "textarea") { el.focus({ preventScroll: true }); el.select(); return { mode: "insert" }; } if (el.isContentEditable) { el.focus({ preventScroll: true }); selectContents(el); return { mode: "insert" }; } throw new Error("tab.fill() requires an input, textarea, select, or contenteditable element; got " + describeElement(el)); }, caretToEnd: sel => { const el = requireFirst(sel); el.focus({ preventScroll: true }); if (el.localName === "input" || el.localName === "textarea") { const end = String(el.value || "").length; try { el.setSelectionRange(end, end); } catch { // Inputs like email/number reject selection APIs; focus already places the caret. } return; } const range = selectContents(el); if (range) range.collapse(false); }, checkState: sel => { const el = requireFirst(sel); const type = el.localName === "input" ? inputType(el) : ""; if (type === "checkbox" || type === "radio") return { checked: !!el.checked }; const role = (el.getAttribute("role") || "").toLowerCase(); if (role === "checkbox" || role === "radio" || role === "switch") { return { checked: el.getAttribute("aria-checked") === "true" }; } throw new Error("tab.check() requires a checkbox, radio, or ARIA switch"); }, select: (sel, values) => { const el = requireFirst(sel); if (el.localName !== "select") throw new Error("tab.select() requires a option matches " + missing.map(value => JSON.stringify(value)).join(", ")); } if (el.multiple) { for (const option of options) option.selected = wanted.has(option); } else { // A single select re-selects its first option the moment none is selected, so pick the // wanted one directly instead of deselecting the others first. const [first] = wanted; el.selectedIndex = first ? first.index : -1; } fireInputChange(el); return options.filter(option => option.selected).map(option => option.value); }, setFiles: (sel, files) => { const el = requireFirst(sel); const transfer = new DataTransfer(); for (const file of files) { const bytes = Uint8Array.from(atob(file.data), char => char.charCodeAt(0)); transfer.items.add(new File([bytes], file.name, { type: file.type || "application/octet-stream" })); } if (el.localName === "input" && inputType(el) === "file") { el.files = transfer.files; fireInputChange(el); return; } for (const type of ["dragenter", "dragover", "drop"]) { el.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer })); } }, isFileInput: sel => fileInputOf(requireFirst(sel)) !== null, openChooser: sel => { const input = fileInputOf(requireFirst(sel)); if (!input) return "none"; if (typeof input.showPicker === "function") { try { input.showPicker(); return "input"; } catch { // showPicker needs transient activation; a programmatic click is the fallback. } } input.click(); return "input"; }, scrollIntoView: sel => { requireFirst(sel).scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); }, scrollBy: (sel, dx, dy) => { const target = sel ? requireFirst(sel) : window; target.scrollBy({ left: Number(dx) || 0, top: Number(dy) || 0, behavior: "instant" }); }, highlight: (sel, id) => { const rect = requireFirst(sel).getBoundingClientRect(); const overlay = document.createElement("div"); overlay.id = String(id); overlay.dataset.ompHighlightOverlay = ""; overlay.setAttribute(OVERLAY_ATTR, String(id)); overlay.setAttribute("aria-hidden", "true"); overlay.setAttribute("role", "presentation"); overlay.inert = true; Object.assign(overlay.style, { position: "fixed", left: rect.left - 3 + "px", top: rect.top - 3 + "px", width: rect.width + 6 + "px", height: rect.height + 6 + "px", border: "3px solid #ff3366", borderRadius: "4px", boxSizing: "border-box", pointerEvents: "none", zIndex: "2147483647", }); document.documentElement.append(overlay); }, removeOverlay, observe, ariaSnapshot: (root, request, urls) => { let rootEl = null; if (root) { rootEl = first(root); if (!rootEl) throw new Error("tab.ariaSnapshot: selector " + JSON.stringify(root) + " matched no element"); } // A fresh bundle instance per snapshot renumbers refs from e1, like the Chromium backend. const fresh = loadAria(); const snapshot = fresh.ariaSnapshot(rootEl, request || {}); const hrefs = {}; if (urls) { for (const match of snapshot.matchAll(/\[ref=(e\d+)\]/g)) { const el = fresh.resolveAriaRef(match[1]); if (el && el.localName === "a" && typeof el.href === "string" && el.href) hrefs[match[1]] = el.href; } } return { snapshot, hrefs }; }, annotate: ids => { const token = "omp-screenshot-" + randomToken(); const boxes = []; const root = document.createElement("div"); root.setAttribute("data-omp-screenshot-annotations", token); root.setAttribute(OVERLAY_ATTR, token); root.style.cssText = "position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none"; for (const id of ids) { const el = registry.get(id); if (!el || !el.isConnected) continue; const box = boxOf(el); if (box.width <= 0 || box.height <= 0) continue; boxes.push({ id, x: box.x, y: box.y, width: box.width, height: box.height }); const outline = document.createElement("div"); outline.style.cssText = "position:absolute;left:" + box.x + "px;top:" + box.y + "px;width:" + box.width + "px;height:" + box.height + "px;box-sizing:border-box;border:2px solid #ff2bd6;background:rgba(255,43,214,.08)"; const label = document.createElement("span"); label.textContent = "[" + id + "]"; label.style.cssText = "position:absolute;left:-2px;top:-20px;padding:1px 4px;border:1px solid #111;border-radius:3px;background:#ffeb3b;color:#111;font:700 13px/16px ui-monospace,monospace;white-space:nowrap"; outline.appendChild(label); root.appendChild(outline); } document.documentElement.appendChild(root); return { token, boxes }; }, mark: sel => { const el = first(sel); return el ? markElement(el) : null; }, markAll: sel => resolveAll(sel).map(markElement), unmark: token => { for (const el of pierceAll("[" + HANDLE_ATTR + "~=\"" + CSS.escape(token) + "\"]")) { const rest = (el.getAttribute(HANDLE_ATTR) || "").split(/\s+/).filter(value => value && value !== token); if (rest.length) el.setAttribute(HANDLE_ATTR, rest.join(" ")); else el.removeAttribute(HANDLE_ATTR); } }, frames: () => { const out = []; listFrames(window, "", out); return out; }, frameOf: sel => { const el = requireFirst(sel); if (el.localName !== "iframe" && el.localName !== "frame") return null; const target = el.contentWindow; return target ? findFramePath(window, target, "") : null; }, frameOrigin: index => { const child = window[index]; if (!child) return null; const owner = frameElementFor(window, child); if (!owner) return null; // Only translations keep frame-local CSS px equal to viewport px; anything else cannot be mapped. for (let node = owner; node; node = composedParent(node)) { const style = getComputedStyle(node); const matrix = new DOMMatrixReadOnly(style.transform === "none" ? undefined : style.transform); const linear = matrix.is2D && matrix.a === 1 && matrix.b === 0 && matrix.c === 0 && matrix.d === 1; const extra = (style.scale && style.scale !== "none") || (style.rotate && style.rotate !== "none") || (style.zoom && style.zoom !== "1" && style.zoom !== "normal"); if (!linear || extra) { throw new Error("Frame " + index + " sits in a CSS-transformed element (" + (node.localName || "node") + "); input and boxes inside it cannot be mapped to the page"); } } const rect = owner.getBoundingClientRect(); const style = getComputedStyle(owner); return { x: rect.left + owner.clientLeft + (parseFloat(style.paddingLeft) || 0), y: rect.top + owner.clientTop + (parseFloat(style.paddingTop) || 0), }; }, hasText: (text, root, exact) => { let content; if (root) { const el = first(root); if (!el) return false; content = el.innerText != null ? el.innerText : el.textContent || ""; } else { content = document.body ? document.body.innerText || "" : ""; } const candidate = String(content).trim(); return exact ? candidate === text : candidate.includes(text); }, geometry: () => ({ innerWidth, innerHeight, dpr: devicePixelRatio, scrollX, scrollY, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, }), activeEditable: () => isTextEditable(deepActiveElement()), }; Object.defineProperty(globalThis, "__ompTernKit", { value: kit, configurable: true }); // Child frames announce their index path so omp can address them by name. if (window !== window.parent) { const path = []; try { for (let win = window; win !== win.parent; win = win.parent) { const parent = win.parent; let found = -1; for (let index = 0; index < parent.length; index++) { if (parent[index] === win) { found = index; break; } } if (found < 0) throw new Error("frame not found in parent"); path.unshift(found); } const handler = globalThis.webkit && webkit.messageHandlers && webkit.messageHandlers.stencilFrame; if (handler) handler.postMessage(path.join(".")); } catch { // Detached or unreachable frame: nothing to register. } } `; /** * Script (valid both as a top-level classic script and as a function body) that installs the kit as * `globalThis.__ompTernKit` in the current realm if absent and, in a child frame, registers the frame's * index path (`"0"`, `"1.0"`, …) via `webkit.messageHandlers.stencilFrame`. Idempotent. Embeds a private * instance of the Playwright ARIA bundle, the semantic query handlers and the role rules; defines no other * globals and uses nothing CSP-gated. */ export const TERN_KIT_SOURCE = [ "(function () {", '"use strict";', "if (globalThis.__ompTernKit) return;", "const loadAria = function () {", "var module = { exports: {} };", ariaBundle, ";", "return module.exports;", "};", `const semantic = {\n${SEMANTIC_SOURCE}\n};`, `const roleRules = ${roleRules.toString()};`, `const INTERACTIVE_ROLES = new Set(${JSON.stringify(INTERACTIVE_ROLES)});`, `const DEFAULT_STYLES = ${JSON.stringify(DEFAULT_STYLE_PROPERTIES)};`, KIT_BODY, "})();", ].join("\n");