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) -- + "", + "", + "" + ); + + 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 `