diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index 17e2170cb2..f1d8b0d30a 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -205,6 +205,20 @@ buttons and their ARIA backend refs. Each page completed a trusted ARIA-handle click after explicit scrolling and screenshot layout publication. AX name reads do not publish layout or change the frozen-layout policy. +The accessibility whitespace workflow was calibrated on 2026-10-11 with +Puppeteer 24.30.0 and Debian Chromium 145.0.7632.116. +`puppeteer_accessibility_whitespace.mjs` covers 31 cases: NBSP within, around, +and alone in names; repeated NBSP; HTML whitespace folding; other Unicode +spaces; and vertical tab. Name sources include contents, ARIA labels and +references, hidden references, native labels, image alternatives, title, +placeholder, input value, descendants, and closed-shadow contents and slots. +Each case checks a cold partial AX request, the full tree, both Puppeteer +snapshot modes, and positive and negative ARIA queries. A separate fixture +keeps ASCII-space and NBSP names side by side and tests exact matching through +`nodeId`, `backendNodeId`, and `objectId`. Repeated NBSP/ASCII-space mutations +must update names and selectors while preserving the target's AX and DOM refs. +The complete Puppeteer group passed all 26 scenarios against both engines. + The `navigation-outcomes` group was calibrated on 2026-08-23 against Debian `/usr/bin/chromium` 145.0.7632.116 and then run unchanged against Moli. It directly drives `Page.navigate` and correlates the result with the matching diff --git a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_accessibility_whitespace.mjs b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_accessibility_whitespace.mjs new file mode 100644 index 0000000000..385184f0cb --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_accessibility_whitespace.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; + +const cases = [ + {id: 'link', html: '', role: 'link', name: 'A\u00a0link', rejects: ['A link']}, + {id: 'nbsp-repeat', html: '', name: 'A\u00a0\u00a0B', rejects: ['A B', 'A\u00a0B']}, + {id: 'nbsp-edges', html: '', name: '\u00a0A\u00a0', rejects: ['A', ' A ']}, + {id: 'nbsp-only', html: '', name: '\u00a0', rejects: ['', 'Tooltip']}, + {id: 'ascii-only', html: '', name: 'Tooltip', rejects: ['']}, + {id: 'ascii-text', html: '', name: 'A B', rejects: ['A B', 'A\tB']}, + {id: 'ascii-label', html: '', name: 'A B', rejects: ['A\tB']}, + {id: 'ascii-aria-fallback', html: '', name: 'Fallback', rejects: ['']}, + {id: 'mixed', html: '', name: 'A\u00a0 B', rejects: ['A B', 'A\u00a0B']}, + {id: 'aria-label', html: '', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'aria-only', html: '', name: '\u00a0', rejects: ['', 'Fallback']}, + {id: 'aria-edges', html: '', name: '\u00a0A\u00a0', rejects: ['A']}, + {id: 'reference', html: 'A B', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'hidden-reference', html: '', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'native-label', html: '', role: 'textbox', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'alt', html: '', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'title', html: '', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'placeholder', html: '', role: 'textbox', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'value', html: '', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'descendant', html: '', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'inline', html: '', name: 'A\u00a0B', rejects: ['A B', 'AB']}, + {id: 'inline-only', html: '', name: '\u00a0', rejects: ['', 'Tooltip']}, + {id: 'shadow', html: '
', shadow: 'A B', name: 'A\u00a0B', rejects: ['A B']}, + {id: 'slot', html: '
A B
', shadow: '', name: 'A\u00a0B', rejects: ['A B']}, + ...['\u0085', '\u2002', '\u2003', '\u2009', '\u202f', '\u3000', '\u000b'].map(space => ({ + id: 'unicode-' + space.codePointAt(0).toString(16), + html: ``, name: `A${space}B`, rejects: ['A B'], + })), +]; + +async function assertAriaMatch(page, name, role, backend, matches) { + const selectors = [`aria/[name="${name}"][role="${role}"]`]; + // Keep the original bare aria/A link reproduction alongside role filters. + if (role === 'link') selectors.push(`aria/${name}`); + for (const selector of selectors) { + const handle = await page.$(selector); + try { + if (matches) { + assert.ok(handle, `Missing ARIA match for ${JSON.stringify(selector)}`); + assert.equal(await handle.backendNodeId(), backend); + } else { + assert.equal(handle, null, `Unexpected ARIA match for ${JSON.stringify(selector)}`); + } + } finally { await handle?.dispose(); } + } +} + +export async function runPuppeteerAccessibilityWhitespaceSmoke(page) { + await page.goto('about:blank'); + const client = await page.createCDPSession(); + try { + for (const fixture of cases) { + await page.evaluate(({html, shadow}) => { + document.body.innerHTML = html; + if (shadow) document.getElementById('target').attachShadow({mode: 'closed'}).innerHTML = shadow; + }, fixture); + const target = await page.$('#target'); + assert.ok(target, fixture.id); + try { + const backend = await target.backendNodeId(); + const role = fixture.role ?? 'button'; + // A local name request must work before any full snapshot warms AX. + const partial = await client.send('Accessibility.getPartialAXTree', { + backendNodeId: backend, fetchRelatives: false, + }); + const node = partial.nodes.find(node => node.backendDOMNodeId === backend); + assert.equal(node?.name?.value, fixture.name, fixture.id); + assert.equal(node?.role?.value, role, fixture.id); + assert.equal(node?.ignored, false, fixture.id); + const full = await client.send('Accessibility.getFullAXTree'); + assert.equal(full.nodes.find(node => node.backendDOMNodeId === backend)?.name?.value, + fixture.name, fixture.id); + for (const interestingOnly of [true, false]) { + const snapshot = await page.accessibility.snapshot({root: target, interestingOnly}); + assert.equal(snapshot?.name, fixture.name, fixture.id); + } + await assertAriaMatch(page, fixture.name, role, backend, true); + for (const rejected of fixture.rejects) { + await assertAriaMatch(page, rejected, role, backend, false); + } + } finally { await target.dispose(); } + } + + // Keep both names present to catch overmatching and preserve DOM refs. + await page.evaluate(() => { + document.body.innerHTML = ''; + }); + const target = await page.$('#target'); + const plain = await page.$('#plain'); + assert.ok(target); + assert.ok(plain); + const {result} = await client.send('Runtime.evaluate', {expression: 'document'}); + const objectId = result.objectId; + assert.ok(objectId); + try { + const backend = await target.backendNodeId(); + const plainBackend = await plain.backendNodeId(); + const {root} = await client.send('DOM.getDocument'); + for (const reference of [ + {nodeId: root.nodeId}, {backendNodeId: root.backendNodeId}, + {objectId}, + ]) { + for (const [accessibleName, expected] of [ + ['A\u00a0link', [backend]], ['A link', [plainBackend]], ['A link', []], + ]) { + const {nodes} = await client.send('Accessibility.queryAXTree', { + ...reference, accessibleName, role: 'link', + }); + assert.deepEqual(nodes.map(node => node.backendDOMNodeId), expected); + } + } + await plain.evaluate(element => element.remove()); + let initialNodeId; + for (const name of ['A\u00a0link', 'A link', 'A\u00a0link']) { + await target.evaluate((element, name) => { element.textContent = name; }, name); + const {nodes} = await client.send('Accessibility.getPartialAXTree', { + backendNodeId: backend, fetchRelatives: false, + }); + const node = nodes.find(node => node.backendDOMNodeId === backend); + assert.equal(node?.name?.value, name); + initialNodeId ??= node.nodeId; + assert.equal(node.nodeId, initialNodeId); + await assertAriaMatch(page, name, 'link', backend, true); + await assertAriaMatch(page, name === 'A link' ? 'A\u00a0link' : 'A link', 'link', backend, false); + } + } finally { + await client.send('Runtime.releaseObject', {objectId}); + await plain.dispose(); + await target.dispose(); + } + return {cases: cases.length, queryReferences: 3, mutations: 3, stableReference: true}; + } finally { await client.detach(); } +} diff --git a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs index d9ea18be82..a1b0bbffa1 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs +++ b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs @@ -8,6 +8,7 @@ import { runPuppeteerAriaQuerySmoke } from './puppeteer_aria_queries.mjs'; import { runPuppeteerShadowAccessibilitySmoke } from './puppeteer_shadow_accessibility.mjs'; import { runPuppeteerAccessibilityVisibilitySmoke } from './puppeteer_accessibility_visibility.mjs'; import { runPuppeteerAccessibilityNamesSmoke } from './puppeteer_accessibility_names.mjs'; +import { runPuppeteerAccessibilityWhitespaceSmoke } from './puppeteer_accessibility_whitespace.mjs'; import { activateXPathElement, runPuppeteerDomInteractionSmoke, @@ -465,6 +466,12 @@ async function main() { ); record('puppeteer_accessibility_names_workflow', accessibilityNamesResult); + const accessibilityWhitespaceResult = await withTimeout( + 'Puppeteer accessibility name whitespace', + runPuppeteerAccessibilityWhitespaceSmoke(page), + ); + record('puppeteer_accessibility_whitespace_workflow', accessibilityWhitespaceResult); + const domInteractionResult = await withTimeout( 'Puppeteer DOM interaction matrix', runPuppeteerDomInteractionSmoke(page), diff --git a/moli-dom/src/accessibility/ax_properties.rs b/moli-dom/src/accessibility/ax_properties.rs index d896dc2f98..dfb57fe154 100644 --- a/moli-dom/src/accessibility/ax_properties.rs +++ b/moli-dom/src/accessibility/ax_properties.rs @@ -216,7 +216,7 @@ fn ax_element_name( } else { String::new() }; - if content.chars().any(|character| !character.is_whitespace()) { + if content.bytes().any(|byte| !byte.is_ascii_whitespace()) { return Ok(AxName::contents(content)); } @@ -505,7 +505,9 @@ fn ax_element_by_id(document: &NativeDom, root: NodeId, id: &str) -> Option String { - value.split_whitespace().collect::>().join(" ") + // Blink's AXObject::SimplifyName folds HTML space, preserving NBSP and + // other Unicode spaces as part of the accessible name. + value.split_ascii_whitespace().collect::>().join(" ") } fn ax_document_name(document: &NativeDom) -> String { @@ -1116,6 +1118,71 @@ mod tests { assert_eq!(node_name(&document, button), "Before After"); } + #[test] + fn accessible_names_preserve_non_html_whitespace() { + for space in [ + '\u{a0}', '\u{85}', '\u{2002}', '\u{2003}', '\u{2009}', '\u{202f}', '\u{3000}', '\u{b}', + ] { + for value in [ + format!("A{space}B"), + format!("{space}A{space}"), + format!("A{space}{space}B"), + ] { + for attribute in [None, Some("aria-label"), Some("title"), Some("alt")] { + let (mut document, root) = document_with_root(); + let element = document.create_element(if attribute == Some("alt") { + "img" + } else { + "button" + }); + document.append_child(root, element); + if let Some(attribute) = attribute { + document.set_attribute(element, attribute, &value); + } else { + append_text(&mut document, element, &value); + } + assert_eq!( + node_name(&document, element), + value, + "{attribute:?}, {space:?}" + ); + } + } + } + } + + #[test] + fn non_html_whitespace_names_take_precedence_over_fallbacks() { + for space in ["\u{a0}", "\u{2003}", "\u{202f}", "\u{3000}"] { + let (mut document, root) = document_with_root(); + let button = document.create_element("button"); + document.append_child(root, button); + document.set_attribute(button, "title", "Tooltip"); + let span = document.create_element("span"); + document.append_child(button, span); + append_text(&mut document, span, space); + assert_eq!(node_name(&document, button), space); + + append_text(&mut document, button, "Fallback"); + document.set_attribute(button, "aria-label", space); + assert_eq!(node_name(&document, button), space); + } + } + + #[test] + fn accessible_names_still_collapse_html_whitespace() { + let (mut document, root) = document_with_root(); + let button = document.create_element("button"); + document.append_child(root, button); + append_text(&mut document, button, "A \t\n\r\u{c} B"); + assert_eq!(node_name(&document, button), "A B"); + + document.set_attribute(button, "aria-label", "C \t\n\r\u{c} D"); + assert_eq!(node_name(&document, button), "C D"); + document.set_attribute(button, "aria-label", " \t\n\r\u{c} "); + assert_eq!(node_name(&document, button), "A B"); + } + #[test] fn contents_and_relations_share_cycle_detection() { let (mut document, root) = document_with_root(); diff --git a/moli-protocol/src/domains/accessibility/tests.rs b/moli-protocol/src/domains/accessibility/tests.rs index 3bc33da049..ec0072c951 100644 --- a/moli-protocol/src/domains/accessibility/tests.rs +++ b/moli-protocol/src/domains/accessibility/tests.rs @@ -3092,6 +3092,59 @@ async fn query_ax_tree_filters_by_role_and_name() { assert_eq!(nodes[0]["name"]["value"], "Go"); } +#[tokio::test(flavor = "multi_thread")] +async fn query_ax_tree_distinguishes_html_space_and_nbsp_in_names() { + let mut ctx = TestContext::new(); + load_page_async( + &mut ctx, + "", + ) + .await; + ctx.process_async(json!({"id": 1, "method": "DOM.getDocument"})) + .await; + let document = ctx.take_response_by_id(1)["result"]["root"].clone(); + enable_runtime_async(&mut ctx).await; + ctx.process_async(json!({ + "id": 2, "method": "Runtime.evaluate", "params": {"expression": "document"} + })) + .await; + let object_id = ctx.take_response_by_id(2)["result"]["result"]["objectId"] + .as_str() + .expect("document object id") + .to_owned(); + + for reference in [ + json!({"nodeId": document["nodeId"]}), + json!({"backendNodeId": document["backendNodeId"]}), + json!({"objectId": object_id}), + ] { + for (name, expected_count) in [ + ("A link", 1), + ("A\u{a0}link", 1), + ("A\u{a0}\u{a0}link", 1), + ("A \u{a0}link", 0), + ("A\tlink", 0), + ] { + let mut params = reference.clone(); + params["role"] = json!("link"); + params["accessibleName"] = json!(name); + ctx.process_async(json!({ + "id": 3, "method": "Accessibility.queryAXTree", "params": params + })) + .await; + let response = ctx.take_response_by_id(3); + let nodes = response["result"]["nodes"].as_array().expect("AX nodes"); + assert_eq!(nodes.len(), expected_count, "{reference}, {name:?}"); + if let Some(node) = nodes.first() { + assert_eq!(node["name"]["value"], name); + assert_eq!(node["role"]["value"], "link"); + } + } + } +} + #[tokio::test(flavor = "multi_thread")] async fn query_ax_tree_supports_object_id_and_empty_match() { let mut ctx = TestContext::new();