//! **P2 命脉:act 反查链端到端集成**(`#[ignore]`,本机/打包 chrome)。 //! //! 验证 `fe` ref → DOM element objectId 的反查 + objectGroup 生命周期 + role 二次校验: //! navigate fixture(含 button "Submit order")→ `observe()`(填 ref 表 + 武装注入侧的 //! `_lastAriaSnapshotForQuery.elements` 缓存)→ 从 entries 取按钮 ref → `resolve_ref` 拿回非空 //! objectId(层②,vendored aria-ref selector engine)+ role 比对通过(层③)→ 不存在/过期 ref //! → NodeStale(层①)→ `release_act_group_by_ref` 释放后不报错。 //! //! 复用 `tests/common` 的 `build_backend_for_fixture`(勿再复制契约母本)。 //! //! 手动跑(本机 Windows 有系统 Chrome): //! set NOMIFUN_CHROME_BINARY=...\chrome.exe //! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(resolve_ref)' //! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。 use nomi_browser_engine::actionability::CheckResult; use nomi_browser_engine::input::Point; use nomi_browser_engine::{BrowserError, BrowserEngine, ObserveOpts}; mod common; /// 端到端:observe → resolve_ref(层②③)成功拿 objectId + role 校验通过;stale ref → 层① NodeStale; /// release 后不报错。一个测试覆盖全反查链(建一次 chrome,最省资源)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn resolve_ref_roundtrip_and_stale_and_release() { let backend = common::build_backend_for_fixture("act").await; backend .navigate(&common::fixture_url("iframe.html"), false) .await .expect("navigate iframe.html"); // observe 填 ref 表 + 武装注入侧 elements 缓存(act 反查的前置)。 let obs = backend .observe(&ObserveOpts::default()) .await .expect("observe"); eprintln!("=== resolve_ref entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?} frame_seq={}", e.r#ref, e.role, e.name, e.frame_seq); } // 取按钮 "Submit order" 的 ref(fixture 固定有此 button)。 let button = obs .entries .iter() .find(|e| e.role == "button" && e.name == "Submit order") .expect("fixture should expose a button \"Submit order\""); let button_ref = button.r#ref.clone(); eprintln!("button ref = {button_ref}"); // ── 反查成功:拿回非空 objectId(层②)+ group 命名(objectGroup)+ role 校验通过(层③)── let handle = backend .resolve_ref(&button_ref, 0) .await .expect("resolve_ref should resolve a live button"); eprintln!("resolved objectId = {} group = {}", handle.object_id, handle.group); assert!(!handle.object_id.is_empty(), "object_id must be non-empty"); assert_eq!(handle.group, "act-0", "objectGroup must be act-"); // ── 不存在 ref → 层① NodeStale(不进浏览器,table resolve 即拦下)── let stale = backend.resolve_ref("f9e99999", 1).await; eprintln!("stale lookup = {stale:?}"); assert!( matches!(stale, Err(BrowserError::NodeStale { .. })), "non-existent ref must be NodeStale, got {stale:?}" ); // ── release:释放刚才 act-0 组的句柄,不报错(幂等)。再释放一次(已空组)仍不报错。── backend.release_act_group_by_ref(&button_ref, 0).await; backend.release_act_group_by_ref(&button_ref, 0).await; // release 后该 group 的 objectId 已失效:对失效句柄再 call 会被 CDP 报错("Could not find object"), // 但这通过 InjectError → BrowserError 体现,不 panic。这里只断言 resolve 仍能拿**新**句柄(注入侧 // elements 缓存未失效,只是宿主释放了上次的 RemoteObject 引用)。 let handle2 = backend .resolve_ref(&button_ref, 2) .await .expect("resolve_ref after release should still resolve (cache intact, fresh handle)"); assert!(!handle2.object_id.is_empty()); assert_eq!(handle2.group, "act-2"); backend.release_act_group_by_ref(&button_ref, 2).await; } /// role 漂移校验(层③):手动构造一个 RefRecord,其 role 与活元素实际 role 不符 → NodeStale。 /// 用 `resolve_ref_to_object`(吃 RefRecord)直接喂入伪造 role,验证层③ 真的拦截(而非只过层②)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn resolve_ref_role_mismatch_is_stale() { use nomi_browser_engine::aria_ref::RefRecord; let backend = common::build_backend_for_fixture("act-role").await; backend .navigate(&common::fixture_url("iframe.html"), false) .await .expect("navigate"); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); let button = obs .entries .iter() .find(|e| e.role == "button" && e.name == "Submit order") .expect("button"); // 用按钮的真 ref,但 RefRecord 谎称 role=link(模拟 backendNodeId 复用后 ref 映射到异角色元素)。 // session_id/frame_id 取主 page session(按钮在主帧)。D1:page_session_id/main_frame_id 现 async。 let forged = RefRecord { session_id: backend.page_session_id().await.expect("active tab session"), frame_id: backend.main_frame_id().await.expect("active tab main frame"), full_ref: button.r#ref.clone(), role: "link".into(), // ← 谎报:实际是 button name: button.name.clone(), }; let res = backend.resolve_ref_to_object(&forged, 7).await; eprintln!("role-mismatch resolve = {res:?}"); assert!( matches!(res, Err(BrowserError::NodeStale { .. })), "role mismatch must be NodeStale (layer③), got {res:?}" ); } /// **B3 actionability 五检查 `check_states`(visible/stable/enabled/editable)三态 + editable 两路径** /// 端到端(一个测试覆盖全状态,建一次 chrome 最省资源)。 /// /// fixture `actionability.html` 含 normal button / disabled button / readonly input / 普通 textbox / /// heading(非可编辑)。navigate → observe 拿各元素 ref → `resolve_ref` 拿 ObjectHandle → /// `check_states`: /// - normal button `["visible","stable","enabled"]` → `Pass`; /// - disabled button `["visible","stable","enabled"]` → `Missing("enabled")`; /// - readonly input `["editable"]` → `Missing("editable")`(**可重试**,区别于不可编辑特例); /// - heading `["editable"]` → `Err(Blocked)`(元素类型根本不支持编辑,NonRecoverable,**禁重试**); /// - 普通 textbox observe 后用 `__eval_page_world_for_test` 改 `display:none`,再 `check_states(["visible"])` /// → `Missing("visible")`(ref 已分配但现已不可见)。 /// /// insta 锚 `CheckResult` 的 Debug 快照(三态判定的稳定契约)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn check_states_three_states_and_editable_two_paths() { let backend = common::build_backend_for_fixture("act-check-states").await; backend .navigate(&common::fixture_url("actionability.html"), false) .await .expect("navigate actionability.html"); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== check_states entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let find = |role: &str, name: &str| -> String { obs.entries .iter() .find(|e| e.role == role && e.name == name) .unwrap_or_else(|| panic!("fixture should expose {role:?} {name:?}")) .r#ref .clone() }; // ── normal button → Pass ────────────────────────────────────────────── let normal_ref = find("button", "Submit order"); let normal = backend.resolve_ref(&normal_ref, 0).await.expect("resolve normal"); let normal_res = backend .check_states(&normal, &["visible", "stable", "enabled"]) .await .expect("check normal"); eprintln!("normal button check = {normal_res:?}"); // ── disabled button → Missing("enabled") ────────────────────────────── let disabled_ref = find("button", "Disabled action"); let disabled = backend.resolve_ref(&disabled_ref, 1).await.expect("resolve disabled"); let disabled_res = backend .check_states(&disabled, &["visible", "stable", "enabled"]) .await .expect("check disabled"); eprintln!("disabled button check = {disabled_res:?}"); // ── readonly input → Missing("editable")(可重试,区别于不可编辑特例的 Blocked)── let readonly_ref = find("textbox", "Locked"); let readonly = backend.resolve_ref(&readonly_ref, 2).await.expect("resolve readonly"); let readonly_res = backend .check_states(&readonly, &["editable"]) .await .expect("check readonly"); eprintln!("readonly input editable check = {readonly_res:?}"); // ── heading(非可编辑元素)editable → Err(Blocked)(NonRecoverable,禁重试)── let heading_ref = find("heading", "Actionability"); let heading = backend.resolve_ref(&heading_ref, 3).await.expect("resolve heading"); let heading_res = backend.check_states(&heading, &["editable"]).await; eprintln!("heading editable check = {heading_res:?}"); let heading_blocked = matches!(heading_res, Err(BrowserError::Blocked { .. })); // ── 普通 textbox:observe 后改为 display:none → visible 检查 Missing("visible") ── let email_ref = find("textbox", "Email"); let email = backend.resolve_ref(&email_ref, 4).await.expect("resolve email"); // 在页面 world 把 Email 输入框隐藏(ref 已分配,但 checkElementStates 现场重读其可见性)。 backend .__eval_page_world_for_test( "(() => { \ const label = [...document.querySelectorAll('label')].find(l => l.textContent.includes('Email')); \ const input = label && label.querySelector('input'); \ if (input) input.style.display = 'none'; \ return !!input; })()", ) .await .expect("hide email input"); let hidden_res = backend .check_states(&email, &["visible"]) .await .expect("check hidden"); eprintln!("hidden input visible check = {hidden_res:?}"); // 释放各动作组(best-effort)。 for (r, s) in [ (&normal_ref, 0), (&disabled_ref, 1), (&readonly_ref, 2), (&heading_ref, 3), (&email_ref, 4), ] { backend.release_act_group_by_ref(r, s).await; } // 直接断言关键三态 + 两路径(不只靠快照,便于失败时定位)。 assert_eq!(normal_res, CheckResult::Pass, "normal button must Pass"); assert_eq!( disabled_res, CheckResult::Missing("enabled".into()), "disabled button must Missing(enabled)" ); assert_eq!( readonly_res, CheckResult::Missing("editable".into()), "readonly input must Missing(editable) (recoverable, NOT Blocked)" ); assert!( heading_blocked, "non-editable heading must be Blocked (NonRecoverable), got {heading_res:?}" ); assert_eq!( hidden_res, CheckResult::Missing("visible".into()), "hidden input must Missing(visible)" ); // insta 契约:锚三态 + editable 两路径的稳定判定。 let summary = format!( "normal(button,visible+stable+enabled) = {normal_res:?}\n\ disabled(button,visible+stable+enabled) = {disabled_res:?}\n\ readonly(input,editable) = {readonly_res:?}\n\ non_editable(heading,editable) = Err(Blocked)={heading_blocked}\n\ hidden(input,visible) = {hidden_res:?}" ); insta::assert_snapshot!("check_states_contract", summary); } /// **B4 double hit-target 三步舞(actionability 第⑤检查 receivesEvents)** 端到端(一个测试覆盖 /// 正常→Ok / 遮挡→Blocked,建一次 chrome 最省资源)。 /// /// fixture `modal-overlay.html`:`#reachable`(modal 内,z-index 高于遮罩,点它真命中自己)+ /// `#covered`(页面背景,被全屏 `#overlay` 遮罩盖住,点它命中 overlay)。两按钮 fixed 定位到确定 /// 坐标,命中点硬编码(不依赖 quad 计算,那是 B5)。navigate → observe 拿按钮 ref → `resolve_ref` /// 拿 ObjectHandle,然后在硬编码命中点上: /// - **可达按钮**((120,64) 在 #reachable 内):`expect_hit_target` → `Ok`(未被遮挡); /// `hit_setup`(mouse, block_all=false)→ `Ok(HitInterceptor)`(预检通过,拿到 interceptor 句柄); /// `hit_stop`(B4 无真点击,无事件触发)→ `Ok`(stop 内 `result || 'done'` 保守放行); /// - **被遮挡按钮**((120,224) 在 #covered 内,但被 overlay 盖住):`expect_hit_target` → /// `Err(Blocked)`(hitTargetDescription 指向 overlay);`hit_setup` 预检短路 → `Err(Blocked)` /// (reason 指向 overlay,无需走点击)。 /// /// insta 锚 `expectHitTarget`/`hit_setup` 的判定 + 遮挡按钮的 `hitTargetDescription`(reason 原文, /// 即 `expectHitTarget`/setup 短路返回的 JSON 的 load-bearing 部分)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn hit_target_setup_stop_and_expect_occlusion() { let backend = common::build_backend_for_fixture("act-hit-target").await; backend .navigate(&common::fixture_url("modal-overlay.html"), false) .await .expect("navigate modal-overlay.html"); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== hit_target entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let find = |role: &str, name: &str| -> String { obs.entries .iter() .find(|e| e.role == role && e.name == name) .unwrap_or_else(|| panic!("fixture should expose {role:?} {name:?}")) .r#ref .clone() }; // 命中点:与 fixture 的 fixed 定位一致(中心点;不走 quad 计算,那是 B5)。 let reachable_point = Point { x: 120.0, y: 64.0 }; // #reachable left:40 top:40 w:160 h:48 let covered_point = Point { x: 120.0, y: 224.0 }; // #covered left:40 top:200 w:160 h:48 // ── 可达按钮(modal 内,未被遮挡)───────────────────────────────────────── let reachable_ref = find("button", "Reachable in modal"); let reachable = backend.resolve_ref(&reachable_ref, 0).await.expect("resolve reachable"); // expect_hit_target:未被遮挡 → Ok。 let reachable_expect = backend.expect_hit_target(&reachable, reachable_point).await; eprintln!("reachable expect_hit_target = {reachable_expect:?}"); // hit_setup(mouse, block_all=false):预检通过 → 拿到 interceptor 句柄(by_value=false 保活)。 let reachable_setup = backend .hit_setup(&reachable, "mouse", reachable_point, false) .await .expect("hit_setup on reachable button should装上拦截器"); eprintln!( "reachable hit_setup interceptor objectId = {} group = {}", reachable_setup.object_id, reachable_setup.group ); assert!(!reachable_setup.object_id.is_empty(), "interceptor objectId 非空"); assert_eq!(reachable_setup.group, "act-0", "interceptor 句柄归元素同组 act-0"); // hit_stop(B4 无真点击,无事件触发)→ stop 内 `result || 'done'` 保守放行 → Ok。 let reachable_stop = backend.hit_stop(&reachable_setup).await; eprintln!("reachable hit_stop (no click) = {reachable_stop:?}"); // ── 被遮挡按钮(页面背景,被全屏 overlay 盖住)────────────────────────────── let covered_ref = find("button", "Behind overlay"); let covered = backend.resolve_ref(&covered_ref, 1).await.expect("resolve covered"); // expect_hit_target:命中 overlay → Err(Blocked),reason 指向 overlay。 let covered_expect = backend.expect_hit_target(&covered, covered_point).await; eprintln!("covered expect_hit_target = {covered_expect:?}"); // hit_setup 预检短路:已被遮挡 → Err(Blocked)(reason 指向 overlay,无需走点击)。 let covered_setup = backend.hit_setup(&covered, "mouse", covered_point, false).await; eprintln!("covered hit_setup (preliminary short-circuit) = {covered_setup:?}"); // 释放各动作组(best-effort)。 backend.release_act_group_by_ref(&reachable_ref, 0).await; backend.release_act_group_by_ref(&covered_ref, 1).await; // ── 直接断言(便于失败时定位)──────────────────────────────────────────── assert!( reachable_expect.is_ok(), "reachable expect_hit_target must Ok, got {reachable_expect:?}" ); assert!( reachable_stop.is_ok(), "reachable hit_stop (no click) must Ok, got {reachable_stop:?}" ); let covered_expect_reason = match &covered_expect { Err(BrowserError::Blocked { reason }) => reason.clone(), other => panic!("covered expect_hit_target must Blocked, got {other:?}"), }; assert!( covered_expect_reason.contains("overlay"), "covered expect_hit_target Blocked reason should指向 overlay, got {covered_expect_reason:?}" ); let covered_setup_reason = match &covered_setup { Err(BrowserError::Blocked { reason }) => reason.clone(), other => panic!("covered hit_setup must Blocked (preliminary short-circuit), got {other:?}"), }; assert!( covered_setup_reason.contains("overlay"), "covered hit_setup Blocked reason should指向 overlay, got {covered_setup_reason:?}" ); // insta 契约:锚三原语判定 + 遮挡 hitTargetDescription(reason 即 expectHitTarget/setup 短路 // JSON 的 load-bearing 部分)。可达侧只锚 Ok(objectId 是运行时随机,不入快照)。 let summary = format!( "reachable.expect_hit_target((120,64)) = {}\n\ reachable.hit_setup(mouse,block=false) = Ok(interceptor handle, group=act-0)\n\ reachable.hit_stop(no click) = {}\n\ covered.expect_hit_target((120,224)) = Err(Blocked: {covered_expect_reason})\n\ covered.hit_setup(preliminary) = Err(Blocked: {covered_setup_reason})", if reachable_expect.is_ok() { "Ok" } else { "ERR" }, if reachable_stop.is_ok() { "Ok" } else { "ERR" }, ); insta::assert_snapshot!("hit_target_contract", summary); } /// **B5 输入合成端到端**(getContentQuads 几何取点 → dispatchMouseEvent / insertText / /// dispatchKeyEvent,全程禁 DPR)。一个测试覆盖:点击聚焦 + 文本键入读回 + 按钮点击态变 + 组合键, /// 建一次 chrome 最省资源。 /// /// fixture `input-synth.html`:`#field`(text input,包在 label "Name")+ `#toggle`(button,点击改 /// 自身文案 "Not clicked" → "Clicked")。navigate → observe 拿 input/button ref → `resolve_ref` /// 拿 ObjectHandle,然后: /// - **input 聚焦 + 键入**:`element_content_quads`(真实 getContentQuads,CSS 像素)→ /// `viewport_size` → `pick_click_point`(B1 几何)→ `click_at`(dispatchMouseEvent,零 DPR)聚焦 /// → `type_text("hello")`(insertText)→ 读回 `#field.value == "hello"`; /// - **按钮点击**:同样几何取点 → `click_at` → 读回 `#toggle.textContent == "Clicked"`(验证点击真 /// 分发到元素,几何点准); /// - **组合键**:再次点 input 聚焦 → `type_text("xyz")` → `key_combo("Ctrl+A")` 全选 → `type_text("Z")` /// 覆盖选区 → 读回 `#field.value == "Z"`(验证 Ctrl+A 真触发选区,否则会是 "xyzZ")。 /// /// 本机 Windows 真跑: /// set NOMIFUN_CHROME_BINARY=...\chrome.exe /// cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(input_synth)' /// 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn input_synth_click_focus_type_and_key_combo() { let backend = common::build_backend_for_fixture("act-input-synth").await; backend .navigate(&common::fixture_url("input-synth.html"), false) .await .expect("navigate input-synth.html"); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== input_synth entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let find = |role: &str, name: &str| -> String { obs.entries .iter() .find(|e| e.role == role && e.name == name) .unwrap_or_else(|| panic!("fixture should expose {role:?} {name:?}")) .r#ref .clone() }; // 几何取点:element_content_quads(真实 getContentQuads,CSS 像素)→ viewport_size → // pick_click_point(B1)→ Point。零 DPR:坐标原样从 quad 流到 dispatchMouseEvent。 async fn click_point_for( backend: &nomi_browser_engine::backend::CdpBackend, h: &nomi_browser_engine::actionability::ObjectHandle, ) -> Point { let quads = backend.element_content_quads(h).await.expect("getContentQuads"); eprintln!(" quads = {quads:?}"); let (vw, vh) = backend.viewport_size().await.expect("viewport_size"); eprintln!(" viewport = ({vw}, {vh})"); nomi_browser_engine::input::pick_click_point(&quads, vw, vh) .expect("pick_click_point should find an in-viewport center") } // 读 #field.value(页面 world)。 let read_field_value = || async { backend .__eval_page_world_for_test("document.getElementById('field').value") .await .expect("read field value") .get("value") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() }; // 读 #toggle.textContent(页面 world)。 let read_toggle_text = || async { backend .__eval_page_world_for_test("document.getElementById('toggle').textContent") .await .expect("read toggle text") .get("value") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() }; // ── input 聚焦 + 文本键入 ──────────────────────────────────────────────── let field_ref = find("textbox", "Name"); let field = backend.resolve_ref(&field_ref, 0).await.expect("resolve field"); let field_point = click_point_for(&backend, &field).await; eprintln!("field click point = {field_point:?}"); backend.click_at(field_point).await.expect("click field (focus)"); backend.type_text("hello").await.expect("insert_text hello"); let field_value = read_field_value().await; eprintln!("field value after type = {field_value:?}"); // ── 按钮点击:态变 ─────────────────────────────────────────────────────── let toggle_ref = find("button", "Not clicked"); let toggle = backend.resolve_ref(&toggle_ref, 1).await.expect("resolve toggle"); let toggle_point = click_point_for(&backend, &toggle).await; eprintln!("toggle click point = {toggle_point:?}"); let toggle_before = read_toggle_text().await; backend.click_at(toggle_point).await.expect("click toggle"); let toggle_after = read_toggle_text().await; eprintln!("toggle text: before={toggle_before:?} after={toggle_after:?}"); // ── 组合键 Ctrl+A 全选 → 覆盖 ──────────────────────────────────────────── // 重新 observe(按钮文案变了,旧代际 ref 仍可能有效,但稳妥起见重定位 field)。重新点 field 聚焦。 let field2 = backend.resolve_ref(&field_ref, 2).await.expect("resolve field again"); let field_point2 = click_point_for(&backend, &field2).await; backend.click_at(field_point2).await.expect("re-focus field"); // 现 field 已有 "hello";再追加 "xyz" → "helloxyz"。 backend.type_text("xyz").await.expect("insert xyz"); let before_combo = read_field_value().await; eprintln!("field before Ctrl+A = {before_combo:?}"); // 全选 → 输入 "Z" 覆盖整段 → value == "Z"。用 `ControlOrMeta+A`(跨平台加速键:mac=Meta、其它=Ctrl); // mac 经 macEditingCommands 补 `commands=["selectAll"]` 才真全选(裸 Meta/Ctrl+A 在 headless 不触发编辑 // 命令,且裸 Ctrl+A 在 mac headless 会挂起后续 CDP)。若全选没触发,会是 before_combo + "Z"。 backend.key_combo("ControlOrMeta+A").await.expect("key_combo ControlOrMeta+A"); backend.type_text("Z").await.expect("insert Z (replace selection)"); let after_combo = read_field_value().await; eprintln!("field after ControlOrMeta+A + 'Z' = {after_combo:?}"); // 释放各动作组(best-effort)。 backend.release_act_group_by_ref(&field_ref, 0).await; backend.release_act_group_by_ref(&toggle_ref, 1).await; backend.release_act_group_by_ref(&field_ref, 2).await; // ── 直接断言(便于失败时定位)──────────────────────────────────────────── assert_eq!(field_value, "hello", "insert_text should write 'hello' into focused input"); assert_eq!(toggle_before, "Not clicked", "toggle should start 'Not clicked'"); assert_eq!( toggle_after, "Clicked", "dispatch_click should真分发到按钮 (geometry point准), toggling text to 'Clicked'" ); assert_eq!( after_combo, "Z", "ControlOrMeta+A 应全选(mac 经 macEditingCommands selectAll),其后 'Z' 覆盖整段 (was {before_combo:?})" ); } /// **B6 detach 事件源 → progress.abort → act 立即返**(端到端,建一次 chrome)。 /// /// navigate fixture → 建一个**大 deadline**(3600s)的 [`Progress`] → `arm_act_abort` 派生子 /// Progress + 装临时 detach/crash 监听 → 在子 Progress 上并发跑一个**永挂** `run_act_with_retry` /// op(永不成功、永不出错;唯一能结束它的就是 progress.abort)。另一处用 /// `__close_page_target_for_test()` 关掉 page target(模拟「用户关标签页」),CDP 随之发 /// `Target.detachedFromTarget`(sessionId == 本 page session)→ 监听任务命中 → /// `progress.abort(PageClosed)` → 进行中的 `run_act_with_retry`(在 `progress.race` 上)**立即** /// 以 `BrowserError::TargetClosed` 返回。 /// /// **关键断言**:① act 返 `TargetClosed`(abort→errmap 链通);② 返回耗时**远早于** 3600s deadline /// (这里用 `tokio::time::timeout(10s)` 兜底——若没接线,op 会永挂到 10s 兜底超时而非秒级返回)。 /// /// 本机 Windows 真跑: /// set NOMIFUN_CHROME_BINARY=...\chrome.exe /// cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(detach_aborts)' /// 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn detach_aborts_in_flight_act_far_before_timeout() { use nomi_browser_engine::progress::Progress; use std::future::pending; use std::time::{Duration, Instant}; let backend = common::build_backend_for_fixture("act-detach-abort").await; backend .navigate(&common::fixture_url("iframe.html"), false) .await .expect("navigate iframe.html"); // 大 deadline:若 detach 接线没生效,op 永挂,只能靠下面 10s 兜底超时暴露(≠ 秒级返回)。 let parent = Progress::new(Duration::from_secs(3600)); // 装 detach/crash 监听 + 派生子 Progress(act 跑在 child 上;guard 持监听任务,drop 收摊)。 // D1:main_frame_id / arm_act_abort 现 async(经 active tab 解引用)。 let main_frame_id = backend.main_frame_id().await.expect("active tab main frame"); let (child, _guard) = backend .arm_act_abort(&parent, &main_frame_id) .await .expect("arm_act_abort on active tab"); let started = Instant::now(); // 并发:A) 在 child 上跑永挂 op 的重试编排;B) 稍候关掉 page target 触发 detach。 // 用 join 而非 spawn:两者都借 backend/child(无需 'static)。op 永挂 → 唯一出口是 abort。 let act_fut = async { nomi_browser_engine::actions::run_act_with_retry::<_, _, ()>( &child, false, // 非不可逆:但 op 永挂,重试逻辑无从触发,唯一出口是 progress.abort。 |_attempt| async { pending().await }, ) .await }; let trigger_fut = async { // 让 op 真正进入等待(observe 不需要;直接小睡即可),再关 target 触发 detach。 tokio::time::sleep(Duration::from_millis(200)).await; backend .__close_page_target_for_test() .await .expect("close page target (trigger detach)"); }; // 10s 兜底:接线生效则 act 秒级返回;没生效则 op 永挂到这里超时(断言会失败并指出未接线)。 let act_result = tokio::time::timeout(Duration::from_secs(10), async { let (act_out, _trigger) = tokio::join!(act_fut, trigger_fut); act_out }) .await; let elapsed = started.elapsed(); eprintln!("detach→abort act returned in {elapsed:?}: {act_result:?}"); let act_out = act_result.expect( "act_with_retry did NOT return within 10s after page detach — \ detach→progress.abort wiring is broken (op hung to fallback timeout)", ); // ① 分类:page target 关闭 → PageClosed → errmap → TargetClosed。 assert!( matches!(act_out, Err(BrowserError::TargetClosed)), "detach must surface TargetClosed via progress.abort(PageClosed), got {act_out:?}" ); // ② 远早于 3600s deadline:秒级(含 200ms 触发延迟 + 调度)。给 5s 上限足够宽松又能证明非走 deadline。 assert!( elapsed < Duration::from_secs(5), "abort must fire near-immediately (≪ 3600s deadline), took {elapsed:?}" ); } // ═══════════════════════════════════════════════════════════════════════════ // C1:click / type / set_value 三分支端到端(经公开 `act()` trait 方法,串 B2-B6)。 // 一个测试覆盖三动作 + 登录表单 + verify 读回(建一次 chrome 最省资源);stale ref 单独一个。 // 本机 Windows 真跑: // set NOMIFUN_CHROME_BINARY=...\chrome.exe // cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(c1_)' // ═══════════════════════════════════════════════════════════════════════════ /// **C1 click + type + set_value + 登录表单全链**(经 `act()`,verify 读回真值)。 /// /// fixture `act-c1.html`:`#name`(text input)+ `#notes`(textarea)+ `#toggle`(button,点击改文案 /// "Not clicked"→"Clicked")+ 登录表单(`#username`/`#password` input + `#submit` button,submit 时把 /// 用户名写进 `#login-status` 的 role=status 标记)。navigate → observe → 经 `act()` 执行: /// - **type**:`Type{name_ref, Literal("Ada Lovelace")}` → 读回 `#name.value == "Ada Lovelace"`; /// - **set_value**:`SetValue{notes_ref, "multi\nline"}` → 读回 `#notes.value` == 设的值; /// - **click**:`Click{toggle_ref}` → 读回 `#toggle.textContent == "Clicked"`(点击真分发); /// - **登录表单**:type 用户名 → type 密码(Secret)→ click submit → 读回 `#login-status` == "submitted:"。 /// /// 每步断言 `ActResult.success == true` + `Effect.changed`(type/set_value 的 before≠after value)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn c1_click_type_set_value_and_login_form() { use nomi_browser_engine::progress::Progress; use nomi_browser_engine::{ActSpec, TypeInput}; use std::time::Duration; let backend = common::build_backend_for_fixture("c1-actions").await; backend .navigate(&common::fixture_url("act-c1.html"), false) .await .expect("navigate act-c1.html"); // 每个动作给宽松 deadline(真 chrome 几何/注入耗时;远小于此)。 let new_progress = || Progress::new(Duration::from_secs(30)); // observe 拿 ref 表(act 反查的前置)。 let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== c1 entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let find = |role: &str, name: &str| -> String { obs.entries .iter() .find(|e| e.role == role && e.name == name) .unwrap_or_else(|| panic!("fixture should expose {role:?} {name:?}")) .r#ref .clone() }; // 页面 world 读 DOM 值的小工具。 let read = |expr: &'static str| { let backend = &backend; async move { backend .__eval_page_world_for_test(expr) .await .expect("eval read") .get("value") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() } }; // ── type:#name ─────────────────────────────────────────────────────────── let name_ref = find("textbox", "Name"); let p = new_progress(); let type_res = backend .act(&ActSpec::Type { r#ref: name_ref.clone(), text: TypeInput::Literal("Ada Lovelace".into()) }, &p) .await .expect("act type #name"); let name_value = read("document.getElementById('name').value").await; eprintln!("type result = {type_res:?}; #name.value = {name_value:?}"); assert!(type_res.success, "type must succeed"); assert_eq!(name_value, "Ada Lovelace", "type should write the literal into #name"); assert!(type_res.effect.changed, "type Effect.changed must be true (value changed)"); // ── set_value:#notes(textarea,含换行——set_value 是整体设值快路径)──────────── let notes_ref = find("textbox", "Notes"); let p = new_progress(); let set_res = backend .act(&ActSpec::SetValue { r#ref: notes_ref.clone(), value: "multi\nline notes".into(), secret: false }, &p) .await .expect("act set_value #notes"); let notes_value = read("document.getElementById('notes').value").await; eprintln!("set_value result = {set_res:?}; #notes.value = {notes_value:?}"); assert!(set_res.success, "set_value must succeed"); assert_eq!(notes_value, "multi\nline notes", "set_value should set the textarea value verbatim"); assert!(set_res.effect.changed, "set_value Effect.changed must be true"); // ── click:#toggle 态变 ──────────────────────────────────────────────────── let toggle_ref = find("button", "Not clicked"); let toggle_before = read("document.getElementById('toggle').textContent").await; let p = new_progress(); let click_res = backend .act(&ActSpec::Click { r#ref: toggle_ref.clone() }, &p) .await .expect("act click #toggle"); let toggle_after = read("document.getElementById('toggle').textContent").await; eprintln!("click result = {click_res:?}; toggle: {toggle_before:?} -> {toggle_after:?}"); assert!(click_res.success, "click must succeed"); assert_eq!(toggle_before, "Not clicked", "toggle starts 'Not clicked'"); assert_eq!(toggle_after, "Clicked", "click should真分发到按钮 toggling text to 'Clicked'"); // ── 登录表单(验收点):type user → type pass(Secret) → click submit → 标记态变 ── // 表单元素文案变了不影响(这些 ref 同一代际仍有效——没导航,没重拍)。 let user_ref = find("textbox", "Username"); let pass_ref = find("textbox", "Password"); let submit_ref = find("button", "Sign in"); let p = new_progress(); backend .act(&ActSpec::Type { r#ref: user_ref.clone(), text: TypeInput::Literal("ada".into()) }, &p) .await .expect("act type username"); let p = new_progress(); let pass_res = backend .act(&ActSpec::Type { r#ref: pass_ref.clone(), text: TypeInput::Secret("s3cr3t-pw".into()) }, &p) .await .expect("act type password (secret)"); // secret 安全契约:ActResult.message 不含明文,Effect 锚点不含值。 assert!(!pass_res.message.contains("s3cr3t-pw"), "secret value must not leak into message: {pass_res:?}"); assert!(pass_res.effect.before_anchor.is_none() && pass_res.effect.after_anchor.is_none(), "secret Effect anchors must be None (no value capture), got {:?}", pass_res.effect); let p = new_progress(); let submit_res = backend .act(&ActSpec::Click { r#ref: submit_ref.clone() }, &p) .await .expect("act click submit"); let login_status = read("document.getElementById('login-status').textContent").await; eprintln!("submit result = {submit_res:?}; #login-status = {login_status:?}"); assert!(submit_res.success, "submit click must succeed"); assert_eq!(login_status, "submitted:ada", "login form submit should fire with typed username"); // 实际读回汇总(贴进汇报)。 eprintln!( "=== C1 READBACK SUMMARY ===\n\ type #name.value = {name_value:?}\n\ set_value #notes.value= {notes_value:?}\n\ click #toggle text = {toggle_before:?} -> {toggle_after:?}\n\ login #login-status = {login_status:?}\n\ password secret leak? = message={:?} anchors={:?}", pass_res.message, pass_res.effect ); } /// **F2 verify-after-act 富锚点 + set_value secret 锚点抑制**(经 `act()`,本机 Windows 真跑)。 /// /// fixture `act-c1.html`:复用 `#password`(set_value secret 写入,验锚点抑制)+ 新增 `#agree` /// (checkbox,验 click 富锚点 checked 翻转 → changed=true,URL 不变也能判)+ `#noop`(无副作用按钮, /// 验失败/无变化 → changed=false 如实,非静默)。navigate → observe → 经 `act()`: /// - **set_value secret**(`SetValue{password_ref, "s3cr3t-pw", secret:true}`):**关键安全回归**—— /// `Effect.before_anchor`/`after_anchor` 必须为 `None`(不采 read-back 明文),`message` 不含明文; /// 读回 `#password.value` == 设的值(证 secret 真写入,只是不进锚点); /// - **checkbox click**(`Click{agree_ref}`):富锚点捕 `checked` 翻转 → `changed=true`,且 after_anchor /// 含 `checked:true`(不导航也判 changed——F2 富锚点主体); /// - **no-op click**(`Click{noop_ref}`):不改态不导航 → `changed=false` 如实(never assume executed==succeeded)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn f2_verify_anchors_and_set_value_secret_suppression() { use nomi_browser_engine::progress::Progress; use nomi_browser_engine::ActSpec; use std::time::Duration; let backend = common::build_backend_for_fixture("f2-verify").await; backend .navigate(&common::fixture_url("act-c1.html"), false) .await .expect("navigate act-c1.html"); let new_progress = || Progress::new(Duration::from_secs(30)); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== f2 entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let find = |role: &str, name: &str| -> String { obs.entries .iter() .find(|e| e.role == role && e.name == name) .unwrap_or_else(|| panic!("fixture should expose {role:?} {name:?}")) .r#ref .clone() }; let read = |expr: &'static str| { let backend = &backend; async move { backend .__eval_page_world_for_test(expr) .await .expect("eval read") .get("value") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() } }; // ── set_value secret(安全红线):anchor=None + message 不含明文 + 真写入 ───────────── let pass_ref = find("textbox", "Password"); let p = new_progress(); let secret_res = backend .act( &ActSpec::SetValue { r#ref: pass_ref.clone(), value: "s3cr3t-setvalue-pw".into(), secret: true, }, &p, ) .await .expect("act set_value (secret)"); let pass_value = read("document.getElementById('password').value").await; eprintln!("set_value secret = {secret_res:?}; #password.value = {pass_value:?}"); assert!(secret_res.success, "set_value secret must succeed"); // **关键安全回归**:锚点必须为 None(不采 read-back 明文)。 assert!( secret_res.effect.before_anchor.is_none() && secret_res.effect.after_anchor.is_none(), "set_value secret Effect anchors MUST be None (no value capture), got {:?}", secret_res.effect ); // message 不含明文。 assert!( !secret_res.message.contains("s3cr3t-setvalue-pw"), "set_value secret value must not leak into message: {}", secret_res.message ); // 真写入(证只是不进锚点,值确实 fill 进了控件)。 assert_eq!(pass_value, "s3cr3t-setvalue-pw", "set_value secret should fill the password verbatim"); // ── checkbox click 富锚点:checked 翻转 → changed=true + after_anchor 含 checked:true ── let agree_ref = find("checkbox", "I agree"); let p = new_progress(); let cb_res = backend .act(&ActSpec::Click { r#ref: agree_ref.clone() }, &p) .await .expect("act click checkbox"); let agree_checked = read("String(document.getElementById('agree').checked)").await; eprintln!("checkbox click = {cb_res:?}; #agree.checked = {agree_checked:?}"); assert!(cb_res.success, "checkbox click must succeed"); assert_eq!(agree_checked, "true", "checkbox should be checked after click"); // 富锚点:URL 没变,但 checked false→true 让 changed=true(F2 富锚点主体)。 assert!( cb_res.effect.changed, "checkbox click Effect.changed must be true via checked anchor (no nav), got {:?}", cb_res.effect ); let after_checked = cb_res .effect .after_anchor .as_ref() .and_then(|v| v.get("checked")) .and_then(|v| v.as_bool()); assert_eq!( after_checked, Some(true), "after_anchor must carry checked:true; effect = {:?}", cb_res.effect ); // ── no-op click:不改态不导航 → changed=false 如实(never assume executed==succeeded)── let noop_ref = find("button", "No-op"); let p = new_progress(); let noop_res = backend .act(&ActSpec::Click { r#ref: noop_ref.clone() }, &p) .await .expect("act click no-op"); eprintln!("no-op click = {noop_res:?}"); assert!(noop_res.success, "no-op click still dispatches (success=true)"); assert!( !noop_res.effect.changed, "no-op click must report changed=false truthfully, got {:?}", noop_res.effect ); eprintln!( "=== F2 READBACK SUMMARY ===\n\ set_value secret: anchors={:?} (MUST be None,None) message-leak?={}\n\ #password.value (written, not in anchor) = {pass_value:?}\n\ checkbox click changed={} after_checked={:?}\n\ no-op click changed={}", (secret_res.effect.before_anchor.as_ref(), secret_res.effect.after_anchor.as_ref()), secret_res.message.contains("s3cr3t-setvalue-pw"), cb_res.effect.changed, after_checked, noop_res.effect.changed, ); } /// **C1 stale ref → NodeStale(文案引导 re-observe)**:用一个不存在/过期的 ref 调 `act()`,应在层① /// (纯 Rust,不进浏览器)即返 [`BrowserError::NodeStale`],且 Display 文案含「stale」(引导模型重拍)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn c1_stale_ref_returns_node_stale() { use nomi_browser_engine::progress::Progress; use nomi_browser_engine::ActSpec; use std::time::Duration; let backend = common::build_backend_for_fixture("c1-stale").await; backend .navigate(&common::fixture_url("act-c1.html"), false) .await .expect("navigate"); // 即便没 observe(表为空)/ 用伪 ref,都应是 NodeStale(层①)。 let p = Progress::new(Duration::from_secs(30)); let res = backend .act(&ActSpec::Click { r#ref: "f9e99999".into() }, &p) .await; eprintln!("stale act = {res:?}"); match res { Err(BrowserError::NodeStale { .. }) => { // 文案应引导 re-observe(Display 含 "stale")。 let msg = format!("{}", BrowserError::NodeStale { generation: 0 }); assert!(msg.contains("stale"), "NodeStale Display should guide re-observe, got {msg:?}"); } other => panic!("stale ref must surface NodeStale (layer①, no browser), got {other:?}"), } } /// **C1 不可编辑元素 type → Blocked(禁重试)**:对一个非可编辑元素(heading)跑 `Type`, /// check_states(editable) 的不可编辑特例 → check_states 返 Err(Blocked) → C1 用 /// `classify_editable_check_err` 判 Fatal(禁重试,**非** classify_browser_err 的 Retryable)→ /// `act()` 返 [`BrowserError::Blocked`](B3 语义:元素类型根本不支持编辑,NonRecoverable)。 /// 复用 `actionability.html`(含 heading "Actionability")。**耗时断言**佐证「Fatal 立返」——绝不 /// 走完 6 槽退避(770ms sleep)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn c1_type_non_editable_is_blocked() { use nomi_browser_engine::progress::Progress; use nomi_browser_engine::{ActSpec, TypeInput}; use std::time::{Duration, Instant}; let backend = common::build_backend_for_fixture("c1-non-editable").await; backend .navigate(&common::fixture_url("actionability.html"), false) .await .expect("navigate actionability.html"); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); let heading_ref = obs .entries .iter() .find(|e| e.role == "heading" && e.name == "Actionability") .expect("fixture should expose heading") .r#ref .clone(); let p = Progress::new(Duration::from_secs(30)); let started = Instant::now(); let res = backend .act(&ActSpec::Type { r#ref: heading_ref, text: TypeInput::Literal("nope".into()) }, &p) .await; let elapsed = started.elapsed(); eprintln!("type-non-editable act = {res:?} (elapsed {elapsed:?})"); assert!( matches!(res, Err(BrowserError::Blocked { .. })), "typing into a non-editable element must be Blocked (NonRecoverable, no retry), got {res:?}" ); // **禁重试佐证**:不可编辑 → Fatal 立返,绝不走完 6 槽退避(770ms sleep)。即使含一次 resolve + // check_states 的真 chrome 往返,单次尝试也远小于「退避全跑完」的下限。给 600ms 宽松上限: // 远低于 770ms 退避 sleep(更别说 + 6 次往返),又足够宽容真 chrome 单次往返抖动。 assert!( elapsed < Duration::from_millis(600), "non-editable Type must fail fast (Fatal, no backoff); took {elapsed:?} \ (≥770ms would mean it wrongly ran the full retry backoff)" ); } // ═══════════════════════════════════════════════════════════════════════════ // C2:hover / select_option / press_key / scroll / scroll_to_text 端到端(经公开 `act()`)。 // 一个测试覆盖全 5 动作 + verify 读回(建一次 chrome 最省资源)。 // 本机 Windows 真跑: // set NOMIFUN_CHROME_BINARY=...\chrome.exe // cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(c2_)' // ═══════════════════════════════════════════════════════════════════════════ /// **C2 hover + select_option + press_key + scroll + scroll_to_text 全链**(经 `act()`,verify 读回真态)。 /// /// fixture `c2.html`:`#hovertarget`(onmouseover 打 `data-hovered=yes` + 文案变 "Hovered")+ `#picker` /// (3-option select)+ `#q`(form 内输入框,Enter→submit 打 `#search-status`)+ 3000px spacer + 底部 /// `#bottommarker`("Unique footer sentinel text")/`#bottomtarget`。navigate → observe → 经 `act()`: /// - **hover**:`Hover{hovertarget_ref}` → 读回 `#hovertarget[data-hovered] == "yes"`(hover 真投递); /// - **select_option**:`SelectOption{picker_ref, ["Second"]}` → 读回 `#picker.value == "opt2"`(按 label 命中); /// - **scroll**:`Scroll{Viewport, Down, 1500}` → scrollY 从 0 变大(视口真滚);再 scroll 到底再 scroll → /// 良性不报错(success=true, changed=false); /// - **scroll_to_text**:`ScrollToText{"Unique footer sentinel text"}` → success=true + 底部元素进视口; /// 未命中文本(乱串)→ success=false 如实(非报错); /// - **press_key**:先点 `#q` 聚焦 + type → `PressKey{"Enter"}`(form 内)→ 读回 `#search-status == "searched:"`。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn c2_hover_select_press_key_scroll_scroll_to_text() { use nomi_browser_engine::progress::Progress; use nomi_browser_engine::{ActSpec, ScrollDir, ScrollTarget, TypeInput}; use std::time::Duration; let backend = common::build_backend_for_fixture("c2-actions").await; backend .navigate(&common::fixture_url("c2.html"), false) .await .expect("navigate c2.html"); let new_progress = || Progress::new(Duration::from_secs(30)); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== c2 entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let find = |role: &str, name: &str| -> String { obs.entries .iter() .find(|e| e.role == role && e.name == name) .unwrap_or_else(|| panic!("fixture should expose {role:?} {name:?}")) .r#ref .clone() }; // 页面 world 读 DOM 的小工具。 let read = |expr: &'static str| { let backend = &backend; async move { backend .__eval_page_world_for_test(expr) .await .expect("eval read") .get("value") .map(|v| { v.as_str() .map(|s| s.to_string()) .unwrap_or_else(|| v.to_string()) }) .unwrap_or_default() } }; // ── hover:onmouseover 打标记 ─────────────────────────────────────────────── // hovertarget 是 button(aria 给它稳定 name "Hover me")。 let hover_ref = find("button", "Hover me"); let p = new_progress(); let hover_res = backend .act(&ActSpec::Hover { r#ref: hover_ref.clone() }, &p) .await .expect("act hover"); let hovered = read("document.getElementById('hovertarget').getAttribute('data-hovered')").await; eprintln!("hover result = {hover_res:?}; data-hovered = {hovered:?}"); assert!(hover_res.success, "hover must succeed"); assert_eq!(hovered, "yes", "hover should真投递 mouseover, setting data-hovered=yes"); // ── select_option:按 label "Second" 选 → value opt2 ───────────────────────── let picker_ref = find("combobox", "Pick"); let p = new_progress(); let select_res = backend .act( &ActSpec::SelectOption { r#ref: picker_ref.clone(), options: vec!["Second".into()], }, &p, ) .await .expect("act select_option"); let picker_value = read("document.getElementById('picker').value").await; eprintln!("select result = {select_res:?}; #picker.value = {picker_value:?}"); assert!(select_res.success, "select_option must succeed"); assert_eq!(picker_value, "opt2", "select 'Second' (label) should set value to opt2"); // ActResult.effect 自身必须反映真实 select.value 变化(守 C2 bug:旧 verify 对 `(含 selected/disabled option)。navigate → observe → 经 `act()`: /// - **get_page_text**:返页面文本,且 **不含明文 secret**(脱敏验证)+ `` 包裹(防注入); /// - **search_page**:grep "unique-sentinel-marker" 命中返片段;grep 乱串未命中 success=false(非报错); /// - **find_elements**:`button.primary` 命中 2 个 → 返 2 个 ref → **用首个 ref `act(Click)` 点中** /// (`#p1` 文案变 + `#click-status==p1-clicked`,证 ref 真可反解端到端); /// - **get_dropdown_options**:`#picker` 枚举 3 个 option(含 selected=Second/disabled=Third); /// - **cursor**:返 pointer-cursor 元素计数 > 0; /// - **wait**:`Wait{50}` 成功;`Wait{超大}` 被钳制(文案含 cap)。 #[tokio::test] #[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"] async fn c3_page_text_search_find_dropdown_cursor_wait() { use nomi_browser_engine::progress::Progress; use nomi_browser_engine::ActSpec; use std::time::Duration; let backend = common::build_backend_for_fixture("c3-readonly").await; backend .navigate(&common::fixture_url("c3.html"), false) .await .expect("navigate c3.html"); let new_progress = || Progress::new(Duration::from_secs(30)); let obs = backend.observe(&ObserveOpts::default()).await.expect("observe"); eprintln!("=== c3 entries ==="); for e in &obs.entries { eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name); } let read = |expr: &'static str| { let backend = &backend; async move { backend .__eval_page_world_for_test(expr) .await .expect("eval read") .get("value") .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_else(|| v.to_string())) .unwrap_or_default() } }; // ── get_page_text:脱敏 + 包裹 ────────────────────────────────────── let p = new_progress(); let text_res = backend .act(&ActSpec::GetPageText, &p) .await .expect("act get_page_text"); eprintln!("get_page_text message (first 400 chars):\n{}", &text_res.message.chars().take(400).collect::()); assert!(text_res.success, "get_page_text must succeed"); // **脱敏验证**:明文 secret 绝不出现(sk- 串 / Bearer token)。 assert!( !text_res.message.contains("sk-ABCDEFGHIJ0123456789xyzQRSTUV"), "get_page_text leaked the plaintext sk- secret:\n{}", text_res.message ); assert!( !text_res.message.contains("abcdef0123456789ABCDEFghij"), "get_page_text leaked the Bearer token:\n{}", text_res.message ); // 包裹(不可信内容防注入)。 assert!(text_res.message.contains("-wrapped: {}", text_res.message); // 正文 sentinel(非 secret 内容)应保留。 assert!( text_res.message.contains("unique-sentinel-marker"), "non-secret page text should survive redaction: {}", text_res.message ); // ── search_page:命中 + 未命中 ───────────────────────────────────────────── let p = new_progress(); let search_hit = backend .act(&ActSpec::SearchPage { query: "unique-sentinel-marker".into() }, &p) .await .expect("act search_page hit"); eprintln!("search hit = {search_hit:?}"); assert!(search_hit.success, "search_page must succeed on a hit"); assert!(search_hit.message.contains("unique-sentinel-marker"), "search hit must include the matched line"); // 命中片段同样脱敏(即便 secret 行被命中也不泄漏)——验证 grep "Bearer" 不回明文。 let p = new_progress(); let search_secret = backend .act(&ActSpec::SearchPage { query: "Authorization".into() }, &p) .await .expect("act search_page on a line that contains a secret"); eprintln!("search-on-secret-line = {search_secret:?}"); assert!( !search_secret.message.contains("abcdef0123456789ABCDEFghij"), "search_page must redact secrets even when the matched line contains one:\n{}", search_secret.message ); let p = new_progress(); let search_miss = backend .act(&ActSpec::SearchPage { query: "zzz-nonexistent-zzz".into() }, &p) .await .expect("act search_page miss must not error"); eprintln!("search miss = {search_miss:?}"); assert!(!search_miss.success, "search_page miss must be success=false (benign, not error)"); // ── find_elements:button.primary → 2 ref → 用首个 ref click 点中(反解验证)────── let p = new_progress(); let find_res = backend .act(&ActSpec::FindElements { selector: "button.primary".into() }, &p) .await .expect("act find_elements"); eprintln!("find_elements = {find_res:?}"); assert!(find_res.success, "find_elements must succeed"); // 提取返回的 ref(message 含 "[ref=f...]")。 let found_refs: Vec = find_res .message .split("[ref=") .skip(1) .filter_map(|s| s.split(']').next().map(|r| r.to_string())) .collect(); eprintln!("found refs = {found_refs:?}"); assert_eq!(found_refs.len(), 2, "button.primary should match exactly 2 elements, got {found_refs:?}"); // **关键:用 find_elements 返回的 ref act(Click) 能点中**(证 ref 真可被 resolve_ref 反解,端到端)。 // #p1 是文档里第一个 .primary(Primary One),点击改文案 + 打 #click-status。 let first_ref = found_refs[0].clone(); let p = new_progress(); let click_res = backend .act(&ActSpec::Click { r#ref: first_ref.clone() }, &p) .await .expect("act click on a find_elements ref"); eprintln!("click-via-found-ref = {click_res:?}"); assert!(click_res.success, "clicking a find_elements ref must succeed (ref reverse-resolves)"); let click_status = read("document.getElementById('click-status').textContent").await; eprintln!("#click-status after clicking found ref = {click_status:?}"); assert_eq!( click_status, "p1-clicked", "clicking the first find_elements ref must hit #p1 (proves ref is resolvable & actionable)" ); // ── get_dropdown_options:枚举 3 个 option(含 selected/disabled)────────────── let picker_ref = obs .entries .iter() .find(|e| e.role == "combobox" && e.name == "Pick") .expect("fixture should expose the picker combobox") .r#ref .clone(); let p = new_progress(); let dd_res = backend .act(&ActSpec::GetDropdownOptions { r#ref: picker_ref.clone() }, &p) .await .expect("act get_dropdown_options"); eprintln!("get_dropdown_options = {dd_res:?}"); assert!(dd_res.success, "get_dropdown_options must succeed on a → success=false(良性)。 let primary_ref = obs .entries .iter() .find(|e| e.role == "button" && e.name == "Secondary") .map(|e| e.r#ref.clone()); if let Some(btn_ref) = primary_ref { let p = new_progress(); let dd_bad = backend .act(&ActSpec::GetDropdownOptions { r#ref: btn_ref }, &p) .await .expect("get_dropdown_options on non-select must not error"); eprintln!("get_dropdown_options on button = {dd_bad:?}"); assert!(!dd_bad.success, "get_dropdown_options on a non-