Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8084dca26 | ||
|
|
b54311d467 | ||
|
|
e691b9fa78 | ||
|
|
3decced268 | ||
|
|
caa8e19332 | ||
|
|
4139400dbd | ||
|
|
f35f0ac979 | ||
|
|
1ff6f59990 | ||
|
|
143fcae34a | ||
|
|
bb03699cce | ||
|
|
f390db9c94 | ||
|
|
e1f162aa82 | ||
|
|
3a9b4fa3c7 | ||
|
|
8d6f0ff755 | ||
|
|
46348413c1 | ||
|
|
6b05a65664 | ||
|
|
158104661c | ||
|
|
fb71c0551b | ||
|
|
58aae7c92f | ||
|
|
cb349ec979 | ||
|
|
d4de0ec245 | ||
|
|
d5d7df8880 | ||
|
|
58ea001e7d | ||
|
|
f0fca2ebfe | ||
|
|
c90584670d | ||
|
|
c973209418 | ||
|
|
9ec301b081 | ||
|
|
7a067173a5 |
No files matched your search
@@ -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 禁令约束。
|
||||
@@ -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,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: {
|
||||
|
||||
Generated
+57
-2
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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 };
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
});
|
||||
File renamed without changes.
+1
-1
@@ -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";
|
||||
+1
-1
@@ -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";
|
||||
+2
-2
@@ -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: {} });
|
||||
+9
-9
@@ -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;
|
||||
});
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -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
-1
@@ -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";
|
||||
+1
-1
@@ -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";
|
||||
File renamed without changes.
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -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";
|
||||
File renamed without changes.
@@ -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 === "";
|
||||
+1
-1
@@ -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 ?? ""))
|
||||
}));
|
||||
+28
-23
@@ -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) => {
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
+1
-1
@@ -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";
|
||||
File renamed without changes.
+1
-1
@@ -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";
|
||||
+6
-5
@@ -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));
|
||||
}
|
||||
+1
-1
@@ -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";
|
||||
File renamed without changes.
@@ -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.
+10
-8
@@ -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);
|
||||
});
|
||||
File renamed without changes.
+1
-1
@@ -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);
|
||||
});
|
||||
|
||||
+3
-3
@@ -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(); }
|
||||
File renamed without changes.
+1
-1
@@ -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";
|
||||
File renamed without changes.
@@ -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) {
|
||||
+1
-1
@@ -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);
|
||||
});
|
||||
+42
-8
@@ -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) => {
|
||||
+1
-1
@@ -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";
|
||||
+7
-19
@@ -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 };
|
||||
});
|
||||
File renamed without changes.
+1
-1
@@ -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";
|
||||
File renamed without changes.
+15
@@ -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 });
|
||||
});
|
||||
});
|
||||
File renamed without changes.
@@ -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
-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";
|
||||
+71
-40
@@ -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 };
|
||||
});
|
||||
+18
-5
@@ -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 };
|
||||
});
|
||||
|
||||
+1
-1
@@ -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";
|
||||
+1
-1
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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 }>;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// 与根配置一致:桌面自动化冒烟测试共用同一物理桌面,串行执行防焦点竞争。
|
||||
fileParallelism: false
|
||||
}
|
||||
});
|
||||
@@ -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)互逆重复, 已统一为转换方向切换, 保留执行兼容"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user