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: 'A B', 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();