| 1 | /// <reference lib="dom" /> |
| 2 | |
| 3 | // Page-side snapshot walker. It is serialised with Function.prototype.toString |
| 4 | // and executed inside the website, so it must stay self-contained: no imports, |
| 5 | // no references to module scope, plain ES2022 only. |
| 6 | |
| 7 | export interface SnapshotInput { |
| 8 | key: string; |
| 9 | snapshotId: string; |
| 10 | prefix: string; |
| 11 | selector: string; |
| 12 | budget: number; |
| 13 | } |
| 14 | |
| 15 | export interface SnapshotOutput { |
| 16 | docId: string; |
| 17 | tree: string; |
| 18 | refs: number; |
| 19 | nodes: number; |
| 20 | truncated: number; |
| 21 | } |
| 22 | |
| 23 | export interface PageRegistry { |
| 24 | docId: string; |
| 25 | snapshotId: string; |
| 26 | refs: Map<string, Element>; |
| 27 | } |
| 28 | |
| 29 | export function pageSnapshot(input: SnapshotInput): SnapshotOutput { |
| 30 | const host = window as unknown as Record<string, unknown>; |
| 31 | let registry = host[input.key] as PageRegistry | undefined; |
| 32 | if (!registry || typeof registry.docId !== "string") { |
| 33 | const random = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); |
| 34 | registry = { docId: `${performance.timeOrigin}:${random}`, snapshotId: "", refs: new Map() }; |
| 35 | Object.defineProperty(host, input.key, { value: registry, enumerable: false, configurable: true, writable: true }); |
| 36 | } |
| 37 | registry.snapshotId = input.snapshotId; |
| 38 | registry.refs = new Map(); |
| 39 | const refs = registry.refs; |
| 40 | const docId = registry.docId; |
| 41 | |
| 42 | let root: Element | null = document.body ?? document.documentElement; |
| 43 | if (input.selector !== "") { |
| 44 | try { |
| 45 | root = document.querySelector(input.selector); |
| 46 | } catch { |
| 47 | root = null; |
| 48 | } |
| 49 | if (!root) return { docId, tree: `(no element matches selector ${JSON.stringify(input.selector)})`, refs: 0, nodes: 0, truncated: 0 }; |
| 50 | } |
| 51 | |
| 52 | const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "SVG", "CANVAS", "VIDEO", "AUDIO", "SOURCE", "TRACK", "MAP", "AREA", "PATH", "DATALIST"]); |
| 53 | const TAG_ROLES: Record<string, string> = { |
| 54 | BUTTON: "button", TEXTAREA: "textbox", OPTION: "option", OPTGROUP: "group", TABLE: "table", TR: "row", TD: "cell", TH: "columnheader", |
| 55 | UL: "list", OL: "list", LI: "listitem", NAV: "navigation", MAIN: "main", FORM: "form", DIALOG: "dialog", IMG: "img", HEADER: "banner", |
| 56 | FOOTER: "contentinfo", ASIDE: "complementary", ARTICLE: "article", SECTION: "region", SUMMARY: "button", DETAILS: "group", IFRAME: "iframe", |
| 57 | FRAME: "iframe", MENU: "list", FIELDSET: "group", PROGRESS: "progressbar", METER: "meter", HR: "separator", H1: "heading", H2: "heading", |
| 58 | H3: "heading", H4: "heading", H5: "heading", H6: "heading", |
| 59 | } |
| 60 | const INTERACTIVE = new Set(["button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox", "listbox", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "slider", "switch", "spinbutton", "treeitem", "iframe"]); |
| 61 | const CONTENT_NAMED = new Set(["button", "link", "heading", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "cell", "columnheader", "rowheader", "treeitem", "listitem", "summary"]); |
| 62 | const TEXT_INPUTS = new Set(["text", "email", "tel", "url", "number", "date", "datetime-local", "month", "week", "time", "color", ""]); |
| 63 | |
| 64 | const lines: string[] = []; |
| 65 | let nodes = 0; |
| 66 | let truncated = 0; |
| 67 | let refCount = 0; |
| 68 | const active = document.activeElement; |
| 69 | |
| 70 | function clip(value: string): string { |
| 71 | const text = value.replace(/\s+/g, " ").trim(); |
| 72 | return text.length > 120 ? `${text.slice(0, 119)}…` : text; |
| 73 | } |
| 74 | |
| 75 | function roleOf(el: Element): string { |
| 76 | const explicit = (el.getAttribute("role") ?? "").trim().toLowerCase(); |
| 77 | if (explicit !== "") return explicit.split(/\s+/)[0]; |
| 78 | const tag = el.tagName; |
| 79 | if (tag === "A") return el.hasAttribute("href") ? "link" : "generic"; |
| 80 | if (tag === "INPUT") { |
| 81 | const type = ((el as HTMLInputElement).type || "text").toLowerCase(); |
| 82 | if (type === "button" || type === "submit" || type === "reset" || type === "image" || type === "file") return "button"; |
| 83 | if (type === "checkbox" || type === "radio") return type; |
| 84 | if (type === "range") return "slider"; |
| 85 | if (type === "search") return "searchbox"; |
| 86 | if (type === "hidden") return "hidden"; |
| 87 | if (type === "password") return "textbox"; |
| 88 | return TEXT_INPUTS.has(type) ? "textbox" : "textbox"; |
| 89 | } |
| 90 | if (tag === "SELECT") return (el as HTMLSelectElement).multiple ? "listbox" : "combobox"; |
| 91 | if (tag === "SECTION" && !el.hasAttribute("aria-label") && !el.hasAttribute("aria-labelledby")) return "generic"; |
| 92 | const mapped = TAG_ROLES[tag]; |
| 93 | if (mapped) return mapped; |
| 94 | const tabindex = el.getAttribute("tabindex"); |
| 95 | if (tabindex !== null && Number(tabindex) >= 0) return "generic-clickable"; |
| 96 | return "generic"; |
| 97 | } |
| 98 | |
| 99 | function labelledBy(el: Element): string { |
| 100 | const ids = (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).filter((id) => id !== ""); |
| 101 | return ids.map((id) => document.getElementById(id)?.textContent ?? "").join(" "); |
| 102 | } |
| 103 | |
| 104 | function nameOf(el: Element, role: string): string { |
| 105 | const aria = el.getAttribute("aria-label"); |
| 106 | if (aria && aria.trim() !== "") return clip(aria); |
| 107 | const byId = labelledBy(el); |
| 108 | if (byId.trim() !== "") return clip(byId); |
| 109 | const tag = el.tagName; |
| 110 | if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || tag === "METER" || tag === "PROGRESS") { |
| 111 | const labels = (el as HTMLInputElement).labels; |
| 112 | if (labels && labels.length) return clip([...labels].map((label) => label.textContent ?? "").join(" ")); |
| 113 | } |
| 114 | if (tag === "INPUT") { |
| 115 | const inputEl = el as HTMLInputElement; |
| 116 | const type = inputEl.type.toLowerCase(); |
| 117 | if ((type === "button" || type === "submit" || type === "reset") && inputEl.value !== "") return clip(inputEl.value); |
| 118 | if (type === "image" && inputEl.alt !== "") return clip(inputEl.alt); |
| 119 | } |
| 120 | if (tag === "IMG") return clip((el as HTMLImageElement).alt); |
| 121 | if (tag === "IFRAME" || tag === "FRAME") return clip(el.getAttribute("title") ?? el.getAttribute("name") ?? ""); |
| 122 | const title = el.getAttribute("title"); |
| 123 | if (title && title.trim() !== "") return clip(title); |
| 124 | const placeholder = el.getAttribute("placeholder"); |
| 125 | if (placeholder && placeholder.trim() !== "") return clip(placeholder); |
| 126 | if (CONTENT_NAMED.has(role) || role === "generic-clickable") return clip(el.textContent ?? ""); |
| 127 | return ""; |
| 128 | } |
| 129 | |
| 130 | function statesOf(el: Element, role: string): string[] { |
| 131 | const states: string[] = []; |
| 132 | const tag = el.tagName; |
| 133 | const inputEl = el as HTMLInputElement; |
| 134 | if (tag === "INPUT" && (inputEl.type === "checkbox" || inputEl.type === "radio")) { |
| 135 | if (inputEl.indeterminate) states.push("mixed"); |
| 136 | else if (inputEl.checked) states.push("checked"); |
| 137 | } else if (el.getAttribute("aria-checked") === "true") states.push("checked"); |
| 138 | else if (el.getAttribute("aria-checked") === "mixed") states.push("mixed"); |
| 139 | if (el.getAttribute("aria-pressed") === "true") states.push("pressed"); |
| 140 | if ((el as HTMLButtonElement).disabled === true || el.getAttribute("aria-disabled") === "true") states.push("disabled"); |
| 141 | const expanded = el.getAttribute("aria-expanded"); |
| 142 | if (expanded === "true" || (tag === "DETAILS" && (el as HTMLDetailsElement).open)) states.push("expanded"); |
| 143 | else if (expanded === "false") states.push("collapsed"); |
| 144 | if ((tag === "OPTION" && (el as HTMLOptionElement).selected) || el.getAttribute("aria-selected") === "true") states.push("selected"); |
| 145 | if (el === active) states.push("focused"); |
| 146 | if (role === "heading") { |
| 147 | const level = el.getAttribute("aria-level") ?? (/^H([1-6])$/.exec(tag)?.[1] ?? "2"); |
| 148 | states.push(`level=${level}`); |
| 149 | } |
| 150 | if (tag === "INPUT" && inputEl.type === "password") states.push("password"); |
| 151 | else if (tag === "INPUT" && inputEl.type === "file") states.push("file"); |
| 152 | else if (tag === "INPUT" && inputEl.type !== "checkbox" && inputEl.type !== "radio" && inputEl.type !== "submit" && inputEl.type !== "button" && inputEl.type !== "reset" && inputEl.type !== "image") { |
| 153 | if (inputEl.value !== "") states.push(`value=${JSON.stringify(clip(inputEl.value))}`); |
| 154 | } else if (tag === "TEXTAREA") { |
| 155 | const value = (el as HTMLTextAreaElement).value; |
| 156 | if (value !== "") states.push(`value=${JSON.stringify(clip(value))}`); |
| 157 | } else if (tag === "SELECT") { |
| 158 | const select = el as HTMLSelectElement; |
| 159 | const chosen = [...select.selectedOptions].map((option) => option.label || option.text); |
| 160 | if (chosen.length) states.push(`value=${JSON.stringify(clip(chosen.join(", ")))}`); |
| 161 | if (select.multiple) states.push("multiple"); |
| 162 | } |
| 163 | if (role === "generic-clickable") states.push("clickable"); |
| 164 | if (tag === "A" && el.hasAttribute("href")) { |
| 165 | const href = el.getAttribute("href") ?? ""; |
| 166 | if (href.startsWith("#")) states.push(`href=${JSON.stringify(clip(href))}`); |
| 167 | } |
| 168 | return states; |
| 169 | } |
| 170 | |
| 171 | function hiddenSubtree(el: Element): boolean { |
| 172 | // Uploads target hidden file inputs, so they must keep their snapshot ref. |
| 173 | if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "file") return false; |
| 174 | if (el.getAttribute("aria-hidden") === "true") return true; |
| 175 | if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "hidden") return true; |
| 176 | const style = window.getComputedStyle(el); |
| 177 | return style.display === "none" || style.visibility === "hidden"; |
| 178 | } |
| 179 | |
| 180 | function zeroSize(el: Element): boolean { |
| 181 | if (el.tagName === "OPTION" || el.tagName === "OPTGROUP") return false; |
| 182 | if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "file") return false; |
| 183 | const rect = el.getBoundingClientRect(); |
| 184 | return rect.width === 0 && rect.height === 0 && el !== active; |
| 185 | } |
| 186 | |
| 187 | function emit(line: string): void { |
| 188 | if (nodes >= input.budget) { |
| 189 | truncated += 1; |
| 190 | return; |
| 191 | } |
| 192 | nodes += 1; |
| 193 | lines.push(line); |
| 194 | } |
| 195 | |
| 196 | function visit(node: Node, depth: number, suppressText: boolean): void { |
| 197 | if (node.nodeType === Node.TEXT_NODE) { |
| 198 | if (suppressText) return; |
| 199 | const text = clip(node.textContent ?? ""); |
| 200 | if (text !== "") emit(`${" ".repeat(depth)}text ${JSON.stringify(text)}`); |
| 201 | return; |
| 202 | } |
| 203 | if (node.nodeType !== Node.ELEMENT_NODE) return; |
| 204 | const el = node as Element; |
| 205 | if (SKIP.has(el.tagName) || hiddenSubtree(el)) return; |
| 206 | const role = roleOf(el); |
| 207 | if (role === "hidden") return; |
| 208 | // A <label> surfaces as its control's name, and a zero-size box is |
| 209 | // invisible: neither contributes a subtree. |
| 210 | if (el.tagName === "LABEL" && (el as HTMLLabelElement).control !== null) return; |
| 211 | if (zeroSize(el)) return; |
| 212 | let childDepth = depth; |
| 213 | let childSuppress = suppressText; |
| 214 | if (role !== "generic") { |
| 215 | const name = nameOf(el, role); |
| 216 | const states = statesOf(el, role); |
| 217 | const displayRole = role === "generic-clickable" ? "generic" : role; |
| 218 | const interactive = INTERACTIVE.has(role) || role === "generic-clickable"; |
| 219 | let line = `${" ".repeat(depth)}${displayRole}`; |
| 220 | if (name !== "") line += ` ${JSON.stringify(name)}`; |
| 221 | if (states.length) line += ` [${states.join(", ")}]`; |
| 222 | if ((interactive || name !== "") && role !== "iframe") { |
| 223 | refCount += 1; |
| 224 | const ref = `${input.prefix}e${refCount}`; |
| 225 | refs.set(ref, el); |
| 226 | line += ` ref=${ref}`; |
| 227 | } |
| 228 | emit(line); |
| 229 | childDepth = depth + 1; |
| 230 | if (CONTENT_NAMED.has(role) || role === "generic-clickable") childSuppress = true; |
| 231 | } |
| 232 | // A textarea's text child duplicates the value state. |
| 233 | if (el.tagName === "TEXTAREA") return; |
| 234 | if (el.tagName === "SELECT" && role !== "generic") { |
| 235 | for (const option of (el as HTMLSelectElement).options) visit(option, childDepth, false); |
| 236 | return; |
| 237 | } |
| 238 | const shadow = (el as HTMLElement).shadowRoot; |
| 239 | const children = shadow ? [...shadow.childNodes, ...el.childNodes] : [...el.childNodes]; |
| 240 | for (const child of children) visit(child, childDepth, childSuppress); |
| 241 | } |
| 242 | |
| 243 | // A selector scopes INTO the element: its own line is omitted and the tree |
| 244 | // starts at its children (including a shadow root's), all at depth zero. |
| 245 | if (input.selector !== "") { |
| 246 | const scopedShadow = (root as HTMLElement).shadowRoot; |
| 247 | const scopedChildren = scopedShadow ? [...scopedShadow.childNodes, ...root.childNodes] : [...root.childNodes]; |
| 248 | for (const child of scopedChildren) visit(child, 0, false); |
| 249 | } else { |
| 250 | visit(root, 0, false); |
| 251 | } |
| 252 | if (truncated > 0) lines.push(`… (${truncated} more nodes)`); |
| 253 | return { docId, tree: lines.join("\n"), refs: refCount, nodes, truncated }; |
| 254 | } |
| 255 | |
| 256 | export const SNAPSHOT_SCRIPT_SOURCE = pageSnapshot.toString(); |
| 257 |