diff --git a/extension/firefox-compat.js b/extension/firefox-compat.js index 3a940e7..e0725ba 100644 --- a/extension/firefox-compat.js +++ b/extension/firefox-compat.js @@ -909,38 +909,116 @@ const root = payload.rootBackendNodeId == null ? document.documentElement : (state.nodes.get(payload.rootBackendNodeId) ?? document.documentElement); - const elements = [root, ...(root.querySelectorAll?.("*") ?? [])] - .filter((element) => element instanceof Element); - const ids = new Map(elements.map((element) => [element, `firefox-ax-${nodeId(element)}`])); - const roleFor = (element) => element.getAttribute?.("role") || ({ - A: "link", BUTTON: "button", INPUT: element.type === "checkbox" ? "checkbox" : "textbox", - TEXTAREA: "textbox", SELECT: "combobox", IMG: "img", H1: "heading", H2: "heading", - H3: "heading", MAIN: "main", NAV: "navigation", FORM: "form", - }[element.tagName] ?? (element.isContentEditable ? "textbox" : "generic")); - const nameFor = (element) => element.getAttribute?.("aria-label") - || element.getAttribute?.("alt") - || element.getAttribute?.("title") - || element.getAttribute?.("placeholder") - || element.labels?.[0]?.textContent?.trim() - || element.textContent?.trim()?.slice(0, 500) - || ""; - return { - nodes: elements.map((element) => { - const backendDOMNodeId = nodeId(element); - return { - nodeId: ids.get(element), - ignored: false, - role: { type: "role", value: roleFor(element) }, - name: { type: "computedString", value: nameFor(element) }, - description: { type: "computedString", value: element.getAttribute?.("aria-description") ?? "" }, - value: { type: "computedString", value: element.value ?? element.textContent ?? "" }, - properties: [], - childIds: [...element.children].map((child) => ids.get(child)).filter(Boolean), - backendDOMNodeId, - frameId: payload.frameId, - }; - }), + const excludedTags = new Set(["HEAD", "SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT"]); + const prunesSubtree = (element) => { + if (excludedTags.has(element.tagName) || element.hidden || element.inert + || element.getAttribute?.("aria-hidden") === "true" + || (element.tagName === "INPUT" && element.type === "hidden")) return true; + const style = getComputedStyle(element); + return style.display === "none"; + }; + const visible = (element) => !["hidden", "collapse"].includes(getComputedStyle(element).visibility); + // Use the composed tree: assigned content replaces slot fallback, and + // a shadow host's light children are reached through its slots only. + const childrenFor = (node) => { + if (node.shadowRoot) return [...node.shadowRoot.childNodes]; + if (node.tagName === "SLOT") { + const assigned = node.assignedNodes?.({ flatten: true }); + if (assigned?.length) return [...assigned]; + } + return [...(node.childNodes ?? [])]; + }; + const roleFor = (element) => { + const explicit = element.getAttribute?.("role")?.trim().split(/\s+/u)[0]; + if (explicit) return explicit; + if (element === document.documentElement) return "RootWebArea"; + if (element.tagName === "INPUT") return ({ + button: "button", submit: "button", reset: "button", checkbox: "checkbox", + radio: "radio", range: "slider", number: "spinbutton", search: "searchbox", + }[element.type] ?? "textbox"); + return ({ + A: element.hasAttribute("href") ? "link" : "generic", BUTTON: "button", + TEXTAREA: "textbox", SELECT: element.multiple ? "listbox" : "combobox", + OPTION: "option", IMG: "image", H1: "heading", H2: "heading", H3: "heading", + H4: "heading", H5: "heading", H6: "heading", MAIN: "main", NAV: "navigation", + FORM: "form", TABLE: "table", TR: "row", TH: "columnheader", TD: "cell", + UL: "list", OL: "list", LI: "listitem", IFRAME: "Iframe", FRAME: "Iframe", + }[element.tagName] ?? (element.isContentEditable ? "textbox" : "generic")); + }; + // Read rendered descendants only for roles whose names come from content. + // Generic ancestors used to duplicate entire documents (including scripts) + // in both name and value, making snapshots quadratic in page depth. + const textFor = (node, includeHidden = false, parentVisible = true) => { + if (node.nodeType === Node.TEXT_NODE) return includeHidden || parentVisible ? node.nodeValue ?? "" : ""; + if (node instanceof Element && (excludedTags.has(node.tagName) || (!includeHidden && prunesSubtree(node)))) return ""; + const nodeVisible = !(node instanceof Element) || visible(node); + if (node.tagName === "BR") return includeHidden || nodeVisible ? " " : ""; + // Visibility can be restored by descendants, so keep descending. + const text = childrenFor(node).map(child => textFor(child, includeHidden, nodeVisible)).join(""); + // Inline fragments join directly; rendered boxes create word breaks. + // display:contents (including slots) has no box of its own. + const separatesText = node instanceof Element + && !["inline", "contents"].includes(getComputedStyle(node).display); + return separatesText && (text || nodeVisible || includeHidden) ? ` ${text} ` : text; + }; + const normalize = (text) => String(text ?? "").replace(/\s+/gu, " ").trim(); + const contentNamedRoles = new Set(["button", "link", "heading", "option", "tab", "menuitem", "checkbox", "radio", "switch", "cell", "columnheader", "rowheader"]); + const nameFor = (element, role) => { + if (role === "RootWebArea") return document.title ?? ""; + const labelledBy = element.getAttribute?.("aria-labelledby"); + if (labelledBy) { + const name = normalize(labelledBy.trim().split(/\s+/u).map((id) => textFor(document.getElementById(id) ?? {}, true)).join(" ")); + if (name) return name; + } + const labels = [...(element.labels ?? [])].map((label) => textFor(label, true)).join(" "); + return normalize(element.getAttribute?.("aria-label") || labels + || element.getAttribute?.("alt") || element.getAttribute?.("title") + || element.getAttribute?.("placeholder") + || (contentNamedRoles.has(role) ? textFor(element) : "")); + }; + const nodes = []; + const visit = (node, parentId, parentVisible = true) => { + const isText = node.nodeType === Node.TEXT_NODE; + if (isText && !parentVisible) return []; + if (!isText && (!(node instanceof Element) || prunesSubtree(node))) return []; + if (!isText && !visible(node)) { + return childrenFor(node).flatMap(child => visit(child, parentId, false)); + } + const name = isText ? normalize(node.nodeValue) : nameFor(node, roleFor(node)); + if (isText && !name) return []; + const backendDOMNodeId = nodeId(node); + const id = `firefox-ax-${backendDOMNodeId}`; + const role = isText ? "StaticText" : roleFor(node); + const entry = { + nodeId: id, ignored: false, + role: { type: "role", value: role }, + name: { type: "computedString", value: name }, + properties: [], childIds: [], backendDOMNodeId, frameId: payload.frameId, + }; + if (parentId) entry.parentId = parentId; + if (!isText) { + const description = node.getAttribute?.("aria-description"); + if (description) entry.description = { type: "computedString", value: description }; + if (typeof node.value === "string" && node.type !== "password") { + entry.value = { type: "string", value: node.value }; + } else if (node.isContentEditable) { + entry.value = { type: "string", value: normalize(textFor(node)) }; + } + if (node.disabled) entry.properties.push({ name: "disabled", value: { type: "boolean", value: true } }); + if (node.readOnly) entry.properties.push({ name: "readonly", value: { type: "boolean", value: true } }); + if ("checked" in node && ["checkbox", "radio", "switch"].includes(role)) { + entry.properties.push({ name: "checked", value: { type: "tristate", value: String(node.checked) } }); + } + if (role === "heading") entry.properties.push({ name: "level", value: { type: "integer", value: Number(node.getAttribute("aria-level") ?? node.tagName.slice(1)) } }); + } + nodes.push(entry); + for (const child of childrenFor(node)) { + entry.childIds.push(...visit(child, id)); + } + return [id]; }; + visit(root, null); + return { nodes }; } case "captureDomSnapshot": { const strings = []; diff --git a/tests/test-firefox-lifecycle-live.mjs b/tests/test-firefox-lifecycle-live.mjs index cec577b..7e9b352 100644 --- a/tests/test-firefox-lifecycle-live.mjs +++ b/tests/test-firefox-lifecycle-live.mjs @@ -45,14 +45,20 @@ const server = http.createServer((req, res) => { res.end("ok"); finish(JSON.parse(body)); }); + } else if (req.url === "/ax-fixture.js") { + res.setHeader("content-type", "text/javascript"); + res.end(`const host = document.querySelector("#ax-shadow"); + host.attachShadow({ mode: "open" }).innerHTML = ''; + const fallback = document.querySelector("#ax-empty-slot"); + fallback.attachShadow({ mode: "open" }).innerHTML = '';`); } else if (req.url === "/fixture.css") { res.setHeader("content-type", "text/css"); - res.end(".editor {position:relative;margin-top:200vh}.overlay {position:absolute;inset:0;background:white}"); + res.end(".ax-block {display:block}.ax-inline-block {display:inline-block}.ax-contents {display:contents}.ax-hidden {display:none}.ax-invisible {visibility:hidden}.ax-visible {visibility:visible}.editor {position:relative;margin-top:200vh}.overlay {position:absolute;inset:0;background:white}"); } else { res.setHeader("content-type", "text/html"); res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'"); res.end( - "Lifecycle fixture

isolated lifecycle test

Editor overlay
", + "Lifecycle fixture

AX_DISPLAY_NONE

isolated lifecycle test

AX_INVISIBLE_TEXT
Selected item

UGREEN Nexode 65W charger

Other charger
Editor overlay
", ); } }); @@ -91,6 +97,24 @@ if(tree.frameTree.frame.loaderId!==lastLoad.params.loaderId)throw Error('Loader const documentEvents=events.filter(e=>['Network.requestWillBeSent','Network.responseReceived'].includes(e.method)&&e.params.type==='Document'&&(e.params.request?.url??e.params.response?.url)===${JSON.stringify(url + "/next")}&&e.params.requestId.startsWith('firefox-request-')); if(!documentEvents.length||documentEvents.some(e=>e.params.loaderId!==lastLoad.params.loaderId))throw Error('Network loader mismatch: '+JSON.stringify({documentEvents, lifecycle})); const ax=await chrome.debugger.sendCommand(debuggee,'Accessibility.getFullAXTree',{}); +const axJson=JSON.stringify(ax); +for(const marker of ['AX_SCRIPT_NOISE','AX_STYLE_NOISE','AX_HIDDEN_TEXT','AX_HIDDEN_ATTRIBUTE','AX_DISPLAY_NONE','AX_PASSWORD_SECRET','AX_INVISIBLE_TEXT','AX_SLOT_FALLBACK','AX_HIDDEN_WORD'])if(axJson.includes(marker))throw Error('AX leaked hidden content: '+marker+' ('+axJson.length+' bytes)'); +if(axJson.length>25000)throw Error('AX snapshot repeats container text: '+axJson.length); +if(ax.nodes.filter(n=>n.role?.value==='StaticText'&&n.name?.value==='isolated lifecycle test').length!==1)throw Error('AX lost or duplicated visible text'); +if(!ax.nodes.some(n=>n.role?.value==='heading'&&n.name?.value==='Other charger'))throw Error('AX H6 role missing'); +if(!ax.nodes.some(n=>n.role?.value==='button'&&n.name?.value==='Shared accessible label'))throw Error('AX labelledby missing'); +if(!ax.nodes.some(n=>n.role.value==='button'&&n.name.value==='Restored visibility'))throw Error('Visibility-restored control missing'); +if(ax.nodes.filter(n=>n.role.value==='StaticText'&&n.name.value==='Selected item').length!==1)throw Error('Assigned slot content must be visited exactly once'); +if(!ax.nodes.some(n=>n.role.value==='button'&&n.name.value==='Selected item'))throw Error('Slot control has wrong name'); +if(!ax.nodes.some(n=>n.role.value==='button'&&n.name.value==='Empty selection'))throw Error('Empty slot fallback missing'); +const github=await chrome.debugger.sendCommand(debuggee,'Accessibility.queryAXTree',{role:'button',accessibleName:'GitHub'}); +if(github.nodes.length!==1)throw Error('Adjacent inline text broke role/name lookup: '+JSON.stringify(github)); +for(const accessibleName of ['Read more','Block boundary end','Read blocks','Inline block separation','GitLab','TeaPot','Inline','Hidden label']){ + const result=await chrome.debugger.sendCommand(debuggee,'Accessibility.queryAXTree',{role:'button',accessibleName}); + if(result.nodes.filter(node=>node.name?.value===accessibleName).length!==1)throw Error('Rendered word boundary/name lookup failed for '+accessibleName+': '+JSON.stringify(result)); +} + +for(const node of ax.nodes)for(const child of node.childIds??[])if(ax.nodes.find(n=>n.nodeId===child)?.parentId!==node.nodeId)throw Error('AX parent/child links inconsistent'); const input=ax.nodes.find(n=>n.name?.value==='Repository search'); if(!input)throw Error('Search input missing from AX tree'); const resolved=await chrome.debugger.sendCommand(debuggee,'DOM.resolveNode',{backendNodeId:input.backendDOMNodeId}); @@ -139,7 +163,7 @@ const releasedGroup=await chrome.debugger.sendCommand(debuggee,'Runtime.callFunc if(!releasedGroup.exceptionDetails?.text)throw Error('Group cleanup retained the handle'); const activeAfter=(await browser.tabs.query({active:true,currentWindow:true}))[0]; if(activeAfter.id!==foreground.id||activations.includes(target.id))throw Error('CSP click activated background tab'); -await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:true,backgroundTabPreserved:true,strictCspAxClickTypeAndClear:true,objectCleanupAndExceptionContract:true,strictCspScreenshotAndEditor:true,lifecycle:lifecycle.map(e=>e.params.name),loaderId:lastLoad.params.loaderId})}); +await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:true,compactAccessibleTree:true,axSnapshotBytes:axJson.length,backgroundTabPreserved:true,strictCspAxClickTypeAndClear:true,objectCleanupAndExceptionContract:true,strictCspScreenshotAndEditor:true,lifecycle:lifecycle.map(e=>e.params.name),loaderId:lastLoad.params.loaderId})}); }catch(e){await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:false,error:String(e)+' '+e.stack})});}})();`, ); const child = spawn(