fix: restore Playwright-parity check/uncheck for Material Design controls (#837)
The v0.20.0 migration from Playwright to the native Rust daemon introduced two regressions in checkbox/radio handling: 1. `is_element_checked` only read `this.checked`, which is undefined on non-input elements. Material Design and ARIA controls use wrapper divs with `role="checkbox"` and `aria-checked`, or hide the native input off-screen inside a label. The function now mirrors Playwright's `getChecked()` with follow-label retargeting: native `.checked`, `aria-checked` for ARIA roles, `label.control` traversal, and nested input lookup. 2. `check`/`uncheck` accepted the coordinate-based CDP click result without verifying the state actually changed. When the AX tree's `backendDOMNodeId` points to a hidden off-screen input (common in Material Design), `Input.dispatchMouseEvent` hits nothing. The actions now re-check state after clicking and fall back to a JS `.click()` on the resolved input — matching Playwright's `_setChecked` verify step. Adds e2e regression test covering Material Design (hidden input + ripple overlay), ARIA-only, and native checkbox patterns. Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
@@ -359,6 +359,14 @@ pub async fn check(
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
if !is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
|
||||
// Verify the click changed the state (Playwright parity: _setChecked re-checks).
|
||||
// If the coordinate-based click missed (e.g. hidden input, overlay), retry
|
||||
// with a JS .click() on the element and its associated input.
|
||||
if !super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?
|
||||
{
|
||||
js_click_checkbox(client, session_id, ref_map, selector_or_ref).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -373,10 +381,73 @@ pub async fn uncheck(
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
if is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
|
||||
// Same verify-and-retry as check().
|
||||
if super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await? {
|
||||
js_click_checkbox(client, session_id, ref_map, selector_or_ref).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fallback for when the coordinate-based CDP click did not toggle the
|
||||
/// checkbox/radio state. This mirrors how Playwright dispatches clicks
|
||||
/// through the DOM rather than via raw Input.dispatchMouseEvent coordinates.
|
||||
///
|
||||
/// Uses the same follow-label resolution as `is_element_checked`:
|
||||
/// 1. If the element is a native input → `.click()` it directly.
|
||||
/// 2. If the element is inside a `<label>` → `.click()` the label's `.control`.
|
||||
/// 3. If the element has a nested `<input>` → `.click()` that input.
|
||||
/// 4. Otherwise → `.click()` the element itself (handles ARIA role controls).
|
||||
async fn js_click_checkbox(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
|
||||
let js = r#"function() {
|
||||
var el = this;
|
||||
var tag = el.tagName && el.tagName.toUpperCase();
|
||||
// 1. Native input — click it directly
|
||||
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
||||
el.click();
|
||||
return;
|
||||
}
|
||||
// 2. Follow label → control association
|
||||
var label = tag === 'LABEL' ? el : (el.closest && el.closest('label'));
|
||||
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
|
||||
label.control.click();
|
||||
return;
|
||||
}
|
||||
// 3. Nested native input
|
||||
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
|
||||
if (input) {
|
||||
input.click();
|
||||
return;
|
||||
}
|
||||
// 4. ARIA role control — click the element itself
|
||||
el.click();
|
||||
}"#;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn focus(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
|
||||
Reference in New Issue
Block a user