| 1 | /// <reference lib="dom" /> |
| 2 | import { InjectedScript } from "reasonix-playwright-injected"; |
| 3 | import type { PageRegistry, SnapshotInput, SnapshotOutput } from "./snapshotScript.js"; |
| 4 | |
| 5 | type Registry = PageRegistry & { kernel: InjectedScript }; |
| 6 | export function pageRegistry(key: string): Registry { |
| 7 | const host = window as unknown as Record<string, unknown>; |
| 8 | let registry = host[key] as Registry | undefined; |
| 9 | if (!registry?.kernel) { |
| 10 | registry = { docId: `${performance.timeOrigin}:${Math.random().toString(36).slice(2)}`, snapshotId: "", refs: new Map(), kernel: new InjectedScript(window, { isUnderTest: false, sdkLanguage: "javascript", testIdAttributeName: "data-testid", stableRafCount: 1, browserName: "chromium", customEngines: [], isUtilityWorld: true }) }; |
| 11 | Object.defineProperty(host, key, { value: registry, configurable: true, enumerable: false }); |
| 12 | } |
| 13 | return registry; |
| 14 | } |
| 15 | |
| 16 | export function pageSemanticSnapshot(input: SnapshotInput): SnapshotOutput { |
| 17 | const registry = pageRegistry(input.key); |
| 18 | registry.snapshotId = input.snapshotId; |
| 19 | registry.refs = new Map(); |
| 20 | const root = input.selector ? registry.kernel.querySelectorAll(registry.kernel.parseSelector(input.selector), document) : [document.body ?? document.documentElement]; |
| 21 | if (root.length > 1) throw new Error("ambiguous selector: snapshot scope matches multiple elements"); |
| 22 | if (!root.length) return { docId: registry.docId, tree: `(no element matches selector ${JSON.stringify(input.selector)})`, refs: 0, nodes: 0, truncated: 0 }; |
| 23 | const snapshot = registry.kernel.ariaSnapshotWithRefs(root[0], { mode: "ai" }); |
| 24 | let next = 0; |
| 25 | const mapped = new Map<string, string>(); |
| 26 | const lines = snapshot.text.split("\n"); |
| 27 | const limit = Math.min(4000, Math.max(1, input.budget)); |
| 28 | const tree = lines.slice(0, limit).join("\n").replace(/\[ref=([^\]]+)\]/g, (_match, ref: string) => { |
| 29 | let external = mapped.get(ref); |
| 30 | if (!external) { |
| 31 | const matches = registry.kernel.querySelectorAll(registry.kernel.parseSelector(`aria-ref=${ref}`), document); |
| 32 | if (matches.length !== 1) throw new Error("Playwright reference contract changed"); |
| 33 | external = `${input.prefix}e${++next}`; |
| 34 | mapped.set(ref, external); |
| 35 | registry.refs.set(external, matches[0]); |
| 36 | } |
| 37 | return `ref=${external}`; |
| 38 | }); |
| 39 | const truncated = Math.max(0, lines.length - limit); |
| 40 | return { docId: registry.docId, tree: tree + (truncated ? `\n… (${truncated} more nodes)` : ""), refs: registry.refs.size, nodes: Math.min(limit, lines.length), truncated }; |
| 41 | } |
| 42 | |
| 43 | export interface QueryInput { key: string; snapshotId: string; docId: string; prefix: string; role?: string; name?: string; text?: string; testId?: string; containerRef?: string; state?: string } |
| 44 | export function pageQuery(input: QueryInput) { |
| 45 | const registry = pageRegistry(input.key); |
| 46 | if (registry.snapshotId !== input.snapshotId || registry.docId !== input.docId) return { error: "stale_document" }; |
| 47 | const root = input.containerRef ? registry.refs.get(input.containerRef) : document; |
| 48 | if (!root || (root instanceof Element && !root.isConnected)) return { error: "stale_document" }; |
| 49 | const quote = (text: string) => JSON.stringify(text); |
| 50 | let selector: string; |
| 51 | if (input.role) { |
| 52 | if (!/^[a-z]+$/.test(input.role)) throw new Error("invalid role"); |
| 53 | selector = `internal:role=${input.role}${input.name !== undefined ? `[name=${quote(input.name)}s]` : ""}`; |
| 54 | } else if (input.testId !== undefined) selector = `internal:testid=[data-testid=${quote(input.testId)}s]`; |
| 55 | else if (input.text !== undefined) selector = `internal:text=${quote(input.text)}s`; |
| 56 | else throw new Error("query requires role, text or testId"); |
| 57 | const matches = registry.kernel.querySelectorAll(registry.kernel.parseSelector(selector), root); |
| 58 | const refs: string[] = []; |
| 59 | for (const element of matches.slice(0, 100)) { |
| 60 | let ref = [...registry.refs].find(([, candidate]) => candidate === element)?.[0]; |
| 61 | if (!ref) { |
| 62 | if (registry.refs.size >= 4000) throw new Error("reference budget exceeded; observe a smaller scope"); |
| 63 | ref = `${input.prefix}e${registry.refs.size + 1}`; registry.refs.set(ref, element); |
| 64 | } |
| 65 | refs.push(ref); |
| 66 | } |
| 67 | return { count: matches.length, refs, ambiguous: matches.length > 1, state: input.state && matches.length === 1 ? registry.kernel.elementState(matches[0], input.state).matches : undefined }; |
| 68 | } |
| 69 | |
| 70 | export function pageReady() { return { ready: document.readyState !== "loading", url: location.href, width: innerWidth, height: innerHeight, scrollX, scrollY }; } |
| 71 |