fix(v1.8): 真机验证抓到的两个 where 相关真 bug(macOS 上永远测不出)

皇上问"能不能连上 Windows 真机",连上后顺带做积压的真机验证,当场暴露两处:

## ① screen.find 的 where-only 被自己的入口校验拦下
v1.7 声称"control 条件可只用 where 不传 text",但只改了 gui.await 侧,
screen.find 入口仍要求 text 非空 → 直连与 await 的 where-only 全部报
"text 不能为空"。修:text 与 where 至少给一个;macOS 侧明确说明
"无 UIA 等价物,where 不可用,请给 text"(而非笼统的参数错误)。

## ② where 的 PowerShell 注入语法错误(更关键)
原实现直接把 JSON 字面量注入 PS:$where = {"enabled":true}
—— PowerShell 不认这种语法(哈希表要 @{enabled=$true}),ParseError 让
**整份助手脚本**失败。真机现象:带 where 的调用一律"窗口操作失败",
不带 where 的完全正常。
修:改为 JSON 字符串 + ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,
PSCustomObject 没有 Keys)。

## ③ 补上此前"只在注释里承诺"的 note
manifest 写明"OCR/image 引擎忽略 where 并带回 note",代码里没实现。
现补 note + where_applied 字段(UIA 时 true,降级时 false 并说明)。
不补的话,调用方会以为"按属性过滤过了"而误判结果。

## 真机已确认可用的部分(无需修复)
- UIA 属性读取 ✓:QQ音乐精简窗的 Text 控件读到 enabled=true,
  value/selected/toggle 均为 null —— **"拿不到就留 null、绝不猜"的设计在真实
  第三方应用上验证成立**(Qt/Electron 类控件给不出这些属性)。
- monitor/log 能力已在被控端就绪(v1.8 构建已部署):monitor.list 返回 ok。

## 验证
单测 250 全绿(新增 where-only 放行断言);e2e 30 全绿;7 包构建通过。
⚠️ 两个修复都只在 Windows UIA 路径生效,**必须部署到被控端才能真机复验**。
This commit is contained in:
jkooe committed 2026-10-09 23:24:40 +08:00
1 parent 933863529a
commit 982eb9b1fa
3 files changed
+60 -7

No files matched your search

+9 -1
View File
@@ -317,7 +317,15 @@ async function ensureOcrBin(): Promise<string> {
export async function macScreenFind(args: Args): Promise<unknown> {
assertDarwin('screen.find');
const text = args['text'] as string;
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
if (!text) {
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
// 明确说清原因,别让调用方以为是参数写错。
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
{ platform: 'darwin', engine: 'ocr' },
);
}
const limit = (args['limit'] as number | undefined) ?? 20;
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
+43 -6
View File
@@ -197,6 +197,7 @@ function Test-UiaWhere($props, $where) {
'enabled' { if ($props.enabled -ne [bool]$want) { return $false } }
'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } }
'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } }
default { } # 未知键忽略(Node 侧已白名单校验)
'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } }
}
}
@@ -709,8 +710,20 @@ export async function screenFind(args: Args): Promise<unknown> {
}
const text = (args['text'] as string | undefined) ?? '';
const wantImage = (args['method'] as string | undefined) === 'image';
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
// v1.8 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
const hasWhere = (() => {
const w = args['where'];
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
})();
if (!wantImage && text.length === 0 && !hasWhere) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'text 与 where 至少给一个(method=image 时用 template)',
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
);
}
const windowTitle = args['window'] as string | undefined;
const controlType = args['control_type'] as string | undefined;
@@ -739,10 +752,22 @@ $text = ${JSON.stringify(text)}
$limit = ${Number(limit)}
$ct = ${JSON.stringify(controlType ?? '')}
$winTitle = ${JSON.stringify(windowTitle ?? '')}
# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键
# (白名单式过滤,避免把任意参数塞进 PowerShell)
$where = ${JSON.stringify(whereArg ?? {})}
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
# ⚠️ 绝不能直接注入 JSON 字面量 —— PowerShell 不认 {"enabled":true} 这种语法,
# 会 ParseError 让**整份助手脚本**失败(真机 2026-10-09 验证:带 where 的调用
# 全部报"窗口操作失败",而不带 where 的正常)。这里用字符串 + ConvertFrom-Json,
# 再摊平成哈希表(Test-UiaWhere 依赖 .Keys 遍历,PSCustomObject 没有 Keys)。
$whereJson = ${JSON.stringify(whereArg ? JSON.stringify(whereArg) : '')}
$where = $null
if ($whereJson -ne '') {
$where = @{}
try {
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
} catch {
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
}
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
}
$out = New-Object System.Collections.ArrayList
if ($winTitle -ne '') {
# 指定窗口:直接在该窗口子树内找
@@ -1006,10 +1031,22 @@ $out | ConvertTo-Json -Compress
await new Promise((r) => setTimeout(r, intervalMs));
last = await attempt();
}
// v1.8:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
const whereIgnored = hasWhere && last.engine !== 'uia';
return {
matches: last.matches,
engine: last.engine,
waited_ms: Date.now() - startedAt,
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
...(whereIgnored
? {
note:
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
where_applied: false,
}
: {}),
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
};
}
+8
View File
@@ -106,3 +106,11 @@ test('any_of 求值语义:任一命中即算(等价于逻辑 OR)', () => {
assert.ok(anyHit, '任一命中 → 整体命中');
assert.ok(![{ hit: false }, { hit: false }].some((s) => s.hit), '全不命中 → 不命中');
});
test('screen.find:where-only 必须放行(v1.8 修——真机验证暴露的缺口)', () => {
// 之前入口校验只认 text,导致 where-only 被"text 不能为空"拦下;
// 真机 2026-10-09 验证时抓到。此处锁住:where 非空即可,text 可省。
assert.equal(check('screen.find', { where: { enabled: true } }).length, 0, 'where-only 应通过');
assert.equal(check('screen.find', {}).length, 0, 'schema 层 text/where 均非必填(类型校验);「两者都缺」由**业务层**拒 —— 见真机验证');
assert.equal(check('screen.find', { where: {} }).length, 0, 'schema 允许空 where 对象(业务层判空后拒)');
});