diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs
index 609afee..540b149 100644
--- a/cli/src/native/e2e_tests.rs
+++ b/cli/src/native/e2e_tests.rs
@@ -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;
assert_success(&resp);
}
+
+// ---------------------------------------------------------------------------
+// Regression: Material Design checkbox/radio (#832)
+//
+// Material Design controls hide the native 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 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,
",
+ // -- Native baseline --
+ " ",
+ // -- Material-style hidden-input checkbox --
+ "",
+ "
",
+ "
",
+ "
Material CB ",
+ "
",
+ // -- ARIA-only checkbox (no native input) --
+ "ARIA CB
",
+ "",
+ ""
+ );
+
+ 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);
+}
diff --git a/cli/src/native/element.rs b/cli/src/native/element.rs
index 35718d6..b40d714 100644
--- a/cli/src/native/element.rs
+++ b/cli/src/native/element.rs
@@ -497,11 +497,44 @@ pub async fn is_element_checked(
) -> Result {
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
.send_command_typed(
"Runtime.callFunctionOn",
&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),
arguments: None,
return_by_value: Some(true),
diff --git a/cli/src/native/interaction.rs b/cli/src/native/interaction.rs
index 0ea959f..a3d807e 100644
--- a/cli/src/native/interaction.rs
+++ b/cli/src/native/interaction.rs
@@ -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 `` → `.click()` the label's `.control`.
+/// 3. If the element has a nested ` ` → `.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,