diff --git a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_aria_queries.mjs b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_aria_queries.mjs new file mode 100644 index 0000000000..45bc6cc4b7 --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_aria_queries.mjs @@ -0,0 +1,97 @@ +export async function runPuppeteerAriaQuerySmoke(page) { + await page.evaluate(async () => { + document.body.innerHTML = ` +
+ + `; + globalThis.__puppeteerAriaClicks = 0; + document.querySelector('#aria-go').addEventListener('click', () => { + globalThis.__puppeteerAriaClicks += 1; + }); + const iframe = document.createElement('iframe'); + iframe.id = 'aria-frame'; + iframe.srcdoc = ''; + await new Promise(resolve => { + iframe.addEventListener('load', resolve, { once: true }); + document.body.append(iframe); + }); + }); + + for (const selector of ['aria/Go', 'aria/Go[role="button"]']) { + const handle = await page.$(selector); + if (!handle) { + throw new Error(`Puppeteer ARIA selector did not match: ${selector}`); + } + try { + const id = await handle.evaluate(element => element.id); + if (id !== 'aria-go') { + throw new Error(`Puppeteer ARIA selector returned ${id}: ${selector}`); + } + } finally { + await handle.dispose(); + } + } + + const buttons = await page.$$('aria/[role="button"]'); + try { + const ids = (await Promise.all(buttons.map(handle => handle.evaluate(element => element.id)))).sort(); + if (JSON.stringify(ids) !== JSON.stringify(['aria-go', 'aria-stay'])) { + throw new Error(`Puppeteer ARIA role query returned unexpected elements: ${JSON.stringify(ids)}`); + } + } finally { + await Promise.all(buttons.map(handle => handle.dispose())); + } + + if (await page.$('aria/Missing')) { + throw new Error('Puppeteer ARIA query must return null for a missing name'); + } + + const root = await page.$('#aria-root'); + if (!root) { + throw new Error('Puppeteer ARIA query fixture root is missing'); + } + try { + if (await root.$('aria/Stay')) { + throw new Error('Puppeteer ARIA query escaped its element subtree'); + } + const go = await root.waitForSelector('aria/Go[role="button"]', { timeout: 3000 }); + if (!go) { + throw new Error('Puppeteer scoped ARIA wait returned no element'); + } + try { + await go.evaluate(element => element.click()); + } finally { + await go.dispose(); + } + } finally { + await root.dispose(); + } + const clicks = await page.evaluate(() => globalThis.__puppeteerAriaClicks); + if (clicks !== 1) { + throw new Error(`Puppeteer ARIA handle must activate the matched element once: ${clicks}`); + } + + const iframe = await page.$('#aria-frame'); + if (!iframe) { + throw new Error('Puppeteer ARIA query fixture iframe is missing'); + } + try { + const frame = await iframe.contentFrame(); + const child = await frame?.$('aria/Child Go[role="button"]'); + if (!child) { + throw new Error('Puppeteer ARIA selector did not match in the child frame'); + } + try { + const id = await child.evaluate(element => element.id); + if (id !== 'aria-child') { + throw new Error(`Puppeteer child-frame ARIA selector returned ${id}`); + } + } finally { + await child.dispose(); + } + } finally { + await iframe.dispose(); + } + + return { selectors: ['name', 'name-and-role', 'role', 'missing', 'scoped-wait', 'iframe'] }; +} diff --git a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs index 7f11b24993..bfcbea3304 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs +++ b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; +import { runPuppeteerAriaQuerySmoke } from './puppeteer_aria_queries.mjs'; import { activateXPathElement, runPuppeteerDomInteractionSmoke, @@ -437,6 +438,12 @@ async function main() { } record('puppeteer_evaluate_fetch'); + const ariaQueryResult = await withTimeout( + 'Puppeteer ARIA selectors', + runPuppeteerAriaQuerySmoke(page), + ); + record('puppeteer_aria_selector_workflow', ariaQueryResult); + const domInteractionResult = await withTimeout( 'Puppeteer DOM interaction matrix', runPuppeteerDomInteractionSmoke(page), diff --git a/moli-protocol/src/domains/accessibility.rs b/moli-protocol/src/domains/accessibility.rs index e9cae6c689..2876dbf0b0 100644 --- a/moli-protocol/src/domains/accessibility.rs +++ b/moli-protocol/src/domains/accessibility.rs @@ -40,7 +40,7 @@ enum PendingAccessibilityCommandKind { }, ChildFrameRoot, ObjectAccessibilityPayloads { - frame_id: String, + frame_id: Option, top_frame_id: String, operation: AccessibilityNodeOperation, }, @@ -412,28 +412,25 @@ fn start_pending_object_reference_command( let Some(top_frame_id) = helpers::top_frame_id_for_session(conn, cmd.session_id) else { return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; - let resolved_frame_id = frame_id.unwrap_or(top_frame_id.as_str()).to_owned(); let Some(object_id) = reference.object_id.as_deref() else { return Err(PendingAccessibilityCommandStartError { code: -32000, message: "Could not find node with given id".to_owned(), }); }; + let inspector_session_id = + conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); let Some(page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; - let pending = start_accessibility_object_page_command( - page, - cmd.session_id.map(str::to_owned), - object_id, - &operation, - ) - .map_err(PendingAccessibilityCommandStartError::renderer_error)?; + let pending = + start_accessibility_object_page_command(page, inspector_session_id, object_id, &operation) + .map_err(PendingAccessibilityCommandStartError::renderer_error)?; Ok(Some(PendingAccessibilityCommandDispatch::from_command( conn, cmd, PendingAccessibilityCommandKind::ObjectAccessibilityPayloads { - frame_id: resolved_frame_id, + frame_id: frame_id.map(str::to_owned), top_frame_id, operation, }, @@ -720,7 +717,7 @@ pub(crate) async fn complete_pending_accessibility_command( fn complete_object_accessibility_payloads_command( page: &mut moli_core::page::Page, completion: CompletedPageCommand, - frame_id: String, + frame_id: Option, top_frame_id: String, operation: AccessibilityNodeOperation, ) -> CommandOutputPlan { @@ -740,7 +737,11 @@ fn complete_object_accessibility_payloads_command( .frame_id .as_deref() .unwrap_or(top_frame_id.as_str()); - if object_frame_id != frame_id { + // Object IDs identify their own frame unless the caller explicitly constrains it. + if frame_id + .as_deref() + .is_some_and(|frame_id| object_frame_id != frame_id) + { return CommandOutputPlan::error(-32000, "Could not find node with given id"); } let Some(mut nodes) = payloads.payloads else { diff --git a/moli-protocol/src/domains/accessibility/native.rs b/moli-protocol/src/domains/accessibility/native.rs index 0bf45c8c21..7c0d2b2e91 100644 --- a/moli-protocol/src/domains/accessibility/native.rs +++ b/moli-protocol/src/domains/accessibility/native.rs @@ -197,9 +197,8 @@ fn reference_operation( top_frame_id: String, operation: AccessibilityNodeOperation, ) -> Result { - let frame_id = frame_id.unwrap_or_else(|| top_frame_id.clone()); if let Some(object_id) = reference.object_id { - let session = cmd.session_id.map(str::to_owned); + let session = conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); let command = match &operation { AccessibilityNodeOperation::Ancestors => { Command::accessibility_node_and_ancestor_payloads_for_object_id(session, object_id) @@ -220,6 +219,7 @@ fn reference_operation( project_nodes(reply, frame_id, top_frame_id, operation) })); } + let frame_id = frame_id.unwrap_or_else(|| top_frame_id.clone()); let reference = if let Some(id) = renderer_backend_node_id_for_reference(&reference) { DevToolsDomNodeReference::BackendNodeId(id) } else { @@ -261,13 +261,13 @@ fn node_operation( } }; Operation::new(command, move |reply| { - project_nodes(reply, frame_id, top_frame_id, operation) + project_nodes(reply, Some(frame_id), top_frame_id, operation) }) } fn project_nodes( reply: anyhow::Result, - frame_id: String, + frame_id: Option, top_frame_id: String, operation: AccessibilityNodeOperation, ) -> Response { @@ -282,7 +282,11 @@ fn project_nodes( } _ => unreachable!("accessibility node reply"), }; - if payload.frame_id.as_deref().unwrap_or(&top_frame_id) != frame_id { + // Object IDs identify their own frame unless the caller explicitly constrains it. + if frame_id + .as_deref() + .is_some_and(|frame_id| payload.frame_id.as_deref().unwrap_or(&top_frame_id) != frame_id) + { return node_not_found(); } let Some(mut nodes) = payload.payloads else { diff --git a/moli-protocol/src/domains/accessibility/tests.rs b/moli-protocol/src/domains/accessibility/tests.rs index 647f20c252..a3b9af97b8 100644 --- a/moli-protocol/src/domains/accessibility/tests.rs +++ b/moli-protocol/src/domains/accessibility/tests.rs @@ -2517,6 +2517,131 @@ async fn query_ax_tree_supports_object_id_and_empty_match() { assert_eq!(empty["result"]["nodes"], json!([])); } +#[tokio::test(flavor = "multi_thread")] +async fn query_ax_tree_object_id_uses_owning_inspector_session() { + let mut ctx = TestContext::new(); + load_page_async( + &mut ctx, + "", + ) + .await; + let browser_context = ctx.conn.browser_context.as_mut().expect("browser context"); + browser_context.attach_active_session("SID-primary"); + assert!(browser_context.assign_attached_session_to_target("TID-1", "SID-attached".to_owned())); + + for (session_id, other_session_id) in [ + ("SID-primary", "SID-attached"), + ("SID-attached", "SID-primary"), + ] { + ctx.process_async(json!({ + "id": 560, + "method": "Runtime.enable", + "sessionId": session_id + })) + .await; + ctx.take_all(); + ctx.process_async(json!({ + "id": 561, + "method": "Page.createIsolatedWorld", + "sessionId": session_id, + "params": { "frameId": "TID-1", "worldName": format!("ax-query-{session_id}") } + })) + .await; + let context_id = ctx.take_response_by_id(561)["result"]["executionContextId"] + .as_i64() + .expect("isolated world id"); + ctx.take_all(); + ctx.process_async(json!({ + "id": 562, + "method": "Runtime.evaluate", + "sessionId": session_id, + "params": { "expression": "document", "contextId": context_id } + })) + .await; + let object_id = ctx.take_response_by_id(562)["result"]["result"]["objectId"] + .as_str() + .expect("document object id") + .to_owned(); + + ctx.process_async(json!({ + "id": 563, + "method": "Accessibility.queryAXTree", + "sessionId": session_id, + "params": { "objectId": object_id, "role": "button", "accessibleName": "Go" } + })) + .await; + let message = ctx.take_response_by_id(563); + let nodes = message["result"]["nodes"] + .as_array() + .unwrap_or_else(|| panic!("{session_id} AX query failed: {message}")); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0]["role"]["value"], "button"); + assert_eq!(nodes[0]["name"]["value"], "Go"); + + ctx.process_async(json!({ + "id": 564, + "method": "Accessibility.queryAXTree", + "sessionId": other_session_id, + "params": { "objectId": object_id, "role": "button" } + })) + .await; + ctx.expect_error(564, -32000, "Could not find node with given id"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn query_ax_tree_object_id_infers_child_frame_and_checks_explicit_frame() { + let mut ctx = TestContext::new(); + load_page_async( + &mut ctx, + "", + ) + .await; + enable_runtime_async(&mut ctx).await; + let child_frame_id = child_frame_id_for_single_iframe_async(&mut ctx).await; + ctx.process_async(json!({ + "id": 565, + "method": "Page.createIsolatedWorld", + "params": { "frameId": child_frame_id, "worldName": "ax-query-child-document" } + })) + .await; + let context_id = ctx.take_response_by_id(565)["result"]["executionContextId"] + .as_i64() + .expect("child isolated world id"); + ctx.take_all(); + ctx.process_async(json!({ + "id": 566, + "method": "Runtime.evaluate", + "params": { "expression": "document", "contextId": context_id } + })) + .await; + let object_id = ctx.take_response_by_id(566)["result"]["result"]["objectId"] + .as_str() + .expect("child document object id") + .to_owned(); + + ctx.process_async(json!({ + "id": 567, + "method": "Accessibility.queryAXTree", + "params": { "objectId": object_id, "role": "button" } + })) + .await; + let message = ctx.take_response_by_id(567); + let nodes = message["result"]["nodes"] + .as_array() + .unwrap_or_else(|| panic!("child AX query failed: {message}")); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0]["name"]["value"], "Go"); + + ctx.process_async(json!({ + "id": 568, + "method": "Accessibility.queryAXTree", + "params": { "objectId": object_id, "frameId": "TID-1", "role": "button" } + })) + .await; + ctx.expect_error(568, -32000, "Could not find node with given id"); +} + #[tokio::test(flavor = "multi_thread")] async fn query_ax_tree_requires_context_loaded_page_and_bound_node() { let mut ctx = TestContext::new();