fix(accessibility): preserve NBSP in accessible names

This commit is contained in:
ldm0
2026-10-11 17:34:34 +08:00
committed by Donough Liu
parent 1b05e14af2
commit fd2ed360cc
5 changed files with 279 additions and 2 deletions
+14
View File
@@ -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
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
const cases = [
{id: 'link', html: '<nav aria-label="Main menu"><a id="target" href="#a">A&nbsp;link</a></nav>', role: 'link', name: 'A\u00a0link', rejects: ['A link']},
{id: 'nbsp-repeat', html: '<button id="target">A&nbsp;&nbsp;B</button>', name: 'A\u00a0\u00a0B', rejects: ['A B', 'A\u00a0B']},
{id: 'nbsp-edges', html: '<button id="target">&nbsp;A&nbsp;</button>', name: '\u00a0A\u00a0', rejects: ['A', ' A ']},
{id: 'nbsp-only', html: '<button id="target" title="Tooltip">&nbsp;</button>', name: '\u00a0', rejects: ['', 'Tooltip']},
{id: 'ascii-only', html: '<button id="target" title="Tooltip"> \t\n\r\f </button>', name: 'Tooltip', rejects: ['']},
{id: 'ascii-text', html: '<button id="target">A \t\n\r\f B</button>', name: 'A B', rejects: ['A B', 'A\tB']},
{id: 'ascii-label', html: '<button id="target" aria-label="A&#9;&#10;&#13;&#12;B">Fallback</button>', name: 'A B', rejects: ['A\tB']},
{id: 'ascii-aria-fallback', html: '<button id="target" aria-label=" &#9;&#10;&#13;&#12; ">Fallback</button>', name: 'Fallback', rejects: ['']},
{id: 'mixed', html: '<button id="target">A&nbsp; \t B</button>', name: 'A\u00a0 B', rejects: ['A B', 'A\u00a0B']},
{id: 'aria-label', html: '<button id="target" aria-label="A&nbsp;B">Fallback</button>', name: 'A\u00a0B', rejects: ['A B']},
{id: 'aria-only', html: '<button id="target" aria-label="&nbsp;">Fallback</button>', name: '\u00a0', rejects: ['', 'Fallback']},
{id: 'aria-edges', html: '<button id="target" aria-label="&nbsp;A&nbsp;">Fallback</button>', name: '\u00a0A\u00a0', rejects: ['A']},
{id: 'reference', html: '<span id="label">A&nbsp;B</span><button id="target" aria-labelledby="label">Fallback</button>', name: 'A\u00a0B', rejects: ['A B']},
{id: 'hidden-reference', html: '<span id="label" hidden>A&nbsp;B</span><button id="target" aria-labelledby="label">Fallback</button>', name: 'A\u00a0B', rejects: ['A B']},
{id: 'native-label', html: '<label for="target">A&nbsp;B</label><input id="target">', role: 'textbox', name: 'A\u00a0B', rejects: ['A B']},
{id: 'alt', html: '<button id="target"><img alt="A&nbsp;B"></button>', name: 'A\u00a0B', rejects: ['A B']},
{id: 'title', html: '<button id="target" title="A&nbsp;B"></button>', name: 'A\u00a0B', rejects: ['A B']},
{id: 'placeholder', html: '<input id="target" placeholder="A&nbsp;B">', role: 'textbox', name: 'A\u00a0B', rejects: ['A B']},
{id: 'value', html: '<input id="target" type="button" value="A&nbsp;B">', name: 'A\u00a0B', rejects: ['A B']},
{id: 'descendant', html: '<button id="target"><span aria-label="A&nbsp;B"></span></button>', name: 'A\u00a0B', rejects: ['A B']},
{id: 'inline', html: '<button id="target">A<span>&nbsp;</span>B</button>', name: 'A\u00a0B', rejects: ['A B', 'AB']},
{id: 'inline-only', html: '<button id="target" title="Tooltip"><span>&nbsp;</span></button>', name: '\u00a0', rejects: ['', 'Tooltip']},
{id: 'shadow', html: '<div id="target" role="button"></div>', shadow: 'A&nbsp;B', name: 'A\u00a0B', rejects: ['A B']},
{id: 'slot', html: '<div id="target" role="button"><span slot="label">A&nbsp;B</span></div>', shadow: '<slot name="label"></slot>', name: 'A\u00a0B', rejects: ['A B']},
...['\u0085', '\u2002', '\u2003', '\u2009', '\u202f', '\u3000', '\u000b'].map(space => ({
id: 'unicode-' + space.codePointAt(0).toString(16),
html: `<button id="target" aria-label="A${space}B"></button>`, 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 = '<nav aria-label="Main menu"><a id="target" href="#a">A&nbsp;link</a><a id="plain" href="#b">A link</a></nav>';
});
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(); }
}
@@ -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),
+69 -2
View File
@@ -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<Node
}
fn normalize_ax_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().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::<Vec<_>>().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();
@@ -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,
"<!doctype html><body><nav aria-label='Main menu'>\
<a href='/a'>A&nbsp;link</a><a href='/b'>A link</a>\
<a href='/c'>A&nbsp;&nbsp;link</a></nav>",
)
.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();