From 66f44ddaabb381838926b9bced3b5e492b6f7343 Mon Sep 17 00:00:00 2001 From: ATG Date: Fri, 23 Oct 2026 02:20:54 +0530 Subject: [PATCH 1/2] fix(macos): hit-test click candidates in window points hitTestElement converted its point as a screenshot pixel, but both callers (clickCandidates and the nearby hit-test fallback for element-index clicks) pass window points. When the screenshot is not 1:1 with the window the AX hit test probed the wrong spot: an auto click on a table row pressed the column header above it, and element-index clicks after include_screenshot:false threw the coordinate-input screenshot error. Use windowPointToGlobalPoint. --- .../20261023-0219-hit-test-window-points.md | 35 +++++++++++++++++++ docs/releases/feature-release-notes.md | 1 + .../ComputerUseService.swift | 3 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/histories/2026-10/20261023-0219-hit-test-window-points.md diff --git a/docs/histories/2026-10/20261023-0219-hit-test-window-points.md b/docs/histories/2026-10/20261023-0219-hit-test-window-points.md new file mode 100644 index 000000000..4df19f4ce --- /dev/null +++ b/docs/histories/2026-10/20261023-0219-hit-test-window-points.md @@ -0,0 +1,35 @@ +## [2026-10-23 02:19] | Task: 修复 macOS 点击命中测试的坐标空间 + +### Execution Context + +- Agent ID: Claude Code subagent(Agent tool 派发) +- Base Model: Claude Opus 5.5 +- Runtime: Claude Code CLI + +### User Query + +> 修复 `hitTestElement` 把窗口点当成截图像素换算的问题,并用最小的回归检查证明修复前后行为差异。 + +### Changes Overview + +- `hitTestElement(at:in:)` 改用 `windowPointToGlobalPoint` 换算,补一行注释说明入参是窗口点(`ElementRecord.localFrame` 的坐标空间)。 +- 两处调用方都传窗口点:坐标点击的 `clickCandidates`(已经由截图像素换算成窗口点)和 element_index 点击的就近命中回退(`clickActionPoints` 来自 `clickFrame`)。 +- 坐标拖拽仍走 `screenshotToGlobalPoint`,它的入参确实是截图像素,不受影响。 + +### Design Intent + +截图与窗口不是 1:1 时(Retina 缩放、`maxLongEdgePixels` 上限,几乎总是如此),命中测试会被再除一次缩放比例,探测到错误位置。实测在 2x 屏幕上点击表格行时,命中测试落到上方的列表头并对它执行了 AXPress。另外,`include_screenshot:false` 的 element_index 点击在这条回退里会误报 "coordinate input requires a delivered screenshot",尽管调用方根本没有给坐标。只改共享的换算函数即可同时修好两个调用方。 + +### Files Modified + +- `packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift` +- `docs/releases/feature-release-notes.md` + +### Validation + +- 用一个不激活、位于所有窗口之后的 AppKit 探针(无动作的表格行 + 列表头)在进程内运行 CLI(禁用 app agent 代理与软件光标),打开点击决策调试日志: + - 修复前:`click element_index`(行,窗口点 y=104..128)的就近命中把窗口点 (125,116) 当作像素换算成全局 (122,608),命中列表头 AXButton(y=42)并执行 AXPress。 + - 修复后:同一窗口点换算为全局 (185,666),命中该行自己的 AXStaticText(y=108),不再按下列表头。 + - 修复前 `get_app_state include_screenshot:false` 后的 element_index 点击报截图缺失错误;修复后不再报错。 +- 坐标点击路径用同一个函数,命中测试同样落到正确的行文字上;但在该探针里快照中的窗口记录会先处理点击,因此该路径的最终结果在探针中看不出差异。 +- `swift test` 全量通过。 diff --git a/docs/releases/feature-release-notes.md b/docs/releases/feature-release-notes.md index c000ea0cd..5641bcdb1 100644 --- a/docs/releases/feature-release-notes.md +++ b/docs/releases/feature-release-notes.md @@ -4,6 +4,7 @@ | 日期 | 功能域 | 用户价值 | 变更摘要 | | --- | --- | --- | --- | +| 2026-10-23 | macOS 点击命中测试 | 截图与窗口比例不是 1:1 时(如 Retina),`auto` 点击不再按下目标旁边的控件;只取 AX 的 element_index 点击也不再误报需要截图。 | `hitTestElement` 的入参本就是窗口点,改用窗口点换算,不再按截图像素再除一次缩放比例;修复前点击表格行会命中并按下上方列表头。 | | 2026-10-13 | Windows 滚动与坐标 | Windows 上 `scroll` 不再在默认配置下直接失败;150% 缩放且窗口位于屏幕右侧时,`get_app_state` 也能返回截图和窗口 bounds。 | 修复 `scroll` 请求漏传 `windowBounds` 导致坐标回退必然报 `No usable screenshot coordinates`;滚轮消息保留屏幕坐标并修正横向方向,PowerShell bridge 启动时对齐 DPI 物理坐标;`ScrollPattern` 在当前状态不可滚动时回退到坐标路径而不是让整个 tool call 失败。 | | 2026-10-13 | Windows 全局输入兜底 | 需要真实指针或键盘的 Windows 动作(`click_method=global`、WinUI 键盘输入、子控件点击)在显式授权后可用,默认路径完全不变。 | Windows 实现 `click_method=global` 真实指针点击;`drag` / `scroll` 的坐标回退在 `OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS=1` 时使用真实指针,按键另需显式 `key_method=global`,`app_post` 仍保持 HWND 消息语义;未授权时在任何物理输入前拒绝,核对目标前台 / 命中 / 几何并在异常时释放输入。 | | 2026-10-17 | 1.1.2 修复发布 | 软件光标持续跟随移动窗口,Linux 批量动作可减少回读开销,安装链接统一到新仓库。 | 同步所有版本源至 1.1.2;发布窗口锚点跟踪、Linux 动作回读配置与仓库改名后的 npm / 插件元信息,沿用既有命令和 App 签名身份。 | diff --git a/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift b/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift index a74e82303..1e89f1db3 100644 --- a/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift +++ b/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift @@ -1565,9 +1565,10 @@ public final class ComputerUseService { .first } + /// `point` is a window point (the space of `ElementRecord.localFrame`), not a screenshot pixel. private func hitTestElement(at point: CGPoint, in snapshot: AppSnapshot) throws -> ElementRecord? { let appElement = AXUIElementCreateApplication(snapshot.app.pid) - let globalPoint = try screenshotToGlobalPoint(snapshot: snapshot, x: Double(point.x), y: Double(point.y)) + let globalPoint = try windowPointToGlobalPoint(snapshot: snapshot, point: point) var hitElement: AXUIElement? let result = AXUIElementCopyElementAtPosition(appElement, Float(globalPoint.x), Float(globalPoint.y), &hitElement) guard result == .success, let hitElement else { From e909c4d1a7b389c5a707c33b8521cb858134e0e3 Mon Sep 17 00:00:00 2001 From: ifuryst Date: Sat, 31 Oct 2026 22:01:48 +0800 Subject: [PATCH 2/2] test(macos): cover scaled and AX-only hit testing with AppKit --- docs/ARCHITECTURE.md | 2 +- .../completed/20261031-pr126-hit-test.md | 6 ++ .../20261023-0219-hit-test-window-points.md | 10 ++ .../ComputerUseService.swift | 1 + .../tests/fixtures/MacOSHitTestFixture.swift | 54 +++++++++++ scripts/tests/macos-hit-test-smoke.mjs | 92 +++++++++++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 docs/exec-plans/completed/20261031-pr126-hit-test.md create mode 100644 scripts/tests/fixtures/MacOSHitTestFixture.swift create mode 100644 scripts/tests/macos-hit-test-smoke.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab4ff0cdd..c95e71480 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,7 @@ - JS REPL 的 API 和当前官方 code-first 形态对齐到异步 app binding,而不是把离散 tools 简单包成同步函数。snapshot 的 `windowPlacement` 以及 keyboard action 的 `keyMethod` 会转换成 native tool 的 snake_case 参数。实现不复制或运行 proprietary `@oai/*` package;协议和行为记录在 `docs/references/js-repl.md`。 - `open-computer-use call --args '{...}'` 会直接输出 MCP-style JSON result;`open-computer-use call --calls '[...]'` / `--calls-file ` 会在同一进程里顺序执行 JSON 数组里的 tool calls,并复用同一个 `ComputerUseService` 内存态,因此 `get_app_state` 之后的 action tool 可以继续使用同一轮 snapshot 的 `element_index`。序列执行默认会在成功的相邻操作之间 sleep 1 秒,也可以用 `--sleep ` 覆盖;遇到 `isError=true` 的 tool result 后停止。 - 对真实 app 的 `get_app_state` / action tool 入口,当前只保留一层密码管理器 bundle denylist:bundle-id 直传时直接返回 safety denial;名称匹配时默认不解析到这些 app。终端、Chrome / Atlas 和系统组件不再属于内置阻止目标。 -- 普通 app 的 element frame 当前按“窗口左上角为原点”的 window-relative 坐标输出,便于后续把 `element_index` 和截图坐标统一到同一套参考系。 +- 普通 app 的 element frame 当前按“窗口左上角为原点”的 window-relative 坐标输出,便于后续把 `element_index` 和截图坐标统一到同一套参考系。点击 AX 命中测试接收窗口点,仅加窗口屏幕原点;截图坐标在入口换算一次,元素索引点击不依赖截图。真实 AppKit 回归可运行 `node scripts/tests/macos-hit-test-smoke.mjs `(需要现有辅助功能、录屏权限和 Retina 显示器)。 - 三个平台的显式 `get_app_state` 均支持 `include_ax` / `include_screenshot`,默认 true,至少保留一项。JS 对应 `includeAX` / `includeScreenshot`,CLI snapshot 提供同名连字符参数。关闭 AX 跳过树、焦点和选区,并使旧快照 / 查询索引及别名失效;关闭截图跳过捕获与编码,元素操作仍可用,截图坐标操作须重新获取图片。参数经 macOS app agent 转发;不改变动作回读,也不做自动图片去重。见 [按需观察](selective-observation.md)。 - macOS 额外提供 `query` 和 `select_text`,当前 native surface 为 11 个工具;Linux / Windows 仍为 9 个核心工具。JS `app.selectText(elementIndex, text, {prefix?, suffix?, selection?})` 复用快照或 query 索引,支持选中文字及前后光标。按字面文本和 UTF-16 定位、拒绝歧义;写入前及返回前检查会话,验证文本未变且实际选区等于请求,即使关闭完整回读也不能省略验证。失败通过 native `isError` / JS 异常传出;结果刷新使用 read-only recovery,不通过激活、键盘或真实指针补救。 - `click` / `set_value` 在执行真实动作前后,会额外驱动一层透明 `SoftwareCursorOverlay` window:两者的移动阶段现在共用一条 heading-driven 的官方风格 motion 内核,显式把“当前 cursor 朝向”和“最终 resting pose”一起喂给选路器,优先生成需要时先掉头、再沿车头方向推进的 C 形/单侧大弧轨迹;首次显示时按官方 binary 的 fresh state 从 AppKit 全局 `(0,0)` window origin 生成起点,后续动作继续复用上一帧 visible tip。真正显示出来的 cursor 不再直接等于 path sample,而是经过一层独立的 visual dynamics 状态,把 visible tip、velocity、angle 和 fog/offset 持续推进。`click` 结尾会衔接 click pulse 和更明显但仍然很小的 rotate wobble,`set_value` 则只做 settle / idle,不给 pulse;两者收尾后会在目标点继续保持 idle 状态,等待下一次动作时 tip 保持 anchored、只保留可感知的小角度摆动;只有连续 30 秒没有新动作时才做 cleanup,这样连续 tool call 不会反复从 fresh `(0,0)` 起步;如果宿主在任务 / turn 结束时发出 `turn-ended`,cursor 会立即消失并清掉本轮位置状态。 diff --git a/docs/exec-plans/completed/20261031-pr126-hit-test.md b/docs/exec-plans/completed/20261031-pr126-hit-test.md new file mode 100644 index 000000000..0fcca86b1 --- /dev/null +++ b/docs/exec-plans/completed/20261031-pr126-hit-test.md @@ -0,0 +1,6 @@ +# PR #126: window-point hit testing + +- Scope: merge current main, retain contributor's coordinate fix, add reproducible real AppKit regression coverage. Keep unrelated descendant selection (#127) separate. +- Verify: native MCP with Retina and capped screenshots; AX-only indexed clicks; screenshot-coordinate refusal without an image; unit suite and document checks. +- Acceptance: correct hit target without changing foreground application or physical pointer; negative control with the old conversion fails; record actual coverage and limitations. +- Status: complete. Merged main; real Retina/capped/AX-only native MCP smoke passed, old-conversion negative control failed as expected. Full Swift suite and documentation checks passed. See the existing hit-test history for evidence and coverage limits. diff --git a/docs/histories/2026-10/20261023-0219-hit-test-window-points.md b/docs/histories/2026-10/20261023-0219-hit-test-window-points.md index 4df19f4ce..105e34d88 100644 --- a/docs/histories/2026-10/20261023-0219-hit-test-window-points.md +++ b/docs/histories/2026-10/20261023-0219-hit-test-window-points.md @@ -33,3 +33,13 @@ - 修复前 `get_app_state include_screenshot:false` 后的 element_index 点击报截图缺失错误;修复后不再报错。 - 坐标点击路径用同一个函数,命中测试同样落到正确的行文字上;但在该探针里快照中的窗口记录会先处理点击,因此该路径的最终结果在探针中看不出差异。 - `swift test` 全量通过。 + +### 2026-10-31 maintainer review + +- Merged current main (`a7fdec1`), retaining both sides of the release-note conflict and the original contributor commit. +- Kept the production fix; added opt-in debug logging of window/screen hit-test points and AX result codes. +- Added `scripts/tests/macos-hit-test-smoke.mjs` and its real AppKit receiver. This uses native MCP and ordinary AX APIs, not the synthetic fixture bridge. Run with a normally signed, admitted OCU executable on an unlocked Retina desktop with Accessibility/Screen Recording permissions. +- Verified 600×432-point window with 1200×864 (2×) and 300×216 (0.5×) screenshots: indexed fallback hits the expected screen point and never presses the unrelated button; screenshot-coordinate auto clicks reach the button, with correct hit-test coordinates. AX-only indexed fallback succeeds, while coordinate clicks without an image fail. Explicit accessibility clicks are observed by the receiver. Foreground PID and physical pointer remain unchanged during actions. +- Negative control: changing only the conversion back to `screenshotToGlobalPoint` makes the same live regression fail: expected (490,627), actual (305,498.5). Restored the fix afterward. +- Limits: one physical Retina display; this tests AX hit locations and real button activation, not general background mouse delivery to actionless custom views. The separate descendant-selection issue remains in #127. +- Full Swift suite: 399 tests, 9 skipped, no failures. Documentation checks passed. diff --git a/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift b/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift index 839878e79..fad875af9 100644 --- a/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift +++ b/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift @@ -1572,6 +1572,7 @@ public final class ComputerUseService { let globalPoint = try windowPointToGlobalPoint(snapshot: snapshot, point: point) var hitElement: AXUIElement? let result = AXUIElementCopyElementAtPosition(appElement, Float(globalPoint.x), Float(globalPoint.y), &hitElement) + debugClickDecision("hit test window=\(point) screen=\(globalPoint) result=\(result.rawValue)") guard result == .success, let hitElement else { return nil } diff --git a/scripts/tests/fixtures/MacOSHitTestFixture.swift b/scripts/tests/fixtures/MacOSHitTestFixture.swift new file mode 100644 index 000000000..5d96642fd --- /dev/null +++ b/scripts/tests/fixtures/MacOSHitTestFixture.swift @@ -0,0 +1,54 @@ +import AppKit + +final class HitTestWindow: NSWindow { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} +final class TargetView: NSView { + var clicks = 0 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func isAccessibilityElement() -> Bool { true } + override func accessibilityRole() -> NSAccessibility.Role? { .row } + override func accessibilityLabel() -> String? { "Hit target row" } + override func mouseDown(with event: NSEvent) { clicks += 1 } + +} +final class Counter: NSObject { + var clicks = 0 + @objc func press(_ sender: Any?) { clicks += 1 } +} +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let window = HitTestWindow(contentRect: NSRect(x: 120, y: 180, width: 600, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) +window.title = "OCU Hit Test Fixture" +window.isReleasedWhenClosed = false +let target = TargetView(frame: NSRect(x: 280, y: 150, width: 180, height: 50)) +let counter = Counter() +// Half the target's window-relative coordinates: the old Retina conversion hits this button. +let decoy = NSButton(frame: NSRect(x: 140, y: 275, width: 110, height: 50)) +decoy.title = "Wrong target" +decoy.target = counter +decoy.action = #selector(Counter.press(_:)) +window.contentView!.addSubview(decoy) +window.contentView!.addSubview(target) +window.orderFront(nil) +let stateURL = URL(fileURLWithPath: CommandLine.arguments[1]) +Timer.scheduledTimer(withTimeInterval: 0.03, repeats: true) { _ in + let screenRect = window.convertToScreen(target.frame) + let top = NSScreen.screens[0].frame.maxY + let frame = window.frame + let buttonRect = window.convertToScreen(decoy.frame) + let data = try! JSONSerialization.data(withJSONObject: ["targetClicks": target.clicks, + "wrongClicks": counter.clicks, "scale": window.backingScaleFactor, + "point": [screenRect.midX - frame.minX, frame.maxY - screenRect.midY], + "screenPoint": [screenRect.midX, top - screenRect.midY], + "buttonPoint": [buttonRect.midX - frame.minX, frame.maxY - buttonRect.midY], + "buttonScreenPoint": [buttonRect.midX, top - buttonRect.midY], + "windowSize": [frame.width, frame.height], + "frontPID": NSWorkspace.shared.frontmostApplication?.processIdentifier ?? -1, + "pointer": [NSEvent.mouseLocation.x, top - NSEvent.mouseLocation.y]]) + try? data.write(to: stateURL, options: .atomic) +} +Timer.scheduledTimer(withTimeInterval: 180, repeats: false) { _ in app.terminate(nil) } +app.run() diff --git a/scripts/tests/macos-hit-test-smoke.mjs b/scripts/tests/macos-hit-test-smoke.mjs new file mode 100644 index 000000000..297782fc2 --- /dev/null +++ b/scripts/tests/macos-hit-test-smoke.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Real AppKit receiver; pass an OCU executable with Accessibility/Screen Recording access. +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { JsonLinePeer } from '../node-repl/open-computer-use-repl.mjs'; +const root = mkdtempSync(path.join(tmpdir(), 'ocu-hit-test-')); +const statePath = path.join(root, 'state.json'); +const fixturePath = path.join(root, 'OCUHitTestFixture'); +const nativePath = path.resolve(process.argv[2] ?? '.build/debug/OpenComputerUse'); +const env = { ...process.env, OPEN_COMPUTER_USE_DISABLE_APP_AGENT_PROXY: '1', + OPEN_COMPUTER_USE_VISUAL_CURSOR: '0', OPEN_COMPUTER_USE_DEBUG_INPUT_FALLBACKS:'1', OPEN_COMPUTER_USE_ACTION_READ_BACK: '0', + OPEN_COMPUTER_USE_CONFIG_FILE: path.join(root, 'config.json') }; +for (const key of Object.keys(env)) if (key.startsWith('OPEN_COMPUTER_USE_IMAGE_')) delete env[key]; +const state = () => JSON.parse(readFileSync(statePath, 'utf8')); +const text = result => result.content.filter(x => x.type === 'text').map(x => x.text).join('\n'); +let fixture, native; +async function waitFor(test) { + const until = Date.now() + 5000; + while (!test()) { assert.ok(Date.now() < until, 'Receiver did not observe the expected click'); await new Promise(r => setTimeout(r, 30)); } +} +async function call(name, args, error = false) { + const result = await native.request('tools/call', { name, arguments: { app: 'OCUHitTestFixture', ...args } }); + assert.equal(!!result.isError, error, text(result)); + return result; +} +try { + const build = spawnSync('swiftc', [fileURLToPath(new URL('./fixtures/MacOSHitTestFixture.swift', import.meta.url)), '-o', fixturePath], {encoding:'utf8'}); + assert.equal(build.status, 0, build.stderr); + fixture = spawn(fixturePath, [statePath], {stdio:'ignore'}); + await waitFor(() => existsSync(statePath)); + await new Promise(r => setTimeout(r, 500)); + const initial = state(); + native = new JsonLinePeer({command:nativePath,args:['mcp'],env}); + await native.initialize(); + for (const cap of [4096, 300, null]) { + writeFileSync(env.OPEN_COMPUTER_USE_CONFIG_FILE, JSON.stringify({image:{maxLongEdgePixels:cap ?? 4096}})); + const result = await call('get_app_state', {include_screenshot:cap !== null}); + const match = text(result).match(/^\s*(\d+)\s+[^\n]*Hit target row/m); + assert.ok(match, text(result)); + const before = state(); + native.stderr = ''; + await call('click', {element_index:match[1]}); + await new Promise(r => setTimeout(r, 100)); + const hit = native.stderr.match(/hit test window=\(([^,]+), ([^)]+)\) screen=\(([^,]+), ([^)]+)\) result=0/); + assert.ok(hit, native.stderr); + assert.ok(Math.abs(Number(hit[3])-before.screenPoint[0]) < 2 && Math.abs(Number(hit[4])-before.screenPoint[1]) < 2, + `AX hit test missed the target: ${hit[0]} expected ${before.screenPoint}`); + assert.equal(state().wrongClicks, before.wrongClicks, 'Hit test pressed the unrelated button'); + const image = result.content.find(x => x.type === 'image'); + if (cap !== null) { + assert.ok(image, text(result)); + const png = Buffer.from(image.data, 'base64'); + const width = png.readUInt32BE(16), height = png.readUInt32BE(20); + if (cap === 300) assert.equal(Math.max(width,height), 300); + else assert.ok(width > initial.windowSize[0], 'Retina coverage requires a >1x screenshot'); + console.log(JSON.stringify({cap, screenshot:[width,height], window:initial.windowSize, indexedHitTest:'passed'})); + native.stderr = ''; + await call('click', {x:before.buttonPoint[0]*width/before.windowSize[0], y:before.buttonPoint[1]*height/before.windowSize[1]}); + await waitFor(() => state().wrongClicks === before.wrongClicks + 1); + const coordinateHit = native.stderr.match(/screen=\(([^,]+), ([^)]+)\) result=0/); + assert.ok(coordinateHit, native.stderr); + assert.ok(Math.abs(Number(coordinateHit[1])-before.buttonScreenPoint[0]) < 2 && Math.abs(Number(coordinateHit[2])-before.buttonScreenPoint[1]) < 2, + 'Screenshot coordinates were converted more than once'); + + } else { + assert.equal(image, undefined); + const refusal = await call('click', {x:10,y:10}, true); + assert.match(text(refusal), /screenshot/i); + console.log('AX-only indexed hit test passed; screenshot-coordinate click correctly refused'); + } + // A real AXPress receiver verifies that actions actually reach this fixture. + const button = text(result).match(/^\s*(\d+)\s+button Wrong target/m); + assert.ok(button); + const beforeControl = state().wrongClicks; + await call('click', {element_index:button[1], click_method:'accessibility'}); + await waitFor(() => state().wrongClicks === beforeControl + 1); + assert.equal(state().frontPID, initial.frontPID, 'Foreground app changed'); + assert.deepEqual(state().pointer, initial.pointer, 'Physical pointer moved'); + } + console.log('Real AppKit/native MCP hit-test smoke passed.'); +} finally { + native?.close(); + if (fixture && fixture.exitCode === null) { + fixture.kill('SIGTERM'); + await new Promise(resolve => { const timer=setTimeout(()=>{fixture.kill('SIGKILL');resolve();},2000); fixture.once('exit',()=>{clearTimeout(timer);resolve();}); }); + } + rmSync(root,{recursive:true,force:true}); +}