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:
@@ -1803,3 +1803,182 @@ async fn e2e_click_stale_ref_falls_back_to_role_name() {
|
|||||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regression: Material Design checkbox/radio (#832)
|
||||||
|
//
|
||||||
|
// Material Design controls hide the native <input> off-screen and place
|
||||||
|
// overlay elements (ripple, touch-target) on top. Coordinate-based CDP
|
||||||
|
// clicks may therefore miss the actual input. The check/uncheck actions
|
||||||
|
// must detect this and fall back to a JS .click() — matching the behaviour
|
||||||
|
// that Playwright provided in v0.19.0.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn e2e_material_checkbox_check_uncheck() {
|
||||||
|
let mut state = DaemonState::new();
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
// Inline HTML that reproduces the Material Design DOM pattern:
|
||||||
|
// - Native <input> is visually hidden (position:absolute, opacity:0, off-screen)
|
||||||
|
// - A ripple overlay sits on top with pointer-events:all, intercepting coordinate clicks
|
||||||
|
// - An ARIA-only checkbox uses role="checkbox" + aria-checked (no native input)
|
||||||
|
let html = concat!(
|
||||||
|
"data:text/html,<html><body>",
|
||||||
|
// -- Native baseline --
|
||||||
|
"<input id='native' type='checkbox'>",
|
||||||
|
// -- Material-style hidden-input checkbox --
|
||||||
|
"<div id='mat' style='position:relative;padding:12px'>",
|
||||||
|
"<input id='mat-input' type='checkbox' style='position:absolute;opacity:0;width:1px;height:1px;top:-9999px;left:-9999px;pointer-events:none'>",
|
||||||
|
"<div style='position:absolute;top:0;left:0;width:48px;height:48px;pointer-events:all;z-index:10'></div>",
|
||||||
|
"<span>Material CB</span>",
|
||||||
|
"</div>",
|
||||||
|
// -- ARIA-only checkbox (no native input) --
|
||||||
|
"<div id='aria' role='checkbox' aria-checked='false' tabindex='0'>ARIA CB</div>",
|
||||||
|
"<script>",
|
||||||
|
"document.getElementById('aria').addEventListener('click',function(){",
|
||||||
|
"var c=this.getAttribute('aria-checked')==='true';",
|
||||||
|
"this.setAttribute('aria-checked',String(!c));",
|
||||||
|
"});",
|
||||||
|
"</script>",
|
||||||
|
"</body></html>"
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "2", "action": "navigate", "url": html }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
// ---- Native checkbox (sanity baseline) ----
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "10", "action": "ischecked", "selector": "#native" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["checked"], false);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "11", "action": "check", "selector": "#native" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "12", "action": "ischecked", "selector": "#native" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["checked"], true, "native check failed");
|
||||||
|
|
||||||
|
// ---- Material checkbox (hidden input + overlay) ----
|
||||||
|
// ischecked on the wrapper should detect the nested hidden input's state
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "20", "action": "ischecked", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["checked"], false);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "21", "action": "check", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "22", "action": "ischecked", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(
|
||||||
|
get_data(&resp)["checked"],
|
||||||
|
true,
|
||||||
|
"Material checkbox should be checked after check action (#832)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idempotency: check again should be a no-op
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "23", "action": "check", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "24", "action": "ischecked", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(
|
||||||
|
get_data(&resp)["checked"],
|
||||||
|
true,
|
||||||
|
"Material checkbox should stay checked on redundant check"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uncheck
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "25", "action": "uncheck", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "26", "action": "ischecked", "selector": "#mat" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(
|
||||||
|
get_data(&resp)["checked"],
|
||||||
|
false,
|
||||||
|
"Material checkbox should be unchecked after uncheck action"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- ARIA-only checkbox ----
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "30", "action": "ischecked", "selector": "#aria" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["checked"], false);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "31", "action": "check", "selector": "#aria" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "32", "action": "ischecked", "selector": "#aria" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(
|
||||||
|
get_data(&resp)["checked"],
|
||||||
|
true,
|
||||||
|
"ARIA checkbox should be checked after check action"
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
}
|
||||||
|
|||||||
@@ -497,11 +497,44 @@ pub async fn is_element_checked(
|
|||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||||
|
|
||||||
|
// Mirrors Playwright's getChecked() with follow-label retargeting:
|
||||||
|
// 1. If element is a native checkbox/radio input, return .checked
|
||||||
|
// 2. If element has an ARIA checked role, return aria-checked
|
||||||
|
// 3. Follow label → input association (label.control)
|
||||||
|
// 4. Check for nested checkbox/radio input as last resort
|
||||||
let result: EvaluateResult = client
|
let result: EvaluateResult = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"Runtime.callFunctionOn",
|
"Runtime.callFunctionOn",
|
||||||
&CallFunctionOnParams {
|
&CallFunctionOnParams {
|
||||||
function_declaration: "function() { return !!this.checked; }".to_string(),
|
function_declaration: r#"function() {
|
||||||
|
var el = this;
|
||||||
|
// Native checkbox/radio input
|
||||||
|
var tag = el.tagName && el.tagName.toUpperCase();
|
||||||
|
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
||||||
|
return el.checked;
|
||||||
|
}
|
||||||
|
// ARIA role-based checked state
|
||||||
|
var role = el.getAttribute && el.getAttribute('role');
|
||||||
|
var ariaCheckedRoles = ['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'];
|
||||||
|
if (role && ariaCheckedRoles.indexOf(role) !== -1) {
|
||||||
|
return el.getAttribute('aria-checked') === 'true';
|
||||||
|
}
|
||||||
|
// Follow label association (Playwright follow-label retarget)
|
||||||
|
var label = el;
|
||||||
|
if (tag !== 'LABEL') {
|
||||||
|
label = el.closest && el.closest('label');
|
||||||
|
}
|
||||||
|
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
|
||||||
|
var ctrl = label.control;
|
||||||
|
if (ctrl.type === 'checkbox' || ctrl.type === 'radio') {
|
||||||
|
return ctrl.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for nested native input
|
||||||
|
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
|
||||||
|
if (input) return input.checked;
|
||||||
|
return false;
|
||||||
|
}"#.to_string(),
|
||||||
object_id: Some(object_id),
|
object_id: Some(object_id),
|
||||||
arguments: None,
|
arguments: None,
|
||||||
return_by_value: Some(true),
|
return_by_value: Some(true),
|
||||||
|
|||||||
@@ -359,6 +359,14 @@ pub async fn check(
|
|||||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||||
if !is_checked {
|
if !is_checked {
|
||||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -373,7 +381,70 @@ pub async fn uncheck(
|
|||||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||||
if is_checked {
|
if is_checked {
|
||||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user