Files
nodeagent/tests/unit/macro.test.mjs
T
jkooe 3ed117770b feat(E4): GUI 宏(步骤序列回放)+ 窗口聚焦加固(能力 35 稳定)
## E4 宏引擎(packages/client/src/macro.ts,CLI 与 MCP 共用)
- 步骤类型:focus / find / click / type / key / drag / sleep / exec / clip / assert / capture
- 通用能力:retry(失败重试)、interval_ms、optional|continue_on_error、${VAR} 与 ${VAR:-默认值} 替换
- 断言内置在步骤里:exec 的 expect_exit/expect_stdout、clip 的 expect、
  assert 支持 kind=screen|window|clip(屏幕断言自动走 UIA→OCR)
- 返回逐步结果(含 ms 与失败定位 failed_at),失败即中止并给出可读原因
- CLI: macro run <file> [--var k=v] [--out 目录] / macro validate / macro init(生成示例)
- MCP: na_macro_run(inline steps + vars)

## 真机验证(记事本全流程)
宏 init 生成示例 -> 启动记事本 -> 回放 9 步:
focus ✓ / key ctrl+a ✓ / delete ✓ / type ✓ / sleep ✓ / key ctrl+a ✓ / key ctrl+c ✓ /
clip 断言「宏回放演示 回放测试」✓ / window 断言 ✓ -> 全部通过(10.1s)

## 顺带修复两个真机踩出的问题
1. **window.focus 可能被静默否决**:SetForegroundWindow 受 Windows 前台锁定限制,
   调用后前台窗口可能仍是别人(实测被系统防火墙对话框抢焦点,导致宏的输入全进了对话框)。
   现改为:调用后**校验当前前台窗口**;未生效则在该窗口标题栏做一次真实点击兜底,
   并返回 activated_by=api|click 让调用方知情。
2. **window.list 会被 limit 截掉真正的前台窗口**:现按 is_foreground 排序置顶并返回 total。
3. 宏引擎:变量缺失原先会在 try 之外抛出、导致整个宏崩掉;现改为「记为该步失败」,
   optional 时继续执行(单测覆盖)。

单测 63 -> 73(新增宏引擎 10 个用例:变量/默认值/缺失、retry、optional、失败中止、
expect_exit/stdout、clip 断言、未知步骤、find→click 坐标透传、坏文件拒绝);e2e 21/21
2026-09-29 14:25:35 +08:00

