import type { Snapshot } from "./types.ts"; /** A DOM-based observation, serialized to run in the page's isolated realm. */ export async function observe( key: string, prefix: string, ): Promise & { documentId: string }> { await new Promise((resolve) => { let idle: ReturnType; const finish = () => { clearTimeout(idle); clearTimeout(deadline); observer.disconnect(); resolve(); }; const observer = new MutationObserver(() => { clearTimeout(idle); idle = setTimeout(finish, 150); }); const deadline = setTimeout(finish, 1_000); observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true, }); idle = setTimeout(finish, 150); }); const refs = new Map(); Object.defineProperty(globalThis, key, { value: refs, configurable: true }); const globals = globalThis as unknown as Record; const documentKey = key + "_document"; if (!globals[documentKey]) Object.defineProperty(globalThis, documentKey, { value: prefix }); const clean = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, 500); const visible = (element: Element) => { const style = getComputedStyle(element); return !element.closest('[hidden], [inert], [aria-hidden="true"]') && style.visibility !== "hidden" && style.display !== "none" && element.getClientRects().length > 0; }; const name = (element: Element) => { const labelled = element.getAttribute("aria-labelledby")?.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "" ).join(" "); const labels = "labels" in element ? [...((element as HTMLInputElement).labels ?? [])].map((label) => { const copy = label.cloneNode(true) as Element; copy.querySelectorAll("input, textarea, select, button").forEach((control) => control.remove() ); return copy.textContent; }).join(" ") : ""; return clean( labelled || element.getAttribute("aria-label") || labels || (element instanceof HTMLInputElement && /^(submit|button|reset)$/.test(element.type) ? element.value : "") || (element as HTMLElement).innerText || element.getAttribute("alt") || element.getAttribute("title") || element.getAttribute("placeholder") || "", ); }; const candidates: Element[] = []; const visit = (root: Document | ShadowRoot) => { for (const element of root.querySelectorAll("*")) { if ( element.matches( "a[href], button, input:not([type=hidden]), textarea, select, summary, [role], [contenteditable=true], [tabindex]", ) && visible(element) ) candidates.push(element); if (element.shadowRoot) visit(element.shadowRoot); } }; visit(document); const elements = candidates.slice(0, 300).map((element, index) => { const ref = `${prefix}e${index + 1}`; refs.set(ref, element); const input = element instanceof HTMLInputElement ? element : null; const role = element.getAttribute("role") || (input ? ({ checkbox: "checkbox", radio: "radio", range: "slider", number: "spinbutton", submit: "button", button: "button", reset: "button", }[input.type] ?? "textbox") : ({ A: "link", BUTTON: "button", TEXTAREA: "textbox", SELECT: "combobox", SUMMARY: "button", }[element.tagName] ?? (element.getAttribute("contenteditable") === "true" ? "textbox" : "element"))); return { ref, role, name: name(element), ...("value" in element && input?.type !== "password" ? { value: String(element.value).slice(0, 500) } : {}), ...(element.matches(":disabled, [aria-disabled=true]") ? { disabled: true } : {}), ...(input && /^(checkbox|radio)$/.test(input.type) ? { checked: input.checked } : {}), ...(element instanceof HTMLSelectElement ? { options: [...element.options].slice(0, 100).map((option) => ({ label: option.label, value: option.value, selected: option.selected, })), } : {}), }; }); const text = document.body?.innerText ?? ""; return { documentId: globals[documentKey], url: location.href, title: document.title, text: text.slice(0, 20_000), elements, truncated: text.length > 20_000 || candidates.length > 300, frames: [...document.querySelectorAll("iframe")].map((frame) => ({ url: frame.src, title: frame.title, })), }; } /** Resolve a reference only while its original element is still actionable. */ export function resolveElement(key: string, ref: string): Element { const refs = (globalThis as unknown as Record>)[key]; const element = refs?.get(ref); if (!element?.isConnected) throw new Error("Stale element reference; take a new snapshot."); if (element.matches(":disabled, [aria-disabled=true]") || element.closest("[inert]")) { throw new Error("Element is disabled."); } element.scrollIntoView({ block: "center", inline: "center" }); const style = getComputedStyle(element); if ( element.closest("[hidden], [aria-hidden=true]") || style.visibility === "hidden" || style.display === "none" || !element.getClientRects().length ) { throw new Error("Element is no longer visible; take a new snapshot."); } return element; }