You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/27 00:13:17 UTC

(maka) branch feat/runtime-host-rust updated: fix(computer-use): retain native bindings across transient AX failures

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
     new fce8d3490e fix(computer-use): retain native bindings across transient AX failures
fce8d3490e is described below

commit fce8d3490e634500477c1f147155c8d7874eb6b4
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Sep 27 08:13:06 2026 +0800

    fix(computer-use): retain native bindings across transient AX failures
    
    Invalidate observations when accessibility reads fail while preserving the retained target identity for a later refresh. Continue rejecting closed windows, stale indices and stale screenshot coordinates. Distinguish missing text from ambiguous selection matches.
    
    Exercise app busy/resume, external edits and window closure through a background AppKit fixture without keyboard input. Document the foreground requirements of the separate rich paste acceptance test.
    
    Generated-by: Codex
---
 crates/computer-use/README.md                      |   6 +
 crates/computer-use/README.zh-CN.md                |   3 +
 crates/computer-use/src/api.md                     |   4 +
 crates/computer-use/src/browser/select-text.js     |   7 +-
 crates/computer-use/src/macos.rs                   |  26 ++-
 crates/computer-use/src/session.rs                 |  72 +++---
 .../computer-use/tests/fixtures/native-form.swift  |  37 ++-
 crates/computer-use/tests/repl_browser.rs          | 259 +++++++++++++++++----
 8 files changed, 329 insertions(+), 85 deletions(-)

diff --git a/crates/computer-use/README.md b/crates/computer-use/README.md
index 7bffbc614a..f20c981e55 100644
--- a/crates/computer-use/README.md
+++ b/crates/computer-use/README.md
@@ -124,5 +124,11 @@ in `tests/repl_browser.rs` operate disposable fixtures: a temporary Chrome profi
 and a compiled AppKit form, including Unicode editing, rich paste, screenshots,
 navigation and stale targets. Set `MAKA_CUA_TEST_CHROME` to override the Chrome binary.
 They are separate from ordinary CI tests and do not prove untested desktop behavior.
+The form editing test temporarily takes keyboard focus; run it while the user is not
+typing. The recovery test stays in the background and uses no keyboard input:
+
+```sh
+cargo nextest run -p maka-computer-use --test repl_browser -E 'test(native_observation_recovers)' --run-ignored all
+```
 
 See [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for source and licensing.
diff --git a/crates/computer-use/README.zh-CN.md b/crates/computer-use/README.zh-CN.md
index ad25c73c32..dfada86cea 100644
--- a/crates/computer-use/README.zh-CN.md
+++ b/crates/computer-use/README.zh-CN.md
@@ -52,4 +52,7 @@ extension/IAB 提供端、visibility/sessionName、交付 UI 标记或 CDP 辅
 
 测试包括独立 REPL/权限/取消/资源边界,以及需主动运行的真实 Chrome、AppKit 表单
 验收。验证成功的范围与完整 cua_repl 等价、性能更优是不同结论,后两者尚未宣称。
+表单编辑验收会短暂切换键盘焦点,需要在用户没有打字时运行;
+`native_observation_recovers` 验收始终在后台运行,不发送键盘输入,并检查应用忙后恢复、
+旧观察失效、外部编辑保留和窗口关闭后的拒绝。
 构建、配置、测试边界与许可说明见 [English README](README.md)。
diff --git a/crates/computer-use/src/api.md b/crates/computer-use/src/api.md
index f44bfca804..757152a31b 100644
--- a/crates/computer-use/src/api.md
+++ b/crates/computer-use/src/api.md
@@ -20,6 +20,8 @@ App and Tab observations:
 - `getAXStateAndScreenshot(options?)` returns `{state, screenshot}` and emits both.
 Observations emit by default; use `{emit:false}` when processing the result yourself.
 Screenshot-only observation clears accessibility indices.
+If a native AX observation fails while an app is busy, refresh the same bound object
+when the app responds again. Failed observations invalidate old indices and coordinates.
 
 App and Tab actions:
 - `click(index | [x,y], {mouseButton?, clickCount?})`, `drag([x,y], [x,y])`.
@@ -27,6 +29,8 @@ App and Tab actions:
 - `setValue(index, string)`.
 - `selectText(index, text, {prefix?, suffix?, selectionType?})` where selectionType is
   `text`, `cursor_before` or `cursor_after`. Native selection is macOS-only.
+  `text_not_found` means the text or supplied context is absent; read the current
+  state again. `ambiguous_text` requires a more specific prefix/suffix.
 - `performSecondaryAction(index, action)`: use an exact action listed in the native
   AX observation. CDP does not expose secondary actions and rejects this method.
 
diff --git a/crates/computer-use/src/browser/select-text.js b/crates/computer-use/src/browser/select-text.js
index c1fdee0e98..d7e539d4be 100644
--- a/crates/computer-use/src/browser/select-text.js
+++ b/crates/computer-use/src/browser/select-text.js
@@ -34,8 +34,11 @@ function selectText(text, prefix, suffix, selectionType) {
       matches.push(at);
     from = at + 1;
   }
-  if (matches.length !== 1)
-    throw Error('text match is missing or ambiguous; provide prefix/suffix');
+  if (matches.length === 0)
+    throw Error(
+      'text_not_found: no match for text and prefix/suffix; read getAXState again because the content may have changed',
+    );
+  if (matches.length > 1) throw Error('ambiguous_text: multiple matches; provide prefix/suffix');
   let start = matches[0],
     end = start + text.length;
   if (selectionType === 'cursor_before') end = start;
diff --git a/crates/computer-use/src/macos.rs b/crates/computer-use/src/macos.rs
index aa9074731b..802e087a71 100644
--- a/crates/computer-use/src/macos.rs
+++ b/crates/computer-use/src/macos.rs
@@ -169,7 +169,9 @@ impl Target {
                 }
                 CFRelease(window.cast());
             }
