## 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
163 lines
5.7 KiB
JavaScript
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 });
|
|
});
|