mirror of
https://github.com/lexmount/moli.git
synced 2026-10-30 16:07:24 +00:00
fix(accessibility): preserve NBSP in accessible names
This commit is contained in:
@@ -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 link</a></nav>', role: 'link', name: 'A\u00a0link', rejects: ['A link']},
|
||||
{id: 'nbsp-repeat', html: '<button id="target">A B</button>', name: 'A\u00a0\u00a0B', rejects: ['A B', 'A\u00a0B']},
|
||||
{id: 'nbsp-edges', html: '<button id="target"> A </button>', name: '\u00a0A\u00a0', rejects: ['A', ' A ']},
|
||||
{id: 'nbsp-only', html: '<button id="target" title="Tooltip"> </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	 B">Fallback</button>', name: 'A B', rejects: ['A\tB']},
|
||||
{id: 'ascii-aria-fallback', html: '<button id="target" aria-label=" 	  ">Fallback</button>', name: 'Fallback', rejects: ['']},
|
||||
{id: 'mixed', html: '<button id="target">A \t B</button>', name: 'A\u00a0 B', rejects: ['A B', 'A\u00a0B']},
|
||||
{id: 'aria-label', html: '<button id="target" aria-label="A B">Fallback</button>', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'aria-only', html: '<button id="target" aria-label=" ">Fallback</button>', name: '\u00a0', rejects: ['', 'Fallback']},
|
||||
{id: 'aria-edges', html: '<button id="target" aria-label=" A ">Fallback</button>', name: '\u00a0A\u00a0', rejects: ['A']},
|
||||
{id: 'reference', html: '<span id="label">A 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 B</span><button id="target" aria-labelledby="label">Fallback</button>', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'native-label', html: '<label for="target">A B</label><input id="target">', role: 'textbox', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'alt', html: '<button id="target"><img alt="A B"></button>', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'title', html: '<button id="target" title="A B"></button>', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'placeholder', html: '<input id="target" placeholder="A B">', role: 'textbox', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'value', html: '<input id="target" type="button" value="A B">', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'descendant', html: '<button id="target"><span aria-label="A B"></span></button>', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'inline', html: '<button id="target">A<span> </span>B</button>', name: 'A\u00a0B', rejects: ['A B', 'AB']},
|
||||
{id: 'inline-only', html: '<button id="target" title="Tooltip"><span> </span></button>', name: '\u00a0', rejects: ['', 'Tooltip']},
|
||||
{id: 'shadow', html: '<div id="target" role="button"></div>', shadow: 'A B', name: 'A\u00a0B', rejects: ['A B']},
|
||||
{id: 'slot', html: '<div id="target" role="button"><span slot="label">A 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 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),
|
||||
|
||||
@@ -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 link</a><a href='/b'>A link</a>\
|
||||
<a href='/c'>A 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();
|
||||
|
||||
Reference in New Issue
Block a user