-            let window = selected.ok_or_else(|| failed("AX window unavailable"))?;
+            let window = selected.ok_or_else(|| {
+                failed("AX window unavailable; the application may be busy or the window closed. Read getAXState again when it is responsive")
+            })?;
             Ok(Self {
                 pid,
                 window_id,
@@ -474,9 +476,14 @@ fn selection(
         })
         .map(|(at, _)| at)
         .collect();
-    if matches.len() != 1 {
+    if matches.is_empty() {
         return Err(failed(
-            "text match is missing or ambiguous; provide prefix/suffix",
+            "text_not_found: no match for text and prefix/suffix; read getAXState again because the content may have changed",
+        ));
+    }
+    if matches.len() > 1 {
+        return Err(failed(
+            "ambiguous_text: multiple matches; provide prefix/suffix",
         ));
     }
     let start = value[..matches[0]].encode_utf16().count();
@@ -512,7 +519,18 @@ mod tests {
             selection("😀 hello hello!", "hello", &options).unwrap(),
             (9, 5)
         );
-        assert!(selection("aaa", "aa", &SelectOptions::default()).is_err());
+        assert!(
+            selection("aaa", "aa", &SelectOptions::default())
+                .unwrap_err()
+                .to_string()
+                .contains("ambiguous_text")
+        );
+        assert!(
+            selection("edited", "hello", &SelectOptions::default())
+                .unwrap_err()
+                .to_string()
+                .contains("text_not_found")
+        );
         assert!(selection("abc", "", &SelectOptions::default()).is_err());
     }
 }
