Compare commits

Author SHA1 Message Date
yu a8084dca26 阶段5-独立验证: 新增rpa-cli.ts CLI host(createEngine+registerAll零Electron跑通5个代表能力: 桌面/Excel/HTTP/文件/屏幕), 新增rpa-cli脚本与tsx依赖; 5.2 Bun冒烟按决策10延期到迁入Agent-Employee时执行; 5.3回归三项全绿 2026-11-04 13:49:27 +08:00
yu b54311d467 阶段4-tool契约: register支持CapabilityMeta(决策8 meta可选+新能力强制), 新增registerAll显式注册入口双入口过渡(决策9), RUNTIME_ERROR_CODES错误码固化+audit校验, 规范v1同步收紧 2026-11-04 13:41:24 +08:00
yu e691b9fa78 阶段3-driver抽象: powershell助手拆至driver/windows, 五域AutomationDriver接口+WindowsDriver(PowerShell实现行为不变), desktop/window-extended/system-extended/common-final改走driver, catalog构建填充platformTag=win32 2026-11-04 13:28:32 +08:00
yu 3decced268 阶段2-断Electron依赖: human-dialog/window-extended/ai-service经HostBridge迁入包, external+transport迁包(session留宿主), 新增check:deps依赖护栏与包README留守清单 2026-11-04 12:57:41 +08:00
yu caa8e19332 阶段1-rpa-core建包: engine+31个纯Node能力迁入packages/rpa-core, 领域类型单一源化, createEngine工厂收拢模块级状态
- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export)
- 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起
- 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例
- remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows
- 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名
- 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
2026-11-04 12:36:03 +08:00
yu 4139400dbd 阶段0-基线与护栏: 修复不可移植/抖动测试全绿(341过), vitest串行执行防桌面竞争, 新增运行时能力注册快照(909个id) 2026-11-04 11:53:41 +08:00
yu f35f0ac979 files 能力增强与 vision 校验修复;落盘 rpa-core 抽离改造任务清单与开发规范 2026-11-04 10:14:55 +08:00
yu 1ff6f59990 非 ASCII 字符走 char code 构造绕开智能引号解析陷阱 2026-11-04 09:36:45 +08:00
yu 143fcae34a 修复撇号字符数组转义顺序导致字符串未闭合 2026-11-04 09:28:46 +08:00
yu bb03699cce 键盘输入转义逻辑移到 JS 侧根治脚本语法错误 2026-11-04 09:14:09 +08:00
yu f390db9c94 错误输出通道改造: GBK 回退解码与 RPA_ERROR 标记防截断 2026-11-03 17:43:46 +08:00
yu e1f162aa82 错误信息解码与窗口列表提示 2026-11-03 17:19:22 +08:00
yu 3a9b4fa3c7 窗口激活多招式兜底修复 Studio 前台下切窗失败 2026-11-03 17:09:45 +08:00
yu 8d6f0ff755 键盘输入支持选择输入目标窗口 2026-11-03 16:56:10 +08:00
yu 46348413c1 键盘输入组件修复: 接入 words 字段与三种输入方式 2026-11-03 16:38:03 +08:00
yu 6b05a65664 鼠标组件坐标修正: DIP 转物理像素修复缩放屏偏移 2026-11-03 16:28:52 +08:00
yu 158104661c fix: 移动鼠标支持对象坐标x/y(屏幕框选生成格式,此前报坐标无效), 实现三种移动方式(模拟人工缓动抖动/匀速直线/瞬间)与移动速度, 相对激活窗口模式叠加前台窗口偏移, 补DPI感知, 冒烟4项真实落位通过 2026-11-03 16:15:03 +08:00
yu fb71c0551b fix: 网页监听通配过滤去锚定(关键字任意位置命中), 网址下载临时锚点改可见1px触发(隐藏元素被点击解析过滤), 浏览器截图path传目录自动补文件名, 补网页自动化真实Chrome冒烟11项 2026-11-03 16:04:33 +08:00
yu 58aae7c92f fix: 大小写转换组件选项值1/2不生效(选转换方式返回原文), CSV读写空路径给结构化报错, 补数据处理与文件族冒烟回归11项 2026-11-03 15:51:45 +08:00
yu cb349ec979 test: 网络与消息冒烟回归: HTTP三件套(参数/JSON表单/PUT Cookie落盘/5xx超时报错)+钉钉飞书企微webhook消息体+真实SMTP会话+AI未配置引导报错 10项通过 2026-11-03 15:35:32 +08:00
yu d4de0ec245 fix: ConvertBool数字语义对齐(数字1为true), 凭据读写改经ps强制UTF8解码中文并返回整条凭据块, 补应用与系统冒烟回归10项 2026-11-03 15:22:59 +08:00
yu d5d7df8880 fix: 同步相对位置组件描述覆盖 2026-11-03 15:08:17 +08:00
yu 58ea001e7d fix: 相对位置互换未命中时给互不包含综合诊断, 不再误导为尺寸错误 2026-11-03 15:07:43 +08:00
yu f0fca2ebfe test: 相对位置冒烟缺少本机夹具时自动跳过 2026-11-03 15:03:06 +08:00
yu c90584670d test: OCR冒烟缺少本机夹具时自动跳过 2026-11-03 15:02:32 +08:00
yu c973209418 fix: 对比图像相对位置字段正名(被搜索图像/查找目标小图)+填反自动互换兜底, 补OCR与相对位置冒烟回归 2026-11-03 14:59:33 +08:00
yu 9ec301b081 fix: 修复重复能力审计中Base64解码同时保留与隐藏的冲突 2026-11-03 14:54:46 +08:00
yu 7a067173a5 test: OCR组件冒烟回归(本地离线推理): 通用/键值/金额/验证码/卡证/票据/离线文本定位表格 10项真实识别通过 2026-11-03 14:50:25 +08:00
133 changed files with 4964 additions and 834 deletions

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# RPA Studio 开发规范(v1 · 面向 rpa-core 抽离)
> 生效日期:2026-11-04。
> 背景:本平台继续作为日常开发载体;所有新代码必须同时满足"当前可用"和"将来可直接搬入
> `packages/rpa-core`(最终迁入 Agent-Employee)"。迁移路线与任务清单见
> `docs/rpa-core-refactor-tasks.md`,本规范是它的日常执行约束。
## 核心思想
**迁移不是将来的改造,而是现在每次写代码时的选择。** 遵守本规范写的能力,抽包时是搬运;
违反规范写的能力,抽包时是返工。
---
## 一、分层归属(硬性规则)
| 层 | 目录 | 判别标准 | 未来去向 |
|---|---|---|---|
| **L1 包内层** | `src/main/engine/`、`src/main/capabilities/` 大部分、`src/main/browser/external.ts`、`transport.ts` | 不依赖 Electron 窗口即可运行 | rpa-core 包 |
| **L2 宿主层** | `src/main/index.ts`、`overlay`、`desktop-pick`、`vision-lab`、`fixture/`、`local-vision/`、`flows`、`run/`、`settings`、`logger`、`browser/session.ts` 内置浏览器部分 | 需要 Electron 窗口/应用生命周期 | 宿主适配层 |
**判别口诀**:这个能力在没有窗口的 CLI 里能跑吗?能 → L1;不能 → L2。
---
## 二、L1 层禁令(现有代码不追溯,新代码必须遵守)
1. **禁止 `import ... from "electron"`**
需要剪贴板/对话框/截屏/屏幕信息时:先看 Node 等价物(fs/os/child_process),
确实需要宿主能力时在 RunContext 或参数中注入,并在代码处注释 `// HostBridge 候选`。
2. **禁止横向依赖宿主模块**
L1 内禁止 `import "../settings"`、`"../logger"`、`"../flows"` 等。配置走参数/ctx,日志走 `ctx.log`。
3. **禁止私起 PowerShell 进程**
所有 PowerShell 调用必须使用 `capabilities/desktop.ts` 导出的 `powershell()` / `psString()`。
禁止自己 `execFile("powershell.exe", ...)`。原因:将来统一切换为常驻 worker(任务 3.5)。
4. **禁止新增模块级可变全局状态**
不写模块顶层 `const cache = new Map()` / `let globalXxx`。需要跨调用状态时挂在显式 store
或 RunContext 上(目标形态:`createEngine()` 实例持有)。
5. **禁止把 PowerShell 逻辑写成不可拆的内联串**
复杂脚本提取为独立函数/常量,便于将来搬进 WindowsDriver。
## 三、能力编写规范(tool 友好,为 Agent-Employee 准备)
1. **结构化进出**:参数从 `args` 对象取具名字段,返回结构化对象或原始值;
禁止返回"为 UI 拼接的字符串"(渲染层需要展示时自行格式化)。
2. **必有超时**:涉及外部交互(进程/网络/窗口等待)的能力必须有 timeout 参数和默认值。
3. **错误契约**:失败抛 `RuntimeError(code, message, { recommendation })`;
code 用稳定英文枚举(必须登记在 `packages/rpa-core/src/engine/error-codes.ts`
的 `RUNTIME_ERROR_CODES`,任务 4.3 起机器可读),message 中文,recommendation 给可执行建议。禁止裸 throw string。
4. **平台标记**:Windows 专用能力(user32/UIA/COM)在 catalog 注册时带
`platforms: ["win32"]`;能用纯 Node 实现的能力不调 PowerShell。
5. **可测试**:每个能力有同名 `*.test.ts`(vitest),与实现同目录。
6. **结构化 meta(任务 4.1 起,新能力强制)**:`register(ids, handler, meta)` 第三参
必填 `CapabilityMeta`(summary / params / returns / timeoutMs / errorCodes /
needsScreenshot / platforms)。老能力逐步补齐,review 时放行不带 meta 的旧文件改动。
## 四、新能力验收 checklist
- [ ] 归属判定正确(L1/L2 口诀)
- [ ] 无 electron / 宿主模块 import(L1 时)
- [ ] PowerShell 只经 `powershell()` 助手(如需要)
- [ ] 无新增模块级全局状态
- [ ] 结构化参数/返回 + 超时 + RuntimeError 错误契约
- [ ] catalog 注册完整(含 platforms 标记)
- [ ] 有测试,`npm run test` + `npm run typecheck` 全绿
## 五、渐进收紧节奏
规范随改造阶段自动收紧,不必一步到位:
| 里程碑(见任务清单) | 规范变化 |
|---|---|
| 现在 | 本文第二节禁令生效(靠 review 执行) |
| 阶段 2 完成 | L1 禁 electron 升级为 ESLint no-restricted-imports 强制 |
| 阶段 3 完成 | `powershell()` 收口进 `driver/windows/`,L1 禁止直接引用 |
| 阶段 4 完成 | 无 schema 的新能力不允许进 catalog |
## 六、明确不动的部分
- 现有 77 处 PowerShell 调用点:**不做预防性重构**,统一等阶段 3 的 driver 抽象一次性处理。
之前只修 bug、只加能力,不重写。
- 渲染层(React)按产品需求正常开发,不为其迁移性投资。
- `local-vision/`、`overlay/` 等宿主层模块正常开发,不受 L1 禁令约束。
+116
View File
@@ -0,0 +1,116 @@
# rpa-core 能力层抽离改造任务清单
> 目标:把 `src/main/capabilities/` + `src/main/engine/` 抽成零 Electron 依赖的独立包,
> 先在当前 demo 验证,最终以包为单位迁入 `D:\code\Agent-Employee`。
> 风险评估见对话记录(2026-11-04),任务编号 R1~R10 对应风险清单。
## 总体原则
1. **单一源**:包建成后 demo 与未来宿主都通过 workspace 引用,任何时候禁止复制代码(R3)。
2. **行为不变**:抽离期间能力行为保持不变,靠现有 vitest 套件做安全网。
3. **先断依赖再抽象**:Electron 依赖清零优先于 driver 接口美化。
---
## 阶段 0 — 基线与护栏
- [ ] **0.1 确认基线全绿**
`npm run test` + `npm run typecheck` 全部通过,记录通过数作为回归基线。
- [ ] **0.2 确定包位置与工作区结构**
建 `packages/rpa-core/`,demo 的 `package.json` 加 workspaces 配置,源码从 `src/main` 移入后以 `@rpa/core` 引用回主进程。
- [ ] **0.3 生成能力目录快照**
用 `npm run catalog` 的产物做一份快照(能力 id / 参数 / 描述清单),抽离完成后 diff 必须为零,作为行为回归基准。
## 阶段 1 — 建包与纯 Node 能力搬运
- [ ] **1.1 包骨架**
`packages/rpa-core`:`package.json`(deps: exceljs / pdf-lib / nodemailer / basic-ftp / adm-zip / pngjs)、`tsconfig.json`、独立 vitest 配置(R6:包内保留 vitest,不迁 bun test)。
- [ ] **1.2 领域类型解耦(R2)**
搬 `engine/`(registry / types / runtime / expression),将 `shared/types` 依赖改为包内自有领域类型(FlowDefinition / FlowNode / CatalogComponent / RunError)。宿主后续做模型映射,不把画布模型塞进包。
- [ ] **1.3 全局状态收拢为工厂(R4)**
新增 `createEngine()` 工厂:excel-store 的模块级 `Map` 缓存、session 的 `globalElementWaitMs` 等进程级状态挂到引擎实例上;registry 注册目标从全局单例改为引擎实例。
- [ ] **1.4 搬运纯 Node 能力**
files / data / data-extended / http / ftp / mail / mssql / mysql / pg / excel(exceljs)/ word / pdf / office-file / time / crypto / data-table 等不依赖 Electron 的能力文件。
- [ ] **1.5 搬运对应测试**
上述能力的 `*.test.ts` 一并移入包内,路径修正后全量跑绿。
- [ ] **1.6 demo 主进程改为引用包**
`src/main` 里被搬走的部分改 import `@rpa/core`,`npm run test` 全绿、catalog 快照 diff 为零。
## 阶段 2 — 断 Electron 依赖
- [ ] **2.1 定义 HostBridge 接口**
包内声明 `HostBridge`:clipboard / dialog / screen / userAgentDataPath 等宿主能力;demo 的 Electron 主进程提供实现并注入。
- [ ] **2.2 改造直接依赖 Electron 的能力(3 处)**
`capabilities/human-dialog.ts`(BrowserWindow)、`capabilities/window-extended.ts`(screen)、`capabilities/ai-service.ts`(settings)→ 走 HostBridge 注入。
- [ ] **2.3 浏览器子系统切分(R1)**
`browser/external.ts` + `transport.ts` 的外部浏览器 CDP 路径搬进包;`session.ts` 中依赖内置 `BrowserWindow` 的部分留在宿主适配层,包内通过 `BrowserLike` 接口(types.ts 已有)注入。
- [ ] **2.4 防回归护栏**
在包内加依赖检查脚本(或 ESLint no-restricted-imports 规则):禁止 `import "electron"` 及对 `src/main` 的相对引用,CI 里跑。
- [ ] **2.5 Electron 相关能力留守清单**
overlay / desktop-pick / vision-lab / fixture server / human-dialog 的 UI 部分:明确标记"宿主适配层",从包中排除,记录在包 README。
## 阶段 3 — Driver 抽象(Windows 自动化能力)
- [ ] **3.1 拆出 powershell 助手(R5)**
`powershell()` / `psString()` / `USER32` 从 `capabilities/desktop.ts` 移到 `driver/windows/powershell.ts`;修正 11 个引用文件的 import;测试全绿。
- [ ] **3.2 定义 AutomationDriver 接口**
按域拆分:`mouse` / `keyboard` / `window` / `element`(UIA)/ `screen`。接口签名以现有 PowerShell 能力的输入输出为准,不重新发明。
- [ ] **3.3 现有实现包进 WindowsDriver**
desktop / desktop-uia / window-extended / remaining-core / system-extended / common-final / message / utility-extended 中的 user32/UIA 调用改为面向接口;`WindowsDriver` 内部仍用 PowerShell 实现(行为不变)。
- [ ] **3.4 driver 注入与平台标记**
引擎创建时注入 driver;能力目录(catalog)加 `platforms: ["win32"]` 字段,渲染层据此置灰。
- [ ] **3.5(可选,迁移前不阻塞)PowerShell 常驻 worker**
单进程 JSON 行协议替代每次 spawn;`powershell()` 接口签名不变,内部切换。性能优化项,放在功能等价验收之后。
## 阶段 4 — Tool 契约(为 Agent-Employee 做)
- [ ] **4.1 能力 schema 化**
每个能力导出结构化定义:参数类型 / 返回值 / 超时 / 错误码 / 是否需要截图回传。新能力一律按此格式写。
- [ ] **4.2 注册机制显式化**
取消 import 副作用注册,改为显式 `registerAll(engine)` 导出,宿主可按子集注册。
- [ ] **4.3 错误契约规范化(R9)**
RuntimeError 错误码枚举固化,中文 message 保留,增加机器可读字段,供 Agent 侧消费。
## 阶段 5 — 独立验证
- [ ] **5.1 CLI host**
写 `scripts/rpa-cli.ts`:Node 直接拉起 `createEngine()` + `WindowsDriver`,跑通 5 个代表能力(1 个桌面、1 个 Excel、1 个 HTTP、1 个文件、1 个窗口)。
- [ ] **5.2 Bun 冒烟(R6/R7)**
在 Agent-Employee 环境用 `bun test` 跑包内纯逻辑测试,验证 exceljs / pdf-lib 等依赖在 Bun 下正常;onnxruntime 相关暂不进包,另行验证。
- [ ] **5.3 demo 全量回归**
demo 切到 workspace 引用后:test + typecheck + catalog 快照 diff 三项全绿。
## 阶段 6 — 迁入 Agent-Employee
- [ ] **6.1 ADR 评审**
按 Agent-Employee 流程提交架构决策记录:包边界 / HostBridge 契约 / driver 生命周期。
- [ ] **6.2 包迁入 `packages/`**
整体搬入,改为 bun workspace 成员;demo 侧保留只读引用或解除引用。
- [ ] **6.3 宿主适配层**
按 Agent-Employee 的 product-shell 契约写 host 适配(clipboard / dialog / screen / BrowserLike 实现)。
- [ ] **6.4 权限与治理对接(R10)**
`System_ExecuteCommand` 等危险能力接入其权限模型;能力目录与插件体系对齐。
---
## 风险 → 任务映射
| 风险 | 缓解任务 |
|---|---|
| R1 浏览器子系统耦合 Electron | 2.3 |
| R2 领域模型冲突 | 1.2、阶段 4 |
| R3 代码漂移 | 0.2、总原则 1 |
| R4 模块级全局状态 | 1.3 |
| R5 powershell 助手位置 | 3.1 |
| R6 测试运行器不兼容 | 1.1、5.2 |
| R7 原生依赖 Bun 兼容 | 5.2(能力层已确认为纯 JS) |
| R8 注册副作用 | 4.2 |
| R9 错误契约 | 4.3 |
| R10 权限治理 | 6.4 |
## 明确不迁入包的部分
- `src/renderer/`(React 渲染层,Agent-Employee 用 SolidJS,不迁移)
- `overlay.ts` / `desktop-pick.ts` / `vision-lab.ts` / `fixture/`(Electron 窗口强耦合,留宿主)
- `local-vision/`(onnxruntime-node 原生依赖,Bun 兼容性单独立项验证)
- `flows.ts` / `run/manager.ts` / `settings.ts`(应用级编排与持久化,属宿主职责)
+6 -3
View File
@@ -6,9 +6,12 @@ export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
"@shared": resolve("src/shared")
}
alias: [
{ find: "@shared", replacement: resolve("src/shared") },
// rpa-core 包以源码形式打进主进程产物(不作为外部依赖运行时 require)
{ find: /^@rpa\/core$/, replacement: resolve("packages/rpa-core/src/index.ts") },
{ find: /^@rpa\/core\/capabilities$/, replacement: resolve("packages/rpa-core/src/capabilities/index.ts") }
]
},
build: {
rollupOptions: {
+57 -2
View File
@@ -7,6 +7,9 @@
"": {
"name": "rpa-studio",
"version": "0.1.0",
"workspaces": [
"packages/*"
],
"dependencies": {
"@cantoo/pdf-lib": "^2.11.1",
"@gutenye/ocr-models": "^1.4.2",
@@ -31,7 +34,6 @@
"react-dom": "^18.3.1",
"sm-crypto": "^0.5.8",
"tesseract.js": "^7.0.0",
"ws": "^8.22.0",
"zustand": "^5.0.2"
},
"devDependencies": {
@@ -43,11 +45,11 @@
"@types/pngjs": "^6.0.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.18.2",
"@vitejs/plugin-react": "^4.7.0",
"electron": "31.7.7",
"electron-builder": "^26.15.7",
"electron-vite": "^6.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^7.1.0",
"vitest": "^2.1.8"
@@ -3140,6 +3142,10 @@
"win32"
]
},
"node_modules/@rpa/core": {
"resolved": "packages/rpa-core",
"link": true
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
@@ -10770,6 +10776,25 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz",
@@ -12645,6 +12670,36 @@
"optional": true
}
}
},
"packages/rpa-core": {
"name": "@rpa/core",
"version": "0.1.0",
"dependencies": {
"@gutenye/ocr-node": "^1.4.9",
"adm-zip": "^0.5.16",
"basic-ftp": "^6.2.3",
"exceljs": "^4.4.0",
"imapflow": "^2.10.0",
"mailparser": "^3.14.1",
"mssql": "^12.7.13",
"mysql2": "^3.24.6",
"nodemailer": "^6.9.16",
"pdf-lib": "^1.17.1",
"pg": "^8.23.1",
"pngjs": "^7.0.0",
"sm-crypto": "^0.5.8",
"ws": "^8.22.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/mssql": "^12.3.0",
"@types/nodemailer": "^6.4.17",
"@types/pg": "^8.23.1",
"@types/pngjs": "^6.0.5",
"@types/ws": "^8.18.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
}
}
+7 -2
View File
@@ -4,6 +4,9 @@
"private": true,
"description": "Canvas-based RPA desktop application",
"main": "./out/main/index.js",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "electron-vite dev",
"dev:local": "node scripts/dev-safe.mjs",
@@ -11,7 +14,10 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check:deps": "node packages/rpa-core/scripts/check-deps.mjs",
"rpa-cli": "tsx scripts/rpa-cli.ts",
"catalog": "node scripts/build-catalog.mjs",
"snapshot": "vitest run scripts/capability-snapshot.test.ts",
"package": "node scripts/package.mjs",
"test": "vitest run",
"test:e2e": "node scripts/demo-e2e.mjs"
@@ -40,7 +46,6 @@
"react-dom": "^18.3.1",
"sm-crypto": "^0.5.8",
"tesseract.js": "^7.0.0",
"ws": "^8.22.0",
"zustand": "^5.0.2"
},
"devDependencies": {
@@ -52,12 +57,12 @@
"@types/pngjs": "^6.0.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.18.2",
"@vitejs/plugin-react": "^4.7.0",
"electron": "31.7.7",
"electron-builder": "^26.15.7",
"electron-vite": "^6.0.0",
"typescript": "^5.7.2",
"tsx": "^4.19.2",
"vite": "^7.1.0",
"vitest": "^2.1.8"
},
+61
View File
@@ -0,0 +1,61 @@
# @rpa/core
RPA 能力层包:零 Electron 依赖的流程引擎、能力注册表与能力实现。
抽离路线见 `docs/rpa-core-refactor-tasks.md`,开发规范见 `docs/dev-conventions.md`。
## 包边界
| 模块 | 说明 |
|---|---|
| `src/engine/` | 注册表、运行时、表达式、`createEngine()` 工厂、`host-bridge.ts` 宿主桥 |
| `src/driver/` | `types.ts` 五域 AutomationDriver 接口(mouse/keyboard/window/screen/element)+ `windows/powershell.ts` 进程助手 + `windows/windows-driver.ts` PowerShell 实现 |
| `src/capabilities/` | 能力实现(副作用注册,经 `@rpa/core/capabilities` 拉起) |
| `src/browser/` | 外部浏览器 CDP:`external.ts`(Edge/Chrome 进程管理)+ `transport.ts`(CdpTransport 接口、ExternalTransport) |
## HostBridge 契约(决策点 4 最小面)
宿主(Electron 主进程 / 未来 Agent-Employee product-shell)启动时注入:
```ts
import { setHostBridge, setSubflowSource } from "@rpa/core";
setHostBridge({
createFormWindow({ title }) { /* 返回 HostDialogWindow 适配(BrowserWindow) */ },
dipToScreenPoint(p) { /* screen.dipToScreenPoint */ },
screenToDipPoint(p) { /* screen.screenToDipPoint */ },
loadAiConfig() { /* settings.aiConfig */ }
});
setSubflowSource({ listFlows, getFlow }); // 子流程仓库
```
未注入时的行为:window-extended 的 DPI 换算回退恒等(与旧 vitest 行为一致);
ai-service 返回空配置(requireAiConfig 统一抛 `ai_not_configured`);
human-dialog 抛 `host_unavailable`。
## Electron 相关能力留守清单(宿主适配层,不入包)
- `src/main/browser/session.ts`:BrowserSession 编排 + 内置运行浏览器(依赖 `BrowserWindow` 与应用生命周期)
- `src/main/browser/transport.ts`:`EmbeddedTransport`(包装 `webContents.debugger`)
- `src/main/capabilities/browser*.ts` / `web-batch2.ts` / `web-crawl.ts`:经 BrowserSession 操作浏览器窗口
- `src/main/capabilities/ai-caps.ts`:AI 编排能力(依赖宿主注入的 chatCompletion 配置面,可随后续阶段评估迁入)
- `src/main/overlay.ts` / `desktop-pick.ts` / `vision-lab.ts` / `fixture/`:Electron 窗口强耦合
- `src/main/local-vision/`:onnxruntime-node 原生依赖,Bun 兼容性单独立项验证
- `src/main/flows.ts` / `run/` / `settings.ts`:应用级编排与持久化
## 依赖护栏
`npm run check:deps`(根目录亦可)扫描 `src/**`:禁止 `import "electron"`、
相对引用逃出包 `src/`、引用宿主 `src/main`。阶段 2 起为提交前必跑项。
## Driver(阶段 3)
- 五域接口 `AutomationDriver`(决策点 6):`RpaEngine.driver` 默认 `new WindowsDriver()`,
`createEngine()` 可注入自定义实现(CLI/Linux driver 等未来形态)。
- 驱动只承载**原始注入原语**(单次 SetCursorPos / SendKeys / 窗口操作);拟真抖动、
DIP 坐标换算、多步组合等策略留在能力文件,保证机械替换不改时序行为。
- element 域实现承载在 `capabilities/desktop-uia.ts`(经 `setElementDriverImplementation`
注册到 WindowsDriver),handler 层面向 driver 的收口在阶段 5/6 按需进行。
- `powershell()` 助手统一位于 `driver/windows/powershell.ts`,包内能力禁止自行
`execFile("powershell.exe")`(check:deps 与 review 双重把关)。
- 平台标记:`npm run catalog` 构建时按组填充 `platformTag: "win32"`(desktop/app/
office/vision 组),渲染层置灰延后(决策点 7)。
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@rpa/core",
"version": "0.1.0",
"private": true,
"description": "RPA 能力层:零 Electron 依赖的流程引擎、能力注册表与纯 Node 能力",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./capabilities": "./src/capabilities/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check:deps": "node scripts/check-deps.mjs"
},
"dependencies": {
"@gutenye/ocr-node": "^1.4.9",
"adm-zip": "^0.5.16",
"basic-ftp": "^6.2.3",
"exceljs": "^4.4.0",
"imapflow": "^2.10.0",
"mailparser": "^3.14.1",
"mssql": "^12.7.13",
"mysql2": "^3.24.6",
"nodemailer": "^6.9.16",
"pdf-lib": "^1.17.1",
"pg": "^8.23.1",
"pngjs": "^7.0.0",
"sm-crypto": "^0.5.8",
"ws": "^8.22.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/mssql": "^12.3.0",
"@types/nodemailer": "^6.4.17",
"@types/pg": "^8.23.1",
"@types/pngjs": "^6.0.5",
"@types/ws": "^8.18.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* rpa-core 依赖护栏(任务 2.4,规范 v1 第二节的机器强制版)。
*
* 禁止:
* 1. import "electron"(含 "electron/..." 子路径);
* 2. 相对 import 逃出包 src/(如指向宿主 src/main 的 ../../../src/main/...);
* 3. 任何 import 路径中出现 src/main(宿主模块横向依赖)。
*
* 用法:node scripts/check-deps.mjs(退出码非 0 表示违规)
*/
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcRoot = path.join(pkgRoot, "src");
const IMPORT_RE = /(?:import|export)\s+(?:[\s\S]*?from\s*)?["']([^"']+)["']/g;
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await walk(full)));
else if (/\.tsx?$/.test(entry.name)) files.push(full);
}
return files;
}
const offenders = [];
for (const file of await walk(srcRoot)) {
const raw = await readFile(file, "utf8");
// 剥掉注释,避免文档示例里的 "electron" 字样误报
const source = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
for (const match of source.matchAll(IMPORT_RE)) {
const spec = match[1];
const rel = path.relative(pkgRoot, file).replaceAll("\\", "/");
if (spec === "electron" || spec.startsWith("electron/")) {
offenders.push(`${rel}: 禁止 import "${spec}"(宿主能力走 HostBridge 注入)`);
continue;
}
if (spec.includes("src/main")) {
offenders.push(`${rel}: 禁止引用宿主模块 "${spec}"`);
continue;
}
if (spec.startsWith(".")) {
const resolved = path.resolve(path.dirname(file), spec);
if (!resolved.startsWith(srcRoot + path.sep) && resolved !== srcRoot) {
offenders.push(`${rel}: 相对引用逃出包 src/ → "${spec}"`);
}
}
}
}
if (offenders.length) {
console.error("rpa-core 依赖护栏检查失败:");
for (const line of offenders) console.error(" - " + line);
process.exit(1);
}
console.log(`rpa-core 依赖护栏检查通过(src/ 下 ${offenders.length === 0 ? "无" : ""}违规 import)`);
File renamed without changes.
+180
View File
@@ -0,0 +1,180 @@
import WebSocket from "ws";
import { RuntimeError } from "../engine/types";
/**
* CDP 传输层抽象。
*
* 上层 BrowserSession(宿主适配层)的全部 CDP 通信都经由本接口:
* - EmbeddedTransport:内置运行浏览器(Electron BrowserWindow.webContents.debugger),留宿主
* - ExternalTransport:外部 Edge/Chrome 浏览器(WebSocket 直连调试端口),在本包内
*
* 两者只在传输方式上不同,CDP 协议域(DOM/Input/Page/Network/Runtime)完全一致。
*/
export type CdpEventHandler = (params: Record<string, unknown>) => void;
export interface CdpTransport {
readonly kind: "embedded" | "external";
/** 连接是否已失效(窗口销毁 / ws 断开) */
readonly closed: boolean;
send<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
/** 订阅 CDP 事件(如 Page.loadEventFired、Network.responseReceived),handler 只收到对应 method 的 params */
on(method: string, handler: CdpEventHandler): void;
off(method: string, handler: CdpEventHandler): void;
close(): Promise<void>;
}
export function mapCdpError(error: unknown, kind: "embedded" | "external"): RuntimeError {
const message = (error as Error)?.message || String(error);
if (/destroyed|Target closed|not attached|connection.*(closed|lost)|websocket/i.test(message)) {
if (kind === "external") {
return new RuntimeError("browser_disconnected", "外部浏览器连接已断开", {
detail: message,
recommendation: "确认浏览器仍处于打开状态;若被手动关闭请重新运行「打开网页」"
});
}
return new RuntimeError("tab_unavailable", "浏览器连接已失效", { detail: message });
}
return new RuntimeError("browser_error", message, { recommendation: "重试或检查页面状态" });
}
interface CdpMessage {
id?: number;
method?: string;
params?: Record<string, unknown>;
result?: unknown;
error?: { message?: string; data?: unknown };
}
/** 外部 Edge/Chrome:WebSocket 直连页面级 CDP 端点(webSocketDebuggerUrl) */
export class ExternalTransport implements CdpTransport {
readonly kind = "external" as const;
private ws: WebSocket | null = null;
private nextId = 1;
private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private handlers = new Map<string, Set<CdpEventHandler>>();
private closedFlag = false;
private readonly openPromise: Promise<void>;
private constructor(
wsUrl: string,
readonly description: string
) {
this.openPromise = new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
this.ws = ws;
ws.on("open", () => resolve());
ws.on("message", (data: unknown) => this.handleMessage(String(data)));
ws.on("error", (error: Error) => {
this.closedFlag = true;
this.failAllPending(new Error(`外部浏览器连接异常:${error.message}`));
reject(error);
});
ws.on("close", () => {
this.closedFlag = true;
this.failAllPending(new Error("外部浏览器连接已关闭"));
});
});
}
static async connect(wsUrl: string, description: string, timeoutMs = 10000): Promise<ExternalTransport> {
const transport = new ExternalTransport(wsUrl, description);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
);
await Promise.race([transport.openPromise, timeout]);
return transport;
}
get closed(): boolean {
return this.closedFlag || !this.ws || this.ws.readyState !== this.ws.OPEN;
}
async send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
if (this.closed) {
throw new RuntimeError("browser_disconnected", "外部浏览器连接已断开", {
recommendation: "确认浏览器未被手动关闭,然后重新运行「打开网页」"
});
}
const id = this.nextId++;
const ws = this.ws as WebSocket;
const reply = new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
ws.send(JSON.stringify({ id, method, params }));
} catch (error) {
this.pending.delete(id);
reject(error as Error);
}
});
try {
return (await reply) as T;
} catch (error) {
throw mapCdpError(error, "external");
}
}
on(method: string, handler: CdpEventHandler): void {
if (!this.handlers.has(method)) this.handlers.set(method, new Set());
this.handlers.get(method)?.add(handler);
}
off(method: string, handler: CdpEventHandler): void {
this.handlers.get(method)?.delete(handler);
}
async close(): Promise<void> {
const ws = this.ws;
this.closedFlag = true;
this.failAllPending(new Error("外部浏览器连接已主动关闭"));
if (!ws) return;
await new Promise<void>((resolve) => {
if (ws.readyState === ws.CLOSED) return resolve();
ws.once("close", () => resolve());
try {
ws.close();
} catch {
resolve();
}
setTimeout(() => resolve(), 1500);
});
}
private handleMessage(raw: string): void {
let message: CdpMessage;
try {
message = JSON.parse(raw) as CdpMessage;
} catch {
return;
}
if (typeof message.id === "number") {
const entry = this.pending.get(message.id);
if (!entry) return;
this.pending.delete(message.id);
if (message.error) {
entry.reject(new Error(message.error.message || "CDP 调用失败"));
} else {
entry.resolve(message.result);
}
return;
}
if (message.method) {
const handlers = this.handlers.get(message.method);
if (!handlers) return;
const params = message.params ?? {};
for (const handler of handlers) {
try {
handler(params);
} catch {
// 单个监听器异常不影响其他监听器
}
}
}
}
private failAllPending(error: Error): void {
for (const [id, entry] of this.pending) {
entry.reject(error);
this.pending.delete(id);
}
}
}
@@ -1,4 +1,4 @@
import { loadSettings } from "../settings";
import { getHostBridge } from "../engine/host-bridge";
import { RuntimeError } from "../engine/types";
export type ChatContentPart =
@@ -18,7 +18,9 @@ export interface ResolvedAiConfig {
}
export function getAiConfig(): ResolvedAiConfig {
const config = loadSettings().aiConfig;
const bridge = getHostBridge();
// 宿主桥未注入(vitest / CLI 未配置)时返回空配置,requireAiConfig 统一抛 ai_not_configured
const config = bridge ? bridge.loadAiConfig() : { baseUrl: "", apiKey: "", model: "", visionModel: "" };
return { ...config, visionModel: config.visionModel || config.model };
}
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
vi.mock("./desktop", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
vi.mock("../driver/windows/powershell", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
import "./data";
import "./control";
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./data";
import "./desktop";
import "./system-extended";
import "./utility-extended";
import "./common-final";
import "./control";
import "./windows-system";
import "./basic-encryption";
const ctx = {
signal: new AbortController().signal,
log: () => {},
vars: {},
evidenceDir: "D:/office-test2",
runId: "smoke-run",
flow: {
id: "smoke-flow", name: "冒烟测试流程", description: "", enabled: true,
trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" },
nodes: []
}
} as unknown as RunContext;
const node = { id: "smoke-app", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
async function run(componentId: string, args: Record<string, unknown>, ctxOverride?: RunContext): Promise<unknown> {
const handler = getHandler(componentId);
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
const n = { ...node, componentId } as unknown as FlowNode;
return handler(ctxOverride ?? ctx, n, args, {} as CatalogComponent);
}
describe("应用与系统组件冒烟", () => {
it("数据类型判断族", async () => {
expect(await run("Basic_IsNumber", { val: "12.5" })).toBe(true);
expect(await run("Basic_IsNumber", { val: "abc" })).toBe(false);
expect(await run("Basic_IsNull", { val: "" })).toBe(true);
expect(await run("Basic_IsNull", { val: "x" })).toBe(false);
expect(await run("Basic_IsArray", { val: [1, 2] })).toBe(true);
expect(await run("Basic_IsDictionary", { val: { a: 1 } })).toBe(true);
expect(await run("Basic_Type", { val: [1] })).toBe("list");
expect(await run("Basic_Type", { val: "s" })).toBe("string");
expect(await run("Basic_JudgeDataType", { val: 5, into_type: "int" })).toBe(true);
expect(await run("Basic_JudgeDataType", { val: 5.5, into_type: "int" })).toBe(false);
console.log("[数据判断族] 通过");
});
it("数据转换族", async () => {
expect(await run("Basic_ConvertString", { val: 123 })).toBe("123");
expect(await run("Basic_ConvertInt", { val: "12.9" })).toBe(12);
expect(await run("Basic_ConvertBool", { val: 1 })).toBe(true);
expect(await run("Basic_ConvertNumber", { val: "3.14" })).toBe(3.14);
expect(await run("Basic_DataTypeConversion", { val: "是", into_type: "bool" })).toBe(true);
expect(await run("Basic_DataTypeConversion", { val: 8, into_type: "string" })).toBe("8");
console.log("[数据转换族] 通过");
});
it("复制/随机/密文", async () => {
const src = { a: [1, { b: 2 }] };
const cloned = (await run("Basic_Clone", { val: src })) as typeof src;
expect(cloned).toEqual(src);
expect(cloned).not.toBe(src);
const ri = (await run("Basic_RandomInt", { min: 5, max: 5 })) as number;
expect(ri).toBe(5);
const rnd = (await run("Basic_Rnd", {})) as number;
expect(rnd).toBeGreaterThanOrEqual(0);
expect(rnd).toBeLessThan(1);
expect(await run("Basic_Dodecryption", { words: Buffer.from("hello-世界", "utf8").toString("base64") })).toBe("hello-世界");
console.log("[复制/随机/密文] 通过");
});
it("延迟/调试输出/设置变量", async () => {
const logs: string[] = [];
const ctxLog = { ...ctx, log: (m: string) => logs.push(m), vars: {} } as unknown as RunContext;
const t0 = Date.now();
expect(await run("Basic_Delay", { mileSecond: 100 })).toBe(0.1);
expect(Date.now() - t0).toBeGreaterThanOrEqual(95);
await run("Basic_DebugOutput", { msg: "冒烟输出" }, ctxLog);
expect(logs.join()).toContain("冒烟输出");
const setNode = { ...node, options: { outputVar: "v_test" } } as unknown as FlowNode;
const handler = getHandler("Basic_SetVariable_V1")!;
expect(await handler(ctxLog, setNode, { value: "42", variableType: "rpa_int" }, {} as CatalogComponent)).toBe(42);
expect((ctxLog as unknown as { vars: Record<string, unknown> }).vars.v_test).toBe(42);
console.log("[延迟/输出/变量] 通过");
});
it("自定义配置与业务计数", async () => {
const cfg = (await run("CustomConfig_GetConfig", {
jsonlist: JSON.stringify([{ name: "host", value: "127.0.0.1" }]),
configName: "host"
})) as string;
expect(cfg).toBe("127.0.0.1");
const ctxCount = { ...ctx, vars: {} } as unknown as RunContext;
expect(await run("Basic_BusinessOrderCount", { start_count: 100, count: 3 }, ctxCount)).toBe(103);
expect(await run("Basic_BusinessOrderCount", { start_count: 100, count: 3 }, ctxCount)).toBe(106);
console.log("[配置/计数] 通过");
});
it("系统路径与环境变量", async () => {
expect(String(await run("System_GetTempPath", {}))).toMatch(/Temp|tmp/i);
expect(String(await run("System_GetHomePath", {}))).toMatch(/Users|home/i);
expect(String(await run("System_GetSystemPath", { path_name: "desktop" }))).toMatch(/Desktop/);
expect(await run("System_GetEnv", { name: "PATH" })).toBeTruthy();
expect(await run("System_SetEnv", { name: "RPA_SMOKE", value: "ok" })).toBe("ok");
expect(await run("System_GetEnv", { name: "RPA_SMOKE" })).toBe("ok");
expect(await run("Basic_GetProcessInfo", {})).toBeTruthy();
console.log("[系统路径/环境变量] 通过");
});
it("输入法状态获取", async () => {
const st = (await run("System_GetInputMethodState", {})) as { culture: string; layout: string };
console.log("[输入法]", JSON.stringify(st));
expect(st.culture).toBeTruthy();
}, 60000);
it("执行命令行两种组件", async () => {
const out1 = String(await run("System_ExecuteCommand", { command: "echo rpa-smoke-123" }));
expect(out1).toContain("rpa-smoke-123");
const out2 = String(await run("System_ExecuteCommandV1", { command_type: "1", dos_command: "echo dos-ok-456" }));
expect(out2).toContain("dos-ok-456");
const out3 = String(await run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output 'ps-ok-789'" }));
expect(out3).toContain("ps-ok-789");
console.log("[执行命令] 通过");
}, 120000);
it("应用生命周期: 启动→查PID→关闭", async () => {
const pid = (await run("App_Run", { executeable: "notepad.exe" })) as number;
expect(Number.isInteger(pid)).toBe(true);
await new Promise((r) => setTimeout(r, 1200));
// Win11 notepad.exe 为应用别名:Start-Process 返回的启动器 PID 会立即退出,真实进程另获新 PID,
// 因此不比较具体 PID,只验证「能查到运行中的 notepad 进程」
const single = (await run("App_GetPid", { pattern: "notepad" })) as number | null;
expect(single).toBeTruthy();
const all = (await run("App_GetPids", { p_name: "notepad" })) as number[];
expect(all.length).toBeGreaterThan(0);
const killed = await run("App_KillV1", { close_type: "name", p_name: "notepad" });
console.log("[应用生命周期] 启动PID:", pid, "查询到:", all, "关闭:", JSON.stringify(killed));
await new Promise((r) => setTimeout(r, 800));
const after = (await run("App_GetPids", { pattern: "notepad" })) as number[];
expect(after.length).toBe(0);
}, 120000);
it("Windows凭据: 添加→读取→删除", async () => {
const target = "RPA_SMOKETEST_GENERIC";
expect((await run("System_AddCredential", { application: target, name: "smoke-user", password: "p@ss" })) as { added: boolean }).toEqual({ added: true, target });
const read = (await run("System_ReadCredential", { application: target })) as { exists: boolean; detail: string };
expect(read.exists).toBe(true);
expect(read.detail).toContain("smoke-user");
expect((await run("System_DeleteCredential", { application: target })) as { deleted: boolean }).toEqual({ deleted: true, target });
const readAfter = (await run("System_ReadCredential", { application: target })) as { exists: boolean };
expect(readAfter.exists).toBe(false);
console.log("[凭据闭环] 通过");
}, 120000);
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./desktop";
import "./basic-encryption";
@@ -7,7 +7,7 @@ import { PDFDocument } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import "./desktop";
import "./office";
import "./pdf";
@@ -4,10 +4,10 @@ import os from "node:os";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./desktop"; import "./pdf"; import "./system-extended"; import "./window-extended"; import "./common-final";
import "./index";
const dirs: string[] = []; const context = { vars: {} } as RunContext; const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
@@ -4,7 +4,11 @@ import path from "node:path";
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";
import { getHandler, register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import { defaultEngine } from "../engine/engine";
// 窗口/屏幕查询统一走驱动(任务 3.3)
const driver = defaultEngine.driver;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); }
@@ -20,25 +24,21 @@ register("Window_GetRect", delegate("Window_GetWindowRect"));
register("Window_MaximumSize", (ctx, node, args, component) => delegate("Window_SetWindowState")(ctx, node, { ...args, WindowType: "maximize" }, component));
register(["Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2"], delegate("Keyboard_Send_V1"));
register("Window_GetScreenSize", async () => {
const output = await powershell("Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; @{width=$b.Width;height=$b.Height;x=$b.X;y=$b.Y} | ConvertTo-Json -Compress");
return JSON.parse(output);
});
register("Window_GetScreenSize", async () => driver.screen.size());
register("Window_WindowList", async (_ctx, _node, args) => {
const title = text(args, ["match_title", "select_title"]); const regex = String(args.is_regex ?? "0") === "1";
const output = await powershell(`Get-Process | Where-Object { $_.MainWindowHandle -ne 0 ${title ? (regex ? `-and $_.MainWindowTitle -match ${psString(title)}` : `-and $_.MainWindowTitle -like ${psString(`*${title}*`)}`) : ""} } | ForEach-Object { @{pid=$_.Id;title=$_.MainWindowTitle;hwnd=$_.MainWindowHandle.ToInt64()} } | ConvertTo-Json -Compress`);
if (!output) return []; const parsed = JSON.parse(output); return Array.isArray(parsed) ? parsed : [parsed];
return driver.window.list({ title, regex });
});
register("Window_SetWindowShow", async (_ctx, node, args) => {
const title = text(args, ["窗口对象", "window_obj", "title"]); if (!title || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const show = !text(args, ["WindowType"], "show").toLowerCase().includes("hide");
await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;if(!$p){throw '找不到窗口'};Add-Type @'\nusing System;using System.Runtime.InteropServices;public static class W{[DllImport("user32.dll")]public static extern bool ShowWindow(IntPtr h,int n);}\n'@;[W]::ShowWindow($p.MainWindowHandle,${show ? 5 : 0})|Out-Null`);
await driver.window.setState(title, show ? 5 : 0);
return show;
});
register("Window_WaitWindow", async (_ctx, node, args) => {
const title = text(args, ["match_title", "select_title", "window_obj", "target_window"]); if (!title) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const expected = text(args, ["switchType"], "0") !== "1"; const deadline = Date.now() + Math.max(0, Number(args.second ?? 5)) * 1000;
do { const exists = (await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;[bool]$p`)).toLowerCase() === "true"; if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
do { const exists = await driver.window.exists(title); if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
return false;
});
@@ -13,7 +13,7 @@ const detectMock = vi.hoisted(() => vi.fn(async () => ({
// desktopCapturer 模拟: 默认无画面(走 GDI 回退), 多屏用例按请求宽度提供对应显示器画面
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
vi.mock("./desktop", () => ({ powershell: powershellMock }));
vi.mock("../driver/windows/powershell", () => ({ powershell: powershellMock }));
vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));
@@ -1,6 +1,6 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell } from "./desktop";
import { powershell } from "../driver/windows/powershell";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
import type { Catalog, CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./data-extended";
@@ -0,0 +1,283 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { capabilityIds, getHandler } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./data";
import "./data-extended";
import "./files";
import "./control";
const ctx = {
signal: new AbortController().signal,
log: () => {},
vars: {} as Record<string, unknown>,
evidenceDir: "D:/office-test2",
runId: "smoke-run",
flow: { id: "smoke-flow", name: "冒烟", description: "", enabled: true, trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" }, nodes: [] }
} as unknown as RunContext;
const node = { id: "smoke-data", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
async function run(componentId: string, args: Record<string, unknown>, context?: RunContext): Promise<unknown> {
const handler = getHandler(componentId);
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
return handler(context ?? ctx, { ...node, componentId } as unknown as FlowNode, args, {} as CatalogComponent);
}
const sandbox = mkdtempSync(path.join(tmpdir(), "rpa-data-smoke-"));
afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); });
describe("数据处理/文件/编排 冒烟", () => {
it("字符串族 StrUtil", async () => {
expect(await run("StrUtil_Len", { string: "你好world" })).toBe(7);
expect(await run("StrUtil_InStr", { string: "hello", search: "ll" })).toBe(2);
expect(await run("StrUtil_Replace", { string: "a-b-c", search: "-", replace: "+" })).toBe("a+b+c");
expect(await run("StrUtil_LowerCase", { string: "AbC" })).toBe("abc");
expect(await run("StrUtil_UpperCase", { string: "AbC" })).toBe("ABC");
expect(await run("StrUtil_CaseChange", { string: "abc", case: "首字母大写" })).toBe("Abc");
expect(await run("StrUtil_CaseChange", { string: "abc", case: "1" })).toBe("ABC");
expect(await run("StrUtil_CaseChange", { string: "ABC", case: "2" })).toBe("abc");
expect(await run("StrUtil_Trim", { string: " x " })).toBe("x");
expect(await run("StrUtil_Left", { string: "hello", length: 2 })).toBe("he");
expect(await run("StrUtil_Right", { string: "hello", length: 2 })).toBe("lo");
expect(await run("StrUtil_Mid", { string: "hello", start: 1, length: 3 })).toBe("ell");
expect(await run("StrUtil_CutOutStrV1", { string: "a[内容]b", start_cut_pos: "a[", end: "]" })).toContain("内容");
expect(await run("StrUtil_Split", { string: "a,b,c", spliter: "," })).toEqual(["a", "b", "c"]);
expect(await run("StrUtil_SplitStr", { string: "a; b ;c", diy_symbol: ";" })).toEqual(["a", "b", "c"]);
expect(await run("StrUtil_AddToStr", { string: "尾", add_str: "头", add_type: "添加到前面", symbol: "-" })).toBe("头-尾");
expect(await run("StrUtil_ReverseStr", { string: "ab cd", letter_reverse: true })).toBe("dc ba");
expect(await run("StrUtil_ListToString", { str_list: ["a", "b"], connectors_custom: "|" })).toBe("a|b");
expect(await run("StrUtil_CompleteStr", { string: "7", sum_length: 3, com_string: "0", com_type: "左边补" })).toBe("007");
expect(await run("StrUtil_CharCode", { code: 65 })).toBe("A");
expect(await run("StrUtil_CharAsc", { char: "A" })).toBe(65);
expect(await run("StrUtil_CreateRandomStr", { string_length: 12 })).toHaveLength(12);
console.log("[字符串族] 20 项通过");
});
it("列表与集合族 List/Collection", async () => {
expect(await run("List_CreateList", { data: JSON.stringify([1, 2]) })).toEqual([1, 2]);
expect(await run("List_ListLength", { arrayRet: [1, 2, 3] })).toBe(3);
expect(await run("List_InsertList", { arrayRet: ["a", "c"], data: "b", insertType: "指定位置", position: 1 })).toEqual(["a", "b", "c"]);
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], data: "b" })).toEqual(["a", "c"]);
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], deleteType: "按位置", position: 0 })).toEqual(["b", "c"]);
expect(await run("List_GetListItem", { arrayRet: [10, 20], position: 1 })).toBe(20);
expect(await run("List_SetListItem", { arrayRet: [1, 2], data: 9, position: 0 })).toEqual([9, 2]);
expect(await run("List_GetIndex", { arrayRet: ["x", "y"], data: "y" })).toBe(1);
expect(await run("List_ListMerge", { arrayRet1: [1], arrayRet2: [2, 3] })).toEqual([1, 2, 3]);
expect(await run("List_ListDuplication", { arrayRet: [1, 1, 2] })).toEqual([1, 2]);
expect(await run("List_ListSorted", { arrayRet: [3, 1, 2] })).toEqual([1, 2, 3]);
expect(await run("List_ListIntersection", { arrayRet1: [1, 2, 3], arrayRet2: [2, 3, 4] })).toEqual([2, 3]);
expect(await run("List_ClearList", {})).toEqual([]);
expect(await run("Collection_Create", {})).toEqual([]);
const withItem = await run("Collection_Add", { collection: [], value: "a" }) as unknown[];
expect(withItem).toEqual(["a"]);
expect(await run("Collection_HasItem", { collection: ["a"], item: "a" })).toBe(true);
expect(await run("Collection_HasItem", { collection: ["a"], item: "z" })).toBe(false);
expect(await run("Collection_Count", { collection: ["a", "b"] })).toBe(2);
expect(await run("Collection_Remove", { collection: ["a", "b"], value: "a" })).toEqual(["b"]);
console.log("[列表/集合族] 18 项通过");
});
it("字典族 Dict", async () => {
expect(await run("Dict_Create", { value: JSON.stringify({ a: 1 }) })).toEqual({ a: 1 });
expect(await run("Dict_SetItem", { dictObj: { a: 1 }, key: "b", value: 2 })).toEqual({ a: 1, b: 2 });
expect(await run("Dict_GetItem", { dictObj: { a: 1 }, key: "a" })).toBe(1);
expect(await run("Dict_GetItem", { dictObj: {}, key: "x", default_value: "dft" })).toBe("dft");
expect(await run("Dict_Keys", { dictObj: { a: 1, b: 2 } })).toEqual(["a", "b"]);
expect(await run("Dict_Values", { dictObj: { a: 1, b: 2 } })).toEqual([1, 2]);
expect(await run("Dict_Items", { dictObj: { a: 1 } })).toEqual([["a", 1]]);
expect(await run("Dict_Delete", { dictObj: { a: 1, b: 2 }, key: "a" })).toEqual({ b: 2 });
expect(await run("Dict_Update", { dictObj: { a: 1 }, targetDict: { b: 2 } })).toEqual({ a: 1, b: 2 });
expect(await run("Dict_Clear", {})).toEqual({});
console.log("[字典族] 10 项通过");
});
it("数学族 Math 与正则族 Regex", async () => {
expect(await run("Math_Abs", { data: -3.5 })).toBe(3.5);
expect(await run("Math_Round", { data: 3.14159, digit: 2 })).toBe(3.14);
expect(await run("Math_Int", { data: 3.2, position: "向上取整" })).toBe(4);
expect(await run("Math_Sqrt", { data: 16 })).toBe(4);
expect(await run("Math_Pow", { x: 2, y: 10 })).toBe(1024);
expect(await run("Math_Sgn", { data: -9 })).toBe(-1);
expect(await run("Math_Exp", { data: 0 })).toBe(1);
expect(await run("Math_Hex", { num: 255 })).toBe("FF");
expect(await run("Math_Oct", { num: 8 })).toBe("10");
expect(await run("Math_Scale", { sourceNum: "1010", sourceNumScale: 2, targetNumScale: 10 })).toBe("10");
expect(await run("Math_Sin", { data: 90, unit: "角度" })).toBeCloseTo(1, 5);
expect(await run("Regex_Test", { text: "abc123", pattern: "\\d+" })).toBe(true);
expect(await run("Regex_TestNew", { text: "abc", pattern: "\\d" })).toBe(false);
expect(await run("Regex_FindAll", { text: "a1b22c333", pattern: "\\d+" })).toEqual(["1", "22", "333"]);
expect(await run("Regex_FindStr", { text: "订单A-100,订单B-200", pattern: "订单[AB]-(\\d+)", index: 1 })).toBe("100");
expect(await run("Regex_FindAllNew", { text: "2026-11-03", pattern: "\\d+" })).toEqual(["2026", "11", "03"]);
console.log("[数学/正则族] 16 项通过");
});
it("时间族 Time", async () => {
expect(String(await run("Time_Now", {}))).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(String(await run("Time_Date", {}))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(String(await run("Time_Time", {}))).toMatch(/^\d{2}:\d{2}:\d{2}$/);
const ts = await run("Time_TimeStamp", {}) as number;
expect(ts).toBeGreaterThan(1_700_000_000);
expect(await run("Time_Format", { timestamp: 1767000000, format: "YYYY-MM-DD" })).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(await run("Time_DatetimeToTimestamp", { datetime: "2026-01-01 00:00:00" })).toBe(typeof 1_767_225_600 === "number" ? Math.floor(new Date("2026-01-01 00:00:00").getTime() / 1000) : 0);
expect(await run("Time_Year", { datetime: "2026-11-03 10:20:30" })).toBe(2026);
expect(await run("Time_Month", { datetime: "2026-11-03 10:20:30" })).toBe(11);
expect(await run("Time_Day", { datetime: "2026-11-03 10:20:30" })).toBe(3);
expect(await run("Time_Hour", { datetime: "2026-11-03 10:20:30" })).toBe(10);
expect(await run("Time_Minute", { datetime: "2026-11-03 10:20:30" })).toBe(20);
expect(await run("Time_Second", { datetime: "2026-11-03 10:20:30" })).toBe(30);
expect(typeof (await run("Time_WeekDay", { datetime: "2026-11-03" }))).toBe("number");
expect(await run("Time_DateOffset_V1", { date_input: "2026-11-01", offset_unit_num: 2, offset_status: "加", get_type: "YYYY-MM-DD" })).toBe("2026-11-03");
expect(await run("Time_CalDate", { date_start: "2026-11-01", date_end: "2026-11-03", time_type: "天" })).toBeCloseTo(2, 5);
expect(await run("Time_TimeTypeConversion", { datetime: "2026-11-03 08:00:00", format: "YYYY/MM/DD" })).toBe("2026/11/03");
console.log("[时间族] 15 项通过");
});
it("JSON 与 CSV 读写回环", async () => {
expect(await run("JSON_Stringify", { string: { a: 1 } })).toBe('{"a":1}');
expect(await run("JSON_Parse", { string: '{"a":1}' })).toEqual({ a: 1 });
expect(await run("JSON_Parse", { string: "bad" })).toBeNull();
const csvPath = path.join(sandbox, "t.csv");
await run("CSV_WriteNew", { path: csvPath, table: [["名", "值"], ["a,b", "含\"引号\""], ["c", "d"]] });
const back = await run("CSV_ReadNew", { path: csvPath }) as string[][];
expect(back[0]).toEqual(["名", "值"]);
expect(back[1]).toEqual(["a,b", '含"引号"']);
console.log("[JSON/CSV] 通过(含引号逗号转义回环)");
});
it("Ini 族完整生命周期", async () => {
const iniPath = path.join(sandbox, "config.ini");
await run("Ini_Write", { file: iniPath, section: "server", key: "host", value: "127.0.0.1" });
await run("Ini_Write", { file: iniPath, section: "server", key: "port", value: "8080" });
await run("Ini_Write", { file: iniPath, section: "log", key: "level", value: "info" });
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "host" })).toBe("127.0.0.1");
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server", "log"]);
expect(await run("Ini_ListKey", { file: iniPath, section: "server" })).toEqual(["host", "port"]);
expect(await run("Ini_DeleteKey", { file: iniPath, section: "server", key: "port" })).toBe(true);
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "port" })).toBe("");
expect(await run("Ini_DeleteSection", { file: iniPath, section: "log" })).toBe(true);
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server"]);
console.log("[Ini族] 写读列举删除全链通过");
});
it("文件族全生命周期(沙盒目录)", async () => {
const dir = path.join(sandbox, "files");
const created = await run("File_CreateFile", { folder_path: dir, name: "a.txt" }) as string;
expect(created).toContain("a.txt");
await run("File_Write", { path: created, content: "第一行" });
await run("File_Write", { path: created, content: "第二行", append: true });
expect(await run("File_Read", { path: created })).toBe("第一行第二行");
expect(await run("File_FileExists", { path: created })).toBe(true);
expect(await run("File_PathExists", { path: path.join(dir, "nope.txt") })).toBe(false);
expect(await run("File_PathSize", { path: created })).toBeGreaterThan(0);
const info = await run("File_GetFileInfo", { file_path: created }) as { name: string; size: number };
expect(info.name).toBe("a.txt");
expect(info.size).toBeGreaterThan(0);
const copy = path.join(dir, "b.txt");
await run("File_Copy", { pathSrc: created, pathDest: copy });
expect(await run("File_Read", { path: copy })).toBe("第一行第二行");
const sub = path.join(dir, "sub");
await run("File_CreateFolder", { path: sub });
await run("File_Copy", { pathSrc: copy, pathDest: path.join(sub, "c.txt") });
const flat = await run("File_GetFileList", { folder_path: dir }) as string[];
expect(flat.length).toBe(2);
const all = await run("File_GetFileList", { folder_path: dir, recursive: true }) as string[];
expect(all.length).toBe(3);
const kw = await run("File_GetFileList", { folder_path: dir, key_word: "a." }) as string[];
expect(kw.length).toBe(1);
const moved = await run("File_Move", { pathSrc: copy, pathDest: path.join(dir, "moved.txt") });
expect(String(moved)).toContain("moved.txt");
expect(await run("File_FileExists", { path: copy })).toBe(false);
const renamed = await run("File_Rename", { pathSrc: moved, name: "renamed.txt" });
expect(String(renamed)).toContain("renamed.txt");
const zip = await run("File_CompressFile", { pathSrc: created, pathDest: path.join(sandbox, "pack.zip"), direct_type: "0" });
expect(String(zip)).toContain("pack.zip");
const unzipDir = path.join(sandbox, "unpacked");
await run("File_DecompressFile", { path: path.join(sandbox, "pack.zip"), target: unzipDir, direct_type: "0" });
expect(await run("File_FileExists", { path: path.join(unzipDir, "a.txt") })).toBe(true);
expect(await run("File_WaitFile", { file_path: created, time_out: 1 })).toBe(true);
await expect(run("File_WaitFile", { file_path: path.join(dir, "ghost.txt"), time_out: 1 })).rejects.toThrow(/超时/);
await run("File_Delete", { file_path: renamed });
expect(await run("File_FileExists", { path: renamed })).toBe(false);
console.log("[文件族] 建/写/读/拷/列举/移/改名/压缩解压/等待/删除 全链通过");
}, 60000);
it("数据表族 Bivartable", async () => {
const table = [["部门", "姓名", "工资"], ["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
const bodyOnly = [["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
const withHeader = await run("Bivartable_DoCreate", { data: JSON.stringify(bodyOnly), columnList: JSON.stringify(["部门", "姓名", "工资"]) }) as unknown[][];
expect(withHeader[0]).toEqual(["部门", "姓名", "工资"]);
expect(withHeader.length).toBe(5);
expect(await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]) })).toBe(350);
expect(await run("Bivartable_GetMaxValue", { df: table, maxvaluecols: JSON.stringify(["工资"]) })).toBe(100);
expect(await run("Bivartable_GetMinValue", { df: table, minvaluecols: JSON.stringify(["工资"]) })).toBe(80);
expect(await run("Bivartable_GetAvgValue", { df: table, avgvaluecols: JSON.stringify(["工资"]) })).toBe(87.5);
const grouped = await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]), groupby: JSON.stringify(["部门"]) }) as unknown[][];
expect(grouped[0]).toEqual(["部门", "工资"]);
expect(grouped).toContainEqual(["技术", 190]);
expect(grouped).toContainEqual(["销售", 160]);
const filtered = await run("Bivartable_DoFilter", { df: table, col_name: "部门", choice: "1", criteria: "技术" }) as unknown[][];
expect(filtered.length).toBe(3);
const expr = await run("Bivartable_DoFilter", { df: table, rule: '["工资"] > 85' }) as unknown[][];
expect(expr.length).toBe(3);
const dedup = await run("Bivartable_DeleteDuplicateRow", { df: table }) as unknown[][];
expect(dedup.length).toBe(4);
console.log("[数据表族] 创建/聚合(含分组)/过滤(简单+表达式)/去重 通过");
});
it("编排控制与日志", async () => {
const varsCtx = { ...ctx, vars: {} } as unknown as RunContext;
expect(await run("logic_control_var", { var_name: "k1", var_value: "v1" }, varsCtx)).toBe("v1");
expect((varsCtx as unknown as { vars: Record<string, unknown> }).vars.k1).toBe("v1");
await expect(run("logic_control_var", {})).rejects.toThrow(/变量名称/);
await expect(run("logic_control_break_for", {})).rejects.toBeTruthy();
await expect(run("logic_control_next_for", {})).rejects.toBeTruthy();
await expect(run("logic_control_stop", {})).rejects.toBeTruthy();
await expect(run("logic_control_stop_return", {})).rejects.toBeTruthy();
expect(await run("Trigger.Manual", {})).toBeNull();
expect(await run("Trigger.Schedule", {})).toBeNull();
expect(await run("Trigger.FileArrival", {})).toBeNull();
const logs: Array<{ m: string; lv: string }> = [];
const logCtx = { ...ctx, log: (m: string, lv: string) => logs.push({ m, lv }) } as unknown as RunContext;
expect(await run("Log_Info", { msg: "提示信息" }, logCtx)).toBe("提示信息");
expect(await run("Log_Error", { msg: "错误信息" }, logCtx)).toBe("错误信息");
expect(logs.some((l) => l.lv === "error")).toBe(true);
await expect(run("ExceptionUtil_Throws", { msg: "自定义异常" })).rejects.toThrow(/自定义异常/);
console.log("[编排/日志] 变量/循环控制信号/触发器/日志/自定义异常 通过");
});
it("全量健康检查: 已注册组件空参调用不产生非结构化崩溃", async () => {
const ids: string[] = capabilityIds();
const skip = new Set([
"FTP_Connect", "FTP_Upload", "FTP_Download", "FTP_Delete", "FTP_Rename", "FTP_Cwd", "FTP_GetFileList", "FTP_CreateDir", "FTP_Exist", "FTP_Disconnect",
"Database_DBConnect", "Database_SQLExecute", "Database_SingleSQLQuery", "Database_AllSQLQuery_V1", "Database_BatchSQLExecute", "Database_BatchInsertExecute", "Database_CloseDBConnect",
"File_OpenFile", "File_StartBusiness", "File_UploadBusinessResults", "File_UploadBusinessResults_v1", "File_Collectupload",
"File_Delete", "File_DeleteFile", "File_EmptyFolder", "File_WaitFile",
"logic_control_break_for", "logic_control_next_for", "logic_control_stop", "logic_control_stop_return",
"child_modules_v1", "concurrent_child_process_v1", "Element_exception_monitor_v2", "logic_control_if_ai_vision"
]);
const crashes: Array<{ id: string; error: string }> = [];
let checked = 0;
for (const id of ids) {
const handler = getHandler(id);
if (!handler || skip.has(id)) continue;
checked += 1;
try {
await handler(ctx, { ...node, componentId: id } as unknown as FlowNode, {}, {} as CatalogComponent);
} catch (error) {
if (!(error instanceof RuntimeError)) {
crashes.push({ id, error: `${(error as Error).name}: ${(error as Error).message}` });
}
}
}
console.log(`[全量健康检查] 空参调用 ${checked} 个已注册组件, 结构化报错全部正常, 非结构化崩溃 ${crashes.length} 个`);
if (crashes.length) console.log(crashes.map((c) => `${c.id}: ${c.error}`).join("\n"));
expect(crashes).toEqual([]);
}, 120000);
});
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./data-table-common";
@@ -120,9 +120,10 @@ register(["StrUtil_UpperCase"], (_ctx, _node, args) => str(first(args, ["string"
register("StrUtil_CaseChange", (_ctx, _node, args) => {
const text = str(first(args, ["string"]));
const mode = str(first(args, ["case"]));
if (mode.includes("小写")) return text.toLowerCase();
if (mode.includes("大写")) return text.toUpperCase();
// 目录选项值为 "1"(大写)/"2"(小写),文字模式兼容 大写/小写/首字母大写
if (mode.includes("首")) return text ? text[0].toUpperCase() + text.slice(1) : "";
if (mode === "1" || mode.includes("大写")) return text.toUpperCase();
if (mode === "2" || mode.includes("小写")) return text.toLowerCase();
return text;
});
register("StrUtil_Trim", (_ctx, _node, args) => str(first(args, ["string"])).trim());
@@ -463,25 +464,30 @@ function toCsv(rows: unknown[][]): string {
}
register(["CSV_Read", "CSV_ReadNew"], async (_ctx, node, args) => {
const filePath = path.resolve(str(first(args, ["path"])));
const filePath = str(first(args, ["path"])).trim();
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
const text = await fs.readFile(filePath, "utf8");
const text = await fs.readFile(path.resolve(filePath), "utf8");
return parseCsv(text);
});
register(["CSV_Write", "CSV_WriteNew"], async (_ctx, node, args) => {
const filePath = path.resolve(str(first(args, ["path"])));
const filePath = str(first(args, ["path"])).trim();
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
const table = asArray(first(args, ["table"])).map((row) => asArray(row));
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, toCsv(table), "utf8");
return filePath;
const resolved = path.resolve(filePath);
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, toCsv(table), "utf8");
return resolved;
});
/* ---------------- Basic ---------------- */
register(["Basic_ConvertNumber", "Basic_StringToNum"], (_ctx, _node, args) => num(first(args, ["val"])));
register("Basic_ConvertString", (_ctx, _node, args) => str(first(args, ["val"])));
register("Basic_ConvertInt", (_ctx, _node, args) => Math.trunc(num(first(args, ["val"]))));
register("Basic_ConvertBool", (_ctx, _node, args) => bool(first(args, ["val"])));
register("Basic_ConvertBool", (_ctx, _node, args) => {
const value = first(args, ["val"]);
if (typeof value === "number") return value !== 0;
return bool(value);
});
register("Basic_IsNull", (_ctx, _node, args) => {
const value = first(args, ["val"]);
return value === null || value === undefined || value === "";
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"clicked":true,"value":"cached"}'));
vi.mock("./desktop", () => ({
vi.mock("../driver/windows/powershell", () => ({
powershell: powershellMock,
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
}));
@@ -2,30 +2,11 @@ import { register } from "../engine/registry";
import fs from "node:fs";
import path from "node:path";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import type { UiaSelector } from "../driver/types";
import { setElementDriverImplementation } from "../driver/windows/windows-driver";
interface UiaSelector {
window?: string;
name?: string;
id?: string;
/** .NET ControlType 字段名(已映射,如 ComboBox/ListItem/DataGrid) */
type?: string;
/** 用户输入的原始类型字符串 */
typeRaw?: string;
className?: string;
screen?: { x: number; y: number; w: number; h: number };
/** Relative physical rect inside the top-level window. */
rel?: { x: number; y: number; w: number; h: number };
process?: string;
winClass?: string;
enriched?: boolean;
aiType?: string;
aiText?: string;
ai?: boolean;
/** 从窗口(或桌面根)到目标元素的祖先路径,逐段用稳定属性定位 */
path?: Array<{ type?: string; name?: string; id?: string; className?: string; index?: number }>;
index: number;
}
// UiaSelector 类型已上移 driver/types.ts(任务 3.2 element 域)
/** ControlType 显式映射:type= 值 → .NET ControlType 字段名;复合名称不得用首字母大写推导 */
const UIA_CONTROL_TYPE_MAP: Record<string, string> = {
@@ -1045,3 +1026,27 @@ register("Element_DesktopChildren", childrenGroundHandler(true));
register("Element_RestoreWindowGround", restoreWindowGround);
register("Element_ActivateWindowGround", activateWindowGround);
register("Element_MoveWindowGround", moveWindowGround);
/** ElementDriver 的 PowerShell 实现(任务 3.3,决策点 6):ground 原语委托本模块内部函数。
* handler 层面向 driver 接口的收口放到阶段 5/6 按需进行,避免大范围重写引入行为回归。 */
setElementDriverImplementation({
parseSelector: (raw) => parseUiaSelector(raw),
async find(selector, options) {
return findElement({ selector, args: {}, node: { id: "driver", componentId: "driver" } }, options?.timeoutMs ?? 5000, options?.gapMs ?? 200);
},
async exists(selector) {
return (await findElement({ selector, args: {}, node: { id: "driver", componentId: "driver" } }, 2000, 200)) !== null;
},
async click(selector, options) {
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
await runScript(clickScript(t), t, options?.timeoutMs ?? 60000);
},
async setValue(selector, value) {
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
await runScript(setValueScript(t, value), t);
},
async getValue(selector) {
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
return runScript(getValueScript(t), t);
}
});
@@ -1,22 +1,12 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { type ManualOperationSettings, defaultEngine } from "../engine/engine";
import { powershell, psString } from "../driver/windows/powershell";
const execFileAsync = promisify(execFile);
interface ManualOperationSettings {
enabled: boolean;
randomMove: boolean;
randomClick: boolean;
randomSleep: boolean;
minMs: number;
maxMs: number;
}
const manualOperation: ManualOperationSettings = {
enabled: false, randomMove: true, randomClick: true, randomSleep: true, minMs: 1000, maxMs: 3000
};
// 人为拟真设置本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
const manualOperation: ManualOperationSettings = defaultEngine.manualOperation;
// 桌面自动化驱动(任务 3.3):user32 注入原语统一走 WindowsDriver
const driver = defaultEngine.driver;
function jitter(value: number, amount = 3): number {
return Math.max(0, Math.round(value + (Math.random() * amount * 2 - amount)));
@@ -35,18 +25,6 @@ async function manualDelay(): Promise<void> {
const timeout = low + Math.random() * (high - low);
if (timeout > 0) await new Promise((resolve) => setTimeout(resolve, timeout));
}
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
}
'@`;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
@@ -74,35 +52,18 @@ function point(value: unknown): [number, number] {
}
}
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024
});
return String(result.stdout || "").trim();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
const keys = text(args, ["keys", "key", "text", "value"]);
// 字段: words=输入内容(键盘输入), keys/key=按键组合(按键系列), interval=输入间隔(V1毫秒/基础版秒), fuctype=输入方式(0后台接口/1人工模拟/2剪贴板粘贴), window_title=输入目标窗口(留空=当前焦点窗口)
const keys = text(args, ["words", "keys", "key", "text", "value"]);
if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId });
await manualDelay();
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(${psString(keys)})`);
const rawInterval = number(args, ["interval"], 0);
const intervalMs = node.componentId === "Keyboard_Send" ? Math.max(0, Math.round(rawInterval * 1000)) : Math.max(0, Math.round(rawInterval));
const mode = text(args, ["fuctype"], "0");
const isTextSend = node.componentId === "Keyboard_Send" || node.componentId === "Keyboard_Send_V1";
// 指定目标窗口时先激活再输入,内容才会进到目标窗口而不是运行时的焦点窗口
const targetTitle = text(args, ["window_title", "target_window", "window_obj", "match_title", "select_title"]);
await driver.keyboard.send({ keys, intervalMs, mode: mode === "2" ? "clipboard" : mode === "1" ? "manual" : "api", textMode: isTextSend, activateTitle: targetTitle || undefined });
await manualDelay();
return keys;
};
@@ -127,70 +88,47 @@ const disableManualOperation: CapabilityHandler = async () => {
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), false, true);
const button = text(args, ["side", "button"], "0").toLowerCase();
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
await driver.mouse.click({ x, y }, { button: button.includes("right") || button === "1" ? "right" : "left" });
return { x, y };
};
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), true, false);
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
await driver.mouse.moveTo({ x, y });
return { x, y };
};
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${sx},${sy}); [RpaUser32]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaUser32]::SetCursorPos(${ex},${ey}); Start-Sleep -Milliseconds 80; [RpaUser32]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)`);
await driver.mouse.drag({ x: sx, y: sy }, { x: ex, y: ey }, { holdMs: 80, releaseDelayMs: 80 });
return { start: [sx, sy], end: [ex, ey] };
};
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
await driver.mouse.scroll(amount);
return amount;
};
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const result = await powershell(`${USER32}
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
if (-not $p) { throw '找不到窗口' }
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
$h = $p.MainWindowHandle
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9) }
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h)
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 200
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
$fgThread = [WinRpaFocus2.RpaFocus2]::GetWindowThreadProcessId([WinRpaFocus2.RpaFocus2]::GetForegroundWindow(), [IntPtr]::Zero)
$thisThread = [WinRpaFocus2.RpaFocus2]::GetCurrentThreadId()
$attached = [WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $true)
try { [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) } finally { if ($attached) { [void][WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $false) } }
Start-Sleep -Milliseconds 200
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { throw '前台切换失败' }
$p.MainWindowTitle`);
return result;
return driver.window.focus(selector);
};
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
const getActiveWindow: CapabilityHandler = async () => driver.window.foregroundTitle();
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
return driver.window.exists(selector);
};
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
return driver.window.close(selector);
};
const runApp: CapabilityHandler = async (_ctx, node, args) => {
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import ExcelJS from "exceljs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./excel-com";
@@ -3,7 +3,7 @@ import path from "node:path";
import ExcelJS from "exceljs";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import { releaseWorkbook } from "./excel-store";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
import type { Catalog, CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./index";
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./office";
@@ -1,10 +1,11 @@
import ExcelJS from "exceljs";
import path from "node:path";
import { defaultEngine } from "../engine/engine";
// Excel 工作簿共享缓存:同一文件路径在整条流程中复用同一个 ExcelJS 实例。
// 此前 office.ts / office-file-extended.ts / excel-common.ts / extended.ts 各自持有
// 工作簿实例并整簿写回磁盘,后保存者会把先保存者的修改整体覆盖掉。
const cache = new Map<string, ExcelJS.Workbook>();
// 缓存本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
function keyOf(filePath: string): string {
return path.resolve(filePath);
@@ -13,22 +14,22 @@ function keyOf(filePath: string): string {
/** 按路径取工作簿:命中缓存直接复用,未命中从磁盘读取后登记。 */
export async function acquireWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
const key = keyOf(filePath);
let workbook = cache.get(key);
let workbook = defaultEngine.excelCache.get(key);
if (!workbook) {
workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(key);
cache.set(key, workbook);
defaultEngine.excelCache.set(key, workbook);
}
return workbook;
}
/** 把一个新建/另建的工作簿实例登记为该路径的共享实例。 */
export function publishWorkbook(filePath: string, workbook: ExcelJS.Workbook): ExcelJS.Workbook {
cache.set(keyOf(filePath), workbook);
defaultEngine.excelCache.set(keyOf(filePath), workbook);
return workbook;
}
/** 释放路径对应的共享实例(关闭工作簿时调用)。 */
export function releaseWorkbook(filePath: string): void {
cache.delete(keyOf(filePath));
defaultEngine.excelCache.delete(keyOf(filePath));
}
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./extended";
@@ -0,0 +1,67 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./files";
function run(id: string, args: Record<string, unknown>): Promise<string> {
const node: FlowNode = { id: "file-test", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
const handler = getHandler(id)!;
return Promise.resolve(handler({ signal: new AbortController().signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent)) as Promise<string>;
}
const temporary: string[] = [];
afterEach(() => {
for (const dir of temporary.splice(0)) rm(dir, { recursive: true, force: true });
});
describe("file compression path semantics", () => {
it("defaults to a sibling archive and creates a copy instead of overwriting", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
temporary.push(dir);
const source = path.join(dir, "report.txt");
await writeFile(source, "hello", "utf8");
const first = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
expect(first).toBe(path.join(dir, "report.txt.zip"));
expect(existsSync(first)).toBe(true);
const second = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
expect(second).toBe(path.join(dir, "report.txt (1).zip"));
expect(existsSync(second)).toBe(true);
});
it("accepts an output directory when custom destination is selected", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
temporary.push(dir);
const source = path.join(dir, "logs");
const output = path.join(dir, "output");
await Promise.all([mkdir(source), mkdir(output)]);
await writeFile(path.join(source, "app.log"), "log", "utf8");
const target = await run("File_CompressFile", {
file_type: "openDirectory", folder_path: source, direct_type: "0", out_path: output
});
expect(target).toBe(path.join(output, "logs.zip"));
expect(existsSync(target)).toBe(true);
});
it("extracts beside the archive by default and to the selected folder for custom mode", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-extract-"));
temporary.push(dir);
const source = path.join(dir, "logs.zip");
const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip();
zip.addFile("app.log", Buffer.from("log", "utf8"));
zip.writeZip(source);
const defaultTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "1" });
expect(defaultTarget).toBe(dir);
expect(readFileSync(path.join(dir, "app.log"), "utf8")).toBe("log");
const custom = path.join(dir, "custom-output");
const customTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "0", out_path: custom });
expect(customTarget).toBe(custom);
expect(readdirSync(custom)).toContain("app.log");
});
});
@@ -196,10 +196,45 @@ register("File_WaitFile", async (ctx, node, args) => {
});
});
function directoryOutputPath(outputPath: string, sourcePath: string): string {
const output = path.resolve(outputPath);
if (/[\\/]$/.test(outputPath) || path.extname(output).toLowerCase() !== ".zip") {
return path.join(output, path.basename(sourcePath) + ".zip");
}
return output;
}
async function uniqueZipPath(targetPath: string): Promise<string> {
try {
await fs.access(targetPath);
} catch {
return targetPath;
}
const dir = path.dirname(targetPath);
const ext = path.extname(targetPath);
const base = path.basename(targetPath, ext);
for (let index = 1; ; index++) {
const candidate = path.join(dir, `${base} (${index})${ext}`);
try {
await fs.access(candidate);
} catch {
return candidate;
}
}
}
register("File_CompressFile", async (_ctx, node, args) => {
// catalog 字段为 file_path(文件模式)/folder_path(文件夹模式)+ out_path,兼容旧键
const source = requirePath(pick(args, ["file_path", "pathSrc", "path", "folder_path"]), "源路径", node.id, node.componentId);
const target = requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包路径", node.id, node.componentId);
const contentType = String(args.file_type ?? "openFile");
const sourceKeys = contentType === "openDirectory"
? ["folder_path", "pathSrc", "path"]
: ["file_path", "pathSrc", "path"];
const source = requirePath(pick(args, sourceKeys), contentType === "openDirectory" ? "待压缩文件夹路径" : "待压缩文件路径", node.id, node.componentId);
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
const requestedTarget = saveToSourceDir
? path.join(path.dirname(source), path.basename(source) + ".zip")
: directoryOutputPath(requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包输出位置", node.id, node.componentId), source);
const target = await uniqueZipPath(requestedTarget);
const zip = new AdmZip();
const stat = await fs.stat(source);
if (stat.isDirectory()) zip.addLocalFolder(source);
@@ -211,7 +246,10 @@ register("File_CompressFile", async (_ctx, node, args) => {
register("File_DecompressFile", async (_ctx, node, args) => {
const source = requirePath(pick(args, ["folder_path", "path", "zip_path"]), "压缩包路径", node.id, node.componentId);
const target = requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目录", node.id, node.componentId);
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
const target = saveToSourceDir
? path.dirname(source)
: requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目标目录", node.id, node.componentId);
await fs.mkdir(target, { recursive: true });
new AdmZip(source).extractAllTo(target, true);
return target;
@@ -3,11 +3,10 @@ import path from "node:path";
import fs from "node:fs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
const flags = globalThis as { __rpaFtpClient?: Client };
import { defaultEngine } from "../engine/engine";
async function requireClient(nodeId: string): Promise<Client> {
if (flags.__rpaFtpClient) return flags.__rpaFtpClient;
if (defaultEngine.ftpClient) return defaultEngine.ftpClient;
throw new RuntimeError("validation_error", "尚未连接 FTP,请先使用 FTP_Connect", { nodeId, componentId: nodeId, recommendation: "先运行 FTP_Connect 建立连接" });
}
@@ -27,14 +26,14 @@ register("FTP_Connect", async (ctx, node, args) => {
const secure = String(args.ftp_type ?? "").toLowerCase().includes("ftps");
const client = new Client(10_000);
await client.access({ host, port, secure, user, password });
flags.__rpaFtpClient = client;
defaultEngine.ftpClient = client;
ctx.log("已连接 FTP:" + host + ":" + port, "info", node.id);
return { connected: true, host, port };
});
register("FTP_Disconnect", async (ctx, node) => {
flags.__rpaFtpClient?.close();
flags.__rpaFtpClient = undefined;
defaultEngine.ftpClient?.close();
defaultEngine.ftpClient = undefined;
ctx.log("已断开 FTP 连接", "info", node.id);
return { disconnected: true };
});
File renamed without changes.
@@ -1,4 +1,4 @@
import { BrowserWindow } from "electron";
import { getHostBridge, type HostDialogWindow } from "../engine/host-bridge";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
@@ -28,6 +28,12 @@ function escapeHtml(s: string): string {
}
async function showForm(options: { title: string; fields: FormFieldSpec[]; timeoutSec: number }): Promise<Record<string, string>> {
const bridge = getHostBridge();
if (!bridge) {
throw new RuntimeError("host_unavailable", "宿主桥未注入,无法打开人机交互窗口", {
recommendation: "宿主(Electron 主进程)启动时需调用 setHostBridge() 注入窗口工厂"
});
}
return new Promise((resolve) => {
const rows = options.fields.map((f, i) => {
const key = escapeHtml(fieldKey(f, i));
@@ -53,11 +59,7 @@ async function showForm(options: { title: string; fields: FormFieldSpec[]; timeo
return "<label>" + label + '<br/><input type="' + inputType + '" data-key="' + key + '" value="' + value + '" /></label><br/>';
}).join("\n");
const html = '<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:Microsoft YaHei,sans-serif;margin:16px;font-size:14px;color:#1f2937}label{display:block;margin-bottom:10px}input,select,textarea{width:100%;box-sizing:border-box;padding:6px;margin-top:4px}#ok{margin-top:8px;padding:8px 24px;background:#2563eb;color:#fff;border:none;border-radius:6px;cursor:pointer}</style></head><body><h3>' + escapeHtml(options.title) + "</h3>" + rows + '<button id="ok">确认</button><script>document.getElementById("ok").onclick=function(){var out={};document.querySelectorAll("[data-key]").forEach(function(el){out[el.dataset.key]=el.type==="checkbox"?el.checked:el.value});window.__formValues=out;};</script></body></html>';
const win = new BrowserWindow({
width: 480, height: 0, useContentSize: true, autoHideMenuBar: true,
title: options.title, alwaysOnTop: true,
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
});
const win: HostDialogWindow = bridge.createFormWindow({ title: options.title });
let settled = false;
let timer: NodeJS.Timeout | undefined;
let timeout: NodeJS.Timeout | undefined;
@@ -71,14 +73,14 @@ async function showForm(options: { title: string; fields: FormFieldSpec[]; timeo
};
timer = setInterval(async () => {
try {
const values = await win.webContents.executeJavaScript("window.__formValues || null", true);
const values = await win.executeJavaScript("window.__formValues || null");
if (values) finish(values as Record<string, string>);
} catch {
finish({});
}
}, 250);
timeout = setTimeout(() => {
try { void win.webContents.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {
try { void win.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {
finish({});
}
}, Math.max(5, options.timeoutSec) * 1000);
@@ -0,0 +1,42 @@
/**
* rpa-core 能力注册入口。
*
* import 本模块即完成全部包内能力的副作用注册(与改造前
* src/main/capabilities/index.ts 的加载顺序保持一致,别名/重复 id 的
* last-wins 语义不变)。依赖 Electron 窗口的浏览器能力(ai-caps / browser* /
* web-batch2 / web-crawl)留在宿主 src/main/capabilities;
* human-dialog / ai-service / window-extended 已于阶段 2 经 HostBridge 迁入。
*/
import "./control";
import "./files";
import "./http";
import "./data";
import "./office";
import "./desktop";
import "./desktop-uia";
import "./time-caps";
import "./windows-system";
import "./ftp";
import "./ocr-caps";
import "./pdf";
import "./word";
import "./word-extended";
import "./message";
import "./vision";
import "./cv-vision";
import "./extended";
import "./system-extended";
import "./office-file-extended";
import "./office-aliases";
import "./excel-common";
import "./excel-com";
import "./data-extended";
import "./utility-extended";
import "./data-table-common";
import "./basic-encryption";
import "./mail-remaining";
import "./remaining-core";
import "./common-final";
import "./human-dialog";
import "./ai-service";
import "./window-extended";
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import "./desktop";
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id: "smoke-kb", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
const ctx = {
runId: "kb-smoke", flowId: "kb-smoke-flow", flow: {} as RunContext["flow"],
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "D:/office-test2",
emit: () => {}, log: () => {}, componentOf: () => undefined
} as unknown as RunContext;
async function invoke(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler).toBeTypeOf("function");
return handler!(ctx, node(componentId), args, component);
}
describe("键盘输入组件真实冒烟", () => {
it("words 字段取值(此前读不到报「键盘输入为空」), 后台接口模式整段发送特殊字符不抛错", async () => {
const out = await invoke("Keyboard_Send_V1", { words: "哈哈哈(测试)100%^2", interval: "10", fuctype: "0" });
expect(out).toBe("哈哈哈(测试)100%^2");
console.log("[键盘输入] 后台接口模式整段发送 ✓:", out);
}, 60000);
it("人工模拟模式逐字符+间隔毫秒真实发送", async () => {
const out = await invoke("Keyboard_Send_V1", { words: "逐字输入ABC", interval: "5", fuctype: "1" });
expect(out).toBe("逐字输入ABC");
console.log("[键盘输入] 人工模拟逐字符 ✓:", out);
}, 60000);
it("人工模拟模式含撇号与特殊字符(用户 09:05 踩坑场景)不抛语法错", async () => {
const out = await invoke("Keyboard_Send_V1", { words: "hhhhh' +^%~(){}[]", interval: "10", fuctype: "1" });
expect(out).toBe("hhhhh' +^%~(){}[]");
console.log("[键盘输入] 人工模拟+撇号+特殊字符 ✓:", out);
}, 60000);
it("人工模拟模式含中文标点(用户 09:32 踩坑场景:】【‘;。/ 智能引号)不抛语法错", async () => {
const words = "jashfjksudfgisa54445哈哈哈哈AAAAHHBHG+t】【‘;。/";
const out = await invoke("Keyboard_Send_V1", { words, interval: "10", fuctype: "1" });
expect(out).toBe(words);
console.log("[键盘输入] 人工模拟+中文标点+智能引号 ✓");
}, 60000);
it("剪切板粘贴模式: 原文进入剪贴板并发送 Ctrl+V", async () => {
const content = `粘贴${Date.now()}`;
const out = await invoke("Keyboard_Send_V1", { words: content, fuctype: "2" });
expect(out).toBe(content);
const clip = await invoke("Clipboard_GetText", {}) as string;
expect(clip).toBe(content);
console.log("[键盘输入] 剪贴板粘贴模式 ✓ 剪贴板内容一致:", clip);
}, 60000);
it("空输入仍报「键盘输入为空」", async () => {
await expect(invoke("Keyboard_Send_V1", { words: "", interval: "10" })).rejects.toThrow("键盘输入为空");
console.log("[键盘输入] 空输入校验 ✓");
}, 60000);
it("输入目标窗口: 先激活记事本再输入, 前台窗口切到位", async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const runPs = promisify(execFile);
const ps = (cmd: string) => runPs("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true, timeout: 30000 });
// 窗口标题随系统语言变化(英文 Notepad / 中文 记事本), 不硬编码, 启动后动态取实际标题
await ps("Start-Process $env:SystemRoot\\System32\\notepad.exe; $diag=''; $ok=$false; foreach($i in 1..24){ $procs=Get-Process Notepad -ErrorAction SilentlyContinue; if($procs){ $p=$procs | Where-Object {$_.MainWindowTitle}; if($p){$ok=$true; break} } else { $diag='no-process' }; Start-Sleep -Milliseconds 500 }; if(-not $ok){ throw \"notepad not visible: $diag\" }");
try {
const titlePs = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); (Get-Process Notepad | Where-Object { $_.MainWindowTitle } | Select-Object -First 1).MainWindowTitle";
const title = (await ps(titlePs)).stdout.trim();
expect(title.length).toBeGreaterThan(0);
// GUI 焦点抢占受系统前台锁约束, 偶发失败属正常抖动, 有界重试
let lastErr: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await invoke("Keyboard_Send_V1", { words: "目标窗口输入'测试", interval: "5", fuctype: "1", window_title: title });
lastErr = undefined;
break;
} catch (error) {
lastErr = error;
await new Promise((r) => setTimeout(r, 1000));
}
}
if (lastErr) throw lastErr;
await new Promise((r) => setTimeout(r, 300));
// activateWindowScript 内部已校验前台句柄=目标窗口, 这里再确认前台标题仍是记事本(输入后标题会带 * 脏标记前缀)
const fg = await ps(titlePs);
expect(fg.stdout.trim()).toContain(title);
console.log("[键盘输入] 目标窗口激活 ✓ 前台:", fg.stdout.trim());
} finally {
await ps("Get-Process notepad* -ErrorAction SilentlyContinue | Stop-Process -Force").catch(() => {});
}
}, 90000);
});
@@ -1,6 +1,6 @@
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler, hasCapability } from "../engine/registry";
import "./message";
@@ -1,7 +1,7 @@
import nodemailer from "nodemailer";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell } from "./desktop";
import { powershell } from "../driver/windows/powershell";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
@@ -0,0 +1,94 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import "./window-extended";
const run = promisify(execFile);
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id: "smoke-mouse", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
const ctx = {
runId: "mouse-smoke", flowId: "mouse-smoke-flow", flow: {} as RunContext["flow"],
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "D:/office-test2",
emit: () => {}, log: () => {}, componentOf: () => undefined
} as unknown as RunContext;
const PS_API = `Add-Type @'
using System; using System.Runtime.InteropServices;
public static class RpaMouseSmoke {
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v);
}
'@; try { [void][RpaMouseSmoke]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; $c = [RpaMouseSmoke+POINT]::new(); [void][RpaMouseSmoke]::GetCursorPos([ref]$c); "$($c.X),$($c.Y)"`;
async function cursorPos(): Promise<[number, number]> {
const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_API], { windowsHide: true });
return stdout.trim().split(",").map(Number) as [number, number];
}
/** 共享桌面环境下光标可能被其他进程短暂挪动,轮询等待落位(容差 px),超时返回最后一次读数 */
async function waitForCursor(target: [number, number], tolerance = 0, attempts = 10, gapMs = 100): Promise<[number, number]> {
let pos = await cursorPos();
for (let i = 0; i < attempts; i += 1) {
if (Math.abs(pos[0] - target[0]) <= tolerance && Math.abs(pos[1] - target[1]) <= tolerance) return pos;
await new Promise((r) => setTimeout(r, gapMs));
pos = await cursorPos();
}
return pos;
}
async function invoke(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler).toBeTypeOf("function");
return handler!(ctx, node(componentId), args, component);
}
let original: [number, number];
afterEach(async () => {
if (original) await invoke("Mouse_MoveToV2", { location: { x: original[0], y: original[1] }, position: "0", move_type: "2", speed: 100 });
});
describe("移动鼠标组件真实冒烟", () => {
it("对象坐标 x/y JSON 不再报无效, 瞬间移动真实落位", async () => {
original = await cursorPos();
// 用户屏幕框选生成的浮点对象坐标(此前直接报「鼠标坐标无效」)
const out = await invoke("Mouse_MoveToV2", { location: { x: 1397, y: 227.60000610351562 }, position: "0", move_type: "2", speed: 100 }) as { x: number; y: number };
expect(out).toEqual({ x: 1397, y: 228 });
const pos = await waitForCursor([1397, 228]);
expect(pos[0]).toBe(1397);
expect(pos[1]).toBe(228);
console.log("[移动鼠标] 对象坐标解析 + 瞬间移动落位 ✓ 实际光标:", pos);
}, 60000);
it("模拟人工移动(默认方式)平滑到达目标", async () => {
original = await cursorPos();
const out = await invoke("Mouse_MoveToV2", { location: "[300,300]", position: "0", move_type: "0", speed: 60 }) as { x: number; y: number };
expect(out).toEqual({ x: 300, y: 300 });
const pos = await waitForCursor([300, 300], 2);
expect(Math.abs(pos[0] - 300)).toBeLessThanOrEqual(2);
expect(Math.abs(pos[1] - 300)).toBeLessThanOrEqual(2);
console.log("[移动鼠标] 模拟人工移动 ✓ 实际光标:", pos);
}, 60000);
it("匀速直线移动与字符串数组坐标", async () => {
original = await cursorPos();
const out = await invoke("Mouse_MoveToV2", { location: "[500,400]", position: "0", move_type: "1", speed: 80 }) as { x: number; y: number };
expect(out).toEqual({ x: 500, y: 400 });
const pos = await waitForCursor([500, 400]);
expect(pos[0]).toBe(500);
expect(pos[1]).toBe(400);
console.log("[移动鼠标] 匀速直线 + 字符串坐标 ✓ 实际光标:", pos);
}, 60000);
it("相对激活窗口左上角模式返回有效坐标", async () => {
original = await cursorPos();
const out = await invoke("Mouse_MoveToV2", { location: { x: 10, y: 10 }, position: "1", move_type: "2", speed: 100 }) as { x: number; y: number };
expect(Number.isFinite(out.x)).toBe(true);
expect(Number.isFinite(out.y)).toBe(true);
console.log("[移动鼠标] 相对激活窗口模式 ✓ 目标:", out);
}, 60000);
});
@@ -2,17 +2,16 @@ import path from "node:path";
import { promises as fs } from "node:fs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { defaultEngine } from "../engine/engine";
interface OcrWord { text: string; x: number; y: number; width: number; height: number; confidence: number }
interface RapidOcrLine { text: string; mean: number; box?: number[][] }
let ocrInstance: unknown = null;
async function getOcr(): Promise<{ detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> }> {
if (ocrInstance) return ocrInstance as { detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> };
if (defaultEngine.ocrInstance) return defaultEngine.ocrInstance as { detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> };
const mod = await import("@gutenye/ocr-node");
const instance = await mod.default.create();
ocrInstance = instance;
defaultEngine.ocrInstance = instance;
return instance;
}
@@ -0,0 +1,102 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./ocr-caps";
import "./remaining-core";
const ctx = {
signal: new AbortController().signal,
log: () => {},
vars: {},
evidenceDir: "D:/office-test2/ocr-test"
} as unknown as RunContext;
const node: FlowNode = {
id: "smoke",
componentId: "smoke",
position: { x: 0, y: 0 },
values: {},
options: {},
};
const ocrFixtures = [
"D:/office-test2/ocr-test/general.png",
"D:/office-test2/ocr-test/code.png",
"D:/office-test2/ocr-test/table.png",
];
const ocrFixturesReady = ocrFixtures.every((file) => existsSync(file));
async function runOcr(id: string, args: Record<string, unknown>) {
const handler = getHandler(id);
expect(handler, `${id} 应已注册`).toBeTruthy();
return handler!(ctx, node, args, {} as CatalogComponent);
}
describe.skipIf(!ocrFixturesReady)("OCR 组件冒烟(本地离线识别,真实模型推理)", () => {
it("OCR_GeneralTextV2 通用文本", async () => {
const out = await runOcr("OCR_GeneralTextV2", { path: "D:/office-test2/ocr-test/general.png" });
console.log("[GeneralTextV2]", JSON.stringify(out));
expect(String(out)).toContain("张三");
}, 300000);
it("OCR_KeyValueIdentification 键值对", async () => {
const out = await runOcr("OCR_KeyValueIdentification", { image_path: "D:/office-test2/ocr-test/general.png" });
console.log("[KeyValue]", JSON.stringify(out));
expect(String(out).length).toBeGreaterThan(0);
}, 300000);
it("OCR_AmountIdentification 金额", async () => {
const out = await runOcr("OCR_AmountIdentification", { path: "D:/office-test2/ocr-test/general.png" });
console.log("[Amount]", JSON.stringify(out));
expect(String(out).length).toBeGreaterThan(0);
}, 300000);
it("OCR_CalculationCode 算术验证码", async () => {
const out = await runOcr("OCR_CalculationCode", { path: "D:/office-test2/ocr-test/code.png" });
console.log("[CalcCode]", JSON.stringify(out));
expect(String(out).length).toBeGreaterThan(0);
}, 300000);
it("OCR_CommonEnglishNumbersCode 普通验证码", async () => {
const out = await runOcr("OCR_CommonEnglishNumbersCode", { path: "D:/office-test2/ocr-test/code.png" });
console.log("[CommonCode]", JSON.stringify(out));
expect(String(out).length).toBeGreaterThan(0);
}, 300000);
it("OCR_IdCardV2 卡证族代表", async () => {
const out = await runOcr("OCR_IdCardV2", { path: "D:/office-test2/ocr-test/general.png" });
console.log("[IdCardV2]", JSON.stringify(out));
expect(String(out).length).toBeGreaterThan(0);
}, 300000);
it("OCR_VatInvoiceV2 票据族代表", async () => {
const out = await runOcr("OCR_VatInvoiceV2", { path: "D:/office-test2/ocr-test/table.png" });
console.log("[VatInvoiceV2]", JSON.stringify(out));
expect(String(out).length).toBeGreaterThan(0);
}, 300000);
it("OcrOffline_GetOcrTextNew 离线文本", async () => {
const out = await runOcr("OcrOffline_GetOcrTextNew", { uiSelector: "D:/office-test2/ocr-test/general.png" });
console.log("[OcrTextNew]", JSON.stringify(out));
expect(String(out)).toContain("订单号");
}, 300000);
it("OcrOffline_GetOcrTextPosition 离线定位", async () => {
const out = await runOcr("OcrOffline_GetOcrTextPosition", { image_path: "D:/office-test2/ocr-test/general.png" });
const words = out as Array<{ text: string; x: number; y: number }>;
console.log("[OcrPosition]", JSON.stringify(words?.slice(0, 3)));
expect(Array.isArray(words)).toBe(true);
expect(words.length).toBeGreaterThan(0);
expect(words[0]).toHaveProperty("x");
}, 300000);
it("OcrOffline_GetOcrTable 离线表格", async () => {
const out = await runOcr("OcrOffline_GetOcrTable", { image_path: "D:/office-test2/ocr-test/table.png", showTable: "1", isExcel: "0" });
const rows = out as string[][];
console.log("[OcrTable]", JSON.stringify(rows));
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThan(0);
}, 300000);
});
@@ -2,9 +2,8 @@ import { readFile, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
import type { Catalog, CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./office";
@@ -21,7 +20,8 @@ afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursiv
describe("common Excel aliases", () => {
it("registers only real catalog aliases", async () => {
const catalog = JSON.parse(await readFile(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../resources/catalog.json"), "utf8")) as Catalog;
// vitest 工作目录为仓库根,包内测试统一以 cwd 定位仓库资源
const catalog = JSON.parse(await readFile(path.resolve(process.cwd(), "resources/catalog.json"), "utf8")) as Catalog;
const ids = new Set(catalog.components.map((item) => item.id));
expect(EXCEL_COMMON_ALIASES.length).toBeGreaterThanOrEqual(25);
for (const id of EXCEL_COMMON_ALIASES) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); }
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import "./office-file-extended";
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { defaultEngine } from "../engine/engine";
import { acquireWorkbook, publishWorkbook, releaseWorkbook } from "./excel-store";
interface ExcelHandle {
@@ -10,7 +11,8 @@ interface ExcelHandle {
path: string;
}
const workbooks = new Map<string, ExcelJS.Workbook>();
// 句柄 id → 工作簿;本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
const workbooks = defaultEngine.excelHandles;
function value(args: Record<string, unknown>, keys: string[], fallback = ""): unknown {
for (const key of keys) {
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./pdf";
File renamed without changes.
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createEngine } from "../engine/engine";
import { capabilityCount, capabilityIds, getCapabilityMeta } from "../engine/registry";
import { isKnownErrorCode } from "../engine/error-codes";
import { capabilityDomains, registerAll } from "./register-all";
import "./index";
describe("registerAll 显式注册入口", () => {
it("副作用入口已注册全量包内能力,registerAll 全量幂等", async () => {
// 包内 capabilities/index 注册 719 个 id(909 快照 = 包内 + 宿主浏览器能力)
const before = capabilityCount();
expect(before).toBeGreaterThan(700);
const engine = await registerAll(createEngine());
expect(engine).toBeDefined();
expect(capabilityCount()).toBe(before);
});
it("按子集注册:只拉起指定域,未知域报错", async () => {
expect(capabilityDomains).toContain("excelCom");
await expect(registerAll(createEngine(), ["excelCom", "no-such-domain"])).rejects.toThrow("未知能力域");
await registerAll(createEngine(), ["files"]);
expect(getCapabilityMeta).toBeDefined();
});
it("meta(任务 4.1):老能力允许缺省;已声明 meta 的能力错误码必须在稳定清单内", () => {
// 全量注册后的每个 meta,其 errorCodes 都必须落在 RUNTIME_ERROR_CODES 清单内
for (const id of capabilityIds()) {
const meta = getCapabilityMeta(id);
if (!meta) continue;
for (const code of meta.errorCodes ?? []) {
expect(isKnownErrorCode(code), `${id} 的错误码 ${code} 未登记 error-codes.ts`).toBe(true);
}
}
});
});
@@ -0,0 +1,59 @@
import type { RpaEngine } from "../engine/engine";
/**
* 显式注册入口(任务 4.2,决策点 9:双入口过渡)。
*
* - 旧入口:`import "@rpa/core/capabilities"` 副作用注册全量(宿主/测试继续使用);
* - 新入口:`await registerAll(engine, groups?)` 按 subset 显式拉起,供
* Agent-Employee 等宿主按需注册。阶段 6 迁移时切显式入口、删副作用入口。
*
* 引擎当前持有全局注册表(阶段 1 决策 2),拉起模块即完成对全局表的注册,
* 返回 engine 以便链式使用。
*/
const DOMAINS: Record<string, () => Promise<unknown>> = {
control: () => import("./control"),
files: () => import("./files"),
http: () => import("./http"),
data: () => import("./data"),
office: () => import("./office"),
desktop: () => import("./desktop"),
desktopUia: () => import("./desktop-uia"),
time: () => import("./time-caps"),
windowsSystem: () => import("./windows-system"),
ftp: () => import("./ftp"),
ocr: () => import("./ocr-caps"),
pdf: () => import("./pdf"),
word: () => import("./word"),
wordExtended: () => import("./word-extended"),
message: () => import("./message"),
vision: () => import("./vision"),
cvVision: () => import("./cv-vision"),
extended: () => import("./extended"),
systemExtended: () => import("./system-extended"),
officeFileExtended: () => import("./office-file-extended"),
officeAliases: () => import("./office-aliases"),
excelCommon: () => import("./excel-common"),
excelCom: () => import("./excel-com"),
dataExtended: () => import("./data-extended"),
utilityExtended: () => import("./utility-extended"),
dataTableCommon: () => import("./data-table-common"),
basicEncryption: () => import("./basic-encryption"),
mailRemaining: () => import("./mail-remaining"),
remainingCore: () => import("./remaining-core"),
commonFinal: () => import("./common-final"),
humanDialog: () => import("./human-dialog"),
aiService: () => import("./ai-service"),
windowExtended: () => import("./window-extended")
};
export async function registerAll(engine: RpaEngine, groups?: string[]): Promise<RpaEngine> {
const wanted = groups && groups.length ? groups : Object.keys(DOMAINS);
for (const group of wanted) {
const load = DOMAINS[group];
if (!load) throw new Error(`未知能力域:${group}(可选:${Object.keys(DOMAINS).join(", ")})`);
await load();
}
return engine;
}
export const capabilityDomains = Object.keys(DOMAINS);
@@ -0,0 +1,59 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./vision";
import "./remaining-core";
const ctx = {
signal: new AbortController().signal,
log: () => {},
vars: {},
evidenceDir: "D:/office-test2/ocr-test"
} as unknown as RunContext;
const node = { id: "smoke-rp", componentId: "Image_RelativePosition", position: { x: 0, y: 0 }, values: {}, options: {} };
const relativePositionFixtures = [
"D:/office-test2/ocr-test/general.png",
"D:/office-test2/ocr-test/order-template.png",
];
const relativePositionFixturesReady = relativePositionFixtures.every((file) => existsSync(file));
describe.skipIf(!relativePositionFixturesReady)("对比图像相对位置冒烟", () => {
it("小图在大图中命中并返回坐标", async () => {
const handler = getHandler("Image_RelativePosition");
expect(handler).toBeTruthy();
const out = await handler!(
ctx,
node,
{
source: "D:/office-test2/ocr-test/general.png",
template: "D:/office-test2/ocr-test/order-template.png"
},
{} as CatalogComponent
);
console.log("[RelativePosition 正向]", JSON.stringify(out));
const hit = out as { x: number; y: number; width: number; height: number; score: number };
expect(hit.x).toBeGreaterThanOrEqual(0);
expect(hit.y).toBeGreaterThan(0);
expect(hit.score).toBeGreaterThan(0.9);
}, 120000);
it("填反(大图填进查找目标)自动互换后仍命中", async () => {
const logs: string[] = [];
const ctxWithLog = { ...ctx, log: (m: string) => logs.push(m) } as unknown as RunContext;
const handler = getHandler("Image_RelativePosition")!;
const out = await handler!(
ctxWithLog,
node,
{
source: "D:/office-test2/ocr-test/order-template.png",
template: "D:/office-test2/ocr-test/general.png"
},
{} as CatalogComponent
);
console.log("[RelativePosition 互换]", JSON.stringify(out), "logs:", logs.join(" | "));
expect(out).toBeTruthy();
}, 120000);
});
@@ -3,7 +3,9 @@ import path from "node:path";
import { getHandler, register } from "../engine/registry";
import { runFlow } from "../engine/runtime";
import { RuntimeError, type CapabilityHandler, type RunContext } from "../engine/types";
import { powershell, psString } from "./desktop";
import { defaultEngine } from "../engine/engine";
import { getSubflowSource } from "../engine/host-bridge";
import { powershell, psString } from "../driver/windows/powershell";
import { findImage } from "./vision";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
@@ -24,7 +26,8 @@ function required(value: string, label: string, node: { id: string; componentId:
return value;
}
const dbConnections = new Map<string, any>();
// 连接 id → 数据库连接;本体挂在引擎实例上(任务 1.3)。
const dbConnections = defaultEngine.dbConnections as Map<string, any>;
function dbConfig(args: Record<string, unknown>): Record<string, unknown> {
const inline = record(args.DatabaseConfig || args.config || args.connectionID);
const merged = { ...inline, ...args };
@@ -67,12 +70,14 @@ register("Database_CloseDBConnect", async (_ctx, _node, args) => {
dbConnections.delete(key); return { closed: true };
});
async function callSubflow(ctx: RunContext, node: { id: string; componentId: string }, args: Record<string, unknown>) {
const { listFlows, getFlow } = await import("../flows");
// 子流程仓库属宿主职责:经 host-bridge 注入(阶段 2 正式化),包内不 import "../flows"
const source = getSubflowSource();
if (!source) throw new RuntimeError("validation_error", "子流程源未注入", { nodeId: node.id, componentId: node.componentId, recommendation: "宿主需在启动时调用 setSubflowSource()" });
const wanted = text(args, ["module_name", "module", "flow"]);
const rows = await listFlows(); const found = rows.find((row) => row.id === wanted || row.name === wanted);
const rows = await source.listFlows(); const found = rows.find((row) => row.id === wanted || row.name === wanted);
if (!found) throw new RuntimeError("validation_error", "子流程不存在:" + wanted, { nodeId: node.id, componentId: node.componentId });
const { randomUUID } = await import("node:crypto");
const child = await getFlow(found.id); const inputs = record(args.args || args.input);
const child = await source.getFlow(found.id); const inputs = record(args.args || args.input);
return await runFlow(child, { runId: randomUUID(), evidenceDir: ctx.evidenceDir, signal: ctx.signal, emit: ctx.emit, log: ctx.log, componentOf: ctx.componentOf, browser: ctx.browser, variables: { ...ctx.vars, ...inputs } });
}
register("child_modules_v1", async (ctx, node, args) => callSubflow(ctx, node, args));
@@ -203,9 +208,38 @@ register("Image_CropImg", async (_ctx, node, args) => {
await powershell(`Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(input)});$rect=[System.Drawing.Rectangle]::new(${x},${y},${width},${height});$bitmap=[System.Drawing.Bitmap]::new($rect.Width,$rect.Height);$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.DrawImage($image,[System.Drawing.Rectangle]::new(0,0,$rect.Width,$rect.Height),$rect,[System.Drawing.GraphicsUnit]::Pixel);$bitmap.Save(${psString(output)});$graphics.Dispose();$bitmap.Dispose();$image.Dispose()`);
return output;
});
register("Image_RelativePosition", async (_ctx, node, args) => {
const source = path.resolve(text(args, ["source", "image"])); const template = path.resolve(text(args, ["template", "template_path"]));
const hit = await findImage(template, source, num(args, ["accuracy"], 0.2)); if (!hit) throw new RuntimeError("not_found", "模板未出现在源图中"); return hit;
register("Image_RelativePosition", async (ctx, node, args) => {
const source = path.resolve(text(args, ["source", "image"]));
const template = path.resolve(text(args, ["template", "template_path"]));
if (!source.trim() || !template.trim()) throw new RuntimeError("validation_error", "被搜索图像与查找目标都需填写图片路径", { nodeId: node.id, componentId: node.componentId });
if (!fs.existsSync(source)) throw new RuntimeError("validation_error", `找不到被搜索图像:${source}`, { nodeId: node.id, componentId: node.componentId });
if (!fs.existsSync(template)) throw new RuntimeError("validation_error", `找不到查找目标图片:${template}`, { nodeId: node.id, componentId: node.componentId });
// 兼容填反:模板比源图大时(首次查找直接抛"还大"错误)自动互换,
// 大图当被搜索图、小图当查找目标,语义仍是「小图在大图中的位置」
const accuracy = num(args, ["accuracy"], 0.2);
let hit: Awaited<ReturnType<typeof findImage>> = null;
let firstError: unknown = null;
try {
hit = await findImage(template, source, accuracy);
} catch (error) {
firstError = error;
try {
hit = await findImage(source, template, accuracy);
if (hit) ctx.log("两张图疑似填反,已自动按「小图在大图中」查找并命中", "warn", node.id);
} catch {
// 互换也失败则回落到原始方向的错误信息
}
}
if (!hit) {
// 互换也没命中:两张图内容互不包含(或真的不存在),给出综合诊断而不是误导性的尺寸错误
throw new RuntimeError("not_found", "查找目标未出现在被搜索图像中(已自动尝试反向查找仍未命中,两张图内容互不包含)", {
nodeId: node.id,
componentId: node.componentId,
detail: firstError instanceof Error ? firstError.message : String(firstError ?? ""),
recommendation: "「被搜索图像」应是较大的底图(如整屏/整窗截图),「查找目标」应是底图中实际出现的小图案(按钮/图标/文字块)的截图;两张完全无关的图无法匹配"
});
}
return hit;
});
register("logic_control_if_ai_vision", async (ctx, node, args) => {
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./system-extended";
@@ -2,7 +2,8 @@ import os from "node:os";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import { defaultEngine } from "../engine/engine";
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
@@ -31,20 +32,8 @@ function selector(args: Record<string, unknown>): string {
return String(raw ?? "");
}
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowApi {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
}
'@`;
function findWindowScript(title: string): string {
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
}
// 窗口矩形/状态操作统一走驱动(任务 3.3)
const driver = defaultEngine.driver;
register("System_GetEnv", (_ctx, node, args) => {
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
@@ -115,15 +104,14 @@ register("Clipboard_ClearClipboard", async () => {
register("Window_GetWindowRect", async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top;title=$p.MainWindowTitle;pid=$p.Id} | ConvertTo-Json -Compress`);
return JSON.parse(output);
return driver.window.findByTitle(title);
});
register(["Window_SetWindowState", "Window_SetWindowState_v1"], async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
const state = value(args, ["WindowType", "switchType"], "maximize").toLowerCase();
const command = state.includes("min") ? 6 : state.includes("max") ? 3 : 9;
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowApi]::ShowWindow($p.MainWindowHandle,${command}) | Out-Null`);
await driver.window.setState(title, command);
return state;
});
@@ -136,6 +124,6 @@ register("Window_SizeWindow_v1", async (_ctx, node, args) => {
}
const [width, height] = Array.isArray(size) ? size.map(Number) : [0, 0];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸无效", { nodeId: node.id, componentId: node.componentId });
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowApi]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`);
await driver.window.setSize(title, width, height);
return { width, height };
});
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowDefinition, FlowNode } from "../../shared/types";
import type { Catalog, CatalogComponent, FlowDefinition, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./utility-extended";
@@ -79,3 +79,18 @@ describe("clickPoint", () => {
expect(clickPoint(region, "bogus")).toEqual({ x: 130, y: 220 });
});
});
describe("legacy position aliases", () => {
it("accepts kebab-case anchors saved by older catalogs", () => {
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
expect(clickPoint(region, "left-top")).toEqual({ x: 100, y: 200 });
expect(clickPoint(region, "right-top")).toEqual({ x: 160, y: 200 });
expect(clickPoint(region, "left-bottom")).toEqual({ x: 100, y: 240 });
expect(clickPoint(region, "right-bottom")).toEqual({ x: 160, y: 240 });
});
it("still falls back to center for unknown anchors", () => {
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
expect(clickPoint(region, "middle")).toEqual({ x: 130, y: 220 });
});
});
@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs";
import { PNG } from "pngjs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
@@ -406,7 +406,7 @@ async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId
await fs.writeFile(screenshot, PNG.sync.write(cropPng(shot.png, cropRect)));
offsetX = rect.x;
offsetY = rect.y;
} else if (rangeMode === "3") {
} else if (rangeMode === "3" || rangeMode === "4") {
const regionRaw = text(args, ["shot_position", "action", "position"]);
if (!regionRaw.trim()) throw new RuntimeError("validation_error", "查找范围为「指定区域」时,需要填写区域 JSON", { nodeId, recommendation: '在 shot_position 填写 {"x":0,"y":0,"width":100,"height":80} 或 [x, y, width, height]' });
// 区域 JSON 是 DIP 坐标(框选工具按 DIP 返回, 支持副屏负坐标),换算物理坐标后减画布原点裁剪
@@ -460,8 +460,8 @@ for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (ctx, n
});
// 等待图像: 在超时窗口内每 300ms 重试一次查找, 命中即返回坐标, 超时仍无则抛 not_found
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node, args) => {
const rawTimeout = Number(text(args, ["timeout", "time_out"], "10"));
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 10;
const rawTimeout = Number(text(args, ["timeout", "time_out"], "20"));
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 20;
const deadline = Date.now() + timeout * 1000;
for (;;) {
try {
@@ -476,8 +476,9 @@ for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node,
// SetCursorPos 传物理坐标, 脚本必须声明 per-monitor DPI 感知(与 injectClick 同理), 否则被系统虚拟化导致偏移
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (ctx, node, args) => {
const hit = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(hit.x + hit.width / 2);
const y = Math.round(hit.y + hit.height / 2);
const point = clickPoint(hit, text(args, ["position"], "center"));
const x = point.x;
const y = point.y;
await powershell("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y);' -Name RpaImageMoveInput -Namespace RpaImageMove | Out-Null; try { [void][RpaImageMove.RpaImageMoveInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; [RpaImageMove.RpaImageMoveInput]::SetCursorPos(" + x + "," + y + ")");
return { ...hit, x, y };
});
@@ -504,7 +505,7 @@ export function clickPoint(result: MatchResult, position: string): { x: number;
// 非法 JSON 落回中心
}
}
const anchor = POSITION_ANCHORS[raw] ?? POSITION_ANCHORS[raw.toLowerCase()];
const anchor = POSITION_ANCHORS[raw] ?? POSITION_ANCHORS[raw.toLowerCase()] ?? POSITION_ANCHORS[raw.replace(/-([a-z])/g, (_: string, char: string) => char.toUpperCase())];
if (anchor) {
return { x: Math.round(result.x + result.width * anchor[0]), y: Math.round(result.y + result.height * anchor[1]) };
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./window-extended";
@@ -1,20 +1,10 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { getHostBridge } from "../engine/host-bridge";
import { defaultEngine } from "../engine/engine";
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowExtended {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
}
'@`;
// 桌面自动化驱动(任务 3.3):窗口矩形/鼠标注入原语统一走 WindowsDriver
const driver = defaultEngine.driver;
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
@@ -44,39 +34,60 @@ function requiredTitle(args: Record<string, unknown>, nodeId: string, componentI
return title;
}
function findWindow(title: string): string {
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
}
function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {
let parsed: unknown = candidate;
if (typeof parsed === "string") {
const source = parsed;
try { parsed = JSON.parse(source); } catch { parsed = source.split(/[,,xX ]+/).map(Number); }
}
// 屏幕框选/拾取坐标生成的是 {"x":..,"y":..} 对象,直接转换
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
if (record.x !== undefined && record.y !== undefined) parsed = [record.x, record.y];
}
if (!Array.isArray(parsed) || parsed.length < 2 || parsed.slice(0, 2).some((item) => !Number.isFinite(Number(item)))) {
throw new RuntimeError("validation_error", `${label}无效`, { nodeId, componentId });
}
return [Number(parsed[0]), Number(parsed[1])];
}
async function windowObject(title: string): Promise<Record<string, unknown>> {
const output = await powershell(`${WINDOW_API}; ${findWindow(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`);
return JSON.parse(output);
/** 界面(框选/元素拾取/截图)产出的是 DIP 逻辑坐标;DPI 感知进程的 SetCursorPos 用物理像素,
* 缩放屏(125%/150%)下直接混用会成比例偏移(用户实测 125% 下差约 25% 距离)。
* 统一在执行前换算;宿主桥未注入时(vitest)回退原值保持旧行为 */
function dipToPhysical(x: number, y: number): [number, number] {
try {
const bridge = getHostBridge();
if (bridge) {
const physical = bridge.dipToScreenPoint({ x, y });
return [Math.round(physical.x), Math.round(physical.y)];
}
} catch {}
return [Math.round(x), Math.round(y)];
}
const getWindow: CapabilityHandler = async (_ctx, node, args) => windowObject(requiredTitle(args, node.id, node.componentId));
/** 反向换算:物理像素(如 GetWindowRect 结果) → DIP,用于「相对激活窗口」偏移叠加 */
function physicalToDip(x: number, y: number): [number, number] {
try {
const bridge = getHostBridge();
if (bridge) {
const dip = bridge.screenToDipPoint({ x, y });
return [Math.round(dip.x), Math.round(dip.y)];
}
} catch {}
return [Math.round(x), Math.round(y)];
}
const getWindow: CapabilityHandler = async (_ctx, node, args) => driver.window.findByTitle(requiredTitle(args, node.id, node.componentId));
register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow);
register("Window_ActiveWindow_v1", async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never);
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`);
return windowObject(title);
return driver.window.activate(title, { maximize });
});
register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => {
const object = await windowObject(requiredTitle(args, node.id, node.componentId));
const object = await driver.window.findByTitle(requiredTitle(args, node.id, node.componentId));
const type = value(args, ["switchType"], "all").toLowerCase();
if (type === "title") return object.title;
if (type === "pid") return object.pid;
@@ -87,7 +98,7 @@ register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx,
register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const current = await windowObject(title);
const current = await driver.window.findByTitle(title);
const moveOnly = node.componentId.includes("MoveWindow");
const sizeOnly = node.componentId === "Window_SizeWindow";
const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number];
@@ -98,36 +109,56 @@ register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_
const width = moveOnly ? Number(current.width) : size[0];
const height = moveOnly ? Number(current.height) : size[1];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId });
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${x},${y},${width},${height},4) | Out-Null`);
return { ...(await windowObject(title)), x, y, width, height };
return driver.window.setPos(title, { x, y, width, height });
});
const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = parsePoint(args.location ?? args.position, "鼠标坐标", node.id, node.componentId);
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null`);
return { x, y };
// 注意:position 字段是「坐标相对位置」(0=屏幕左上角/1=激活窗口左上角),坐标本体在 location
const [rawX, rawY] = parsePoint(args.location ?? args.point, "鼠标坐标", node.id, node.componentId);
const relative = String(args.position ?? "0") === "1";
const moveType = String(args.move_type ?? "0");
const speed = Math.min(100, Math.max(1, Number(args.speed ?? 35) || 35));
let dipTarget: [number, number];
if (relative) {
// 相对激活窗口左上角:窗口矩形是物理像素,换回 DIP 再叠加偏移,最后统一转物理
const { x: wx, y: wy } = await driver.window.foregroundPosition();
if (!Number.isFinite(wx) || !Number.isFinite(wy)) throw new RuntimeError("browser_error", "无法获取激活窗口位置", { nodeId: node.id, componentId: node.componentId });
const [dipWx, dipWy] = physicalToDip(wx, wy);
dipTarget = [dipWx + rawX, dipWy + rawY];
} else {
dipTarget = [rawX, rawY];
}
const [tx, ty] = dipToPhysical(dipTarget[0], dipTarget[1]);
if (moveType === "2") {
await driver.mouse.moveTo({ x: tx, y: ty });
} else {
// 平滑移动:从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系)
await driver.mouse.moveSmooth({ x: tx, y: ty }, { ease: moveType === "0", speed });
}
return { x: Math.round(dipTarget[0]), y: Math.round(dipTarget[1]) };
};
register(["Mouse_MoveToV1", "Mouse_MoveToV2"], moveMouse);
const dragMouse: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = parsePoint(args.Start ?? args.start, "起始坐标", node.id, node.componentId);
const [ex, ey] = parsePoint(args.End ?? args.end, "结束坐标", node.id, node.componentId);
const [spx, spy] = dipToPhysical(sx, sy);
const [epx, epy] = dipToPhysical(ex, ey);
const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase());
const down = right ? 8 : 2;
const up = right ? 16 : 4;
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${sx},${sy}) | Out-Null; [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaWindowExtended]::SetCursorPos(${ex},${ey}) | Out-Null; [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
return { start: [sx, sy], end: [ex, ey] };
// 旧实现按下后停 80ms、拖到终点立即松开(与桌面版 Window_Mouse 不同,松开前不停顿)
await driver.mouse.drag({ x: spx, y: spy }, { x: epx, y: epy }, { button: right ? "right" : "left", holdMs: 80, releaseDelayMs: 0 });
return { start: [Math.round(sx), Math.round(sy)], end: [Math.round(ex), Math.round(ey)] };
};
register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse);
register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"], async (_ctx, node, args) => {
if (value(args, ["target"], "0") !== "0") throw new RuntimeError("validation_error", "组合点击当前仅支持坐标目标", { nodeId: node.id, componentId: node.componentId });
const [x, y] = parsePoint(args.location, "点击坐标", node.id, node.componentId);
const [px, py] = dipToPhysical(x, y);
const right = value(args, ["click_key"], "left").toLowerCase() === "right";
const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double");
const down = right ? 8 : 2;
const up = right ? 16 : 4;
const repeats = doubleClick ? 2 : 1;
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null; 1..${repeats} | ForEach-Object { [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80 }`);
return { x, y, button: right ? "right" : "left", clicks: repeats };
// 旧实现每次 down-up 之后停 80ms
await driver.mouse.click({ x: px, y: py }, { button: right ? "right" : "left", double: doubleClick, settleMs: 80 });
return { x: Math.round(x), y: Math.round(y), button: right ? "right" : "left", clicks: doubleClick ? 2 : 1 };
});
@@ -5,6 +5,13 @@ import { promisify } from "node:util";
const run = promisify(execFile);
const ps = (script: string) => run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 60000 });
// cmdkey 在中文 Windows 上输出 GBK,直接 execFile 按 UTF-8 解码会乱码;统一经 PowerShell 强制 UTF-8 控制台编码捕获
const cmdkey = async (args: string[]): Promise<string> => {
const quoted = args.map((item) => "'" + item.replace(/'/g, "''") + "'");
const script = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); & cmdkey " + quoted.join(" ");
const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 60000 });
return stdout;
};
function reqText(args: Record<string, unknown>, keys: string[], label: string, nodeId: string): string {
for (const key of keys) {
@@ -42,10 +49,16 @@ register("System_ScreenSaverStop", async () => {
register("System_ReadCredential", async (ctx, node, args) => {
const app = reqText(args, ["application"], "凭据目标", node.id);
const { stdout } = await run("cmdkey", ["/list"], { windowsHide: true });
const stdout = await cmdkey(["/list"]);
const exists = stdout.includes(app);
const userLine = stdout.split(/\r?\n/).find(l => l.includes(app));
return { target: app, exists, detail: exists ? (userLine ?? "").trim() : "" };
// cmdkey /list 每条凭据是「目标/类型/用户」多行块(空行分隔);取整块才有用户名等有效信息
const lines = stdout.split(/\r?\n/);
const idx = lines.findIndex((l) => l.includes(app));
const block: string[] = [];
if (idx >= 0) {
for (let i = idx; i < lines.length && lines[i].trim(); i++) block.push(lines[i].trim());
}
return { target: app, exists, detail: exists ? block.join(" | ") : "" };
});
register("System_AddCredential", async (ctx, node, args) => {
@@ -53,13 +66,13 @@ register("System_AddCredential", async (ctx, node, args) => {
const user = reqText(args, ["name"], "用户名", node.id);
const pwd = String(args.password ?? "");
const psArgs = pwd ? ["/generic:" + app, "/user:" + user, "/pass:" + pwd] : ["/generic:" + app, "/user:" + user];
await run("cmdkey", psArgs, { windowsHide: true });
await cmdkey(psArgs);
return { added: true, target: app };
});
register("System_DeleteCredential", async (ctx, node, args) => {
const app = reqText(args, ["application"], "凭据目标", node.id);
await run("cmdkey", ["/delete:" + app], { windowsHide: true });
await cmdkey(["/delete:" + app]);
return { deleted: true, target: app };
});
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./word";
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { getHandler, register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
interface WordHandle { path: string; }
@@ -1,6 +1,6 @@
import path from "node:path";
import { register } from "../engine/registry";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
interface WordHandle { path: string; }
+165
View File
@@ -0,0 +1,165 @@
/**
* AutomationDriver 驱动抽象(任务 3.2,决策点 6:五域接口 + 机械包一层)。
*
* 接口签名以现有 PowerShell 能力的输入输出为准,不重新发明:
* WindowsDriver(driver/windows/windows-driver.ts)是 PowerShell 实现,行为与
* 改造前完全一致;引擎创建时注入 driver(createEngine 可注入自定义实现)。
*
* 约定:driver 只承载「原始注入原语」(单个 SendKeys/一次 SetCursorPos/一次
* 窗口操作);带拟真抖动、坐标换算、多步组合的「策略」仍留在能力文件,
* 保证机械替换不改任何时序行为。
*/
/** UIA 选择器(element 域类型,能力与 driver 共用) */
export interface UiaSelector {
window?: string;
name?: string;
id?: string;
/** .NET ControlType 字段名(已映射,如 ComboBox/ListItem/DataGrid) */
type?: string;
/** 用户输入的原始类型字符串 */
typeRaw?: string;
className?: string;
screen?: { x: number; y: number; w: number; h: number };
/** Relative physical rect inside the top-level window. */
rel?: { x: number; y: number; w: number; h: number };
process?: string;
winClass?: string;
enriched?: boolean;
aiType?: string;
aiText?: string;
ai?: boolean;
/** 从窗口(或桌面根)到目标元素的祖先路径,逐段用稳定属性定位 */
path?: Array<{ type?: string; name?: string; id?: string; className?: string; index?: number }>;
index: number;
}
export interface DriverPoint {
x: number;
y: number;
}
export interface DriverRect {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowInfo extends DriverRect {
pid: number;
title: string;
hwnd: number;
}
export interface WindowSummary {
pid: number;
title: string;
hwnd: number;
}
export type MouseButton = "left" | "right";
export interface MouseClickOptions {
button?: MouseButton;
double?: boolean;
/** 每次 down-up 后的停顿毫秒(0 = 不停顿,保持旧桌面能力的紧凑时序) */
settleMs?: number;
}
export interface MouseDragOptions {
button?: MouseButton;
/** 按下后的按住毫秒 */
holdMs?: number;
/** 拖到终点后、松开前的停顿毫秒 */
releaseDelayMs?: number;
}
export interface MouseSmoothOptions {
/** true=缓入缓出+轻微抖动(拟真),false=匀速直线 */
ease?: boolean;
/** 1~100,越大越快 */
speed?: number;
}
/** mouse 域:坐标一律为物理像素(DIP 换算属于能力层策略) */
export interface MouseDriver {
moveTo(point: DriverPoint): Promise<void>;
click(point: DriverPoint, options?: MouseClickOptions): Promise<void>;
drag(from: DriverPoint, to: DriverPoint, options?: MouseDragOptions): Promise<void>;
scroll(amount: number): Promise<void>;
/** 从当前光标位置出发按速度插值移动到目标 */
moveSmooth(target: DriverPoint, options?: MouseSmoothOptions): Promise<void>;
}
export interface KeyboardSendOptions {
keys: string;
intervalMs: number;
/** api=后台接口整段发送 / manual=逐字符拟真 / clipboard=剪贴板粘贴 */
mode: "api" | "manual" | "clipboard";
/** 文本类组件做 SendKeys 字面转义;按键组合组件原样发送 */
textMode: boolean;
/** 目标窗口标题(非空时先多招式激活再输入,与旧行为一致) */
activateTitle?: string;
}
/** keyboard 域 */
export interface KeyboardDriver {
send(options: KeyboardSendOptions): Promise<void>;
}
export interface WindowDriver {
/** 按标题模糊匹配窗口并返回矩形信息(GetWindowRect,物理像素) */
findByTitle(title: string): Promise<WindowInfo>;
/** 简单激活(SetForegroundWindow + 可选最大化),返回窗口信息 */
activate(title: string, options?: { maximize?: boolean }): Promise<WindowInfo>;
/** 多招式前台激活(失败抛错),返回窗口主标题 */
focus(title: string): Promise<string>;
/** 前台窗口标题 */
foregroundTitle(): Promise<string>;
/** 前台窗口矩形左上角(物理像素) */
foregroundPosition(): Promise<DriverPoint>;
exists(title: string): Promise<boolean>;
/** CloseMainWindow 结果("True"/"False") */
close(title: string): Promise<string>;
/** ShowWindow 命令(6=最小化 3=最大化 9=恢复 5=显示 0=隐藏) */
setState(title: string, showCommand: number): Promise<void>;
/** 只改尺寸,保持左上角不动 */
setSize(title: string, width: number, height: number): Promise<void>;
setPos(title: string, rect: DriverRect): Promise<WindowInfo>;
list(filter?: { title?: string; regex?: boolean }): Promise<WindowSummary[]>;
}
/** screen 域(截图等 System.Drawing 组合操作不在接口面内,能力层可继续用 powershell()) */
export interface ScreenDriver {
/** 主屏边界(System.Windows.Forms.Screen.PrimaryScreen.Bounds) */
size(): Promise<{ x: number; y: number; width: number; height: number }>;
}
export interface ElementFindOptions {
timeoutMs?: number;
gapMs?: number;
}
/**
* element 域(UIA)。PowerShell 实现承载在 capabilities/desktop-uia.ts
* (ground 原语集),WindowsDriver.element 委托该实现;能力 handler 层
* 到 driver 形式的收口在阶段 5/6 按需进行,避免行为回归。
*/
export interface ElementDriver {
parseSelector(raw: unknown): UiaSelector;
find(selector: UiaSelector, options?: ElementFindOptions): Promise<Record<string, unknown> | null>;
exists(selector: UiaSelector): Promise<boolean>;
click(selector: UiaSelector, options?: ElementFindOptions): Promise<void>;
setValue(selector: UiaSelector, value: string): Promise<void>;
getValue(selector: UiaSelector): Promise<string | Record<string, unknown> | null>;
}
/** 五域聚合,注入点(RpaEngine.driver) */
export interface AutomationDriver {
readonly mouse: MouseDriver;
readonly keyboard: KeyboardDriver;
readonly window: WindowDriver;
readonly screen: ScreenDriver;
readonly element: ElementDriver;
}
@@ -0,0 +1,74 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { RuntimeError } from "../../engine/types";
/**
* PowerShell 进程助手(任务 3.1 从 capabilities/desktop.ts 拆出)。
*
* 包内所有 PowerShell 调用必须经此助手(开发规范 v1 第二节第 3 条),
* 禁止各能力文件自行 execFile("powershell.exe", ...);
* 阶段 3.5(可选)将统一切换为常驻 worker,接口签名不变。
*/
const execFileAsync = promisify(execFile);
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
/** 从 PowerShell 错误输出里提取 CLIXML 段的真实错误文本(否则显示为乱码转义序列) */
function decodeCliXml(detail: string): string {
const lines = [...detail.matchAll(/<S S="Error">(.*?)<\/S>/gs)].map((m) => m[1]);
if (lines.length === 0) return detail;
// _xHHHH_ 是 CLIXML 转义
const decoded = lines.join("\n").replace(/_x([0-9A-Fa-f]{4})_/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
const parts = decoded.split("\n").map((l) => l.trim());
const position = parts.filter((l) => l.startsWith("所在位置")).slice(0, 1);
const message = parts.filter(
(l) => l && !l.startsWith("所在位置") && !l.startsWith("+") && !l.startsWith("~~") && !l.includes("CategoryInfo") && !l.includes("FullyQualifiedErrorId") && !l.startsWith("<Objs") && !l.startsWith("#<")
);
const main = message[0] || parts[0] || detail;
return position.length ? `${main}(${position[0]})` : main;
}
/** 管道字节流解码:优先 UTF-8;出现替换符说明是系统代码页(GBK)写入的(如解析类错误不走 UTF-8 前缀),回退 GB18030 */
function decodePipe(buf: Buffer): string {
const utf8 = buf.toString("utf8");
if (!utf8.includes("\uFFFD")) return utf8;
try {
return new TextDecoder("gb18030").decode(buf);
} catch {
return utf8;
}
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024,
encoding: "buffer"
} as never);
return decodePipe(result.stdout as unknown as Buffer).trim();
} catch (error) {
const err = error as Error & { stdout?: unknown; stderr?: unknown };
// 脚本主动失败(RpaFail)把完整错误写在 stdout 的 RPA_ERROR:: 标记后,不经 CLIXML 渲染,无截断
const stdoutText = Buffer.isBuffer(err.stdout) ? decodePipe(err.stdout) : String(err.stdout ?? "");
const marked = stdoutText.match(/RPA_ERROR::([\s\S]*)/);
if (marked) {
const detail = marked[1].trim();
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
const stderrBuf = Buffer.isBuffer(err.stderr) ? err.stderr : Buffer.from(String(err.stderr ?? ""), "utf8");
const stderrText = stderrBuf.length > 0 ? decodePipe(stderrBuf) : err.message;
const detail = decodeCliXml(stderrText);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}
@@ -0,0 +1,272 @@
import type {
AutomationDriver,
DriverPoint,
DriverRect,
ElementDriver,
KeyboardSendOptions,
MouseClickOptions,
MouseDragOptions,
MouseSmoothOptions,
MouseDriver,
KeyboardDriver,
ScreenDriver,
WindowDriver,
WindowInfo,
WindowSummary
} from "../types";
import { powershell, psString } from "./powershell";
/**
* WindowsDriver(任务 3.3):AutomationDriver 的 PowerShell 实现。
* 脚本与改造前各能力文件内的片段逐字一致(仅 Add-Type 块收拢到本文件),
* 行为不变;坐标一律物理像素,DIP 换算等策略留在能力层。
*/
/** 基础注入原语(原 capabilities/desktop.ts 的 USER32 块,补 SetProcessDpiAwarenessContext:
* 原 window-extended.ts 的 WINDOW_API 在同一进程内已设置 per-monitor DPI 感知,
* 驱动统一携带该声明,保证 SetCursorPos 全程物理像素语义一致) */
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
}
'@; try { [void][RpaUser32]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}`;
/** 窗口矩形/鼠标原语(原 capabilities/window-extended.ts 的 WINDOW_API 块) */
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowExtended {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
}
'@; try { [void][RpaWindowExtended]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}`;
function findWindowScript(title: string): string {
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
}
async function windowObject(title: string): Promise<WindowInfo> {
const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`);
return JSON.parse(output);
}
/** 把匹配标题的窗口带到前台;多招式依次兜底(应用点击「运行」后自身是前台,
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
function activateWindowScript(selector: string): string {
const safeTitle = selector.replace(/'/g, "''");
return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 }
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) }
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
$h = $p.MainWindowHandle
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9); Start-Sleep -Milliseconds 150 }
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::SwitchToThisWindow($h, $true)
Start-Sleep -Milliseconds 150
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h)
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 200
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
$fgThread = [WinRpaFocus2.RpaFocus2]::GetWindowThreadProcessId([WinRpaFocus2.RpaFocus2]::GetForegroundWindow(), [IntPtr]::Zero)
$thisThread = [WinRpaFocus2.RpaFocus2]::GetCurrentThreadId()
$attached = [WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $true)
try { [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) } finally { if ($attached) { [void][WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $false) } }
Start-Sleep -Milliseconds 200
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 6)
Start-Sleep -Milliseconds 150
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9)
Start-Sleep -Milliseconds 300
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { RpaFail '无法把窗口带到前台,请确认窗口未被关闭或全屏占用' }`;
}
/** 把 SendKeys 特殊字符按字面转义(+^%~(){}[] → {+} 等);SendKeys 用花括号表示字面量 */
function sendKeysEscape(ch: string): string {
return "+^%~(){}[]".includes(ch) ? `{${ch}}` : ch;
}
/** 把字符包装成 PowerShell 安全字面量。
* ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造——
* Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。 */
function psCharLiteral(text: string): string {
if (/^[\x20-\x7E]*$/.test(text)) return psString(text);
return Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join("+") || psString("");
}
/** 生成从当前位置到目标的插值路径点(JS 侧算好,PS 侧只执行循环)(原 window-extended.ts 的 mousePath) */
function mousePath(from: DriverPoint, to: DriverPoint, mode: string, speed: number): Array<[number, number]> {
const dx = to.x - from.x;
const dy = to.y - from.y;
const distance = Math.hypot(dx, dy);
if (distance < 2) return [[to.x, to.y]];
const stepCount = Math.max(6, Math.min(50, Math.round(distance / 12)));
const points: Array<[number, number]> = [];
for (let i = 1; i <= stepCount; i++) {
const t = i / stepCount;
// 模拟人工:缓入缓出 + 轻微垂直抖动;匀速直线:线性
const ease = mode === "0" ? (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2) : t;
const jitter = mode === "0" ? Math.sin(t * Math.PI) * (Math.sin(i * 12.9898) * 43758.5453 % 1) * 3 : 0;
points.push([
Math.round(from.x + dx * ease - (dy / distance) * jitter),
Math.round(from.y + dy * ease + (dx / distance) * jitter)
]);
}
points.push([to.x, to.y]);
return points;
}
const windowsMouse: MouseDriver = {
async moveTo(point) {
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${point.x},${point.y})`);
},
async click(point, options: MouseClickOptions = {}) {
const right = options.button === "right";
const down = right ? 0x0008 : 0x0002;
const up = right ? 0x0010 : 0x0004;
const repeats = options.double ? 2 : 1;
const settle = options.settleMs && options.settleMs > 0 ? `; Start-Sleep -Milliseconds ${Math.round(options.settleMs)}` : "";
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${point.x},${point.y});${Array.from({ length: repeats }, () => ` [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)${settle}`).join(";")}`);
},
async drag(from, to, options: MouseDragOptions = {}) {
const right = options.button === "right";
const down = right ? 0x0008 : 0x0002;
const up = right ? 0x0010 : 0x0004;
const hold = Math.max(0, Math.round(options.holdMs ?? 80));
const release = Math.max(0, Math.round(options.releaseDelayMs ?? 80));
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${from.x},${from.y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds ${hold}; [RpaUser32]::SetCursorPos(${to.x},${to.y});${release > 0 ? ` Start-Sleep -Milliseconds ${release};` : ""} [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
},
async scroll(amount) {
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
},
async moveSmooth(target, options: MouseSmoothOptions = {}) {
const ease = options.ease !== false;
const speed = Math.min(100, Math.max(1, options.speed ?? 35));
const delay = Math.min(80, Math.max(4, Math.round(1500 / speed)));
// 从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系)
const output = await powershell(`${WINDOW_API}; $cur = [RpaWindowExtended+POINT]::new(); [void][RpaWindowExtended]::GetCursorPos([ref]$cur); "$($cur.X),$($cur.Y)"`);
const [cx, cy] = output.split(",").map(Number);
const from: DriverPoint = Number.isFinite(cx) && Number.isFinite(cy) ? { x: cx, y: cy } : { x: 0, y: 0 };
const path = mousePath(from, target, ease ? "0" : "1", speed);
const ptsLiteral = path.map(([px, py]) => `@(${px},${py})`).join(",");
await powershell(`${WINDOW_API}; $pts = @(${ptsLiteral}); foreach ($pt in $pts) { [void][RpaWindowExtended]::SetCursorPos($pt[0], $pt[1]); Start-Sleep -Milliseconds ${delay} }`);
}
};
const windowsKeyboard: KeyboardDriver = {
async send(options: KeyboardSendOptions) {
// 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱
let send: string;
if (options.mode === "clipboard") {
// 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造
const contentExpr = /^[\x20-\x7E]*$/.test(options.keys) ? psString(options.keys) : `(-join @(${Array.from(options.keys).map((c) => psCharLiteral(c)).join(",")}))`;
send = `Set-Clipboard -Value ${contentExpr}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`;
} else if (options.mode === "manual" && options.textMode) {
// 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环
const escapedChars = Array.from(options.keys).map((ch) => sendKeysEscape(ch));
const arr = escapedChars.map((c) => psCharLiteral(c)).join(",");
send = `$chars = @(${arr}); foreach ($c in $chars) { [System.Windows.Forms.SendKeys]::SendWait([string]$c); Start-Sleep -Milliseconds ${options.intervalMs} }`;
} else {
// 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义
const escaped = options.textMode ? Array.from(options.keys).map(sendKeysEscape).join("") : options.keys;
const contentExpr = /^[\x20-\x7E]*$/.test(escaped) ? psString(escaped) : `(-join @(${Array.from(escaped).map((c) => psCharLiteral(c)).join(",")}))`;
send = `[System.Windows.Forms.SendKeys]::SendWait(${contentExpr})`;
}
const activate = options.activateTitle ? `${activateWindowScript(options.activateTitle)}; Start-Sleep -Milliseconds 300; ` : "";
await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);
}
};
const windowsWindow: WindowDriver = {
findByTitle: (title) => windowObject(title),
async activate(title, options: { maximize?: boolean } = {}) {
const maximize = options.maximize === true;
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`);
return windowObject(title);
},
async focus(title) {
return powershell(`${USER32}\n${activateWindowScript(title)}\n$p.MainWindowTitle`);
},
async foregroundTitle() {
return powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
},
async foregroundPosition(): Promise<DriverPoint> {
const output = await powershell(`${WINDOW_API}; $fw = [RpaWindowExtended]::GetForegroundWindow(); $r = New-Object RpaWindowExtended+RECT; [void][RpaWindowExtended]::GetWindowRect($fw, [ref]$r); "$($r.Left),$($r.Top)"`);
const [x, y] = output.split(",").map(Number);
return { x, y };
},
async exists(title) {
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
},
async close(title): Promise<string> {
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
},
async setState(title, showCommand): Promise<void> {
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowExtended]::ShowWindow($p.MainWindowHandle,${showCommand}) | Out-Null`);
},
async setSize(title, width, height) {
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`);
},
async setPos(title, rect: DriverRect): Promise<WindowInfo> {
if (!(rect.width > 0 && rect.height > 0)) throw new Error("窗口尺寸必须大于 0");
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${rect.x},${rect.y},${rect.width},${rect.height},4) | Out-Null`);
return { ...(await windowObject(title)), x: rect.x, y: rect.y, width: rect.width, height: rect.height };
},
async list(filter: { title?: string; regex?: boolean } = {}) {
const title = filter.title ?? "";
const regex = filter.regex === true;
const output = await powershell(`Get-Process | Where-Object { $_.MainWindowHandle -ne 0 ${title ? (regex ? `-and $_.MainWindowTitle -match ${psString(title)}` : `-and $_.MainWindowTitle -like ${psString(`*${title}*`)}`) : ""} } | ForEach-Object { @{pid=$_.Id;title=$_.MainWindowTitle;hwnd=$_.MainWindowHandle.ToInt64()} } | ConvertTo-Json -Compress`);
if (!output) return [] as WindowSummary[];
const parsed = JSON.parse(output);
return (Array.isArray(parsed) ? parsed : [parsed]) as WindowSummary[];
}
};
const windowsScreen: ScreenDriver = {
async size() {
const output = await powershell("Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; @{width=$b.Width;height=$b.Height;x=$b.X;y=$b.Y} | ConvertTo-Json -Compress");
return JSON.parse(output);
}
};
/** element 域实现承载在 capabilities/desktop-uia.ts(阶段 3.3 说明),此处运行时注入,避免 driver → capabilities 的模块依赖 */
let elementImplementation: ElementDriver | undefined;
export function setElementDriverImplementation(impl: ElementDriver): void {
elementImplementation = impl;
}
export class WindowsDriver implements AutomationDriver {
readonly mouse = windowsMouse;
readonly keyboard = windowsKeyboard;
readonly window = windowsWindow;
readonly screen = windowsScreen;
get element(): ElementDriver {
if (!elementImplementation) {
throw new Error("ElementDriver 实现未注册:请 import @rpa/core/capabilities(desktop-uia 模块自动注册)");
}
return elementImplementation;
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { Client } from "basic-ftp";
import type { Workbook as ExcelJSWorkbook } from "exceljs";
import { WindowsDriver } from "../driver/windows/windows-driver";
import type { AutomationDriver } from "../driver/types";
/** 人为拟真操作的开关与随机参数(desktop 能力的进程级设置)。 */
export interface ManualOperationSettings {
enabled: boolean;
randomMove: boolean;
randomClick: boolean;
randomSleep: boolean;
minMs: number;
maxMs: number;
}
/**
* 引擎实例状态容器(任务 1.3,R4:模块级全局状态收拢)。
*
* 折中方案(决策记录 2026-11-04):能力文件的 register() 调用与全局注册表保持不变,
* 但 Excel 缓存、数据库连接、FTP 客户端等进程级可变状态统一挂到引擎实例上。
* 包内各能力模块通过 defaultEngine 访问;宿主/CLI 可用 createEngine() 产生隔离实例,
* 或拿到 defaultEngine 做测试间重置。
*/
export class RpaEngine {
/** excel-store:路径 → ExcelJS 工作簿共享缓存 */
readonly excelCache = new Map<string, ExcelJSWorkbook>();
/** office.ts:Excel 句柄 id → 工作簿 */
readonly excelHandles = new Map<string, ExcelJSWorkbook>();
/** remaining-core:连接 id → 数据库连接(mysql2/pg/mssql) */
readonly dbConnections = new Map<string, unknown>();
/** ftp.ts:当前 FTP 连接 */
ftpClient?: Client;
/** ocr-caps.ts:懒加载的 OCR 实例 */
ocrInstance?: unknown;
/** desktop.ts:人为拟真操作设置 */
manualOperation: ManualOperationSettings = {
enabled: false,
randomMove: true,
randomClick: true,
randomSleep: true,
minMs: 1000,
maxMs: 3000
};
/** 桌面自动化驱动(任务 3.4 注入点,默认 PowerShell 版 WindowsDriver) */
readonly driver: AutomationDriver = new WindowsDriver();
}
export function createEngine(): RpaEngine {
return new RpaEngine();
}
/** 能力模块内部使用的默认引擎实例(与全局注册表配套的进程级单例)。 */
export const defaultEngine = createEngine();
@@ -0,0 +1,52 @@
/**
* 稳定错误码枚举(任务 4.3,R9)。
*
* 契约:RuntimeError 的 code 必须取自本清单;新增能力需要新错误码时先在此登记。
* message 保持中文(给人看),机器消费走 code 与 options 里的结构化字段
* (recommendation / nodeId / componentId / arg / detail)。
* 宿主侧另有自己的扩展码(browser 会话 / 拾取 / vision-lab 等),不进本清单。
*/
export const RUNTIME_ERROR_CODES = [
// 通用
"validation_error",
"timeout",
"cancelled",
"capability_not_implemented",
"expression_error",
"user_exception",
"not_found",
"network_error",
"http_error",
"external_service_failed",
// 桌面驱动
"desktop_command_failed",
"desktop_element_not_found",
"desktop_error",
// 浏览器
"browser_error",
"browser_disconnected",
"tab_unavailable",
// AI 服务
"ai_not_configured",
"ai_request_timeout",
"ai_rate_limited",
"ai_request_failed",
// 视觉
"cv_not_found",
// Office
"excel_com_failed",
"word_table_not_found",
"word_text_not_found",
"outlook_unavailable",
// 其他
"decryption_failed",
"host_unavailable"
] as const;
export type ErrorCode = (typeof RUNTIME_ERROR_CODES)[number];
/** 运行期校验(audit 测试用):code 必须在清单内 */
export function isKnownErrorCode(code: string): boolean {
return (RUNTIME_ERROR_CODES as readonly string[]).includes(code);
}
File renamed without changes.
@@ -0,0 +1,69 @@
import type { FlowDefinition } from "../types";
/**
* 宿主桥(任务 2.1 正式化)。
*
* 包内能力需要的宿主能力(子流程仓库 / Electron 窗口 / DPI 换算 / 应用配置)
* 通过本接口注入,包本身禁止 import "electron" 或宿主模块(见包内依赖检查脚本)。
* 宿主启动时调用 setSubflowSource() / setHostBridge()。
*/
export interface SubflowSource {
listFlows(): Promise<Array<{ id: string; name: string }>>;
getFlow(id: string): Promise<FlowDefinition>;
}
let subflowSource: SubflowSource | undefined;
export function setSubflowSource(source: SubflowSource): void {
subflowSource = source;
}
export function getSubflowSource(): SubflowSource | undefined {
return subflowSource;
}
/** 人机交互表单窗口的最小面(能力只用到这些成员,宿主用 BrowserWindow 适配) */
export interface HostDialogWindow {
isDestroyed(): boolean;
destroy(): void;
loadURL(url: string): Promise<void>;
on(event: "closed", listener: () => void): void;
/** 在窗口页面里执行 JS 并取回结果(宿主侧以 userGesture=true 执行) */
executeJavaScript(script: string): Promise<unknown>;
}
export interface HostPoint {
x: number;
y: number;
}
/** AI 服务配置(宿主 settings.aiConfig) */
export interface HostAiConfig {
baseUrl: string;
apiKey: string;
model: string;
visionModel: string;
}
/** 宿主能力桥最小面(决策点 4):dialog 窗口工厂 / DPI 坐标换算 / 配置读取 */
export interface HostBridge {
/** 创建人机交互表单窗口(human-dialog 用) */
createFormWindow(options: { title: string }): HostDialogWindow;
/** DIP 逻辑坐标 → 物理像素(window-extended 的鼠标/拖拽坐标换算) */
dipToScreenPoint(point: HostPoint): HostPoint;
/** 物理像素 → DIP 逻辑坐标 */
screenToDipPoint(point: HostPoint): HostPoint;
/** 读取 AI 服务配置(ai-service 用) */
loadAiConfig(): HostAiConfig;
}
let hostBridge: HostBridge | undefined;
export function setHostBridge(bridge: HostBridge): void {
hostBridge = bridge;
}
export function getHostBridge(): HostBridge | undefined {
return hostBridge;
}
+62
View File
@@ -0,0 +1,62 @@
import type { CapabilityHandler } from "./types";
/**
* 能力结构化元数据(任务 4.1,决策点 8:meta 可选 + 新能力强制)。
* 老能力逐步补齐;规范 v1 验收 checklist 对新能力要求必填。
*/
export interface CapabilityParamMeta {
key: string;
label?: string;
type: "string" | "number" | "boolean" | "object" | "array";
required?: boolean;
defaultValue?: string | number | boolean | null;
description?: string;
}
export interface CapabilityMeta {
/** 一句话能力摘要(中文) */
summary?: string;
/** 参数 schema(按字段名列出) */
params?: CapabilityParamMeta[];
/** 返回值说明 */
returns?: string;
/** 默认超时毫秒(无外部交互语义的能力省略) */
timeoutMs?: number;
/** 可能抛出的稳定错误码(见 engine/error-codes.ts) */
errorCodes?: string[];
/** 执行中是否需要回传截图(Agent 侧视觉判断用) */
needsScreenshot?: boolean;
/** 平台限制;省略表示跨平台 */
platforms?: Array<"win32">;
}
const handlers = new Map<string, CapabilityHandler>();
const metas = new Map<string, CapabilityMeta>();
export function register(ids: string | string[], handler: CapabilityHandler, meta?: CapabilityMeta): void {
const list = Array.isArray(ids) ? ids : [ids];
for (const id of list) {
handlers.set(id, handler);
if (meta) metas.set(id, meta);
}
}
export function getHandler(id: string): CapabilityHandler | undefined {
return handlers.get(id);
}
export function getCapabilityMeta(id: string): CapabilityMeta | undefined {
return metas.get(id);
}
export function hasCapability(id: string): boolean {
return handlers.has(id);
}
export function capabilityIds(): string[] {
return [...handlers.keys()];
}
export function capabilityCount(): number {
return handlers.size;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition } from "../../shared/types";
import type { CatalogComponent, FlowDefinition } from "../types";
import { register } from "./registry";
import { runFlow } from "./runtime";
@@ -10,7 +10,7 @@ import type {
FlowEdge,
FlowNode,
RunEvent
} from "../../shared/types";
} from "../types";
import "../capabilities";
import { register } from "./registry";
import { runFlow } from "./runtime";
@@ -1,4 +1,4 @@
import type { FlowDefinition, FlowNode, RunError } from "../../shared/types";
import type { FlowDefinition, FlowNode, RunError } from "../types";
import { evaluate, evaluateCondition, interpolate, toNumber } from "./expression";
import { getHandler } from "./registry";
import {
@@ -1,4 +1,4 @@
import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } from "../../shared/types";
import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } from "../types";
export interface BrowserLike {
navigate(url: string): Promise<{ url: string; title: string }>;
+104
View File
@@ -0,0 +1,104 @@
/**
* @rpa/core 包入口。
*
* 只导出引擎 API 与类型,不做能力注册副作用;
* 纯 Node 能力通过 `@rpa/core/capabilities` 子路径按需拉起(保持与 demo 宿主
* src/main/capabilities/index.ts 相同的副作用注册模型,见任务 4.2 再显式化)。
*/
export { createEngine, defaultEngine, RpaEngine, type ManualOperationSettings } from "./engine/engine";
export { register, getHandler, hasCapability, capabilityIds, capabilityCount } from "./engine/registry";
export { runFlow } from "./engine/runtime";
export { RuntimeError, BreakSignal, ContinueSignal, StopSignal, asRuntimeError } from "./engine/types";
export { setSubflowSource, getSubflowSource, type SubflowSource } from "./engine/host-bridge";
// 错误契约(任务 4.3):稳定错误码枚举 + 机器可读结构(RuntimeError.code / recommendation / detail 等)
export { RUNTIME_ERROR_CODES, isKnownErrorCode, type ErrorCode } from "./engine/error-codes";
// 能力元数据(任务 4.1)与显式注册入口(任务 4.2,双入口过渡)
export { getCapabilityMeta, type CapabilityMeta, type CapabilityParamMeta } from "./engine/registry";
export { registerAll, capabilityDomains } from "./capabilities/register-all";
export {
setHostBridge,
getHostBridge,
type HostBridge,
type HostDialogWindow,
type HostPoint,
type HostAiConfig
} from "./engine/host-bridge";
// AI 服务(阶段 2 经 HostBridge 迁入包内):宿主注入 loadAiConfig,消费方走此导出
export {
chatCompletion,
getAiConfig,
requireAiConfig,
type ChatCompletionOptions,
type ChatContentPart,
type ChatMessage,
type ResolvedAiConfig
} from "./capabilities/ai-service";
// 浏览器 CDP 外部路径(阶段 2 迁入):ExternalTransport + 外部浏览器进程管理
export { ExternalTransport, mapCdpError, type CdpTransport, type CdpEventHandler } from "./browser/transport";
export {
listPageTargets,
openExternalBrowser,
openNewPageTarget,
sessionKeyOf,
detectBrowserPath,
connectRemoteBrowser,
launchExternalBrowser,
type ExternalBrowserOptions,
type ExternalBrowserType,
type ExternalLaunchOptions,
type ExternalRemoteOptions,
type ExternalConnection
} from "./browser/external";
// 桌面自动化驱动(阶段 3):五域接口 + PowerShell 实现,引擎创建时注入
export type {
AutomationDriver,
DriverPoint,
DriverRect,
ElementDriver,
ElementFindOptions,
KeyboardDriver,
KeyboardSendOptions,
MouseClickOptions,
MouseDragOptions,
MouseDriver,
MouseSmoothOptions,
MouseButton,
ScreenDriver,
UiaSelector,
WindowDriver,
WindowInfo,
WindowSummary
} from "./driver/types";
export { WindowsDriver, setElementDriverImplementation } from "./driver/windows/windows-driver";
// PowerShell 助手(任务 3.1 已移入 driver/windows/)
export { powershell, psString } from "./driver/windows/powershell";
export type {
BrowserLike,
BrowserTabInfo,
BrowserElementReference,
BrowserElementInfo,
BrowserSelectOption,
BrowserSelectState,
BrowserSimilarElementOptions,
BrowserElementRegion,
RunContext,
CapabilityHandler,
NodeState,
RunResult
} from "./engine/types";
export type {
CatalogWidget,
CatalogField,
CatalogOutput,
CatalogComponent,
CatalogGroup,
Catalog,
FlowVariable,
FlowNodeOptions,
FlowNode,
FlowEdge,
FlowDefinition,
RunError,
RunEvent
} from "./types";
+187
View File
@@ -0,0 +1,187 @@
/**
* rpa-core 领域类型(任务 1.2,类型单一源)
*
* 从 src/shared/types 拆出:与流程/目录/运行相关、引擎必须依赖的模型归包所有。
* 宿主专属类型(AppInfo / AppSettings / FlowSummary / VisionLab* 等)留在 src/shared/types,
* 该文件通过 type-only re-export 引用本文件,保证全仓不出现两份定义。
*/
export type CatalogWidget =
| "text"
| "textarea"
| "number"
| "select"
| "radio"
| "boolean"
| "file"
| "folder"
| "element"
| "code"
| "secret"
| "region"
| "hidden";
export interface CatalogField {
key: string;
label: string;
widget: CatalogWidget;
type: "string" | "number" | "boolean" | "object";
displayType: string;
required: boolean;
defaultValue: string;
placeholder: string;
description: string;
advanced: boolean;
valueTypes: string[];
/** 下拉/单选选项:字符串(显示=存储值)或 { label, value }(显示与存储分离) */
options?: Array<string | { label: string; value: string }>;
unit?: string;
}
export interface CatalogOutput {
label: string;
key: string;
suggestedName: string;
valueTypes: string[];
fields: Array<{ key: string; label: string; defaultValue: string; valueTypes: string[] }>;
}
export interface CatalogComponent {
id: string;
name: string;
kind: "vendor" | "native" | "trigger";
group: string;
categories: string[];
categorySignature: string;
description: string;
messageTemplate: string;
baseType: string;
functionName: string;
functionCalls: Array<{ function: string; args: string[]; assignTo: string }>;
version: string;
tags: string[];
platformTag: string | null;
icon: string;
risk: "low" | "high";
fields: CatalogField[];
output: CatalogOutput;
slots: string[];
nodeKind?: string;
/** 同名旧版本 / 重复别名指向的保留能力 ID。 */
duplicateOf?: string;
}
export interface CatalogGroup {
id: string;
label: string;
icon: string;
count: number;
}
export interface Catalog {
schemaVersion: number;
generatedAt: string;
counts: {
total: number;
vendor: number;
native: number;
triggers: number;
fields: number;
};
groups: CatalogGroup[];
components: CatalogComponent[];
}
export interface FlowVariable {
name: string;
type: string;
defaultValue?: string;
description?: string;
scope?: "flow" | "local";
}
export interface FlowNodeOptions {
outputVar?: string;
delayBeforeMs?: number;
delayAfterMs?: number;
timeoutMs?: number;
retryCount?: number;
retryIntervalMs?: number;
skipOnError?: boolean;
logException?: boolean;
errorRouteEnabled?: boolean;
}
export interface FlowNode {
id: string;
componentId: string;
label?: string;
position: { x: number; y: number };
values: Record<string, unknown>;
options: FlowNodeOptions;
parentId?: string | null;
slot?: string;
enabled?: boolean;
width?: number;
height?: number;
}
export interface FlowEdge {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
label?: string;
}
export interface FlowDefinition {
schemaVersion: 1;
id: string;
name: string;
description?: string;
enabled?: boolean;
trigger?: {
type: "manual" | "schedule" | "file";
label: string;
};
variables: FlowVariable[];
nodes: FlowNode[];
edges: FlowEdge[];
meta: {
createdAt: string;
updatedAt: string;
appVersion: string;
};
}
export interface RunError {
code: string;
reason: string;
recommendation?: string;
location?: { flowId?: string; nodeId?: string; componentId?: string; arg?: string };
detail?: string;
}
export interface RunEvent {
type:
| "run.started"
| "node.started"
| "node.log"
| "node.finished"
| "run.finished"
| "run.total";
runId: string;
nodeId?: string;
level?: "info" | "warn" | "error";
message?: string;
status?: string;
output?: unknown;
error?: RunError;
durationMs?: number;
evidence?: string[];
attempt?: number;
startedAt?: string;
nodeLabel?: string;
total?: number;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// 与根配置一致:桌面自动化冒烟测试共用同一物理桌面,串行执行防焦点竞争。
fileParallelism: false
}
});
+4 -10
View File
@@ -3,9 +3,9 @@
"generatedAt": "2026-11-02T04:56:17.341Z",
"policy": "同名能力按目标域、协议和最新版本保留主能力;重复旧版本仅隐藏,不删除执行器",
"editableBefore": 894,
"keep": 654,
"hide": 240,
"editableAfter": 654,
"keep": 653,
"hide": 241,
"editableAfter": 653,
"decisions": [
{
"id": "Trigger.Manual",
@@ -3338,12 +3338,6 @@
"status": "keep",
"reason": "唯一能力"
},
{
"id": "Image_Base64Decode",
"name": "图片base64编码转二进制",
"status": "keep",
"reason": "唯一能力"
},
{
"id": "Image_Find_V1",
"name": "查找图像",
@@ -5619,4 +5613,4 @@
"reason": "与图片Base64编解码(Image_Base64Encode)互逆重复, 已统一为转换方向切换, 保留执行兼容"
}
]
}
}
+915
View File
@@ -0,0 +1,915 @@
{
"generatedAt": "2026-11-04T04:34:06.782Z",
"count": 909,
"ids": [
"App_GetPid",
"App_GetPids",
"App_Kill",
"App_KillV1",
"App_Run",
"App_Start",
"Array_Reshape2Dim",
"Basic_BusinessOrderCount",
"Basic_Clone",
"Basic_ConvertBool",
"Basic_ConvertInt",
"Basic_ConvertNumber",
"Basic_ConvertString",
"Basic_DataTypeConversion",
"Basic_DebugOutput",
"Basic_Delay",
"Basic_Delay_V1",
"Basic_Delay_V2",
"Basic_Dodecryption",
"Basic_GetProcessInfo",
"Basic_IsArray",
"Basic_IsDictionary",
"Basic_IsNull",
"Basic_IsNumber",
"Basic_JudgeDataType",
"Basic_RandomInt",
"Basic_Rnd",
"Basic_SetVariable",
"Basic_SetVariable_V1",
"Basic_StringToNum",
"Basic_Type",
"Bivartable_ColumnOperationDf",
"Bivartable_CreateDf",
"Bivartable_CreateDfV1",
"Bivartable_DelDfColRow",
"Bivartable_DeleteDuplicateRow",
"Bivartable_DfWriteToFile",
"Bivartable_DoCal",
"Bivartable_DoCreate",
"Bivartable_DoFilter",
"Bivartable_DoJoint",
"Bivartable_DoMerge",
"Bivartable_DoOpenExcel",
"Bivartable_DoSave",
"Bivartable_DoSort",
"Bivartable_Dtype",
"Bivartable_FilterDf",
"Bivartable_GetAvgValue",
"Bivartable_GetMaxValue",
"Bivartable_GetMinValue",
"Bivartable_GetSumValue",
"Bivartable_HandleDfMissValue",
"Bivartable_InsertDfData",
"Bivartable_MergeDf",
"Bivartable_ModifyColName",
"Bivartable_ModifyDfName",
"Bivartable_PivotDf",
"Bivartable_ReadDfData",
"Bivartable_ReadDfData_V1",
"Bivartable_ReadDfIndexColumns",
"Bivartable_ReadDfShape",
"Bivartable_ReadTable",
"Bivartable_SortDf",
"Bivartable_SpliceColData",
"Bivartable_SummaryCalculationDf",
"Bivartable_WriteDfData",
"Bivartable_WriteTable",
"CSV_Read",
"CSV_ReadNew",
"CSV_Write",
"CSV_WriteNew",
"CV_GetText",
"CV_MouseClick",
"CV_MouseHover",
"CV_SetText",
"Clipboard_ClearClipboard",
"Clipboard_GetText",
"Clipboard_GetTextV1",
"Clipboard_SaveImage",
"Clipboard_SaveImageV1",
"Clipboard_SetImage",
"Clipboard_SetImageV1",
"Clipboard_SetText",
"Clipboard_SetTextV1",
"Collection_Add",
"Collection_Count",
"Collection_Create",
"Collection_HasItem",
"Collection_Remove",
"CustomConfig_GetConfig",
"DataService_AIGenTxt",
"DataService_AgentCall",
"DataService_ChatBot",
"DataService_Correct",
"DataService_Diff",
"DataService_KeyWord",
"DataService_LargeModel",
"DataService_Review",
"DataService_TextComparison",
"DataService_WorkingDay",
"DataService_ZBrain",
"Database_AllSQLQuery",
"Database_AllSQLQuery_V1",
"Database_BatchInsertExecute",
"Database_BatchSQLExecute",
"Database_CloseDBConnect",
"Database_DBConnect",
"Database_SQLExecute",
"Database_SingleSQLQuery",
"Databook_Clear",
"Databook_DeleteColumn",
"Databook_DeleteRow",
"Databook_Export",
"Databook_GetColumnInfo",
"Databook_GetRowAndColCount",
"Databook_Import",
"Databook_ReadContent",
"Databook_SetColumnInfo",
"Databook_WriteContent",
"Dialog_CsInputBox",
"Dialog_CsMsgBox",
"Dialog_GetConfig",
"Dialog_GetConfigV1",
"Dialog_GetConfigV2",
"Dialog_GetConfigV3",
"Dialog_GetConfigV4",
"Dialog_GetConfigV5",
"Dialog_GetConfigV6",
"Dialog_GetConfigV7",
"Dialog_GetConfigV8",
"Dialog_InputBox",
"Dialog_MsgBox",
"Dialog_Notice",
"Dialog_Notice_V1",
"Dialog_Notice_V2",
"Dialog_OpenFile",
"Dialog_OpenFiles",
"Dialog_SaveFile",
"Dict_Clear",
"Dict_Create",
"Dict_Delete",
"Dict_GetItem",
"Dict_Items",
"Dict_Keys",
"Dict_SetItem",
"Dict_Update",
"Dict_Values",
"Element_ActivateWindowGround",
"Element_CheckBoxOperation",
"Element_CheckBoxOperation_v1",
"Element_CheckBoxOperation_v2",
"Element_CheckBoxOperation_v3",
"Element_Children",
"Element_ChildrenGround",
"Element_ChildrenV1",
"Element_Children_V1",
"Element_Children_V2",
"Element_Click",
"Element_ClickGround",
"Element_Click_V1",
"Element_Click_V2",
"Element_Click_V3",
"Element_Click_V4",
"Element_Click_V5",
"Element_Click_V6",
"Element_DesktopChildren",
"Element_ElementAarray",
"Element_ElementCaptureSimilar",
"Element_ElementExists",
"Element_Exists",
"Element_ExistsGround",
"Element_ExistsV1",
"Element_FileUploader",
"Element_GetAttribute",
"Element_GetAttributeGround",
"Element_GetAttribute_v1",
"Element_GetAttribute_v2",
"Element_GetAttribute_v3",
"Element_GetButtonEnable",
"Element_GetButtonEnableGround",
"Element_GetButtonEnable_v1",
"Element_GetButtonEnable_v2",
"Element_GetButtonEnable_v3",
"Element_GetButtonEnable_v4",
"Element_GetCheck",
"Element_GetCheckGround",
"Element_GetElement",
"Element_GetElementInfo",
"Element_GetElementInfo_V1",
"Element_GetElementNew",
"Element_GetElementV1",
"Element_GetElementV2",
"Element_GetHtml",
"Element_GetLineBarChartData",
"Element_GetPieChartData",
"Element_GetSelect",
"Element_GetSelectGround",
"Element_GetTableData",
"Element_GetText",
"Element_GetText_V1",
"Element_GetText_V2",
"Element_GetText_V3",
"Element_GetText_V4",
"Element_GetText_V5",
"Element_GetValue",
"Element_GetValueGround",
"Element_MouseTo",
"Element_MouseToGround",
"Element_MouseTo_V1",
"Element_MouseTo_V2",
"Element_MouseTo_V3",
"Element_MouseTo_V4",
"Element_MoveWindowGround",
"Element_Parent",
"Element_Parent_V1",
"Element_Parent_V2",
"Element_Region",
"Element_Region2",
"Element_Region2_v1",
"Element_Region2_v2",
"Element_Region2_v3",
"Element_RegionGround",
"Element_RestoreWindowGround",
"Element_ScrollIntoView",
"Element_ScrollIntoViewGround",
"Element_ScrollIntoView_v1",
"Element_ScrollIntoView_v2",
"Element_SelectOperation",
"Element_SelectOperation_v1",
"Element_SelectOperation_v2",
"Element_SelectOperation_v3",
"Element_SetAttribute",
"Element_SetAttributeGround",
"Element_SetAttribute_v1",
"Element_SetAttribute_v2",
"Element_SetCheck",
"Element_SetCheckGround",
"Element_SetSelect",
"Element_SetSelectGround",
"Element_SetValue",
"Element_SetValueGround",
"Element_SetValuePassword",
"Element_SetValuePassword_V1",
"Element_SetValuePassword_V5",
"Element_SetValuePassword_V6",
"Element_SetValue_V1",
"Element_SetValue_V2",
"Element_SetValue_V3",
"Element_SetValue_V4",
"Element_SetValue_V5",
"Element_SetValue_V6",
"Element_Shot",
"Element_ShotGround",
"Element_Shot_V1",
"Element_Shot_V2",
"Element_Sibling",
"Element_Sibling_V1",
"Element_Sibling_V2",
"Element_SimilarElementList",
"Element_SimilarElementList_V1",
"Element_SimilarElementList_V2",
"Element_StartAutoClosePopup",
"Element_StopAutoClosePopup",
"Element_WaitElement",
"Element_WaitElementGround",
"Element_WaitElement_v1",
"Element_WaitElement_v2",
"Element_WaitElement_v3",
"Element_WaitElement_v4",
"Element_WaitElement_v5",
"Element_exception_monitor_v2",
"Excel_ActiveSheet",
"Excel_ClearRange",
"Excel_Close",
"Excel_CopyRange",
"Excel_CopySheet",
"Excel_CreateSheet",
"Excel_Cs_Active",
"Excel_Cs_AutoFill",
"Excel_Cs_AutoFillV2",
"Excel_Cs_ClearExcelFilter",
"Excel_Cs_ClearRange",
"Excel_Cs_ClearRangeV2",
"Excel_Cs_ClearRangeV3",
"Excel_Cs_ClearRangeV4",
"Excel_Cs_Close",
"Excel_Cs_CloseV2",
"Excel_Cs_ColumnSplit",
"Excel_Cs_ColumnSplitV2",
"Excel_Cs_ConvertColumn",
"Excel_Cs_Copy",
"Excel_Cs_CopyRange",
"Excel_Cs_CopyRangeV2",
"Excel_Cs_CopyRangeV3",
"Excel_Cs_CopyV2",
"Excel_Cs_Delete",
"Excel_Cs_DeleteColumn",
"Excel_Cs_DeleteDuplicates",
"Excel_Cs_DeletePicture",
"Excel_Cs_DeletePictureV2",
"Excel_Cs_DeleteRange",
"Excel_Cs_DeleteRangeV2",
"Excel_Cs_DeleteRangeV3",
"Excel_Cs_DeleteRow",
"Excel_Cs_EnableExcelFilter",
"Excel_Cs_Encrypt",
"Excel_Cs_ExcelAddin",
"Excel_Cs_ExcelFilter",
"Excel_Cs_Export",
"Excel_Cs_Filter",
"Excel_Cs_FilterPivotTable",
"Excel_Cs_Find",
"Excel_Cs_FindV2",
"Excel_Cs_GetEditRange",
"Excel_Cs_GetEmptyCell",
"Excel_Cs_GetLastEmptyCell",
"Excel_Cs_GetName",
"Excel_Cs_GetPath",
"Excel_Cs_Group",
"Excel_Cs_Hide",
"Excel_Cs_Insert",
"Excel_Cs_InsertColumn",
"Excel_Cs_InsertColumnV2",
"Excel_Cs_InsertColumnV3",
"Excel_Cs_InsertPicture",
"Excel_Cs_InsertPictureV2",
"Excel_Cs_InsertPivotTable",
"Excel_Cs_InsertPivotView",
"Excel_Cs_InsertRow",
"Excel_Cs_InsertRowV2",
"Excel_Cs_InsertRowV3",
"Excel_Cs_Interop",
"Excel_Cs_Load",
"Excel_Cs_LoadV2",
"Excel_Cs_Merge",
"Excel_Cs_MergeCells",
"Excel_Cs_MergeCellsV2",
"Excel_Cs_Move",
"Excel_Cs_New",
"Excel_Cs_NewV2",
"Excel_Cs_NewV3",
"Excel_Cs_NewV4",
"Excel_Cs_NewV5",
"Excel_Cs_Open",
"Excel_Cs_OpenV2",
"Excel_Cs_OpenV3",
"Excel_Cs_OpenV4",
"Excel_Cs_PastRange",
"Excel_Cs_PastRangeV2",
"Excel_Cs_ReadCell",
"Excel_Cs_ReadCellBackground",
"Excel_Cs_ReadCellFormula",
"Excel_Cs_ReadColumn",
"Excel_Cs_ReadColumnCount",
"Excel_Cs_ReadContent",
"Excel_Cs_ReadContentV2",
"Excel_Cs_ReadContentV3",
"Excel_Cs_ReadContentV4",
"Excel_Cs_ReadRange",
"Excel_Cs_ReadRangeV2",
"Excel_Cs_ReadRow",
"Excel_Cs_ReadRowCount",
"Excel_Cs_RefreshAll",
"Excel_Cs_RefreshPivotTable",
"Excel_Cs_Rename",
"Excel_Cs_RunMacro",
"Excel_Cs_RunMacroV2",
"Excel_Cs_Save",
"Excel_Cs_SaveAs",
"Excel_Cs_Search",
"Excel_Cs_SelectRange",
"Excel_Cs_SelectRangeV2",
"Excel_Cs_SelectRangeV3",
"Excel_Cs_SetCellBackground",
"Excel_Cs_SetCellFont",
"Excel_Cs_SetCellFormat",
"Excel_Cs_SetCellFormat_V2",
"Excel_Cs_SetCellFormat_V3",
"Excel_Cs_SetHeight",
"Excel_Cs_SetHeightV1",
"Excel_Cs_SetRangeBackground",
"Excel_Cs_SetRangeBackgroundV2",
"Excel_Cs_SetRangeFont",
"Excel_Cs_SetRangeFontV2",
"Excel_Cs_SetSlicer",
"Excel_Cs_SetWidth",
"Excel_Cs_SetWidthV1",
"Excel_Cs_ShowTableStyle",
"Excel_Cs_Sort",
"Excel_Cs_SortV1",
"Excel_Cs_WriteCell",
"Excel_Cs_WriteCellV2",
"Excel_Cs_WriteColumn",
"Excel_Cs_WriteContent",
"Excel_Cs_WriteRange",
"Excel_Cs_WriteRangeV2",
"Excel_Cs_WriteRow",
"Excel_Cs_WriteRowV2",
"Excel_DeleteColumn",
"Excel_DeleteImage",
"Excel_DeleteRange",
"Excel_DeleteRow",
"Excel_DeleteSheet",
"Excel_ExecuteMacro",
"Excel_ExportRangeAsPicture",
"Excel_GetColData",
"Excel_GetColDataNew",
"Excel_GetColumnId",
"Excel_GetColumnsCount",
"Excel_GetPath",
"Excel_GetRowData",
"Excel_GetRowDataNew",
"Excel_GetRowsCount",
"Excel_GetSheetNames",
"Excel_GetSheetsName",
"Excel_InsertColumn",
"Excel_InsertImage",
"Excel_InsertRow",
"Excel_InsertSheet",
"Excel_Loop",
"Excel_New",
"Excel_NewExcel",
"Excel_Open",
"Excel_OpenExcel",
"Excel_OpenExcelNew",
"Excel_PasteRange",
"Excel_ReadCell",
"Excel_ReadCellNew",
"Excel_ReadRange",
"Excel_ReadRangeNew",
"Excel_RenameSheet",
"Excel_Save",
"Excel_SaveAs",
"Excel_SaveV2",
"Excel_SelectRange",
"Excel_SetCellColor",
"Excel_SetCellFontColor",
"Excel_SetColumnWidth",
"Excel_SetRangeColor",
"Excel_SetRangeFontColor",
"Excel_SetRowHeight",
"Excel_WriteCell",
"Excel_WriteCellNew",
"Excel_WriteColumn",
"Excel_WriteColumnNew",
"Excel_WriteRange",
"Excel_WriteRangeNew",
"Excel_WriteRow",
"Excel_WriteRowNew",
"ExceptionUtil_Throws",
"FTP_Connect",
"FTP_CreateDir",
"FTP_Cwd",
"FTP_Delete",
"FTP_Disconnect",
"FTP_Download",
"FTP_Exist",
"FTP_GetFileList",
"FTP_Rename",
"FTP_Upload",
"FileTypeConversion_Office2pdf",
"FileTypeConversion_Pdf2image2",
"File_CompressFile",
"File_Copy",
"File_Copy_V1",
"File_CreateFile",
"File_CreateFolder",
"File_DecompressFile",
"File_Delete",
"File_DeleteFile",
"File_EmptyFolder",
"File_FileExists",
"File_File_Move",
"File_GetFileInfo",
"File_GetFileList",
"File_GetFileList_V1",
"File_GetFolderList",
"File_GetFolderList_V1",
"File_MakeDir",
"File_MakeFolder",
"File_Move",
"File_MoveFolder",
"File_OpenFile",
"File_PathExists",
"File_PathSize",
"File_PathSizeNew",
"File_Read",
"File_ReadXml",
"File_Rename",
"File_Rename_V1",
"File_WaitFile",
"File_Write",
"File_Write_V1",
"GoogleTable_ChangeSheet",
"GoogleTable_CopyPasteSheet",
"GoogleTable_DownloadSheets",
"GoogleTable_GetAllData",
"GoogleTable_GetAllSheetsName",
"GoogleTable_GetColumnsByTableTitle",
"GoogleTable_GetSheetRowCount",
"GoogleTable_RenameSheet",
"GoogleTable_WriteToSheetCell",
"GoogleTable_WriteToSheetCellFormula",
"GoogleTable_WriteToSheetRegionColumns",
"GoogleTable_WriteToSheetRegionRows",
"GoogleTable_WriteToSheetRegionregion",
"Http_Get",
"Http_Get_V1",
"Http_Get_V2",
"Http_Http_Request",
"Http_Post",
"Http_Post_V2",
"Http_Post_V3",
"Image_Base64Decode",
"Image_Base64Encode",
"Image_Click",
"Image_Click_V1",
"Image_Click_V2",
"Image_Click_V3",
"Image_CropImg",
"Image_Exists",
"Image_Exists_V1",
"Image_Find",
"Image_Find_V1",
"Image_Location",
"Image_Location_V1",
"Image_Move",
"Image_Move_V1",
"Image_Move_V2",
"Image_RelativePosition",
"Image_Screenshot",
"Image_Wait",
"Image_Wait_V1",
"Ini_DeleteKey",
"Ini_DeleteSection",
"Ini_ListKey",
"Ini_ListSection",
"Ini_Read",
"Ini_Write",
"JSON_Parse",
"JSON_Stringify",
"Keyboard_DisableManualOperation",
"Keyboard_DriveSend",
"Keyboard_DriveSend_V1",
"Keyboard_DriveSend_V2",
"Keyboard_EnableManualOperation",
"Keyboard_Press",
"Keyboard_PressNew",
"Keyboard_Send",
"Keyboard_Send_V1",
"List_ClearList",
"List_CreateList",
"List_DeleteList",
"List_GetIndex",
"List_GetListItem",
"List_InsertList",
"List_ListDuplication",
"List_ListIntersection",
"List_ListLength",
"List_ListMerge",
"List_ListSorted",
"List_SetListItem",
"Log_Error",
"Log_Info",
"Mail_DoGetMailMessage",
"Mail_DoGetMailMessage_v1",
"Mail_DoGetMailMessage_v2",
"Mail_DoMoveMail",
"Mail_DoMoveMail_v1",
"Mail_DoReplyMail",
"Mail_DoReplyMail_v1",
"Mail_DoSendMail",
"Mail_DoSendMail_v1",
"Mail_ExConnect",
"Mail_ExOperateMail",
"Mail_ExSearchMail",
"Mail_ExSendMail",
"Mail_FindMail",
"Mail_FindMail_v1",
"Mail_FindMail_v2",
"Mail_MarkSeen",
"Mail_OperateMail",
"Mail_RecvClose",
"Mail_RecvConnect",
"Mail_RecvCount",
"Mail_RecvGetList",
"Mail_RecvMailGet",
"Mail_ReplyMail",
"Mail_ReplyMail_v1",
"Mail_ReplyMail_v2",
"Mail_Send",
"Mail_Send_V1",
"Mail_UnionConnect",
"Mail_UnionSend",
"Mail_UnionSend_v1",
"Mail_UnmarkSeen",
"Math_Abs",
"Math_Cos",
"Math_Exp",
"Math_Hex",
"Math_Int",
"Math_Oct",
"Math_Pow",
"Math_Round",
"Math_Scale",
"Math_Sgn",
"Math_Sin",
"Math_Sqrt",
"Math_Tan",
"MessageNotification_DingTalk_Message",
"MessageNotification_Feishu_Message",
"MessageNotification_WXWork_Message",
"Mouse_Click",
"Mouse_ClickCombine",
"Mouse_ClickCombine_v1",
"Mouse_ClickCombine_v2",
"Mouse_ClickCombine_v3",
"Mouse_ClickCombine_v4",
"Mouse_ClickNew",
"Mouse_Drag",
"Mouse_DragTo",
"Mouse_DragToNew",
"Mouse_DragToV1",
"Mouse_DragToV2",
"Mouse_Move",
"Mouse_MoveTo",
"Mouse_MoveToV1",
"Mouse_MoveToV2",
"Mouse_RollTo",
"Mouse_Scroll",
"NLP_AnalyseProductReview",
"NLP_BatchAnalyseProductReview",
"NLP_EmotionalAnalysis",
"NLP_KeyWordsAnalysis",
"NLP_PerspectiveAnalysis",
"NLP_SemanticSimilarity",
"NLP_SplitText",
"NLP_SplitWords",
"NLP_XunFei_FeelingAnalysis",
"NLP_XunFei_KeywordExtraction",
"NLP_XunFei_MachineTranslation",
"NLP_XunFei_NLPBaseServer",
"OCR_AccountPageV2",
"OCR_AmountIdentification",
"OCR_BankCardV2",
"OCR_BankCardV3",
"OCR_BusinessCardV2",
"OCR_BusinessLicenseV2",
"OCR_BusinessLicenseV3",
"OCR_CalculationCode",
"OCR_CommonEnglishNumbersCode",
"OCR_DriverLicenseV2",
"OCR_DrivingLicenseV2",
"OCR_GeneralTextV2",
"OCR_HouseCertV2",
"OCR_IdCardV2",
"OCR_IdCardV3",
"OCR_KeyValueIdentification",
"OCR_MergeCarOCR",
"OCR_MergeCardOCR",
"OCR_MergeEducationOCR",
"OCR_MergeGeneralOCR",
"OCR_MergeOtherOCR",
"OCR_MergePaperOCR",
"OCR_OfficialSealV2",
"OCR_PassportV2",
"OCR_PlateLicenseV2",
"OCR_RealEstateCertV2",
"OCR_TaxiInvoiceV2",
"OCR_TrainTicketV2",
"OCR_VatInvoiceV2",
"OCR_VatInvoiceV3",
"OCR_VehicleInvoiceV2",
"OCR_VinV2",
"OcrOffline_GetOcrTable",
"OcrOffline_GetOcrTextNew",
"OcrOffline_GetOcrTextPosition",
"PDF_ConcatPdf",
"PDF_ConcatPdfV1",
"PDF_CreateNewPdf",
"PDF_GetPageCount",
"PDF_GetPdfImg",
"PDF_GetPdfInformation",
"PDF_GetPdfPageNum",
"PDF_GetPdfTable",
"PDF_GetPdfText",
"PDF_PdfEncryption",
"PDF_PdfToImg",
"PDF_SetPdfWaterMark",
"PDF_SetPdfWaterMarkV1",
"Regex_Find",
"Regex_FindAll",
"Regex_FindAllNew",
"Regex_FindNew",
"Regex_FindNew_1",
"Regex_FindStr",
"Regex_FindStrV1",
"Regex_Test",
"Regex_TestNew",
"Scenario_TextComparison",
"ScreenControl_ScreenUnlock",
"ScreenControl_Screenlock",
"StrUtil_AddToStr",
"StrUtil_CaseChange",
"StrUtil_CharAsc",
"StrUtil_CharCode",
"StrUtil_CompleteStr",
"StrUtil_CreateRandomStr",
"StrUtil_CutOutStr",
"StrUtil_CutOutStrV1",
"StrUtil_DelTrim",
"StrUtil_InStr",
"StrUtil_Left",
"StrUtil_Len",
"StrUtil_ListToString",
"StrUtil_LookUpStr",
"StrUtil_LowerCase",
"StrUtil_Ltrim",
"StrUtil_Mid",
"StrUtil_Replace",
"StrUtil_ReverseStr",
"StrUtil_Right",
"StrUtil_Rtrim",
"StrUtil_Split",
"StrUtil_SplitStr",
"StrUtil_Trim",
"StrUtil_UpperCase",
"System_AddCredential",
"System_CloseRemoteDesktop",
"System_ConnectRemoteDesktop",
"System_DeleteCredential",
"System_ExecuteCommand",
"System_ExecuteCommandV1",
"System_GetEnv",
"System_GetHomePath",
"System_GetInputMethodState",
"System_GetSystemPath",
"System_GetTempPath",
"System_PlaySound",
"System_ReadCredential",
"System_ScreenSaverShow",
"System_ScreenSaverShowV1",
"System_ScreenSaverStop",
"System_SetEnv",
"System_SetInputMethodState",
"Time_CalDate",
"Time_Date",
"Time_DateOffset",
"Time_DateOffset_V1",
"Time_DateToTimestamp",
"Time_DatetimeToTimestamp",
"Time_Day",
"Time_Format",
"Time_FormatNew",
"Time_GetDate",
"Time_GetDateV1",
"Time_GetDateV2",
"Time_GetDatetimeObj",
"Time_Hour",
"Time_Minute",
"Time_Month",
"Time_Now",
"Time_Second",
"Time_Time",
"Time_TimeStamp",
"Time_TimeTypeConversion",
"Time_TimeTypeConversion_V1",
"Time_WeekDay",
"Time_Year",
"Trigger.FileArrival",
"Trigger.Manual",
"Trigger.Schedule",
"WebBrowser_BatchDataCrawl",
"WebBrowser_BatchDataCrawlNew",
"WebBrowser_BatchDataCrawlNewV1",
"WebBrowser_Close",
"WebBrowser_CloseWeb",
"WebBrowser_CloseWeb_V1",
"WebBrowser_CloseWeb_V2",
"WebBrowser_Close_V1",
"WebBrowser_Create",
"WebBrowser_CreateBackground",
"WebBrowser_CreateV2",
"WebBrowser_CreateV3",
"WebBrowser_DataCollection",
"WebBrowser_DataCollectionV1",
"WebBrowser_DataCollectionV2",
"WebBrowser_DataCollectionV3",
"WebBrowser_DataCollectionV4",
"WebBrowser_DataCollectionV5",
"WebBrowser_DataCrawl",
"WebBrowser_DataCrawl_software",
"WebBrowser_DataCrawl_web",
"WebBrowser_DataCrawler",
"WebBrowser_DownFile",
"WebBrowser_DownFile_V1",
"WebBrowser_DownloadFile",
"WebBrowser_DownloadFile_V1",
"WebBrowser_GetBrowserPic",
"WebBrowser_GetBrowserPicV1",
"WebBrowser_GetCookie",
"WebBrowser_GetCookieNew",
"WebBrowser_GetCookies",
"WebBrowser_GetDocument",
"WebBrowser_GetScrollPos",
"WebBrowser_GetScrollPos_V1",
"WebBrowser_GetTAB",
"WebBrowser_GetTAB_V1",
"WebBrowser_GetTAB_V2",
"WebBrowser_GetTitle",
"WebBrowser_GetUrl",
"WebBrowser_GetWeblistening",
"WebBrowser_GoBack",
"WebBrowser_GoForward",
"WebBrowser_GoUrl",
"WebBrowser_Maximize",
"WebBrowser_Minimize",
"WebBrowser_Navigate",
"WebBrowser_Reload",
"WebBrowser_RunJs",
"WebBrowser_RunJsInIeBySelector",
"WebBrowser_RunJsInIeBySelectorGround",
"WebBrowser_RunJsInIeBySelector_v1",
"WebBrowser_RunJsInIeBySelector_v2",
"WebBrowser_RunJsInIeBySelector_v3",
"WebBrowser_RunJsInIeByTitle",
"WebBrowser_SaveAs",
"WebBrowser_SaveAs_V1",
"WebBrowser_Screenshot",
"WebBrowser_SetCookies",
"WebBrowser_SetCookies_V1",
"WebBrowser_SetScrollPos",
"WebBrowser_SetScrollPos_V1",
"WebBrowser_StartWeblistening",
"WebBrowser_Stop",
"WebBrowser_StopWeblistening",
"WebBrowser_Switch",
"WebBrowser_Switch_V1",
"WebBrowser_Upload",
"WebBrowser_UploadDialog",
"WebBrowser_Wait",
"Window_Activate",
"Window_ActiveWindow",
"Window_ActiveWindow_v1",
"Window_Close",
"Window_CloseWindow",
"Window_CloseWindow_v1",
"Window_Exists",
"Window_Get",
"Window_GetActive",
"Window_GetActiveWindow",
"Window_GetRect",
"Window_GetScreenSize",
"Window_GetWindow",
"Window_GetWindowMessage",
"Window_GetWindowMessage_v1",
"Window_GetWindowRect",
"Window_GetWindow_v1",
"Window_MaximumSize",
"Window_MoveWindow",
"Window_MoveWindow_v1",
"Window_SetActive",
"Window_SetPos",
"Window_SetWindowShow",
"Window_SetWindowState",
"Window_SetWindowState_v1",
"Window_SizeWindow",
"Window_SizeWindow_v1",
"Window_WaitWindow",
"Window_WindowExists",
"Window_WindowList",
"Word_Close",
"Word_CopySelect",
"Word_CutSelect",
"Word_DeleteSelect",
"Word_Enter",
"Word_GetPath",
"Word_InsertPage",
"Word_InsertPicture",
"Word_MovePosition",
"Word_New",
"Word_New_V1",
"Word_Open",
"Word_PasteSelect",
"Word_PositionToSelect",
"Word_ReadAll",
"Word_ReadSelect",
"Word_ReadTable",
"Word_Replace",
"Word_Save",
"Word_SaveAs",
"Word_SelectAll",
"Word_SelectRow",
"Word_SetAlign",
"Word_SetFontColor",
"Word_SetFontName",
"Word_SetFontSize",
"Word_SetFontStyle",
"Word_SetPosition",
"Word_Write",
"Word_WriteTable",
"child_modules_v1",
"concurrent_child_process_v1",
"logic_control_break_for",
"logic_control_if_ai_vision",
"logic_control_next_for",
"logic_control_stop",
"logic_control_stop_return",
"logic_control_var"
]
}
+132 -15
View File
@@ -231,24 +231,67 @@
"Image_Move_V2": {
"fields": {
"window_obj": {
"description": "目标窗口对象;留空则作用于当前活动窗口"
"label": "窗口标题/对象",
"description": "查找范围为「指定窗口」时必填:可填窗口完整标题、标题关键字或进程名;点「选窗口」从可见窗口列表中选择。",
"placeholder": "选择窗口或填写标题/进程名"
},
"action": {
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"label": "查找区域",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}"
},
"shot_position": {
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"label": "查找区域",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}"
},
"image": {
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件"
"description": "要查找的小图模板路径;建议只框选按钮、图标或文字块等目标图案,不要框整屏大图。"
},
"position": {
"description": "相对目标的点击位置(中心、左上角等)"
"description": "命中目标图像后,鼠标移动到目标区域的哪个位置。",
"options": [
{
"label": "左上",
"value": "leftTop"
},
{
"label": "右上",
"value": "rightTop"
},
{
"label": "左下",
"value": "leftBottom"
},
{
"label": "右下",
"value": "rightBottom"
},
{
"label": "中心",
"value": "center"
}
]
},
"show_track": {
"description": "勾选后运行时显示鼠标移动轨迹动画,便于观察执行过程"
"description": "开启后鼠标按轨迹移动到目标位置;关闭后直接跳到目标位置。"
},
"find_range": {
"description": "选择模板图片的查找范围:整个屏幕、指定窗口,或先框选一块屏幕区域后只在该区域内查找。",
"options": [
{
"label": "整个屏幕",
"value": "1"
},
{
"label": "指定窗口",
"value": "2"
},
{
"label": "指定屏幕范围",
"value": "3"
}
]
}
}
},
@@ -273,7 +316,7 @@
"placeholder": "如 D:\\output"
},
"outImageName": {
"placeholder": "输入内容或 ${变量}"
"placeholder": "留空则自动生成,如 screenshot_1730000000.png"
}
}
},
@@ -287,25 +330,50 @@
"Image_Base64Encode": {
"fields": {
"path": {
"description": "填写参数值,支持 ${变量} 引用上游输出"
"description": "编码方向的源图片路径,支持 ${变量} 引用上游输出(如上游截图的保存路径)"
}
}
},
"Image_Wait_V1": {
"fields": {
"window_obj": {
"description": "目标窗口对象;留空则作用于当前活动窗口"
"label": "窗口标题/对象",
"description": "查找范围为「指定窗口」时必填:可填窗口完整标题、标题关键字或进程名;点「选窗口」从可见窗口列表中选择。",
"placeholder": "选择窗口或填写标题/进程名"
},
"action": {
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"label": "查找区域",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}"
},
"position": {
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"label": "查找区域",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}"
},
"image": {
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件"
"description": "要查找的小图模板路径;建议只框选按钮、图标或文字块等目标图案,不要框整屏大图。"
},
"find_range": {
"description": "选择模板图片的查找范围:整个屏幕、指定窗口,或先框选一块屏幕区域后只在该区域内查找。",
"options": [
{
"label": "整个屏幕",
"value": "1"
},
{
"label": "指定窗口",
"value": "2"
},
{
"label": "指定屏幕范围",
"value": "3"
}
]
},
"time_out": {
"description": "等待目标图像出现的秒数;默认 20 秒。",
"placeholder": "20"
}
}
},
@@ -432,10 +500,10 @@
"Image_RelativePosition": {
"fields": {
"source": {
"description": "填写参数值,支持 ${变量} 引用上游输出"
"description": "被搜索的较大图片路径(如整屏截图),支持 ${变量} 引用上游输出"
},
"template": {
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件"
"description": "要寻找的小图案完整路径(按钮/图标/文字块),可点右侧按钮选择文件"
}
}
},
@@ -1229,5 +1297,54 @@
"description": "使用的识别引擎类型"
}
}
},
"File_CompressFile": {
"fields": {
"file_type": {
"label": "待压缩内容",
"description": "选择要压缩的对象类型:单个文件或文件夹。"
},
"file_path": {
"label": "待压缩文件",
"description": "待压缩的单个文件完整路径;仅在「待压缩内容=文件」时使用。",
"placeholder": "选择或输入待压缩文件,例如 C:\\desktop\\report.docx"
},
"folder_path": {
"label": "待压缩文件夹",
"description": "待压缩的文件夹完整路径;仅在「待压缩内容=文件夹」时使用。",
"placeholder": "选择或输入待压缩文件夹,例如 C:\\desktop\\reports"
},
"direct_type": {
"label": "压缩包保存位置",
"description": "「源文件路径」表示自动保存到待压缩对象所在目录;「其他路径」表示使用下方指定的输出位置。同名压缩包不会覆盖,会自动生成副本。"
},
"out_path": {
"label": "压缩包输出位置",
"description": "选择输出文件夹,或填写以 .zip 结尾的完整压缩包路径;仅在「压缩包保存位置=其他路径」时使用。",
"placeholder": "选择输出目录,或输入 C:\\desktop\\result.zip"
}
}
},
"File_DecompressFile": {
"fields": {
"folder_path": {
"label": "压缩包路径",
"description": "要解压的 .zip 文件完整路径。",
"placeholder": "选择或输入 C:\\desktop\\archive.zip"
},
"direct_type": {
"label": "解压目标位置",
"description": "「源文件路径」表示解压到压缩包所在目录;「其他路径」表示解压到下方指定的文件夹。"
},
"out_path": {
"label": "解压目标目录",
"description": "选择或输入解压结果保存的文件夹;仅在「解压目标位置=其他路径」时使用。",
"placeholder": "选择或输入 C:\\desktop\\archive-output"
},
"compress_tool": {
"label": "解压方式",
"description": "选择解压使用的执行方式;不确定时保持默认。"
}
}
}
}
}
+85 -67
View File
@@ -10,7 +10,7 @@
"vendor": 997,
"native": 16,
"triggers": 3,
"fields": 4294
"fields": 4298
},
"groups": [
{
@@ -58963,8 +58963,8 @@
"displayType": "selectFile",
"required": true,
"defaultValue": "",
"placeholder": "请选择或输入压缩包路径",
"description": "选择或输入压缩包路径,例如 C:\\desktop\\实在智能.zip",
"placeholder": "选择或输入 C:\\desktop\\archive.zip",
"description": "要解压的 .zip 文件完整路径。",
"advanced": false,
"valueTypes": [
"rpa_str_file",
@@ -58973,14 +58973,14 @@
},
{
"key": "direct_type",
"label": "解压至",
"label": "解压目标位置",
"widget": "radio",
"type": "string",
"displayType": "radioGroup",
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "",
"description": "「源文件路径」表示解压到压缩包所在目录;「其他路径」表示解压到下方指定的文件夹。",
"advanced": false,
"valueTypes": [],
"options": [
@@ -58996,14 +58996,14 @@
},
{
"key": "out_path",
"label": "out_path",
"label": "解压目标目录",
"widget": "folder",
"type": "object",
"displayType": "selectFile",
"required": false,
"defaultValue": "",
"placeholder": "请选择解压文件存放路径",
"description": "",
"placeholder": "选择或输入 C:\\desktop\\archive-output",
"description": "选择或输入解压结果保存的文件夹;仅在「解压目标位置=其他路径」时使用。",
"advanced": false,
"valueTypes": [
"rpa_str_folder",
@@ -59041,14 +59041,14 @@
},
{
"key": "compress_tool",
"label": "解压工具",
"label": "解压方式",
"widget": "radio",
"type": "string",
"displayType": "radioGroup",
"required": false,
"defaultValue": "default",
"placeholder": "",
"description": "",
"description": "选择解压使用的执行方式;不确定时保持默认。",
"advanced": true,
"valueTypes": [],
"options": [
@@ -62112,7 +62112,7 @@
"required": false,
"defaultValue": "openFile",
"placeholder": "",
"description": "选择或输入待压缩文件/文件夹路径,例如 C:\\desktop\\实在智能",
"description": "选择要压缩的对象类型:单个文件或文件夹。",
"advanced": false,
"valueTypes": [],
"options": [
@@ -62128,14 +62128,14 @@
},
{
"key": "file_path",
"label": "file_path",
"label": "待压缩文件",
"widget": "file",
"type": "object",
"displayType": "multiSelectFileOrDir",
"required": true,
"defaultValue": "",
"placeholder": "请选择或输入压缩包路径",
"description": "请选择或输入压缩包路径。",
"placeholder": "选择或输入待压缩文件,例如 C:\\desktop\\report.docx",
"description": "待压缩的单个文件完整路径;仅在「待压缩内容=文件」时使用。",
"advanced": false,
"valueTypes": [
"rpa_str"
@@ -62143,14 +62143,14 @@
},
{
"key": "folder_path",
"label": "folder_path",
"label": "待压缩文件夹",
"widget": "folder",
"type": "object",
"displayType": "multiSelectFileOrDir",
"required": true,
"defaultValue": "",
"placeholder": "请选择或输入压缩包路径",
"description": "请选择或输入压缩包路径。",
"placeholder": "选择或输入待压缩文件夹,例如 C:\\desktop\\reports",
"description": "待压缩的文件夹完整路径;仅在「待压缩内容=文件夹」时使用。",
"advanced": false,
"valueTypes": [
"rpa_str"
@@ -62158,14 +62158,14 @@
},
{
"key": "direct_type",
"label": "压缩至",
"label": "压缩包保存位置",
"widget": "radio",
"type": "string",
"displayType": "radioGroup",
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "默认压缩至当前待压缩文件所在目录,如果压缩文件已存在将生成副本。",
"description": "「源文件路径」表示自动保存到待压缩对象所在目录;「其他路径」表示使用下方指定的输出位置。同名压缩包不会覆盖,会自动生成副本。",
"advanced": false,
"valueTypes": [],
"options": [
@@ -62181,14 +62181,14 @@
},
{
"key": "out_path",
"label": "out_path",
"label": "压缩包输出位置",
"widget": "folder",
"type": "object",
"displayType": "selectFile",
"required": false,
"defaultValue": "",
"placeholder": "请选择或输入压缩至路径",
"description": "",
"placeholder": "选择输出目录,或输入 C:\\desktop\\result.zip",
"description": "选择输出文件夹,或填写以 .zip 结尾的完整压缩包路径;仅在「压缩包保存位置=其他路径」时使用。",
"advanced": false,
"valueTypes": [
"rpa_str_folder",
@@ -66894,7 +66894,7 @@
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "请选择查找目标图像的范围",
"description": "选择模板图片的查找范围:整个屏幕、指定窗口,或先框选一块屏幕区域后只在该区域内查找。",
"advanced": false,
"valueTypes": [],
"options": [
@@ -66903,29 +66903,25 @@
"value": "1"
},
{
"label": "当前激活窗口",
"label": "指定窗口",
"value": "2"
},
{
"label": "指定窗口对象",
"value": "3"
},
{
"label": "指定屏幕范围",
"value": "4"
"value": "3"
}
]
},
{
"key": "window_obj",
"label": "窗口对象",
"label": "窗口标题/对象",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": true,
"defaultValue": "",
"placeholder": "",
"description": "目标窗口对象;留空则作用于当前活动窗口",
"placeholder": "选择窗口或填写标题/进程名",
"description": "查找范围为「指定窗口」时必填:可填窗口完整标题、标题关键字或进程名;点「选窗口」从可见窗口列表中选择。",
"advanced": false,
"valueTypes": [
"rpa_window_new"
@@ -66933,27 +66929,27 @@
},
{
"key": "action",
"label": "action",
"label": "查找区域",
"widget": "region",
"type": "object",
"displayType": "getScreenShot",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"advanced": false,
"valueTypes": []
},
{
"key": "shot_position",
"label": "shot_position",
"label": "查找区域",
"widget": "region",
"type": "object",
"displayType": "screenShotValue",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"advanced": false,
"valueTypes": []
},
@@ -66966,7 +66962,7 @@
"required": true,
"defaultValue": "",
"placeholder": "截取或选择图像",
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件",
"description": "要查找的小图模板路径;建议只框选按钮、图标或文字块等目标图案,不要框整屏大图。",
"advanced": false,
"valueTypes": [
"rpa_str"
@@ -66981,28 +66977,28 @@
"required": false,
"defaultValue": "center",
"placeholder": "",
"description": "相对目标的点击位置(中心、左上角等)",
"description": "命中目标图像后,鼠标移动到目标区域的哪个位置。",
"advanced": false,
"valueTypes": [],
"options": [
{
"label": "左上",
"value": "left-top"
"value": "leftTop"
},
{
"label": "右上",
"value": "right-top"
"value": "rightTop"
},
{
"label": "左下",
"value": "left-bottom"
"value": "leftBottom"
},
{
"label": "右下",
"value": "right-bottom"
"value": "rightBottom"
},
{
"label": "中间",
"label": "中心",
"value": "center"
}
]
@@ -67031,7 +67027,7 @@
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "勾选后运行时显示鼠标移动轨迹动画,便于观察执行过程",
"description": "开启后鼠标按轨迹移动到目标位置;关闭后直接跳到目标位置。",
"advanced": true,
"valueTypes": []
}
@@ -67540,7 +67536,7 @@
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "请选择查找目标图像的范围",
"description": "选择模板图片的查找范围:整个屏幕、指定窗口,或先框选一块屏幕区域后只在该区域内查找。",
"advanced": false,
"valueTypes": [],
"options": [
@@ -67549,29 +67545,25 @@
"value": "1"
},
{
"label": "当前激活窗口",
"label": "指定窗口",
"value": "2"
},
{
"label": "指定窗口对象",
"value": "3"
},
{
"label": "指定屏幕范围",
"value": "4"
"value": "3"
}
]
},
{
"key": "window_obj",
"label": "窗口对象",
"label": "窗口标题/对象",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": true,
"defaultValue": "",
"placeholder": "",
"description": "目标窗口对象;留空则作用于当前活动窗口",
"placeholder": "选择窗口或填写标题/进程名",
"description": "查找范围为「指定窗口」时必填:可填窗口完整标题、标题关键字或进程名;点「选窗口」从可见窗口列表中选择。",
"advanced": false,
"valueTypes": [
"rpa_window_new"
@@ -67579,27 +67571,27 @@
},
{
"key": "action",
"label": "action",
"label": "查找区域",
"widget": "region",
"type": "object",
"displayType": "getScreenShot",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"advanced": false,
"valueTypes": []
},
{
"key": "position",
"label": "position",
"label": "查找区域",
"widget": "region",
"type": "object",
"displayType": "screenShotValue",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"description": "查找范围为「指定屏幕范围」时使用:点右侧「屏幕框选」生成区域坐标 JSON。",
"advanced": false,
"valueTypes": []
},
@@ -67612,7 +67604,7 @@
"required": true,
"defaultValue": "",
"placeholder": "截取或选择图像",
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件",
"description": "要查找的小图模板路径;建议只框选按钮、图标或文字块等目标图案,不要框整屏大图。",
"advanced": false,
"valueTypes": [
"rpa_str"
@@ -67641,8 +67633,8 @@
"displayType": "text-expression",
"required": true,
"defaultValue": "20",
"placeholder": "请输入超时等待的时间(秒)",
"description": "输入超时等待的时间,单位:秒,默认20秒,非必填",
"placeholder": "20",
"description": "等待目标图像出现的秒数;默认 20 秒。",
"advanced": false,
"valueTypes": [
"rpa_num"
@@ -68766,7 +68758,7 @@
"image"
],
"categorySignature": "image",
"description": "获取截图在整体图片中的位置坐标。",
"description": "在一张较大的图(如整屏/整窗截图)中查找指定小图,返回其相对位置坐标。",
"messageTemplate": "获取图像 $1 中,$2 的相对位置坐标, 然后把结果保存到 $0",
"baseType": "Image_RelativePosition",
"functionName": "Image.RelativePosition",
@@ -68794,14 +68786,14 @@
"fields": [
{
"key": "source",
"label": "模板图像",
"label": "被搜索图像",
"widget": "text",
"type": "string",
"displayType": "imageSelector",
"required": true,
"defaultValue": "",
"placeholder": "截取或选择图像",
"description": "填写参数值,支持 ${变量} 引用上游输出",
"description": "被搜索的较大图片路径(如整屏截图),支持 ${变量} 引用上游输出",
"advanced": false,
"valueTypes": [
"rpa_str"
@@ -68809,14 +68801,14 @@
},
{
"key": "template",
"label": "目标图像",
"label": "查找目标(小图)",
"widget": "text",
"type": "string",
"displayType": "imageSelector",
"required": true,
"defaultValue": "",
"placeholder": "截取或选择图像",
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件",
"description": "要寻找的小图案完整路径(按钮/图标/文字块),可点右侧按钮选择文件",
"advanced": false,
"valueTypes": [
"rpa_str"
@@ -71044,6 +71036,19 @@
"value": "2"
}
]
},
{
"key": "window_title",
"label": "输入目标窗口",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": false,
"defaultValue": "",
"placeholder": "留空则输入到当前焦点窗口",
"description": "选择目标窗口后,运行时会先把该窗口带到前台再输入内容",
"advanced": false,
"valueTypes": []
}
],
"output": {
@@ -71145,6 +71150,19 @@
"value": "2"
}
]
},
{
"key": "window_title",
"label": "输入目标窗口",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": false,
"defaultValue": "",
"placeholder": "留空则输入到当前焦点窗口",
"description": "选择目标窗口后,运行时会先把该窗口带到前台再输入内容",
"advanced": false,
"valueTypes": []
}
],
"output": {
@@ -125556,4 +125574,4 @@
"slots": []
}
]
}
}
+3 -3
View File
@@ -5,8 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { buildSuite, bundleRoot } from "./app-system-test-flows.mjs";
import { installBundle, upgradeBundle, makeReport, caseResults } from "./app-system-tests.mjs";
import { applyCatalogOverrides } from "./catalog-overrides.mjs";
import { hasCapability } from "../src/main/capabilities/index.ts";
import { runFlow } from "../src/main/engine/runtime.ts";
import { hasCapability, runFlow } from "@rpa/core";
import "../src/main/capabilities/index.ts";
import { validateFlow } from "../src/renderer/src/validation/flowValidation.ts";
const containers = new Set(["logic_control_try_catch", "logic_control_if_else"]);
@@ -58,7 +58,7 @@ describe("应用与系统测试流程包", () => {
const options = component.fields.find(field => field.key === "into_type").options;
expect(options.length).toBeGreaterThanOrEqual(4);
for (const option of options) {
const output = await import("../src/main/engine/registry.ts").then(({ getHandler }) => getHandler(id)({}, { id: "type-test", componentId: id }, { val: 42, into_type: option.value }, component));
const output = await import("@rpa/core").then(({ getHandler }) => getHandler(id)({}, { id: "type-test", componentId: id }, { val: 42, into_type: option.value }, component));
if (id === "Basic_JudgeDataType") expect(typeof output).toBe("boolean");
}
}
+5 -1
View File
@@ -228,6 +228,10 @@ function normalizeVendorComponent(component, vendorMap) {
const fields = (component.inputFields || []).map((f) => enrichFromVendor(normalizeVendorField(f), vendor));
const output = outputOf(component, vendor);
const risk = ["web", "app", "desktop", "message", "mobile"].includes(group) ? "high" : "low";
// 任务 3.4(决策点 7):平台标记只填 catalog 数据,渲染层置灰延后。
// Windows 专用组:desktop(user32/键鼠)、app(COM/进程)、office(Excel/Word COM)、vision(PowerShell 截屏);
// data/file/message 纯 Node,web 走 CDP,均视为跨平台(null)。
const WIN32_GROUPS = new Set(["desktop", "app", "office", "vision"]);
return {
id: component.id,
name: component.name,
@@ -242,7 +246,7 @@ function normalizeVendorComponent(component, vendorMap) {
functionCalls: (component.functions || []).map((f) => ({ function: f.function, args: f.arguments || [], assignTo: f.assignTo || "" })),
version: component.version || "",
tags: component.tags || [],
platformTag: component.platformTag || null,
platformTag: component.platformTag || (WIN32_GROUPS.has(group) ? "win32" : null),
icon: group,
risk,
fields,
+36
View File
@@ -0,0 +1,36 @@
/**
* 能力注册快照(任务 0.3,见 docs/rpa-core-refactor-tasks.md)
*
* 以与生产完全相同的路径(import "../src/main/capabilities" 的副作用注册)
* 拉起全部能力模块,把运行时注册表的 id 清单写入
* resources/capability-snapshot.json。
*
* 用法:npm run snapshot
* 抽离改造各阶段完成后重跑并 git diff,快照变化必须为零(行为回归基准)。
*/
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { capabilityCount, capabilityIds } from "../src/main/capabilities";
const OUT_FILE = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"resources",
"capability-snapshot.json"
);
describe("能力注册快照", () => {
it("全量注册后导出能力 id 清单", () => {
const ids = [...capabilityIds()].sort();
expect(ids.length).toBeGreaterThan(0);
const snapshot = {
generatedAt: new Date().toISOString(),
count: ids.length,
ids
};
writeFileSync(OUT_FILE, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
console.log(`[能力快照] ${capabilityCount()} 个能力已写入 ${path.basename(OUT_FILE)}`);
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* rpa-core CLI host(任务 5.1):Node 直接拉起 createEngine() + WindowsDriver,
* 不经过 Electron,验证包的零宿主可运行性。
*
* 跑通 5 个代表能力(全部只读或落在临时目录,可重复执行):
* 1. 桌面(PowerShell user32):Window_GetActiveWindow —— 读前台窗口标题
* 2. Excel(exceljs): Excel_Cs_NewV5 —— 临时目录新建工作簿
* 3. HTTP(fetch): Http_Get —— GET 百度首页
* 4. 文件(fs): File_Write / File_Read
* 5. 窗口/屏幕(PowerShell): Window_GetScreenSize
*
* 运行:npm run rpa-cli
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createEngine,
capabilityCount,
getHandler,
registerAll,
type CapabilityHandler,
type RunContext
} from "@rpa/core";
import "@rpa/core/capabilities";
const ctx = {
runId: "rpa-cli",
flowId: "rpa-cli",
flow: { id: "rpa-cli", name: "rpa-cli", nodes: [], edges: [] },
vars: {},
secrets: {},
signal: new AbortController().signal,
evidenceDir: "",
log: (message: string) => console.log(" " + message),
componentOf: () => undefined
} as unknown as RunContext;
const node = (componentId: string) => ({ id: "rpa-cli", componentId, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(name: string, handler: CapabilityHandler, args: Record<string, unknown>): Promise<void> {
process.stdout.write(`[${name}] `);
const result = await handler(ctx, node(name), args, undefined as never);
console.log("OK ->", typeof result === "string" ? result.slice(0, 120) : JSON.stringify(result)?.slice(0, 200));
}
async function main(): Promise<void> {
const engine = createEngine();
await registerAll(engine);
console.log(`rpa-core CLI host:引擎已创建,注册能力 ${capabilityCount()} 个\n`);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-cli-"));
try {
await run("Window_GetActiveWindow", getHandler("Window_GetActiveWindow")!, {});
await run("Window_GetScreenSize", getHandler("Window_GetScreenSize")!, {});
await run("Excel_Cs_NewV5", getHandler("Excel_Cs_NewV5")!, { filePath: tmpDir, name: "rpa-cli-demo.xlsx" });
const file = path.join(tmpDir, "hello.txt");
await run("File_Write", getHandler("File_Write")!, { path: file, content: "hello from rpa-core CLI" });
await run("File_Read", getHandler("File_Read")!, { path: file });
await run("Http_Get", getHandler("Http_Get")!, { url: "https://www.baidu.com", timeout: 15 });
console.log("\n5 项代表能力全部通过 ✓");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
void main().catch((error) => {
console.error("\nCLI host 失败:", (error as Error).message);
process.exit(1);
});
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more