Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 109 additions & 31 deletions extension/firefox-compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
30 changes: 27 additions & 3 deletions tests/test-firefox-lifecycle-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<button><slot>AX_SLOT_FALLBACK</slot></button>';
const fallback = document.querySelector("#ax-empty-slot");
fallback.attachShadow({ mode: "open" }).innerHTML = '<button><slot>Empty selection</slot></button>';`);
} 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(
"<!doctype html><title>Lifecycle fixture</title><link rel='stylesheet' href='/fixture.css'><p>isolated lifecycle test</p><input aria-label='Repository search'><button>Search</button><textarea aria-label='Read-only editor' readonly>do not change</textarea><div class='editor'><textarea aria-label='Code editor'>old blueprint</textarea><div class='overlay'>Editor overlay</div></div>",
"<!doctype html><title>Lifecycle fixture</title><link rel='stylesheet' href='/fixture.css'><script>/* AX_SCRIPT_NOISE " + "x".repeat(8192) + " */</script><style>/* AX_STYLE_NOISE */</style><section aria-hidden='true'><p>AX_HIDDEN_TEXT</p></section><p hidden>AX_HIDDEN_ATTRIBUTE</p><p class='ax-hidden'>AX_DISPLAY_NONE</p><input type='password' aria-label='Password' value='AX_PASSWORD_SECRET'><p>isolated lifecycle test</p><div class='ax-invisible'>AX_INVISIBLE_TEXT<button class='ax-visible'>Restored visibility</button></div><button>Git<span>Hub</span></button><button>Read<br>more</button><button>Block<span class='ax-block'>boundary</span>end</button><button><span class='ax-block'>Read</span><span class='ax-block'>blocks</span></button><button>Inline<span class='ax-inline-block'>block</span>separation</button><button><span class='ax-contents'>Git<span>Lab</span></span></button><button>Tea<span hidden>AX_HIDDEN_WORD</span>Pot</button><button>In<br hidden>line</button><span id='hidden-line-label' hidden>Hidden<br>label</span><button aria-labelledby='hidden-line-label'>Referenced button</button><div id='ax-shadow'>Selected item</div><div id='ax-empty-slot'></div><script src='/ax-fixture.js'></script><label id='shared-label'>Shared accessible label</label><button aria-labelledby='shared-label'>Labeled button</button><input aria-label='Repository search'><button>Search</button><a id='charger' href='/charger'><h2 aria-label='UGREEN Nexode 65W'>UGREEN Nexode 65W charger</h2></a><h6>Other charger</h6><h2 aria-hidden='true'>Hidden charger</h2><textarea aria-label='Read-only editor' readonly>do not change</textarea><div class='editor'><textarea aria-label='Code editor'>old blueprint</textarea><div class='overlay'>Editor overlay</div></div>",
);
}
});
Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -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(
Expand Down