diff --git a/crates/computer-use/src/session.rs b/crates/computer-use/src/session.rs
index 56e6f05b66..ee55af8aee 100644
--- a/crates/computer-use/src/session.rs
+++ b/crates/computer-use/src/session.rs
@@ -228,14 +228,21 @@ impl Session {
                 kind,
                 options,
             } => {
-                self.observe(
-                    &handle,
-                    kind,
-                    !options.disable_diffing,
-                    session,
-                    cancellation,
-                )
-                .await
+                let result = self
+                    .observe(
+                        &handle,
+                        kind,
+                        !options.disable_diffing,
+                        session,
+                        cancellation,
+                    )
+                    .await;
+                if result.is_err()
+                    && let Some(target) = self.targets.get_mut(&handle)
+                {
+                    target.invalidate_observation();
+                }
+                result
             }
             Command::Action {
                 handle: Handle::App(handle),
@@ -300,7 +307,17 @@ impl Session {
         }
         let id = target.window.window_id;
         #[cfg(target_os = "macos")]
-        let geometry = if let Some(macos) = target.macos.clone() {
+        let macos = target.macos.clone();
+        let windows = self.windows(session, cancellation).await?;
+        let Some(window) = windows
+            .into_iter()
+            .find(|w| w.pid == Some(pid) && w.window_id == id)
+        else {
+            self.targets.remove(handle);
+            return Err(failed("stale_target: window closed; bind a current window"));
+        };
+        #[cfg(target_os = "macos")]
+        let window = if let Some(macos) = macos {
             let verified = tokio::task::spawn_blocking(move || {
                 let target = macos.lock().unwrap();
                 target.verify()?;
@@ -309,30 +326,22 @@ impl Session {
             .await
             .map_err(failed)?;
             match verified {
-                Ok(bounds) => Some(bounds),
+                Ok(bounds) => WindowInfo { bounds, ..window },
                 Err(error) => {
-                    self.targets.remove(handle);
+                    // AX timeouts do not prove that the window was replaced.
+                    // Keep its retained identity, but require a fresh observation
+                    // before any indexed or coordinate input can resume.
+                    self.targets
+                        .get_mut(handle)
+                        .unwrap()
+                        .invalidate_observation();
                     return Err(error);
                 }
             }
         } else {
-            None
+            window
         };
-        let windows = self.windows(session, cancellation).await?;
-        if let Some(window) = windows
-            .into_iter()
-            .find(|w| w.pid == Some(pid) && w.window_id == id)
-        {
-            #[cfg(target_os = "macos")]
-            let window = if let Some(bounds) = geometry {
-                WindowInfo { bounds, ..window }
-            } else {
-                window
-            };
-            return Ok(window);
-        }
-        self.targets.remove(handle);
-        Err(failed("stale_target: window closed; bind a current window"))
+        Ok(window)
     }
     async fn observe(
         &mut self,
@@ -642,6 +651,15 @@ impl Session {
     }
 }
 impl Target {
+    fn invalidate_observation(&mut self) {
+        self.snapshot = None;
+        self.capture = None;
+        self.previous = None;
+        #[cfg(target_os = "macos")]
+        if let Some(macos) = &self.macos {
+            macos.lock().unwrap().clear();
+        }
+    }
     fn element(&self, index: u64) -> Result<&cua_driver_contract::WindowElement, ToolError> {
         self.snapshot
             .as_ref()
diff --git a/crates/computer-use/tests/fixtures/native-form.swift b/crates/computer-use/tests/fixtures/native-form.swift
index 9a51ee0183..1d83d9ac37 100644
--- a/crates/computer-use/tests/fixtures/native-form.swift
+++ b/crates/computer-use/tests/fixtures/native-form.swift
@@ -24,6 +24,7 @@ final class Fixture: NSObject, NSApplicationDelegate {
     let name = NSTextField(string: "hello hello")
     let message = NSTextView()
     let output = NSTextField(labelWithString: "Not submitted")
+    let resume = DispatchSemaphore(value: 0)
 
     func applicationDidFinishLaunching(_ notification: Notification) {
         let menu = NSMenu()
@@ -39,6 +40,7 @@ final class Fixture: NSObject, NSApplicationDelegate {
 
         window = NSWindow(contentRect: NSRect(x: 220, y: 220, width: 420, height: 250), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
         window.title = "Maka Cua Form Fixture"
+        window.isReleasedWhenClosed = false
         name.frame = NSRect(x: 24, y: 190, width: 360, height: 28)
         name.setAccessibilityLabel("Name")
         message.frame = NSRect(x: 24, y: 140, width: 360, height: 28)
@@ -47,11 +49,40 @@ final class Fixture: NSObject, NSApplicationDelegate {
         button.frame = NSRect(x: 24, y: 85, width: 160, height: 32)
         output.frame = NSRect(x: 24, y: 25, width: 370, height: 40)
         for view in [name, message, button, output] { window.contentView!.addSubview(view) }
-        window.makeKeyAndOrderFront(nil)
-        NSApp.activate(ignoringOtherApps: true)
+        if ProcessInfo.processInfo.arguments.contains("--background") {
+            window.orderBack(nil)
+        } else {
+            window.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+        }
         FileHandle.standardOutput.write(Data("ready \(window.windowNumber)\n".utf8))
         DispatchQueue.global().async { [self] in
             while let command = readLine() {
+                if command == "activity" {
+                    DispatchQueue.main.async {
+                        FileHandle.standardOutput.write(Data((NSApp.isActive ? "active\n" : "inactive\n").utf8))
+                    }
+                }
+                if command == "pause" {
+                    DispatchQueue.main.async { [self] in
+                        FileHandle.standardOutput.write(Data("paused\n".utf8))
+                        resume.wait()
+                        FileHandle.standardOutput.write(Data("resumed\n".utf8))
+                    }
+                }
+                if command == "resume" { resume.signal() }
+                if command == "edit" {
+                    DispatchQueue.main.async { [self] in
+                        name.stringValue = "externally edited"
+                        FileHandle.standardOutput.write(Data("edited\n".utf8))
+                    }
+                }
+                if command == "close" {
+                    DispatchQueue.main.async { [self] in
+                        window.close()
+                        FileHandle.standardOutput.write(Data("closed\n".utf8))
+                    }
+                }
                 if command == "move" {
                     DispatchQueue.main.async { [self] in
                         let before = window.frame.origin
@@ -73,7 +104,7 @@ final class Fixture: NSObject, NSApplicationDelegate {
     }
 }
 let app = NSApplication.shared
-app.setActivationPolicy(.regular)
+app.setActivationPolicy(ProcessInfo.processInfo.arguments.contains("--background") ? .accessory : .regular)
 let fixture = Fixture()
 app.delegate = fixture
 app.run()
diff --git a/crates/computer-use/tests/repl_browser.rs b/crates/computer-use/tests/repl_browser.rs
index a90dc72abd..74cbfef8e8 100644
--- a/crates/computer-use/tests/repl_browser.rs
+++ b/crates/computer-use/tests/repl_browser.rs
@@ -94,40 +94,8 @@ fn index(state: &str, role: &str, name: &str) -> u64 {
 #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
 #[ignore = "requires macOS Accessibility; compiles and operates a disposable native form"]
 async fn native_form_retains_exact_ax_elements_and_verifies_submission() {
-    assert!(
-        unsafe { platform_macos::ax::bindings::AXIsProcessTrusted() },
-        "native acceptance requires macOS Accessibility permission for the test host"
-    );
-    let directory = tempfile::tempdir().unwrap();
-    let contents = directory.path().join("Maka Cua Fixture.app/Contents");
-    std::fs::create_dir_all(contents.join("MacOS")).unwrap();
-    std::fs::write(contents.join("Info.plist"),r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>org.apache.maka.cua.fixture.FIXTURE_ID</string><key>CFBundleName</key><string>Maka Cua Fixture</string><key>CFBundleExecutable</key><string>fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>"#.replace("FIXTURE_ID", &uuid::Uuid::new_v4().to_string())).unwrap();
-    let executable = contents.join("MacOS/fixture");
-    let compiled = tokio::process::Command::new("/usr/bin/swiftc")
-        .arg(concat!(
-            env!("CARGO_MANIFEST_DIR"),
-            "/tests/fixtures/native-form.swift"
-        ))
-        .arg("-o")
-        .arg(&executable)
-        .output()
-        .await
-        .unwrap();
-    assert!(
-        compiled.status.success(),
-        "{}",
-        String::from_utf8_lossy(&compiled.stderr)
-    );
-    let mut app = tokio::process::Command::new(&executable)
-        .stdin(Stdio::piped())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::null())
-        .kill_on_drop(true)
-        .spawn()
-        .unwrap();
-    let mut lines = BufReader::new(app.stdout.take().unwrap()).lines();
-    let ready = lines.next_line().await.unwrap().unwrap();
-    let window_id: u64 = ready.strip_prefix("ready ").unwrap().parse().unwrap();
+    let mut form = NativeForm::start(false).await;
+    let window_id = form.window_id;
     let driver = Arc::new(Mutex::new(Driver::default()));
     let mut native = Session::new(driver.clone());
     let windows = native
@@ -167,11 +135,7 @@ async fn native_form_retains_exact_ax_elements_and_verifies_submission() {
     let button = index(&state, "AXButton", "Submit fixture");
     let (ok,output)=evaluate(&repl,bridge.clone(),format!("await app.click({name}); await app.selectText({name}, 'hello', {{prefix:'hello '}}); await app.paste('world'); await app.setValue({message}, 'placeholder'); await app.click({message}); await app.selectText({message}, 'placeholder'); await app.paste('<b>你好</b>', {{format:'html'}}); await app.click({button}); await app.getAXState({{disableDiffing:true}});")).await;
     assert!(ok, "{}", texts(&output));
-    let submitted = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
-        .await
-        .unwrap()
-        .unwrap()
-        .unwrap();
+    let submitted = form.line().await;
     let submitted: Value = serde_json::from_str(&submitted).unwrap();
     assert!(
         submitted["submitted"] == "hello world / 你好",
@@ -201,20 +165,13 @@ async fn native_form_retains_exact_ax_elements_and_verifies_submission() {
             .iter()
             .any(|part| matches!(part, CellOutput::Media { .. }))
     );
-    app.stdin
-        .as_mut()
-        .unwrap()
-        .write_all(b"move\n")
-        .await
-        .unwrap();
-    assert_eq!(lines.next_line().await.unwrap().as_deref(), Some("moved"));
+    assert_eq!(form.command("move").await, "moved");
     let (ok, _) = evaluate(&repl, bridge.clone(), "await app.click([5,5]);".into()).await;
     assert!(
         !ok,
         "external window movement must reject old screenshot coordinates"
     );
-    app.kill().await.unwrap();
-    app.wait().await.unwrap();
+    form.stop().await;
     let (ok, _) = evaluate(
         &repl,
         bridge.clone(),
@@ -227,6 +184,191 @@ async fn native_form_retains_exact_ax_elements_and_verifies_submission() {
     driver.lock().await.close().await.unwrap();
 }
 
+#[cfg(target_os = "macos")]
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+#[ignore = "requires macOS Accessibility; observes a background fixture without keyboard input"]
+async fn native_observation_recovers_after_busy_and_preserves_external_edits() {
+    let mut form = NativeForm::start(true).await;
+    assert_eq!(form.command("activity").await, "inactive");
+    let driver = Arc::new(Mutex::new(Driver::default()));
+    let bridge = Arc::new(Bridge(Arc::new(Mutex::new(Session::new(driver.clone())))));
+    let repl = Repl::new(
+        facade(),
+        CellLimits {
+            max_value_bytes: 16 * 1024 * 1024,
+            heap_bytes: 128 * 1024 * 1024,
+            ..Default::default()
+        },
+    );
+    let (ok, output) = evaluate(
+        &repl,
+        bridge.clone(),
+        format!(
+            "const app = await cua.getApp({{windowId:{}}}); await app.getAXStateAndScreenshot();",
+            form.window_id
+        ),
+    )
+    .await;
+    assert!(ok, "{}", texts(&output));
+    let name = index(&texts(&output), "AXTextField", "Name");
+
+    // Hold the fixture's UI thread until the AX request times out, then
+    // explicitly release it. No timing assumption or physical input is needed.
+    assert_eq!(form.command("pause").await, "paused");
+    let (ok, output) = evaluate(
+        &repl,
+        bridge.clone(),
+        "try { await app.getAXState(); } catch (e) { nodeRepl.write(String(e)); }".into(),
+    )
+    .await;
+    assert!(
+        ok && texts(&output).contains("AX window unavailable"),
+        "{}",
+        texts(&output)
+    );
+    assert_eq!(form.command("resume").await, "resumed");
+
+    let (ok, output) = evaluate(&repl, bridge.clone(), format!(
+        "try {{ await app.setValue({name}, 'must not overwrite'); }} catch (e) {{ nodeRepl.write(String(e)); }}
+         try {{ await app.click([5,5]); }} catch (e) {{ nodeRepl.write(String(e)); }}"
+    )).await;
+    assert!(
+        ok && texts(&output).contains("stale_element")
+            && texts(&output).contains("capture_required"),
+        "{}",
+        texts(&output)
+    );
+    let (ok, output) = evaluate(&repl, bridge.clone(), "await app.getAXState();".into()).await;
+    assert!(
+        ok && texts(&output).contains("hello hello"),
+        "the same binding must recover without rebind: {}",
+        texts(&output)
+    );
+    let name = index(&texts(&output), "AXTextField", "Name");
+
+    assert_eq!(form.command("edit").await, "edited");
+    let (ok, output) = evaluate(&repl, bridge.clone(), format!(
+        "try {{ await app.selectText({name}, 'hello', {{prefix:'hello '}}); }} catch (e) {{ nodeRepl.write(String(e)); }}"
+    )).await;
+    assert!(
+        ok && texts(&output).contains("text_not_found"),
+        "{}",
+        texts(&output)
+    );
+    let (ok, output) = evaluate(&repl, bridge.clone(), "await app.getAXState();".into()).await;
+    assert!(
+        ok && texts(&output).contains("externally edited"),
+        "external edits must remain intact: {}",
+        texts(&output)
+    );
+
+    assert_eq!(form.command("activity").await, "inactive");
+    assert_eq!(form.command("close").await, "closed");
+    assert!(
+        form.app.try_wait().unwrap().is_none(),
+        "the window closed, not the process"
+    );
+    let (ok, _) = evaluate(
+        &repl,
+        bridge.clone(),
+        format!("await app.setValue({name}, 'closed window');"),
+    )
+    .await;
+    assert!(
+        !ok,
+        "preserving a binding must not permit input to a closed window"
+    );
+    form.stop().await;
+    repl.close().await.unwrap();
+    bridge.0.lock().await.close().await.unwrap();
+    driver.lock().await.close().await.unwrap();
+}
+
+#[cfg(target_os = "macos")]
+struct NativeForm {
+    app: tokio::process::Child,
+    lines: tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
+    window_id: u64,
+    _directory: tempfile::TempDir,
+}
+
+#[cfg(target_os = "macos")]
+impl NativeForm {
+    async fn start(background: bool) -> Self {
+        assert!(
+            unsafe { platform_macos::ax::bindings::AXIsProcessTrusted() },
+            "native acceptance requires macOS Accessibility permission for the test host"
+        );
+        let directory = tempfile::tempdir().unwrap();
+        let contents = directory.path().join("Maka Cua Fixture.app/Contents");
+        std::fs::create_dir_all(contents.join("MacOS")).unwrap();
+        std::fs::write(contents.join("Info.plist"),r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>org.apache.maka.cua.fixture.FIXTURE_ID</string><key>CFBundleName</key><string>Maka Cua Fixture</string><key>CFBundleExecutable</key><string>fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>"#.replace("FIXTURE_ID", &uuid::Uuid::new_v4().to_string())).unwrap();
+        let executable = contents.join("MacOS/fixture");
+        let compiled = tokio::process::Command::new("/usr/bin/swiftc")
+            .arg(concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/tests/fixtures/native-form.swift"
+            ))
+            .arg("-o")
+            .arg(&executable)
+            .output()
+            .await
+            .unwrap();
+        assert!(
+            compiled.status.success(),
+            "{}",
+            String::from_utf8_lossy(&compiled.stderr)
+        );
+        let mut launch = tokio::process::Command::new(&executable);
+        if background {
+            launch.arg("--background");
+        }
+        let mut app = launch
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::null())
+            .kill_on_drop(true)
+            .spawn()
+            .unwrap();
+        let lines = BufReader::new(app.stdout.take().unwrap()).lines();
+        let mut fixture = Self {
+            app,
+            lines,
+            window_id: 0,
+            _directory: directory,
+        };
+        fixture.window_id = fixture
+            .line()
+            .await
+            .strip_prefix("ready ")
+            .unwrap()
+            .parse()
+            .unwrap();
+        fixture
+    }
+    async fn line(&mut self) -> String {
+        tokio::time::timeout(Duration::from_secs(5), self.lines.next_line())
+            .await
+            .expect("native fixture responds")
+            .unwrap()
+            .expect("native fixture remains alive")
+    }
+    async fn command(&mut self, command: &str) -> String {
+        self.app
+            .stdin
+            .as_mut()
+            .unwrap()
+            .write_all(format!("{command}\n").as_bytes())
+            .await
+            .unwrap();
+        self.line().await
+    }
+    async fn stop(&mut self) {
+        self.app.kill().await.unwrap();
+        self.app.wait().await.unwrap();
+    }
+}
+
 #[cfg(target_os = "macos")]
 fn clipboard_snapshot() -> Vec<Vec<(String, Vec<u8>)>> {
     objc2_app_kit::NSPasteboard::generalPasteboard()
@@ -328,7 +470,26 @@ async fn browser_form_uses_independent_repl_and_rejects_stale_document_and_tab()
     )
     .await;
     assert!(ok, "{}", texts(&output));
-    let state = texts(&output);
+    let mut state = texts(&output);
+    for (text, diagnostic) in [("missing", "text_not_found"), ("hello", "ambiguous_text")] {
+        let name = index(&state, "textbox", "Name ");
+        let (ok, output) = evaluate(&repl, bridge.clone(), format!(
+            "try {{ await tab.selectText({name}, {text:?}); }} catch (e) {{ nodeRepl.write(String(e)); }}"
+        )).await;
+        assert!(
+            ok && texts(&output).contains(diagnostic),
+            "{}",
+            texts(&output)
+        );
+        let (ok, output) = evaluate(
+            &repl,
+            bridge.clone(),
+            "await tab.getAXState({disableDiffing:true});".into(),
+        )
+        .await;
+        assert!(ok);
+        state = texts(&output);
+    }
     let name = index(&state, "textbox", "Name ");
     let message = index(&state, "textbox", "Message ");
     let color = index(&state, "combobox", "Color");


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org