163 lines
5.7 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runMacro, loadMacroFile } from '../../packages/client/dist/index.js';
/**
* 用一个「假客户端」驱动宏引擎 —— 不需要真实设备,只验证引擎自身的逻辑:
* 变量替换、重试、可选步骤、失败中止、返回值透传。
*/
function fakeClient(handlers = {}) {
const calls = [];
return {
calls,
async invoke(capability, args) {
calls.push({ capability, args });
const h = handlers[capability];
if (typeof h === 'function') {
const r = h(args);
if (r && r.__fail) return { status: 'failed', error: { name: r.name, message: r.message } };
return { status: 'ok', data: r };
}
return { status: 'ok', data: {} };
},
};
}
test('宏:变量替换(含默认值与数组内替换)', async () => {
const c = fakeClient({
'input.key.type': (args) => ({ typed: true, text: args.text }),
});
const res = await runMacro(
{ client: c },
{
name: 'vars',
steps: [
{ action: 'type', text: '你好 ${NAME}' },
{ action: 'type', text: '缺省 ${MISSING:-兜底}' },
],
},
{ NAME: '世界' },
);
assert.equal(res.ok, true);
assert.deepEqual(c.calls[0].args.text, '你好 世界');
assert.deepEqual(c.calls[1].args.text, '缺省 兜底');
});
test('宏:变量缺失且无默认值时该步失败', async () => {
const c = fakeClient({ 'input.key.type': () => ({ typed: true }) });
const res = await runMacro({ client: c }, { steps: [{ action: 'type', text: '${NOPE}' }] }, {});
assert.equal(res.ok, false);
assert.match(res.steps[0].detail, /变量未提供/);
});
test('宏:retry 生效(前两次失败、第三次成功)', async () => {
let n = 0;
const c = fakeClient({
'window.focus': () => {
n += 1;
return n < 3 ? { __fail: true, name: 'E_NOT_FOUND', message: '窗口未出现' } : { focused: true, title: 'X', x: 0, y: 0, width: 10, height: 10 };
},
});
const res = await runMacro(
{ client: c },
{ steps: [{ action: 'focus', title: 'X', retry: 3, interval_ms: 1 }] },
);
assert.equal(res.ok, true);
assert.equal(n, 3, '应重试到第 3 次成功');
});
test('宏:optional 步骤失败不中断后续', async () => {
const c = fakeClient({
'window.focus': () => ({ __fail: true, name: 'E_NOT_FOUND', message: '没有' }),
'exec': () => ({ exit_code: 0 }),
});
const res = await runMacro(
{ client: c },
{
steps: [
{ action: 'focus', title: '无关窗口', optional: true },
{ action: 'exec', command: 'echo ok' },
],
},
);
assert.equal(res.ok, true, '整体应成功');
assert.equal(res.steps[0].ok, false, '可选步骤标记为失败');
assert.equal(res.steps[1].ok, true, '后续步骤仍执行');
});
test('宏:必需步骤失败即中止,并给出失败位置', async () => {
const c = fakeClient({
'exec': (args) => (String(args.command).includes('bad') ? { exit_code: 0, stdout: 'no' } : { exit_code: 0, stdout: 'yes' }),
});
const res = await runMacro(
{ client: c },
{
steps: [
{ action: 'exec', command: 'echo good' },
{ action: 'exec', command: 'echo bad', expect_stdout: '期望出现' },
{ action: 'exec', command: 'echo never' },
],
},
);
assert.equal(res.ok, false);
assert.equal(res.failed_at, 1, '应在第 1 步失败');
assert.equal(res.steps.length, 2, '失败后不再执行第 2 步');
assert.match(res.steps[1].detail, /未包含/);
assert.equal(c.calls.length, 2, '后续命令不应被下发');
});
test('宏:exec 的 expect_exit 断言生效', async () => {
const c = fakeClient({ 'exec': () => ({ exit_code: 1 }) });
const res = await runMacro({ client: c }, { steps: [{ action: 'exec', command: 'x', expect_exit: 0 }] });
assert.equal(res.ok, false);
assert.match(res.steps[0].detail, /退出码不符/);
});
test('宏:clip 断言与 assert(kind=clip) 复用剪贴板内容', async () => {
const c = fakeClient({ 'clip.get': () => ({ text: '包含关键字的内容' }) });
const res = await runMacro(
{ client: c },
{
steps: [
{ action: 'clip', expect: '关键字' },
{ action: 'assert', kind: 'clip', text: '不存在的词', optional: true },
],
},
);
assert.equal(res.steps[0].ok, true);
assert.equal(res.steps[1].ok, false, '不含该词应失败(但 optional 不中止)');
assert.equal(res.ok, true);
});
test('宏:未知步骤类型被明确拒绝', async () => {
const c = fakeClient({});
const res = await runMacro({ client: c }, { steps: [{ action: 'teleport' }] });
assert.equal(res.ok, false);
assert.match(res.steps[0].detail, /不支持的步骤/);
});
test('宏:loadMacroFile 拒绝缺少 steps 的文件', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-macro-'));
const bad = join(dir, 'bad.json');
writeFileSync(bad, JSON.stringify({ name: 'x' }));
assert.throws(() => loadMacroFile(bad), /steps/);
rmSync(dir, { recursive: true, force: true });
});
test('宏:find 步骤命中后按需点击(坐标来自引擎返回值)', async () => {
const c = fakeClient({
'screen.find': () => ({ engine: 'uia', matches: [{ name: '保存', x: 123, y: 456 }] }),
'input.mouse.click': (args) => ({ clicked: true, x: args.x, y: args.y }),
});
const res = await runMacro(
{ client: c },
{ steps: [{ action: 'find', text: '保存', click: true }] },
);
assert.equal(res.ok, true);
const click = c.calls.find((x) => x.capability === 'input.mouse.click');
assert.deepEqual({ x: click.args.x, y: click.args.y }, { x: 123, y: 456 });
});