Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d489288c8e | ||
|
|
5f9e28715c | ||
|
|
f74d1865da | ||
|
|
483b01c8b6 | ||
|
|
11fc88eec9 | ||
|
|
4ad238ff37 | ||
|
|
8de4791e57 | ||
|
|
b9bd6d671b | ||
|
|
a12e6cb0bd | ||
|
|
e6c34bafe5 | ||
|
|
ed9b9daa50 | ||
|
|
a1490303d4 | ||
|
|
a968075100 | ||
|
|
fd507e4f62 | ||
|
|
f28b5ffc5e | ||
|
|
3d64392eb0 | ||
|
|
ccfd82e894 | ||
|
|
a25f8c76b6 | ||
|
|
cc28d49ab5 | ||
|
|
bc0ed3fcd8 | ||
|
|
f048be792c | ||
|
|
517d08d7ef | ||
|
|
c592cd891f | ||
|
|
19eb53b5d8 | ||
|
|
a3fbae9d83 | ||
|
|
a4b7e24acd | ||
|
|
745da55e4e | ||
|
|
7920a61e12 | ||
|
|
bb3412fff7 | ||
|
|
b19f9aa9ba | ||
|
|
e59034d700 | ||
|
|
f353254a86 | ||
|
|
c2b09f39e6 | ||
|
|
0694bd45d3 | ||
|
|
543d5d1564 | ||
|
|
8cedf45628 | ||
|
|
4163b57d38 | ||
|
|
8870050aaa | ||
|
|
b40fcbdd35 | ||
|
|
778f69e14d | ||
|
|
8b0004bf72 | ||
|
|
1e649d7121 | ||
|
|
0e65fd751d | ||
|
|
3b0883c80f | ||
|
|
06886c5b31 | ||
|
|
6f008ed8c0 | ||
|
|
a4cccbc858 | ||
|
|
d351556293 | ||
|
|
d331d56f2e | ||
|
|
26c682d2f9 | ||
|
|
fc1207cddd | ||
|
|
bb23bb192c | ||
|
|
de514500a7 | ||
|
|
303560f0f8 | ||
|
|
b08f056aba | ||
|
|
f6bddb08d8 | ||
|
|
69e5a1c35c | ||
|
|
5248165e83 | ||
|
|
4ac9755416 | ||
|
|
2ce0c13f7b | ||
|
|
afb2ed7c08 | ||
|
|
38a561a7cd | ||
|
|
d6165c910c | ||
|
|
4c84674b29 | ||
|
|
560134171f | ||
|
|
9b8bc0eb1a | ||
|
|
67a418129b | ||
|
|
a5d9f0bfee | ||
|
|
6cb10db375 | ||
|
|
ab8b0cde48 | ||
|
|
a7c74b85a0 | ||
|
|
f2eb449b87 | ||
|
|
e00a1e4743 | ||
|
|
69e9554250 | ||
|
|
2b372e2d7f | ||
|
|
37f2c8977b | ||
|
|
7c7a1b1a1f | ||
|
|
3a1c176f4d | ||
|
|
7f3aaac2ff | ||
|
|
9d996f137b | ||
|
|
06981dfff3 | ||
|
|
093fc302fa | ||
|
|
3b05d10ff4 | ||
|
|
9c973d9bad | ||
|
|
0fe264a884 | ||
|
|
2c32a8f51d | ||
|
|
753508b500 | ||
|
|
876e4969bd | ||
|
|
97fda1696a | ||
|
|
12cf94da27 | ||
|
|
73a3f47bdf | ||
|
|
5863baa0ab | ||
|
|
3bea611750 | ||
|
|
8a816fd41d | ||
|
|
8dd0aeefa6 | ||
|
|
9de0eeaa88 | ||
|
|
54571eff8d | ||
|
|
ac20d59283 | ||
|
|
432e038e3c | ||
|
|
cb6d2ac365 | ||
|
|
aaa0f2c3c7 | ||
|
|
ced89e26cf | ||
|
|
bec3d9daa4 | ||
|
|
c5ac0ebc31 | ||
|
|
877dab703f | ||
|
|
5e9f0df57f | ||
|
|
7d96badb1f | ||
|
|
9cb4eeab96 | ||
|
|
4fb0f64d4f | ||
|
|
cf05e4aa6f | ||
|
|
f675cc0086 | ||
|
|
12de8fbf58 | ||
|
|
dd27ec27f2 | ||
|
|
442eeaf9c1 | ||
|
|
cb68af5a0f | ||
|
|
5de47ee420 | ||
|
|
76f2160950 |
No files matched your search
@@ -7,3 +7,8 @@ etest2/
|
|||||||
*.tmp
|
*.tmp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
test-output/
|
||||||
|
web-capability-audit.txt
|
||||||
|
|
||||||
|
*.traineddata
|
||||||
@@ -3,19 +3,49 @@
|
|||||||
画布式 RPA 桌面端(Electron + React Flow)。把 1016 项能力目录拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。
|
画布式 RPA 桌面端(Electron + React Flow)。把 1016 项能力目录拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。
|
||||||
|
|
||||||
> 当前状态:任务优先的编排工作台。默认进入“编排”任务列表,编辑任务后进入上下流向画布;组件库按分类树展示,设置为独立入口。
|
> 当前状态:任务优先的编排工作台。默认进入“编排”任务列表,编辑任务后进入上下流向画布;组件库按分类树展示,设置为独立入口。
|
||||||
|
> 可执行能力持续接入中:**732 / 1016**(网页自动化、桌面自动化、数据、文件、办公、HTTP 等),其余灰标「待接入」,运行前检查会提前定位。
|
||||||
|
|
||||||
## 已实现(Day 1)
|
## 已实现
|
||||||
|
|
||||||
|
### 编排与运行
|
||||||
|
|
||||||
- Electron 31 + React 18 + TypeScript + React Flow 画布
|
- Electron 31 + React 18 + TypeScript + React Flow 画布
|
||||||
- 1016 项组件目录:997 厂商 + 16 控制 + 3 触发器,搜索 / 11 组筛选 / 可执行与待接入状态
|
- 1016 项组件目录:搜索 / 11 组筛选 / 可执行与待接入状态;任务列表实时刷新
|
||||||
- 编排工作区:任务列表、任务概览、运行/编辑入口;左侧主导航仅保留“编排”和“设置”
|
|
||||||
- 组件库树:按能力分组展开/收起,搜索命中时自动展开分类,叶子组件支持拖拽
|
|
||||||
- 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做
|
- 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做
|
||||||
- 画布主流程上下连接;异常出口默认隐藏,可在节点失败策略中按需开启
|
|
||||||
- IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体
|
- IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体
|
||||||
- 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时)
|
- 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时)
|
||||||
- 流程 JSON 本地原子保存、自动保存、重启恢复
|
- 流程 JSON 本地原子保存、自动保存、重启恢复;**保存冲突检测**——磁盘被外部修改时弹窗选择“加载磁盘版”或“覆盖磁盘”,不再静默互相覆盖
|
||||||
- 运行页与设置页骨架;运行期完全离线
|
- 运行中心:节点进度、运行日志(分级过滤/复制/导出)、运行变量查看
|
||||||
|
- 任务列表忽略备份文件;编排页与运行页骨架分离
|
||||||
|
|
||||||
|
### 网页自动化(Electron 内置浏览器 + CDP 可信输入)
|
||||||
|
|
||||||
|
- 浏览器:打开/关闭/切换标签、跳转/后退/前进/刷新、等待加载/停止加载、最大化/最小化
|
||||||
|
- 信息获取:URL、Title、网页源码、Cookies 读写、浏览器截图、滚动条读写
|
||||||
|
- 元素操作:点击、输入、密码框、下拉选择、勾选框、相似元素列表/数组、元素对象与信息、父/子/兄弟元素、文本/值/属性/源码读取、存在判断、按钮可用性、滚动到元素、鼠标悬停、元素截图、元素坐标
|
||||||
|
- 执行 JS(多版本)、文件上传(CDP 直塞文件输入框,绕开原生对话框)
|
||||||
|
- 网页数据表格写入(按行列号精确覆盖)
|
||||||
|
- 可信输入机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击
|
||||||
|
|
||||||
|
### 桌面自动化(Ground 系列,PowerShell + UIA 桥)
|
||||||
|
|
||||||
|
- 桌面元素点击(前台化 + AttachThreadInput 强制前台 + 真实鼠标坐标点击)、文本输入(ValuePattern + 剪贴板粘贴兜底)、元素存在判断、文本读取、等待元素
|
||||||
|
- 窗口自动唤起:EnumWindows 跨虚拟桌面查找 → ShowWindow 还原 → 移回主屏可视区
|
||||||
|
- Per-Monitor DPI 感知:多屏混合缩放下坐标不错位
|
||||||
|
- 隐式等待重试:元素未就绪时 4 秒内自动重试
|
||||||
|
- 底层统一走 PowerShell + UIAutomation(EncodedCommand,中文安全)
|
||||||
|
|
||||||
|
### AI 与离线 OCR(可选自配)
|
||||||
|
|
||||||
|
- AI 文本能力:文本生成 / 纠错 / 审核 / 关键词 / 对话 / 模型调用 / 文档比对等,通过设置页配置的 OpenAI 兼容服务接入
|
||||||
|
- 离线 OCR:图像识别 / 表格识别(含坐标)走本地模型,无需联网
|
||||||
|
|
||||||
|
### 质量与测试
|
||||||
|
|
||||||
|
- 全量测试 206 项(能力注册冒烟、流程运行集成、组件缺陷回归、测试流程包与安装保护、Word/Excel/PDF/桌面桥真实设备测试)
|
||||||
|
- 本地演示站(`127.0.0.1:17863`):网页订单页 + 网页元素演练场(表单/下拉/勾选/密码/禁用按钮/悬停/长页面)
|
||||||
|
- 内置 3 个网页自动化测试任务(浏览器导航、元素读写、滚动与 Cookie)+ 6 条固定示例任务
|
||||||
|
- 打包冒烟测试:跳过单实例锁隔离启动,验证产物可用性
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -35,24 +65,30 @@ npm run test:e2e # 隔离启动应用并执行本地网页订单完整流
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```
|
```
|
||||||
src/main 主进程:窗口、目录加载、流程存储、IPC
|
src/main 主进程:窗口、目录加载、流程存储、IPC
|
||||||
src/preload 白名单桥
|
src/main/browser 运行浏览器会话(BrowserSession,CDP 桥)
|
||||||
src/renderer React UI:画布、组件库、属性面板、运行/设置页
|
src/main/capabilities 能力实现(网页/桌面/数据/文件/办公/HTTP…)
|
||||||
src/shared 主/渲染共享类型
|
src/main/fixture 本地演示站
|
||||||
scripts build-catalog / dev-safe / start / cdp-probe / 打包
|
src/preload 白名单桥
|
||||||
resources catalog.json(1016 项派生元数据)、夹具站、桌面桥
|
src/renderer React UI:画布、组件库、属性面板、运行/设置页
|
||||||
docs 现状分析、实施方案
|
src/shared 主/渲染共享类型
|
||||||
|
scripts build-catalog / dev-safe / start / cdp-probe / 打包冒烟
|
||||||
|
resources catalog.json(1016 项派生元数据)、夹具站(含元素演练场)、桌面桥
|
||||||
|
docs 现状分析、实施方案
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
|
- [应用与系统 49 项组件测试任务](resources/test-flows/app-system/README.md):13 组可安装任务、使用步骤、覆盖清单及实际验收报告。
|
||||||
- [现状分析与可行性](docs/01-现状分析与可行性.md)
|
- [现状分析与可行性](docs/01-现状分析与可行性.md)
|
||||||
- [实施方案(三天)](docs/02-实施方案.md)
|
- [实施方案(三天)](docs/02-实施方案.md)
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- 1016 项目录中当前有 509 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
- 1016 项目录中当前 732 项可执行;未接入的主要为:异常监控组件、桌面 Ground 剩余变体、SAP 专用、邮件收件(IMAP)、Word 选区 COM 等。图表数据读取(ECharts)已接入;下载对话框为明确不接入(下载能力已覆盖)。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||||
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
|
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
|
||||||
- 浏览器可信输入与定位机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击。
|
- 编排首页提供“安装示例任务”,可安装网页订单、文件文本、Excel读写、剪贴板、Word文档和异常分支 6 条固定示例;重复安装会更新同名示例,不会无限复制。
|
||||||
|
- Tauri/WebView2 类应用的 UIA 支持天然受限(Invoke 假成功、ValuePattern 读回掩码):桌面元素点击采用“前台化 + 真实坐标点击”策略,输入采用“ValuePattern 写入 + 剪贴板粘贴兜底”。
|
||||||
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
||||||
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。
|
- 运行期不访问厂商云服务;HTTP、邮件与 AI 能力属于用户在设置页显式配置的外部系统(OpenAI 兼容服务)。
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
|
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
|
||||||
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
|
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
|
||||||
|
|
||||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **509 项可执行 / 1016 项总目录**。当前 20 个测试文件共 124 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM层级、Excel/PDF/Word COM/SMTP/模板匹配、文件与数据表扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。数据处理仅剩 8 个明确排除的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
|
> **2026-10-30 当前状态**:常用本地能力已进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击、FTP、人机交互表单对话框、Windows 凭据/远程桌面/屏保、ECharts 图表读取、AI 文本能力(用户自配服务)、离线 OCR;启动后目录显示 **732 项可执行 / 1016 项总目录**。当前 26 个测试文件共 167 条测试,浏览器 E2E 真实结果为“订单创建成功:RPA 客户,金额 128”。数据处理仅剩 8 个待评估的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。此后以集中场景测试与稳定性修复为主,不再追求厂商云端、硬件和专用通道数量。(原 2026-10-27 快照:619 项可执行 / 23 个测试文件 138 条测试)
|
||||||
|
|
||||||
> 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。
|
> 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# 能力接入规划与排期
|
||||||
|
|
||||||
|
> 生成时间:2026-10-28 · 校准时间:2026-10-30 · 目录 1016 项 · 已接入 732 项(运行时注册 744 个 id;以 src/main/catalog.ts 判定为准)
|
||||||
|
|
||||||
|
## 一、明确不接入清单(永久排除)
|
||||||
|
|
||||||
|
组件库显示「不接入」;运行前检查直接提示"该能力已明确不接入"。
|
||||||
|
|
||||||
|
### 门户专用(3 个)——绑定特定门户系统账号
|
||||||
|
|
||||||
|
- Basic_GetSystemAccount_v2 | 获取平台账号(通用)
|
||||||
|
- Basic_GetSystemAccount | 获取系统账号(门户专用)
|
||||||
|
- Basic_GetSystemAccount_v1 | 获取平台账号
|
||||||
|
|
||||||
|
### 厂商专属云服务(17 个)——依赖厂商专属数据/场景服务
|
||||||
|
|
||||||
|
- DataService_DeleteControllerFiles_v1 | 删除文件
|
||||||
|
- DataService_ReportException | 上报异常信息
|
||||||
|
- DataService_ProductionQueue | 将数据存入队列
|
||||||
|
- DataService_UploadControllerFiles_v1 | 上传文件
|
||||||
|
- DataService_ServiceNew | 数据服务(松塔)
|
||||||
|
- DataService_DeleteControllerFiles | 删除控制器文件
|
||||||
|
- DataService_Service | 数据服务
|
||||||
|
- DataService_DownloadControllerFiles | 下载控制器文件
|
||||||
|
- DataService_DownloadControllerFiles_v1 | 下载文件
|
||||||
|
- DataService_ConsumptionQueue | 从队列获取数据
|
||||||
|
- DataService_UploadControllerFiles | 上传文件至控制器
|
||||||
|
- DataService_AiScreenText | 提取屏幕数据(AI)
|
||||||
|
- DataService_AiScreenDate | 选择日期时间(AI)
|
||||||
|
- DataService_AiScreenOperate | 操作屏幕(AI)
|
||||||
|
- Scenario_CreditVerify | 征信报告解析
|
||||||
|
- Scenario_VatInvoice | 增值税发票验真
|
||||||
|
- Scenario_PhysicalExamination | 体检报告解析
|
||||||
|
|
||||||
|
### SAP 专用(1 个)——依赖SAP客户端
|
||||||
|
|
||||||
|
- Element_DoubleClickSap | 双击SAP表格单元格
|
||||||
|
|
||||||
|
### 硬件外设(41 个)——依赖手机设备连接
|
||||||
|
|
||||||
|
- Mobile_ConnectPhone | 连接手机
|
||||||
|
- Mobile_MobileElementAarray | 获取手机相似元素数组
|
||||||
|
- Mobile_Call | 拨打电话
|
||||||
|
- Mobile_MobileSetText_V1 | 手机键盘输入
|
||||||
|
- Mobile_ClickButton | 系统导航键
|
||||||
|
- Mobile_SetInputV1 | 手机输入文本
|
||||||
|
- Mobile_ConnectPhoneV3 | 连接手机
|
||||||
|
- Mobile_GetAttribute | 手机元素属性拾取
|
||||||
|
- Mobile_Exists | 手机元素判断
|
||||||
|
- Mobile_MobileElementList | 获取手机相似元素列表
|
||||||
|
- Mobile_FileTransfer | 手机文件传输
|
||||||
|
- Mobile_GetPhoneInfomation | 获取手机连接详情
|
||||||
|
- Mobile_ClickLocation | 手机屏幕坐标点击
|
||||||
|
- Mobile_SaveFilePhone | 保存文件到手机
|
||||||
|
- Mobile_SetInput | 设置文本|填写输入框
|
||||||
|
- Mobile_MobileCvClick | 手机智能元素点击
|
||||||
|
- Mobile_TouchSlideV1 | 触摸滑动
|
||||||
|
- Mobile_GetPhoneFiles | 从手机中获取文件
|
||||||
|
- Mobile_OpenMobileApp | 打开手机APP
|
||||||
|
- Mobile_TouchSlide | 触摸滑动
|
||||||
|
- Mobile_GetMobileUiTree | 获取手机UI树
|
||||||
|
- Mobile_GetScreenOrientation | 获取手机屏幕方向
|
||||||
|
- Mobile_GetElement | 获取手机元素对象
|
||||||
|
- Mobile_MobileCvSetText | 智能设置文本|填写输入框
|
||||||
|
- Mobile_ClosePhone | 关闭连接手机
|
||||||
|
- Mobile_SetInputV2 | 手机输入文本
|
||||||
|
- Mobile_SetScreenOrientation | 设置手机屏幕方向
|
||||||
|
- Mobile_ImageClick | 点击手机图像
|
||||||
|
- Mobile_CloseMobileApp | 关闭手机APP
|
||||||
|
- Mobile_ElementShot | 手机元素截图
|
||||||
|
- Mobile_ScreenShot | 手机截屏
|
||||||
|
- Mobile_GetClipboardText | 获取手机剪切板文本
|
||||||
|
- Mobile_ConnectPhoneV2 | 连接手机
|
||||||
|
- Mobile_GetText | 手机元素文本拾取
|
||||||
|
- Mobile_MobileSetText | 手机键盘输入
|
||||||
|
- Mobile_MobileElementList_V1 | 获取手机相似元素列表
|
||||||
|
- Mobile_ElementClick | 手机元素点击
|
||||||
|
- Mobile_GetChildren | 获取手机子元素
|
||||||
|
- Mobile_SetClipboardText | 设置手机剪切板文本
|
||||||
|
- Mobile_ConnectPhoneV1 | 连接手机
|
||||||
|
- System_GetMobileMessage | 获取手机短信
|
||||||
|
|
||||||
|
### 厂商专属云服务(30 个)——依赖厂商训练模型/打码服务
|
||||||
|
|
||||||
|
- OCR_RotateVerificationCodeV1 | 旋转图片验证码
|
||||||
|
- OCR_PuzzleVerificationCode_v1 | 推理拼图验证码
|
||||||
|
- OCR_ClickVerificationCodeV2 | 点选验证码
|
||||||
|
- OCR_SourceCommonEnglishNumbersCodeV2 | 普通验证码
|
||||||
|
- OCR_SimpleSliderVerificationCode | 滑块验证码(滑动条式)
|
||||||
|
- OCR_SourceMergeCardOCR | 识别卡证文字
|
||||||
|
- OCR_SliderVerificationCodeV5 | 滑动拼图验证码
|
||||||
|
- OCR_SliderVerificationCodeV4 | 滑动拼图验证码
|
||||||
|
- OCR_SourceMergeOtherOCR | 识别其他场景文字
|
||||||
|
- OCR_SliderCode | 滑块验证码
|
||||||
|
- OCR_SourceMergeGeneralOCR | 通用文字识别
|
||||||
|
- OCR_RotateVerificationCodeV2 | 旋转图片验证码
|
||||||
|
- OCR_RotateVerificationCode | 滑块验证码(旋转式)
|
||||||
|
- OCR_ClickVerificationCodeV1 | 点选验证码
|
||||||
|
- OCR_PuzzleVerificationCode | 推理拼图验证码
|
||||||
|
- OCR_SimpleSliderVerificationCodeV2 | 滑动条验证码
|
||||||
|
- OCR_SourceCommonEnglishNumbersCode | 普通验证码
|
||||||
|
- OCR_SourceMergeCarOCR | 识别汽车场景文字
|
||||||
|
- OCR_SliderVerificationCodeV2 | 滑块验证码(拼图样式)
|
||||||
|
- OCR_SliderVerificationCodeV1 | 滑块验证码(拼图样式)
|
||||||
|
- OCR_SliderVerificationCode | 滑块验证码(拼图样式)
|
||||||
|
- OCR_SourceMergeEducationOCR | 识别教育场景文字
|
||||||
|
- OCR_SliderVerificationCodeV3 | 滑动拼图验证码
|
||||||
|
- OCR_ClickVerificationCode | 点选验证码
|
||||||
|
- OCR_SourceCommonEnglishNumbersCodeV1 | 普通验证码
|
||||||
|
- OCR_PuzzleVerificationCode_v2 | 推理拼图验证码
|
||||||
|
- OCR_SourceMergePaperOCR | 识别票据文字
|
||||||
|
- OCR_SimpleSliderVerificationCodeV1 | 滑动条验证码
|
||||||
|
- OCR_SourceMergeGeneralOCR_V1 | 识别通用文字
|
||||||
|
- OCR_SliderVerificationCodePosition | 获取拼图滑块位置
|
||||||
|
|
||||||
|
### 厂商专属云服务(4 个)——依赖厂商离线OCR模型文件
|
||||||
|
|
||||||
|
- OcrOffline_ClickOcrText_V1 | 鼠标点击OCR文本
|
||||||
|
- OcrOffline_MoveToOcrText | 鼠标移动到OCR文本上
|
||||||
|
- OcrOffline_ClickOcrText | 鼠标点击OCR文本
|
||||||
|
- OcrOffline_MoveToOcrText_V1 | 鼠标移动到OCR文本上
|
||||||
|
|
||||||
|
### 厂商专属云服务(6 个)——依赖百度云OCR账号与密钥
|
||||||
|
|
||||||
|
- OCR_BaiDu_CarOCR | 汽车场景类OCR
|
||||||
|
- OCR_BaiDu_OtherOCR | 其他场景类OCR
|
||||||
|
- OCR_BaiDu_EducationOCR | 教育场景类OCR
|
||||||
|
- OCR_BaiDu_GeneralOCR | 通用OCR
|
||||||
|
- OCR_BaiDu_CardOCR | 卡片类OCR
|
||||||
|
- OCR_BaiDu_PaperOCR | 票据类OCR
|
||||||
|
|
||||||
|
### 硬件外设(6 个)——依赖USB通道外设
|
||||||
|
|
||||||
|
- peripherals_UsbCom | 开/关USB通道
|
||||||
|
- peripherals_CloseNetworkUsbCom | 关闭NetworkUSB通道
|
||||||
|
- peripherals_CloseUsbCom | 关闭USB通道
|
||||||
|
- peripherals_NetworkUsbCom | 开/关NetworkUSB通道
|
||||||
|
- peripherals_OpenNetworkUsbCom | 开启NetworkUSB通道
|
||||||
|
- peripherals_OpenUsbCom | 开启USB通道
|
||||||
|
|
||||||
|
### 硬件外设(1 个)——依赖银行安全控件键盘
|
||||||
|
|
||||||
|
- SafeControlInput_KeyInput | 安全控件文本输入
|
||||||
|
|
||||||
|
### 硬件外设(4 个)——依赖机械臂设备
|
||||||
|
|
||||||
|
- System_ResetMechanicalArm | 复位机械臂
|
||||||
|
- System_MechanicalArmClick | 机械臂点击
|
||||||
|
- System_ConnectingMechanicalArm | 连接机械臂
|
||||||
|
- System_CloseMechanicalArm | 断开机械臂
|
||||||
|
|
||||||
|
### 硬件外设(2 个)——依赖U盾设备
|
||||||
|
|
||||||
|
- System_ConnectingUshield | 连接U盾
|
||||||
|
- System_CloseUshield | 断开U盾
|
||||||
|
|
||||||
|
### 语义已被覆盖(1 个)——下载能力已覆盖下载场景
|
||||||
|
|
||||||
|
- WebBrowser_DownloadDialog | 处理下载对话框
|
||||||
|
|
||||||
|
**小计:116 个 id**(2026-10-30 校准:原 128 项中 12 项已接入,移至下方清单)
|
||||||
|
|
||||||
|
### 已接入(原排除项,2026-10-30 校准移出)
|
||||||
|
|
||||||
|
通过设置页配置的 OpenAI 兼容 AI 服务接入(`src/main/capabilities/ai-caps.ts`):
|
||||||
|
|
||||||
|
- DataService_AIGenTxt | AI生成文本
|
||||||
|
- DataService_LargeModel | 大模型
|
||||||
|
- DataService_ZBrain | 自定义AI模型
|
||||||
|
- DataService_AgentCall | 调用智能体
|
||||||
|
- DataService_ChatBot | chatbot对话机器人
|
||||||
|
- DataService_KeyWord | 关键词抽取
|
||||||
|
- DataService_Correct | 文本纠错
|
||||||
|
- DataService_Review | 文本审核
|
||||||
|
- Scenario_TextComparison | 文档比对
|
||||||
|
|
||||||
|
通过本地离线 OCR 模型接入(`src/main/capabilities/ocr-caps.ts`):
|
||||||
|
|
||||||
|
- OcrOffline_GetOcrTextNew | 图像OCR识别
|
||||||
|
- OcrOffline_GetOcrTable | 获取表格数据(OCR)
|
||||||
|
- OcrOffline_GetOcrTextPosition | 图像OCR识别(含坐标)
|
||||||
|
|
||||||
|
## 二、暂缓池
|
||||||
|
|
||||||
|
### 已完成(超出原排期,2026-10-30 校准)
|
||||||
|
|
||||||
|
- [P1] 人机交互对话框 17/17:输入/文件/消息/自定义表单对话框(`src/main/capabilities/human-dialog.ts`)
|
||||||
|
- [P2] Windows 系统 12/12:凭据/远程桌面/屏保/输入法(`src/main/capabilities/windows-system.ts`)
|
||||||
|
- [P2] 图表数据读取 2/2:Element_GetLineBarChartData、Element_GetPieChartData(`src/main/capabilities/web-batch2.ts`)
|
||||||
|
- [P2] Excel 高阶 14/38:单元格格式、区域选择、行列显隐、尾部空白单元格、列标识、表格样式、Load/Close(`src/main/capabilities/excel-common.ts`)
|
||||||
|
- [P3] 待评估池移出 20 项:FTP 10 项(`src/main/capabilities/ftp.ts`)、NLP 9 项(AI 服务 `src/main/capabilities/ai-caps.ts`)、File_OpenFile
|
||||||
|
- 原排除清单移出 12 项:见第一节「已接入」
|
||||||
|
|
||||||
|
以下为剩余暂缓项:
|
||||||
|
|
||||||
|
### [P3] 待评估(84 个)
|
||||||
|
|
||||||
|
- child_modules_v1 | 调用子流程
|
||||||
|
- concurrent_child_process_v1 | 并发调用子流程
|
||||||
|
- Basic_Dodecryption | 设置密文变量
|
||||||
|
- Database_BatchSQLExecute | 批量执行SQL语句
|
||||||
|
- Database_DBConnect | 连接数据库
|
||||||
|
- Database_SingleSQLQuery | 执行单SQL查询
|
||||||
|
- Database_BatchInsertExecute | SQL批量插入数据
|
||||||
|
- Database_AllSQLQuery_V1 | 执行全SQL查询
|
||||||
|
- Database_CloseDBConnect | 关闭连接
|
||||||
|
- Database_SQLExecute | 执行SQL语句
|
||||||
|
- Database_AllSQLQuery | 执行全SQL查询
|
||||||
|
- Element_DesktopChildren | 获取子元素
|
||||||
|
- File_UploadBusinessResults_v1 | 上报办理结果
|
||||||
|
- File_StartBusiness | 上报办理业务
|
||||||
|
- File_UploadBusinessResults | 上报办理结果(门户专用)
|
||||||
|
- File_Collectupload | 上报采集数据
|
||||||
|
- File_UploadFolder | 结果打包上传
|
||||||
|
- FileTypeConversion_Office2pdf | OFFICE文档转PDF
|
||||||
|
- FileTypeConversion_Pdf2image2 | PDF转图片
|
||||||
|
- GoogleTable_GetAllData | 获取列信息(Google Sheets)
|
||||||
|
- GoogleTable_GetAllSheetsName | 获取Sheet名(Google Sheets)
|
||||||
|
- GoogleTable_WriteToSheetRegionColumns | 写入列(Google Sheets)
|
||||||
|
- GoogleTable_WriteToSheetRegionRows | 写入行(Google Sheets)
|
||||||
|
- GoogleTable_WriteToSheetCell | 写入单元格(Google Sheets)
|
||||||
|
- GoogleTable_WriteToSheetCellFormula | 写入单元格公式(Google Sheets)
|
||||||
|
- GoogleTable_GetSheetRowCount | 获取行数(Google Sheets)
|
||||||
|
- GoogleTable_DownloadSheets | 导出Sheet(Google Sheets)
|
||||||
|
- GoogleTable_CopyPasteSheet | 复制粘贴(Google Sheets)
|
||||||
|
- GoogleTable_GetColumnsByTableTitle | 按表头读取列(Google Sheets)
|
||||||
|
- GoogleTable_ChangeSheet | 激活Sheet(Google Sheets)
|
||||||
|
- GoogleTable_RenameSheet | 重命名Sheet(Google Sheets)
|
||||||
|
- GoogleTable_WriteToSheetRegionregion | 写入区域(Google Sheets)
|
||||||
|
- Image_Move_V2 | 鼠标移动到图像上
|
||||||
|
- Image_Base64Decode | 图片base64编码转二进制
|
||||||
|
- Image_Base64Encode | 图片转base64编码
|
||||||
|
- Image_Wait_V1 | 等待图像
|
||||||
|
- Image_Exists | 判断图像
|
||||||
|
- Image_Location | 图像位置
|
||||||
|
- Image_Location_V1 | 获取图像位置
|
||||||
|
- Image_Exists_V1 | 判断图像
|
||||||
|
- Image_CropImg | 截取图像
|
||||||
|
- Image_RelativePosition | 对比图像相对位置
|
||||||
|
- Image_Wait | 等待图像
|
||||||
|
- Image_Move | 移动到图像
|
||||||
|
- Image_Move_V1 | 移动到图像
|
||||||
|
- Keyboard_EnableManualOperation | 开启模拟人工操作
|
||||||
|
- Keyboard_DisableManualOperation | 关闭模拟人工操作
|
||||||
|
- Mark_Mark | 图片打标
|
||||||
|
- NLP_XunFei_MachineTranslation | 机器翻译
|
||||||
|
- NLP_XunFei_FeelingAnalysis | 第三方情感分析模型
|
||||||
|
- NLP_XunFei_KeywordExtraction | 关键字提取
|
||||||
|
- OCR_MergeCardOCR | 卡证文字识别
|
||||||
|
- OCR_CalculationCode | 算术验证码
|
||||||
|
- OCR_IdCardV2 | OCR身份证
|
||||||
|
- OCR_BusinessCardV2 | OCR名片
|
||||||
|
- OCR_VehicleInvoiceV2 | OCR机动车发票
|
||||||
|
- OCR_MergeOtherOCR | 其他场景文字识别
|
||||||
|
- OCR_BusinessLicenseV3 | OCR营业执照(自研)
|
||||||
|
- OCR_PassportV2 | OCR护照
|
||||||
|
- OCR_MergeGeneralOCR | 通用文字识别
|
||||||
|
- OCR_KeyValueIdentification | 识别键值对结构
|
||||||
|
- OCR_AmountIdentification | 识别价格金额
|
||||||
|
- OCR_GeneralTextV2 | OCR高精度文本
|
||||||
|
- OCR_VatInvoiceV2 | OCR增值税发票
|
||||||
|
- OCR_DrivingLicenseV2 | OCR行驶证
|
||||||
|
- OCR_AccountPageV2 | OCR户口页
|
||||||
|
- OCR_PlateLicenseV2 | OCR车牌
|
||||||
|
- OCR_BankCardV2 | OCR银行卡
|
||||||
|
- OCR_BankCardV3 | OCR银行卡(自研)
|
||||||
|
- OCR_IdCardV3 | OCR身份证(自研)
|
||||||
|
- OCR_VatInvoiceV3 | OCR增值税发票(自研)
|
||||||
|
- OCR_MergePaperOCR | 票据文字识别
|
||||||
|
- OCR_DriverLicenseV2 | OCR驾驶证
|
||||||
|
- OCR_RealEstateCertV2 | OCR不动产权证
|
||||||
|
- OCR_TrainTicketV2 | OCR火车票
|
||||||
|
- OCR_TaxiInvoiceV2 | OCR出租车发票
|
||||||
|
- OCR_OfficialSealV2 | OCR公章
|
||||||
|
- OCR_MergeCarOCR | 汽车场景文字识别
|
||||||
|
- OCR_MergeEducationOCR | 教育场景文字识别
|
||||||
|
- OCR_HouseCertV2 | OCR房产证
|
||||||
|
- OCR_CommonEnglishNumbersCode | 普通验证码
|
||||||
|
- OCR_BusinessLicenseV2 | OCR营业执照
|
||||||
|
- OCR_VinV2 | OCR车辆vin码
|
||||||
|
- Element_exception_monitor_v2 | 异常监控
|
||||||
|
|
||||||
|
### [P2] 视觉定位与 OCR 读屏(5 个)
|
||||||
|
|
||||||
|
- logic_control_if_ai_vision | 判断屏幕图像(AI)
|
||||||
|
- CV_GetText | 获取文本
|
||||||
|
- CV_SetText | 文本输入
|
||||||
|
- CV_MouseHover | 移动到元素上
|
||||||
|
- CV_MouseClick | 元素点击
|
||||||
|
|
||||||
|
### [P3] 桌面 Ground 剩余变体(12 个)
|
||||||
|
|
||||||
|
- Element_GetCheckGround | 获取勾选框状态值
|
||||||
|
- Element_SetCheckGround | 设置勾选框状态
|
||||||
|
- Element_RegionGround | 获取元素坐标
|
||||||
|
- Element_GetSelectGround | 获取下拉框选项值
|
||||||
|
- Element_MouseToGround | 鼠标移动到元素上
|
||||||
|
- Element_SetAttributeGround | 设置网页元素属性
|
||||||
|
- Element_ChildrenGround | 获取子元素
|
||||||
|
- Element_GetAttributeGround | 获取网页元素属性
|
||||||
|
- Element_ScrollIntoViewGround | 滚动页面
|
||||||
|
- Element_SetSelectGround | 设置下拉框选择项
|
||||||
|
- Element_ShotGround | 元素截图
|
||||||
|
- Element_GetButtonEnableGround | 元素是否可点击
|
||||||
|
|
||||||
|
### [P2] Excel 高阶:格式/图片/加密/透视表/筛选等(24 个)
|
||||||
|
|
||||||
|
- Excel_Cs_Group | 组合行列
|
||||||
|
- Excel_Cs_DeletePicture | 删除图片
|
||||||
|
- Excel_Cs_RefreshPivotTable | 刷新Excel数据透视表
|
||||||
|
- Excel_Cs_DeletePictureV2 | 删除图片
|
||||||
|
- Excel_Cs_Encrypt | 加密Excel
|
||||||
|
- Excel_Cs_Move | 移动Sheet
|
||||||
|
- Excel_Cs_InsertPicture | 插入图片
|
||||||
|
- Excel_Cs_Merge | 合并Sheet
|
||||||
|
- Excel_InsertImage | 插入图片
|
||||||
|
- Excel_Cs_InsertPivotView | 新建透视图
|
||||||
|
- Excel_Cs_Interop | Excel动态调用
|
||||||
|
- Excel_Cs_InsertPivotTable | 新建Excel数据透视表
|
||||||
|
- Excel_Cs_RunMacro | 执行宏
|
||||||
|
- Excel_Loop | excel内容循环
|
||||||
|
- Excel_Cs_Export | 导出Excel为PDF
|
||||||
|
- Excel_Cs_ExcelAddin | 数据处理
|
||||||
|
- Excel_Cs_InsertPictureV2 | 插入图片
|
||||||
|
- Excel_Cs_FilterPivotTable | 筛选Excel数据透视表
|
||||||
|
- Excel_Cs_RunMacroV2 | 执行宏
|
||||||
|
- Excel_Cs_SetSlicer | Excel切片器
|
||||||
|
- Excel_ExecuteMacro | 执行宏
|
||||||
|
- Excel_Cs_RefreshAll | 刷新Excel数据
|
||||||
|
- Excel_DeleteImage | 删除图片
|
||||||
|
- Excel_ExportRangeAsPicture | 导出区域为图片
|
||||||
|
|
||||||
|
### [P2] 邮件收件:需 IMAP 库(28 个)
|
||||||
|
|
||||||
|
- Mail_UnmarkSeen | 标为未读
|
||||||
|
- Mail_DoReplyMail_v1 | 回复邮件(Outlook)
|
||||||
|
- Mail_RecvConnect | 连接邮箱
|
||||||
|
- Mail_RecvMailGet | 取第几封邮件
|
||||||
|
- Mail_ExOperateMail | 操作邮件
|
||||||
|
- Mail_RecvGetList | 邮件筛选
|
||||||
|
- Mail_ReplyMail_v1 | 回复邮件
|
||||||
|
- Mail_FindMail_v1 | 查找邮件
|
||||||
|
- Mail_DoMoveMail | 移动邮件
|
||||||
|
- Mail_UnionConnect | 连接邮箱
|
||||||
|
- Mail_ExConnect | 连接Exchange邮箱
|
||||||
|
- Mail_RecvClose | 关闭连接
|
||||||
|
- Mail_DoSendMail | 发送邮件
|
||||||
|
- Mail_RecvCount | 取收件箱邮件数
|
||||||
|
- Mail_DoGetMailMessage_v2 | 获取邮件(Outlook)
|
||||||
|
- Mail_DoGetMailMessage_v1 | 查找邮件(Outlook)
|
||||||
|
- Mail_ReplyMail | 回复邮件
|
||||||
|
- Mail_FindMail_v2 | 获取邮件
|
||||||
|
- Mail_ExSearchMail | 查找邮件
|
||||||
|
- Mail_DoGetMailMessage | 查看邮件
|
||||||
|
- Mail_ExSendMail | 发送邮件
|
||||||
|
- Mail_OperateMail | 操作邮件
|
||||||
|
- Mail_MarkSeen | 标为已读
|
||||||
|
- Mail_DoMoveMail_v1 | 操作邮件(Outlook)
|
||||||
|
- Mail_ReplyMail_v2 | 回复邮件
|
||||||
|
- Mail_DoReplyMail | 回复邮件
|
||||||
|
- Mail_DoSendMail_v1 | 发送邮件(Outlook)
|
||||||
|
- Mail_FindMail | 查找邮件
|
||||||
|
|
||||||
|
### [P2] PDF 高阶:需引入渲染能力(4 个)
|
||||||
|
|
||||||
|
- PDF_PdfEncryption | 加密 PDF 文档
|
||||||
|
- PDF_GetPdfImg | 提取 PDF 图片
|
||||||
|
- PDF_GetPdfTable | 读取 PDF 表格
|
||||||
|
- PDF_PdfToImg | PDF 转图片
|
||||||
|
|
||||||
|
### [P2] Word 选区与光标:COM 操作(11 个)
|
||||||
|
|
||||||
|
- Word_ReadSelect | 读取选中文本
|
||||||
|
- Word_CutSelect | 剪切
|
||||||
|
- Word_ReadTable | 读取Word表格
|
||||||
|
- Word_SelectRow | 选择行
|
||||||
|
- Word_DeleteSelect | 删除
|
||||||
|
- Word_SetPosition | 设置光标位置
|
||||||
|
- Word_PositionToSelect | 查找文本后设置光标位置
|
||||||
|
- Word_MovePosition | 移动光标位置
|
||||||
|
- Word_CopySelect | 复制
|
||||||
|
- Word_PasteSelect | 粘贴
|
||||||
|
- Word_SelectAll | 全选
|
||||||
|
|
||||||
|
## 三、接入排期总览
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 | 网页核心 170、桌面 Ground 核心 5、网页批二 15 id、网页批三 16 id | 已完成 2026-10-28 |
|
||||||
|
| P1 | 人机交互对话框 17、Time 家族 24 | 已完成 2026-10-30 校准 |
|
||||||
|
| P1 | 文件上传验证 | 下一迭代 |
|
||||||
|
| P2 | Excel 高阶 24、PDF 高阶 4、Word 选区 11、邮件收件 28、视觉 5 | 后续迭代(图表 2、Windows 系统 12 已完成) |
|
||||||
|
| P3 | 桌面 Ground 剩余 12、待评估 84 | 按需 |
|
||||||
|
| 永久排除 | 明确不接入清单 116 项(第一节) | 不排期 |
|
||||||
|
|
||||||
|
## 四、落地机制
|
||||||
|
|
||||||
|
- 本文档为能力接入的唯一规划来源;新增能力须先在此登记阶段
|
||||||
|
- 2026-10-30 校准:数字以运行时注册表为准(744 个 id,目录可执行 732 / 1016);原排除/暂缓中已实现的 78 项已移入「已接入 / 已完成」
|
||||||
|
- 下一步:将第一节清单固化为 resources/excluded.json,组件库显示「不接入」灰标
|
||||||
|
|
||||||
|
## 六、桌面拾取与定位——原厂实现解读与我们的增强项(2026-10-30)
|
||||||
|
|
||||||
|
### 原厂实在智能的实现(源码解读)
|
||||||
|
|
||||||
|
- 拾取(ui_selector_window.py):全屏半透明覆盖窗 + 高亮窗贴**窗格级**元素 + 左键确认;输出 uiSelector = { window: [{class,type,name} 链], shot: 自动截图 }——**拾取时自动截取目标窗口图片存档**
|
||||||
|
- 执行定位(Element/py/scope/native.py):路径链逐层走 UIA 树——每层匹配 type/class/name **通配符** + 同名兄弟 **index 序号**;窗口锚用标题通配(EnumWindows)
|
||||||
|
- 桌面点击执行:**窗口定位 + 图像/坐标模式**(原厂 Image/CV 能力家族庞大的原因)
|
||||||
|
|
||||||
|
### 我们当前方案对比
|
||||||
|
|
||||||
|
- 定位:UIA 属性选择器(name/id 精确匹配,单层)——精度高于原厂通配符,但**缺路径链/通配/序号 fallback**
|
||||||
|
- 拾取:v2 两阶段(悬停/微调)+ PageUp/PageDown 层级切换 + 最小化应用——已实现
|
||||||
|
|
||||||
|
### 登记的增强项(后续迭代)
|
||||||
|
|
||||||
|
1. 链式查找:拾取器已输出元素链(layers),执行端支持逐层匹配(原厂模式)——解决 name 重复场景
|
||||||
|
2. name 通配符匹配与同名序号 index
|
||||||
|
3. 拾取确认时自动截取目标窗口图片存档(调试回看)
|
||||||
|
4. WebView2 深层 DOM 控件的 UIA 暴露深度调优
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 04-外接浏览器支持改动方案(Edge / Chrome)
|
||||||
|
|
||||||
|
- **编写时间**:2026-10-30
|
||||||
|
- **编写人**:刘爱家
|
||||||
|
- **关联文档**:01-现状分析与可行性 / 02-实施方案 / 03-能力接入规划与排期
|
||||||
|
- **目标**:让「打开网页」及全部网页自动化组件支持在客户本机的 Edge / Chrome 浏览器中执行,不再强制使用内置运行浏览器;内置浏览器保留为默认兜底。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状分析(代码级)
|
||||||
|
|
||||||
|
### 1.1 现有架构
|
||||||
|
|
||||||
|
```
|
||||||
|
网页组件(web.ts / web-batch2.ts / web-crawl.ts,169 项目录项)
|
||||||
|
│ BrowserLike 接口(engine/types.ts,33 个方法)
|
||||||
|
▼
|
||||||
|
BrowserSession(browser/session.ts,1056 行,单例)
|
||||||
|
│ this.cdp(method, params)
|
||||||
|
▼
|
||||||
|
win.webContents.debugger(Electron 内置窗口「RPA 运行浏览器」)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **会话模型**:`BrowserSession.create()` 单例复用一个 `BrowserWindow`,全局变量 `__rpaBrowserWindow` 持有;单窗口单 Tab 模型(`tab_unavailable` 错误即来自此)。
|
||||||
|
- **CDP 使用现状**:全部经 `webContents.debugger`(协议版本 1.3),使用的域:
|
||||||
|
- `DOM.getDocument / DOM.querySelector / DOM.setFileInputFiles`
|
||||||
|
- `Input.dispatchMouseEvent / Input.dispatchKeyEvent / Input.insertText`
|
||||||
|
- `Runtime.evaluate`(仅 runJs 一处)
|
||||||
|
- `Page.enable / Page.captureScreenshot / Page.handleJavaScriptDialog`
|
||||||
|
- `Network.enable / Network.getResponseBody`(网页监听)
|
||||||
|
- **全部是标准 CDP 域,Edge / Chrome(同为 Chromium)原生支持,命令层零改动。**
|
||||||
|
- **目录字段已预留**:`WebBrowser_Create / V2 / V3` 均有 `type`(浏览器类型,默认 chrome)、`isRemote`(是否远程)、`ipAddress`、`port`(默认 61619)、`arguments`(携带参数)、`openNewTab` 字段——**但实现不读取**,无论填什么一律打开内置窗口。
|
||||||
|
|
||||||
|
### 1.2 关键约束
|
||||||
|
|
||||||
|
| 约束 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| Electron 31 主进程 = Node 20.x | 无稳定原生 WebSocket 客户端,连接外部调试端口需引入 `ws` |
|
||||||
|
| 用户已开着的浏览器 | 未带调试端口启动的实例无法连接(Chromium 安全限制),必须带参重启 |
|
||||||
|
| 内置窗口独有 API | `webContents.debugger` 仅 Electron 可用;`Page.captureScreenshot` 外部浏览器同样可用(证据截图无障碍) |
|
||||||
|
| 元素拾取器 | 现拾取链路绑定内置窗口,外部浏览器需改走 CDP DOM 快照 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、总体设计
|
||||||
|
|
||||||
|
**核心思路:传输层抽象,一次替换,上层不动。**
|
||||||
|
|
||||||
|
```
|
||||||
|
BrowserLike(接口不变)
|
||||||
|
▼
|
||||||
|
BrowserSession(cdp() 委托给 transport,其余逻辑不变)
|
||||||
|
▼
|
||||||
|
CdpTransport 接口(新增)
|
||||||
|
├── EmbeddedTransport —— 现有 webContents.debugger 封装(内置浏览器,默认)
|
||||||
|
└── ExternalTransport —— WebSocket 直连外部浏览器调试端口(新增,依赖 ws)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `BrowserLike` 接口 33 个方法、上层 169 个网页组件**全部零改动**。
|
||||||
|
- 内置浏览器保留:`type` 未填或填内置类型时走原路径,行为与现在完全一致(回归风险隔离)。
|
||||||
|
|
||||||
|
### 浏览器类型枚举约定
|
||||||
|
|
||||||
|
| type 值 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `chrome` | 探测本机 Chrome 路径 → 带调试端口启动 → CDP 接管 |
|
||||||
|
| `edge` | 探测本机 Edge 路径 → 同上 |
|
||||||
|
| `chrome_remote` / `edge_remote`(或 isRemote=1) | 不启动进程,直接连 `ipAddress:port` 上已监听调试端口的实例 |
|
||||||
|
| `zexplorer` / 缺省 | 内置运行浏览器(现状行为,兜底) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、分阶段实施
|
||||||
|
|
||||||
|
### 阶段一:传输层抽象 + 外部浏览器连接(核心,约 2~3 天)
|
||||||
|
|
||||||
|
**新增 `src/main/browser/transport.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface CdpTransport {
|
||||||
|
send<T>(method: string, params?: Record<string, unknown>): Promise<T>;
|
||||||
|
on(event: string, handler: (params: unknown) => void): void; // Page.loadEventFired 等
|
||||||
|
close(): Promise<void>;
|
||||||
|
kind: "embedded" | "external";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `EmbeddedTransport`:包装现有 `webContents.debugger` 的 `send/on`(`session.ts` 现有 `cdp()` 逻辑原样迁入)。
|
||||||
|
- `ExternalTransport`:基于 `ws` 包,连接 `http://ip:port` 的调试端点:
|
||||||
|
1. `GET /json/list` 枚举页面级 target,取 `webSocketDebuggerUrl`(或 `PUT /json/new?url=` 开新 Tab)
|
||||||
|
2. 建立WebSocket,消息按 CDP 格式封包(`{id, method, params}` / 响应 `{id, result|error}` / 事件 `{method, params}`),promise map 按 id 配对
|
||||||
|
3. 断线检测(ws close 事件)→ 抛 `browser_disconnected`,带「请带调试端口重启浏览器」提示
|
||||||
|
|
||||||
|
**新增 `src/main/browser/external.ts`(外部浏览器进程管理)**
|
||||||
|
|
||||||
|
- 路径探测(按优先级):
|
||||||
|
- Chrome:注册表 `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe` → `C:\Program Files\Google\Chrome\Application\chrome.exe` → `C:\Program Files (x86)\...`
|
||||||
|
- Edge:`HKLM\...\App Paths\msedge.exe` → `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`(Win10/11 必装,探测成功率 100%)
|
||||||
|
- 启动:`child_process.spawn(browserPath, [--remote-debugging-port=<port>, --user-data-dir=<独立目录>, url, ...args])`
|
||||||
|
- **必须带独立 `--user-data-dir`**:否则端口参数会被用户已开的实例吞掉(连不上且无报错的头号坑)
|
||||||
|
- 端口管理:默认取 `port` 字段(61619),被占用时自增重试;启动后轮询 `GET /json/version`(超时 15s)确认就绪
|
||||||
|
- 进程生命周期:记录 child process 引用;流程 `WebBrowser_Close` 时可选关闭(目录可加「执行后是否关闭浏览器」字段)
|
||||||
|
|
||||||
|
**改造 `src/main/browser/session.ts`**
|
||||||
|
|
||||||
|
- `BrowserSession` 构造函数改为接收 `CdpTransport`;`cdp()` 方法改为一行委托 `this.transport.send()`
|
||||||
|
- 单例键从 `__rpaBrowserWindow` 扩展为「按 (type, user-data/port) 维度」的会话缓存,支持同流程混合使用内置 + 外部
|
||||||
|
- `close()`:embedded 关窗口(现状逻辑);external 断开 ws,按配置决定是否杀浏览器进程
|
||||||
|
|
||||||
|
### 阶段二:「打开网页」组件参数生效(约 1 天)
|
||||||
|
|
||||||
|
**改造 `src/main/capabilities/browser.ts` 的 `WebBrowser_Create / V2 / V3 / CreateBackground`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
type + isRemote + ipAddress + port + arguments ──→ BrowserSessionFactory
|
||||||
|
├─ type ∈ {chrome, edge} 且 isRemote=0 → external.ts 启动并连接
|
||||||
|
├─ isRemote=1 → 直连 ipAddress:port
|
||||||
|
└─ 其余 → 内置浏览器(现状)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `waitingload`(等待页面加载完成):external 模式下监听 `Page.loadEventFired` 实现,语义与内置一致
|
||||||
|
- `arguments`(携带参数):透传给浏览器启动命令行
|
||||||
|
- 日志明确输出实际使用的浏览器(如「已使用 Edge (127.0.0.1:9222) 打开 https://...」),便于客户排查
|
||||||
|
|
||||||
|
### 阶段三:多标签页管理(约 2~3 天)
|
||||||
|
|
||||||
|
现状单 Tab 模型在外部浏览器不成立,需引入 `Target` 域:
|
||||||
|
|
||||||
|
- `Target.getTargets` / `Target.attachToTarget`(flatten 模式)/ `Target.closeTarget`
|
||||||
|
- 落地组件:`WebBrowser_GetTAB_V1`(按标题/URL 匹配 Tab,type 字段终于生效)、`WebBrowser_Switch`(切换当前操作的 Tab)、`CreateV3` 的 `openNewTab`
|
||||||
|
- 实现方式:ExternalTransport 按 targetId 维护多个 ws 会话,BrowserSession 增加 `switchTab(targetId)`;内置浏览器对应到多个 `webContents`
|
||||||
|
|
||||||
|
### 阶段四:拾取器与高级组件适配(约 2~3 天)
|
||||||
|
|
||||||
|
- 元素拾取:外部浏览器场景拾取请求走 CDP `DOMSnapshot.captureSnapshot` / `DOM.getDocument` 生成候选元素树(内置窗口继续走现有拾取链路)
|
||||||
|
- 逐组件验证(阶段一~三完成后自然可用,重点回归):`WebBrowser_GetCookieNew`、`WebBrowser_StartWeblistening / GetWeblistening`(Network 域,外部同样可用)、`WebBrowser_BatchDataCrawlNew`(browserType 字段接阶段二同一工厂)、`WebBrowser_SaveAs`(`DOM.setFileInputFiles` + `Page.handleJavaScriptDialog`)
|
||||||
|
- `Element_*` 系列 100+ 组件无需改动,靠接口层自动生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、依赖与风险
|
||||||
|
|
||||||
|
### 新增依赖
|
||||||
|
|
||||||
|
| 包 | 大小 | 用途 | 风险 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ws` | ~50KB 纯 JS | 连接外部浏览器调试端口(Node 20 无原生 WebSocket) | 无原生编译,供应链风险低 |
|
||||||
|
|
||||||
|
**客户机器零安装**:Edge/Chrome 原生支持 CDP,不装任何扩展插件。
|
||||||
|
|
||||||
|
### 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 对策 |
|
||||||
|
|---|---|
|
||||||
|
| 用户已开浏览器实例吞掉端口参数(连不上且无提示) | 必带独立 `--user-data-dir` 启动;文档注明该限制 |
|
||||||
|
| 调试端口与客户其他软件冲突 | 端口自增探测 + `/json/version` 就绪轮询 |
|
||||||
|
| 外部浏览器被客户手动关闭,流程中途断连 | ws close 事件 → 明确报错「浏览器已关闭」,错误路由可接重开逻辑 |
|
||||||
|
| 页面弹窗阻塞 CDP | `Page.handleJavaScriptDialog` 与内置行为一致,现有组件已覆盖 |
|
||||||
|
| 混用内置/外部时上下文串流 | 会话缓存按 (type, port) 隔离;`RunContext.browser` 生命周期不变 |
|
||||||
|
| URL 安全 | 沿用现有 `assertUrl`(仅 http/https)白名单逻辑,外部连接的 ip:port 另做私网地址校验 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、工作量与排期估算
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预估 |
|
||||||
|
|---|---|---|
|
||||||
|
| 一 | 传输层抽象 + 外部连接 + 进程管理 | 2~3 天 |
|
||||||
|
| 二 | 打开网页组件参数生效 | 1 天 |
|
||||||
|
| 三 | 多标签 Target 域 | 2~3 天 |
|
||||||
|
| 四 | 拾取器适配 + 高级组件回归 | 2~3 天 |
|
||||||
|
| 测试 | 与同事网页自动化测试联动,双浏览器(Edge/Chrome)× 关键链路 | 1~2 天 |
|
||||||
|
| **合计** | | **约 1.5~2 周** |
|
||||||
|
|
||||||
|
阶段一+二完成后即可向客户交付「用 Edge/Chrome 打开并执行基础网页自动化」;阶段三、四为完整体验补齐,可穿插进行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试计划(验收标准)
|
||||||
|
|
||||||
|
1. **连接**:Edge / Chrome 各验证——全新启动、已装非默认路径(注册表探测)、isRemote 远程连接、端口占用自增
|
||||||
|
2. **基础链路**:打开网页 → 点击 → 输入 → 取文本 → 截图 → 关闭,双浏览器全通过
|
||||||
|
3. **多标签**:开 3 个 Tab → GetTAB 按标题匹配 → 切换 → 各 Tab 独立操作 → closeTarget
|
||||||
|
4. **兜底**:type 缺省/填 zexplorer 时行为与现版本完全一致(内置浏览器回归)
|
||||||
|
5. **异常**:浏览器中途被手动关、URL 非法、调试端口不通、页面弹窗
|
||||||
|
6. **数据采集/监听**:BatchDataCrawlNew、StartWeblistening 在外部浏览器下与内置结果一致
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 图像与OCR 能力问题检测报告
|
||||||
|
|
||||||
|
- **检测时间**:2026-10-30 10:00–10:10
|
||||||
|
- **检测人**:刘爱家(AI 辅助静态核对 + 真实运行验证)
|
||||||
|
- **检测范围**:图像与OCR 分组已接入的全部 10 项能力(Image 7 项 + OcrOffline 3 项)
|
||||||
|
- **检测方式**:
|
||||||
|
1. 静态核对——组件目录(catalog.json)字段定义 vs 实现代码(vision.ts / ocr-caps.ts)逐键比对
|
||||||
|
2. 真实运行——GDI 生成含中英文的测试图、真实截屏、模板匹配直测、OCR 引擎实识别(飞桨 @gutenye/ocr-node)
|
||||||
|
- **说明**:本次仅检测未修改任何代码,问题留给同事修复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论概览
|
||||||
|
|
||||||
|
| 组件 | 名称 | 可用性 | 核心问题 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Image_Screenshot | 屏幕截图 | ⚠️ 基本可用 | 截图范围/图像来源字段无效,仅支持全屏 |
|
||||||
|
| Image_Find | 查找图像 | ✅ 可用 | accuracy2 字段无效 |
|
||||||
|
| Image_Find_V1 | 查找图像 | ⚠️ 有隐患 | window_obj 必填但被忽略,实际全屏查找 |
|
||||||
|
| Image_Click | 点击图像 | ⚠️ 有缺陷 | position(点击位置)被忽略,永远点中心 |
|
||||||
|
| Image_Click_V1 | 点击图像 | ⚠️ 有缺陷 | 同上 |
|
||||||
|
| Image_Click_V2 | 点击图像 | ⚠️ 有缺陷 | 同上 |
|
||||||
|
| Image_Click_V3 | 鼠标点击图像 | ❌ 功能缺失 | click_key(左/右键)被忽略,右键点击无法实现;窗口限定无效 |
|
||||||
|
| OcrOffline_GetOcrTextNew | 图像OCR识别 | ❌ 不可用 | **按目录配置无法传入图片路径,任何填写方式都报「图片路径为空」** |
|
||||||
|
| OcrOffline_GetOcrTextPosition | 图像OCR识别(含坐标) | ✅ 可用 | 无阻塞性问题 |
|
||||||
|
| OcrOffline_GetOcrTable | 获取表格数据(OCR) | ⚠️ 部分可用 | 保存至数据表/Excel 的 4 个字段全部无效;返回扁平文本非表格结构 |
|
||||||
|
|
||||||
|
**正常项(已实测确认)**:截屏产物正确;模板匹配精度高(自测得分 0.9993);OCR 中文/数字识别准确、坐标与置信度返回正常;找图失败时报错提示合理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、问题清单(按严重度)
|
||||||
|
|
||||||
|
### P0-1 OcrOffline_GetOcrTextNew:目录字段与实现完全脱节,组件不可用
|
||||||
|
|
||||||
|
- **现象**:该组件在目录里唯一的图片相关字段是 `uiSelector`(目标,非必填),而实现读取图片路径的键列表为 `image_path/imagePath/file_path/filePath/path/image/pictureSource`——**不包含 uiSelector**。
|
||||||
|
- **后果**:用户按目录界面填参数,无论怎么填都无法把图片传进实现,必然报「图片路径为空」。已实测复现:`{ uiSelector: "D:/.../ocr_input.png" }` → `RuntimeError: 图片路径为空`。
|
||||||
|
- **修复建议**:实现读取键中加入 `uiSelector`,或目录为该组件增加 `image_path` 必填字段(与 GetOcrTextPosition 保持一致)。
|
||||||
|
|
||||||
|
### P1-1 Image_Click 全 4 版本:忽略「点击位置」字段
|
||||||
|
|
||||||
|
- **现象**:目录字段 `position`(点击位置,默认 center),实现拿到匹配区域后**固定点击中心点**(`vision.ts` 中 `x + width/2, y + height/2`),position 值从不读取。
|
||||||
|
- **后果**:用户选择「左上角/右上角/自定义偏移」等位置时静默失效,点击落点与预期不符且无任何提示。
|
||||||
|
|
||||||
|
### P1-2 Image_Click_V3:忽略「鼠标按键」字段,右键点击无法实现
|
||||||
|
|
||||||
|
- **现象**:目录字段 `click_key`(鼠标按键,默认 left),实现写死左键单击(`mouse_event(2,...)`按下 + `mouse_event(4,...)`弹起)。
|
||||||
|
- **后果**:右键菜单类自动化场景完全无法实现,且不报错(静默按左键执行)。
|
||||||
|
|
||||||
|
### P1-3 OcrOffline_GetOcrTable:输出相关 4 个字段全部无效
|
||||||
|
|
||||||
|
- **现象**:目录字段 `showTable`(保存至数据表)、`isExcel`(保存至Excel)、`excelName`、`excelPath`,实现全部不读取;仅返回按 y 坐标排序的**扁平文本行数组**。
|
||||||
|
- **后果**:「将结果保存至数据表/Excel」的承诺静默不执行;且行内多列文本不拆分(如「鼠标 20」作为一整行文本返回),拿不到结构化表格。
|
||||||
|
|
||||||
|
### P2-1 Image_Find_V1 / Image_Click_V3:「窗口对象」必填但被忽略
|
||||||
|
|
||||||
|
- **现象**:目录要求 `window_obj` 必填、`find_range`(图像查找范围)可选,实现两者都不读取,永远全屏截图查找。
|
||||||
|
- **后果**:用户以为限定了窗口范围,实际全屏找图——找到错误目标(其他窗口的同款图标)或找不到时无从排查。且 window_obj 必填迫使用户必须配置一个无意义的参数。
|
||||||
|
|
||||||
|
### P2-2 Image_Screenshot:「截图范围」「图像来源」字段无效
|
||||||
|
|
||||||
|
- **现象**:目录字段 `pictureSource`(图像来源)、`shotRange`(截图范围),实现不读取,固定全屏(`PrimaryScreen`)。目录里还有 `action/position/position_img` 三个疑似遗留占位字段。
|
||||||
|
- **后果**:只支持全屏截图;另外多显示器环境只截主屏,副屏内容丢失。
|
||||||
|
|
||||||
|
### P3-1 「特征相似度」accuracy2 字段无效
|
||||||
|
|
||||||
|
- Image_Find / Image_Click / Image_Click_V1 的 `accuracy2`(特征相似度)字段,实现不读取。当前实现是纯像素差模板匹配,没有特征匹配层,该字段为冗余配置。
|
||||||
|
|
||||||
|
### P3-2 「展示鼠标移动轨迹」show_track 字段无效
|
||||||
|
|
||||||
|
- Image_Click 全版本的 `show_track` 被忽略。当前实现 SetCursorPos 直接跳转,无轨迹动画。低优先级。
|
||||||
|
|
||||||
|
### P3-3 其他小问题
|
||||||
|
|
||||||
|
- Image_Screenshot 当 `image_path` 为空时输出文件落盘到进程工作目录(不可预期);因该字段必填,实际风险低,建议加显式校验。
|
||||||
|
- OcrOffline_GetOcrTable 的行排序仅按 y 坐标,同一行内多个文本块不合并、不按 x 排序,多列场景顺序不稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、实测验证记录(正常部分证据)
|
||||||
|
|
||||||
|
| 测试 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| GDI 生成测试图(600×200,含「RPA流程测试ABC123」「名称 数量」「鼠标 20」) | 成功 |
|
||||||
|
| Image_Screenshot 截屏逻辑(同款 PowerShell 脚本) | 成功,产物 246KB PNG |
|
||||||
|
| findImage 模板匹配(app_logo_tpl 模板 vs 实时截屏) | 命中 (93,27),得分 **0.9993** |
|
||||||
|
| OcrOffline_GetOcrTextNew(传入 image_path 时) | 识别结果 `RPA流程测试ABC123\n名称 数量\n鼠标 20` 完全正确 |
|
||||||
|
| OcrOffline_GetOcrTextPosition | 3 行文本坐标 + 置信度(0.999/0.949/0.899)返回正常 |
|
||||||
|
| 找图失败路径 | 正确抛「屏幕中未找到目标图像」并附建议(注:vitest 环境内 PowerShell 受 PATH 限制报 ENOENT,真实应用主进程不受影响) |
|
||||||
|
|
||||||
|
**说明**:OCR 引擎(飞桨)与模板匹配算法本身质量良好,问题集中在**目录字段与实现的键名/功能脱节**——与此前 Office、数据处理组发现的拼接列、单元格格式问题是同一类「接入只对了一半」的缺陷。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、给同事的修复优先级建议
|
||||||
|
|
||||||
|
1. **先修 P0-1**(GetOcrTextNew 传图键)——1 行改动,组件从不可用变可用
|
||||||
|
2. **再修 P1 三项**(Click 的 position/click_key、GetOcrTable 的输出字段)——都是读键 + 少量分支逻辑
|
||||||
|
3. **P2/P3 可排后**——窗口限定、截图范围属于功能增强,当前行为可接受但建议在 UI 上隐藏无效字段避免误导
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
||||||
|
|
||||||
|
/** @type {import('electron-builder').Configuration} */
|
||||||
|
export default {
|
||||||
|
appId: "com.rpastudio.desktop",
|
||||||
|
productName: "RPA Studio",
|
||||||
|
artifactName: "RPA-Studio-Setup-${version}-win-${arch}.${ext}",
|
||||||
|
directories: {
|
||||||
|
output: path.join(os.tmpdir(), "RPA-Studio-electron-builder"),
|
||||||
|
buildResources: path.join(root, "build")
|
||||||
|
},
|
||||||
|
files: [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
extraResources: [
|
||||||
|
{ from: path.join(root, "resources", "catalog.json"), to: "catalog.json" },
|
||||||
|
{ from: path.join(root, "resources", "fixture"), to: "fixture" }
|
||||||
|
],
|
||||||
|
asar: true,
|
||||||
|
asarUnpack: [
|
||||||
|
"node_modules/onnxruntime-node/**",
|
||||||
|
"node_modules/sharp/**",
|
||||||
|
"node_modules/@gutenye/**"
|
||||||
|
],
|
||||||
|
compression: "maximum",
|
||||||
|
npmRebuild: false,
|
||||||
|
electronVersion: packageJson.devDependencies.electron,
|
||||||
|
electronDist: path.join(root, "node_modules", "electron", "dist"),
|
||||||
|
win: {
|
||||||
|
target: [{ target: "nsis", arch: ["x64"] }],
|
||||||
|
icon: path.join(root, "build", "icon.ico"),
|
||||||
|
signAndEditExecutable: true
|
||||||
|
},
|
||||||
|
nsis: {
|
||||||
|
oneClick: false,
|
||||||
|
perMachine: false,
|
||||||
|
allowElevation: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
installerLanguages: ["zh_CN"],
|
||||||
|
language: "2052",
|
||||||
|
shortcutName: "RPA Studio",
|
||||||
|
uninstallDisplayName: "RPA Studio ${version}",
|
||||||
|
createDesktopShortcut: true,
|
||||||
|
createStartMenuShortcut: true,
|
||||||
|
runAfterFinish: true,
|
||||||
|
deleteAppDataOnUninstall: false
|
||||||
|
},
|
||||||
|
publish: null
|
||||||
|
};
|
||||||
Generated
+2617
-540
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -17,8 +17,11 @@
|
|||||||
"test:e2e": "node scripts/demo-e2e.mjs"
|
"test:e2e": "node scripts/demo-e2e.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@gutenye/ocr-models": "^1.4.2",
|
||||||
|
"@gutenye/ocr-node": "^1.4.9",
|
||||||
"@xyflow/react": "^12.12.0",
|
"@xyflow/react": "^12.12.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
"basic-ftp": "^6.2.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
@@ -27,21 +30,25 @@
|
|||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
|
"ws": "^8.22.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/packager": "^18.3.6",
|
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/ws": "^8.18.2",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"electron": "31.7.7",
|
"electron": "31.7.7",
|
||||||
|
"electron-builder": "^26.15.7",
|
||||||
"electron-vite": "^6.0.0",
|
"electron-vite": "^6.0.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
}
|
},
|
||||||
|
"author": "RPA Studio"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"Basic_JudgeDataType": {
|
||||||
|
"description": "按所选目标类型判断数据,返回布尔值 true 或 false。数值判断区分数字与数字文本。",
|
||||||
|
"fields": {
|
||||||
|
"into_type": {
|
||||||
|
"defaultValue": "number",
|
||||||
|
"description": "选择要判断的目标类型。历史默认值 1 兼容数值类型。",
|
||||||
|
"options": [
|
||||||
|
{ "label": "数值", "value": "number" },
|
||||||
|
{ "label": "整数", "value": "int" },
|
||||||
|
{ "label": "空值", "value": "null" },
|
||||||
|
{ "label": "字典", "value": "dict" },
|
||||||
|
{ "label": "数组", "value": "list" },
|
||||||
|
{ "label": "文本", "value": "string" },
|
||||||
|
{ "label": "布尔", "value": "bool" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Basic_DataTypeConversion": {
|
||||||
|
"description": "将数据转换为所选的数值、整数、布尔或文本类型;无法转换时给出参数错误。",
|
||||||
|
"fields": {
|
||||||
|
"into_type": {
|
||||||
|
"defaultValue": "number",
|
||||||
|
"description": "选择目标类型。历史默认值 1 兼容数值;其他数字别名 2=整数、3=布尔、4=文本。",
|
||||||
|
"options": [
|
||||||
|
{ "label": "数值", "value": "number" },
|
||||||
|
{ "label": "整数", "value": "int" },
|
||||||
|
{ "label": "布尔", "value": "bool" },
|
||||||
|
{ "label": "文本", "value": "string" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"App_KillV1": {
|
||||||
|
"fields": {
|
||||||
|
"close_type": {
|
||||||
|
"options": [
|
||||||
|
{ "label": "按进程 PID", "value": "id" },
|
||||||
|
{ "label": "按进程名称", "value": "name" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System_ExecuteCommandV1": {
|
||||||
|
"fields": {
|
||||||
|
"command_type": {
|
||||||
|
"label": "命令类型",
|
||||||
|
"options": [
|
||||||
|
{ "label": "DOS 命令", "value": "1" },
|
||||||
|
{ "label": "BAT 文件", "value": "2" },
|
||||||
|
{ "label": "PowerShell 命令", "value": "3" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System_ScreenSaverShowV1": {
|
||||||
|
"description": "开启 Windows 已配置的系统屏保。当前组件不支持自定义屏保文字、透明度或解锁密码;登录保护遵循 Windows 系统设置。",
|
||||||
|
"messageTemplate": "开启 Windows 系统屏保",
|
||||||
|
"removeFields": ["text", "effect", "status", "set_password", "password"]
|
||||||
|
},
|
||||||
|
"ScreenControl_ScreenUnlock": {
|
||||||
|
"name": "唤醒屏幕(非登录解锁)",
|
||||||
|
"description": "仅尝试唤醒屏幕,不支持通过账号密码自动完成 Windows 登录解锁。锁屏后仍需本人手动登录。",
|
||||||
|
"messageTemplate": "尝试唤醒屏幕;Windows 登录锁屏需人工解锁",
|
||||||
|
"removeFields": ["username", "pwd"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+113833
-1
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
|||||||
|
param([string]$Mode = "element")
|
||||||
|
$ErrorActionPreference = "Continue";
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
||||||
|
$logPath = "D:/RPA/test-output/desktop-picker.log";
|
||||||
|
function Write-PickLog([string]$msg) {
|
||||||
|
try { $logDirectory = Split-Path -Parent $logPath; if (-not (Test-Path -LiteralPath $logDirectory)) { New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null }; Add-Content -Path $logPath -Value ((Get-Date -Format "HH:mm:ss.fff") + " | " + $msg) -Encoding UTF8 } catch {}
|
||||||
|
}
|
||||||
|
Write-PickLog ("=== start Mode=$Mode ===")
|
||||||
|
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, WindowsBase
|
||||||
|
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);' -Name PickDpi -Namespace RpaPick | Out-Null
|
||||||
|
try { [RpaPick.PickDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}
|
||||||
|
Write-PickLog "dpi aware set"
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Write-PickLog "assemblies ok"
|
||||||
|
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int key); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder sb, int max); [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);' -Name PickW32 -Namespace RpaPick | Out-Null
|
||||||
|
Write-PickLog "w32 ok"
|
||||||
|
if ($Mode -eq "windows") {
|
||||||
|
$wins = @()
|
||||||
|
foreach ($p in Get-Process) { if ($p.MainWindowHandle -ne [IntPtr]::Zero -and $p.MainWindowTitle) { $wins += @{ title = $p.MainWindowTitle; process = $p.ProcessName } } }
|
||||||
|
Write-PickLog "windows: $($wins.Count)"
|
||||||
|
Write-Output (ConvertTo-Json @($wins) -Compress)
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$info = New-Object System.Windows.Forms.Form
|
||||||
|
$info.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
|
||||||
|
$info.TopMost = $true
|
||||||
|
$info.ShowInTaskbar = $false
|
||||||
|
$info.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
|
||||||
|
$sw2 = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width
|
||||||
|
$info.Location = New-Object System.Drawing.Point(([int]($sw2 / 2) - 280), 10)
|
||||||
|
$info.Size = New-Object System.Drawing.Size(560, 64)
|
||||||
|
$info.BackColor = [System.Drawing.Color]::FromArgb(15, 40, 90)
|
||||||
|
$info.Opacity = 0.92
|
||||||
|
$infoLabel = New-Object System.Windows.Forms.Label
|
||||||
|
$infoLabel.Dock = [System.Windows.Forms.DockStyle]::Fill
|
||||||
|
$infoLabel.ForeColor = [System.Drawing.Color]::White
|
||||||
|
$infoLabel.Font = New-Object System.Drawing.Font("Microsoft YaHei", 11)
|
||||||
|
$infoLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
||||||
|
$infoLabel.Text = "移动鼠标到目标元素上"
|
||||||
|
$info.Controls.Add($infoLabel)
|
||||||
|
$hl = New-Object System.Windows.Forms.Form
|
||||||
|
$hl.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
|
||||||
|
$hl.TopMost = $true
|
||||||
|
$hl.ShowInTaskbar = $false
|
||||||
|
$hl.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
|
||||||
|
$hl.BackColor = [System.Drawing.Color]::Fuchsia
|
||||||
|
$hl.TransparencyKey = [System.Drawing.Color]::Fuchsia
|
||||||
|
$hl.Add_Paint({
|
||||||
|
param($sender, $e)
|
||||||
|
$pen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(255, 255, 32, 32), 3)
|
||||||
|
try {
|
||||||
|
$size = $sender.ClientSize
|
||||||
|
$e.Graphics.DrawRectangle($pen, 1, 1, ($size.Width - 3), ($size.Height - 3))
|
||||||
|
} finally {
|
||||||
|
$pen.Dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
function Set-ClickThrough($form) {
|
||||||
|
if ($form.IsHandleCreated) {
|
||||||
|
$GWL_EXSTYLE = -20
|
||||||
|
$WS_EX_LAYERED = 0x80000
|
||||||
|
$WS_EX_TRANSPARENT = 0x20
|
||||||
|
$WS_EX_NOACTIVATE = 0x08000000
|
||||||
|
$WS_EX_TOOLWINDOW = 0x80
|
||||||
|
$style = [RpaPick.PickW32]::GetWindowLong($form.Handle, $GWL_EXSTYLE)
|
||||||
|
[void][RpaPick.PickW32]::SetWindowLong($form.Handle, $GWL_EXSTYLE, $style -bor $WS_EX_LAYERED -bor $WS_EX_TRANSPARENT -bor $WS_EX_NOACTIVATE -bor $WS_EX_TOOLWINDOW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-HighlightBox([int]$x, [int]$y, [int]$w, [int]$h) {
|
||||||
|
$hl.Location = New-Object System.Drawing.Point(($x - 2), ($y - 2))
|
||||||
|
$hl.Size = New-Object System.Drawing.Size(($w + 4), ($h + 4))
|
||||||
|
$hl.Show()
|
||||||
|
Set-ClickThrough $hl
|
||||||
|
}
|
||||||
|
function Hide-Highlight { $hl.Hide() }
|
||||||
|
function Get-DeepestAtPoint([double]$px, [double]$py) {
|
||||||
|
$point = New-Object System.Windows.Point($px, $py)
|
||||||
|
$cur = [System.Windows.Automation.AutomationElement]::FromPoint($point)
|
||||||
|
if (-not $cur) { return $null }
|
||||||
|
for ($depth = 0; $depth -lt 25; $depth++) {
|
||||||
|
$children = $cur.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition)
|
||||||
|
$next = $null
|
||||||
|
$nextArea = [double]::MaxValue
|
||||||
|
foreach ($c in $children) {
|
||||||
|
try {
|
||||||
|
$r = $c.Current.BoundingRectangle
|
||||||
|
if ($r.Width -gt 1 -and $r.Height -gt 1 -and $px -ge $r.X -and $px -le ($r.X + $r.Width) -and $py -ge $r.Y -and $py -le ($r.Y + $r.Height)) {
|
||||||
|
$area = $r.Width * $r.Height
|
||||||
|
if ($area -lt $nextArea) { $next = $c; $nextArea = $area }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (-not $next) { break }
|
||||||
|
$cur = $next
|
||||||
|
}
|
||||||
|
return $cur
|
||||||
|
}
|
||||||
|
$info.Show()
|
||||||
|
Set-ClickThrough $info
|
||||||
|
$stage = "hover"
|
||||||
|
$chain = New-Object System.Collections.ArrayList
|
||||||
|
$selIdx = -1
|
||||||
|
$prevSpace = $false
|
||||||
|
$prevPgUp = $false
|
||||||
|
$prevPgDn = $false
|
||||||
|
Write-PickLog "loop start"
|
||||||
|
while ($true) {
|
||||||
|
try {
|
||||||
|
$space = (([RpaPick.PickW32]::GetAsyncKeyState(32) -band 0x8001) -ne 0)
|
||||||
|
$esc = (([RpaPick.PickW32]::GetAsyncKeyState(27) -band 0x8001) -ne 0)
|
||||||
|
$pgUp = (([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8001) -ne 0)
|
||||||
|
$pgDn = (([RpaPick.PickW32]::GetAsyncKeyState(34) -band 0x8001) -ne 0)
|
||||||
|
if ($esc) { Write-PickLog "esc"; break }
|
||||||
|
if ($stage -eq "hover") {
|
||||||
|
$ptX = [System.Windows.Forms.Cursor]::Position.X
|
||||||
|
$ptY = [System.Windows.Forms.Cursor]::Position.Y
|
||||||
|
$el = Get-DeepestAtPoint $ptX $ptY
|
||||||
|
if ($el) {
|
||||||
|
$c = $el.Current
|
||||||
|
$ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "")
|
||||||
|
$r = $c.BoundingRectangle; Set-HighlightBox ([int]$r.X) ([int]$r.Y) ([int]$r.Width) ([int]$r.Height)
|
||||||
|
$infoLabel.Text = "名称: " + $c.Name + " 类型: " + $ctrlName + " [空格]锁定 [Esc]取消"
|
||||||
|
} else { Hide-Highlight }
|
||||||
|
if ($space -and -not $prevSpace -and $el) {
|
||||||
|
Write-PickLog "space lock"
|
||||||
|
$stage = "locked"
|
||||||
|
$chain = New-Object System.Collections.ArrayList
|
||||||
|
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||||
|
$cur = $el
|
||||||
|
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
|
||||||
|
$c = $cur.Current
|
||||||
|
$r = $c.BoundingRectangle
|
||||||
|
[void]$chain.Insert(0, @{ el = $cur; name = [string]$c.Name; automationId = [string]$c.AutomationId; controlType = $c.ControlType.ProgrammaticName.Replace("ControlType.", ""); x = [int]$r.X; y = [int]$r.Y; w = [int]$r.Width; h = [int]$r.Height; topLevel = $walker.GetParent($cur).Equals([System.Windows.Automation.AutomationElement]::RootElement) })
|
||||||
|
$cur = $walker.GetParent($cur)
|
||||||
|
}
|
||||||
|
$selIdx = $chain.Count - 1
|
||||||
|
Write-PickLog ("locked chain=" + $chain.Count)
|
||||||
|
$infoLabel.Text = "已锁定 [" + ($selIdx + 1) + "/" + $chain.Count + "] [PageUp/PageDown]调层级 [空格]确认回填 [Esc]取消"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($stage -eq "locked") {
|
||||||
|
$item = $chain[$selIdx]
|
||||||
|
if ($item) {
|
||||||
|
Set-HighlightBox $item.x $item.y $item.w $item.h
|
||||||
|
$infoLabel.Text = "层级 " + ($selIdx + 1) + "/" + $chain.Count + " 名称: " + $item.name + " 类型: " + $item.controlType + " [PageUp/PageDown]调层级 [空格]确认 [Esc]取消"
|
||||||
|
}
|
||||||
|
if ($pgUp -and -not $prevPgUp -and $selIdx -gt 0) { $selIdx -= 1 }
|
||||||
|
if ($pgDn -and -not $prevPgDn -and $selIdx -lt ($chain.Count - 1)) { $selIdx += 1 }
|
||||||
|
if ($space -and -not $prevSpace) {
|
||||||
|
$item = $chain[$selIdx]
|
||||||
|
$rootItem = $chain | Where-Object { $_.topLevel -and $_.name } | Select-Object -First 1
|
||||||
|
if ($rootItem) { $rootTitle = $rootItem.name } else { foreach ($it in $chain) { if ($it.name) { $rootTitle = $it.name; break } } }
|
||||||
|
$result = @{ selected = $selIdx; window = $rootTitle; name = $item.name; automationId = $item.automationId; controlType = $item.controlType; x = $item.x; y = $item.y; w = $item.w; h = $item.h }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-PickLog ("LOOP ERR: " + $_.Exception.Message)
|
||||||
|
}
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
$prevSpace = $space
|
||||||
|
$prevPgUp = $pgUp
|
||||||
|
$prevPgDn = $pgDn
|
||||||
|
Start-Sleep -Milliseconds 60
|
||||||
|
}
|
||||||
|
$info.Close()
|
||||||
|
$hl.Close()
|
||||||
|
if ($result) { Write-Output (ConvertTo-Json $result -Compress) } else { Write-Output (ConvertTo-Json @{ cancelled = $true } -Compress) }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><title>网页元素演练场</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Microsoft YaHei", sans-serif; margin: 24px; color: #1f2937; }
|
||||||
|
.card { border: 2px solid #2563eb; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||||
|
#result { margin-top: 12px; padding: 12px; background: #f0fdf4; border-radius: 6px; min-height: 24px; }
|
||||||
|
#hover-area { width: 240px; height: 60px; background: #e5e7eb; line-height: 60px; text-align: center; border-radius: 6px; }
|
||||||
|
#hover-area:hover { background: #fde68a; }
|
||||||
|
#spacer { height: 1600px; background: linear-gradient(#ffffff, #dbeafe); }
|
||||||
|
#bottom-area { padding: 20px; background: #fee2e2; border-radius: 8px; }
|
||||||
|
label { margin-right: 16px; }
|
||||||
|
button { padding: 6px 16px; }
|
||||||
|
button:disabled { color: #9ca3af; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<h1>网页元素演练场</h1>
|
||||||
|
<div class="card" id="card">
|
||||||
|
<h2>基础表单</h2>
|
||||||
|
<label>名称 <input id="inp-name" placeholder="请输入名称" /></label><br/><br/>
|
||||||
|
<label>密码 <input id="inp-pass" type="password" /></label><br/><br/>
|
||||||
|
<label>城市
|
||||||
|
<select id="sel-city">
|
||||||
|
<option value="bj">北京</option>
|
||||||
|
<option value="sh">上海</option>
|
||||||
|
<option value="gz">广州</option>
|
||||||
|
</select>
|
||||||
|
</label><br/><br/>
|
||||||
|
<label><input type="checkbox" id="chk-agree" /> 同意条款</label>
|
||||||
|
<label><input type="radio" name="plan" id="rd-basic" checked /> 基础版</label>
|
||||||
|
<label><input type="radio" name="plan" id="rd-pro" /> 专业版</label><br/><br/>
|
||||||
|
<button id="btn-submit" onclick="document.getElementById('result').textContent = '已提交:' + document.getElementById('inp-name').value + ' / 城市:' + document.getElementById('sel-city').value + ' / 同意:' + document.getElementById('chk-agree').checked">提交</button>
|
||||||
|
<button id="btn-disabled" disabled>禁用按钮</button>
|
||||||
|
<div id="result">等待提交…</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="attr-card">
|
||||||
|
<h2>属性测试</h2>
|
||||||
|
<div id="attr-target" data-info="hello-rpa" title="属性目标">属性目标元素</div><br/>
|
||||||
|
<input type="file" id="file-input" />
|
||||||
|
</div>
|
||||||
|
<div id="hover-area">悬停我变色</div>
|
||||||
|
<div id="spacer"></div>
|
||||||
|
<div id="bottom-area" class="card">页底区域(滚动测试目标)</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 应用与系统组件测试
|
||||||
|
|
||||||
|
本流程包覆盖“可执行 → 应用与系统”的 **49 个组件 ID**,分为 13 个任务、74 个用例。同名组件的不同版本分别统计。11 个待接入组件不计入验收。
|
||||||
|
|
||||||
|
测试发现的 PID 空结果、类型判断/转换、毫秒等待、按模式校验参数、命令行编码及 BAT 路径问题现已修复。绿色运行状态仅表示引擎没有抛出异常;本流程还会核对实际值、返回类型、进程状态和等待耗时。
|
||||||
|
|
||||||
|
## 在当前应用中使用
|
||||||
|
|
||||||
|
1. 返回左侧“编排”首页,点击任务列表右上角的刷新按钮。
|
||||||
|
2. 搜索“组件测试”,打开所需任务并点击“编辑流程”。
|
||||||
|
3. 先使用 01~07 组。运行后打开底部“运行日志”,搜索“用例结果”或查看末尾的“本组汇总”。
|
||||||
|
4. 每条用例结果依次为:`[用例编号, 组件 ID, 状态, 输入, 预期, 实际]`。
|
||||||
|
5. 状态 `PASS` 是通过,`FAIL` 是失败,`BLOCKED` 是缺少条件或无法校验,`MANUAL` 是待人工确认。有任一未通过项目,任务最终显示“失败”,详细原因以用例结果为准。
|
||||||
|
|
||||||
|
01~07 组现可自动验证;类型选项使用明确的语义值,07 组只填写当前命令模式需要的参数。实际结果见本包的最新报告,修复前的失败证据另行保留。
|
||||||
|
|
||||||
|
## 分组与预期
|
||||||
|
|
||||||
|
| 组 | 测试目标 | 关键输入与验收条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| 01 · 应用与 PID | 5 个组件,8 个用例 | 生成唯一 `rpatest_` 程序,启动两个实例;返回 PID 与列表应匹配;两个关闭组件各关闭一个实例;无匹配时应返回 `null` / `[]`。 |
|
||||||
|
| 02 · 数据转换 | 6 个组件,10 个用例 | `"123.5" → 123.5`、`"-42.25" → -42.25`、`-3.9 → -3`、真假布尔值、`42 → "42"`;通用转换分别检查数值、整数、布尔和文本。 |
|
||||||
|
| 03 · 数据判断 | 6 个组件,12 个用例 | 对象与数组、数字与中文、`null` / 空文本 / `0`、真实数组与文本 `"[]"`;“判断数据类型”按目录契约应返回布尔值。 |
|
||||||
|
| 04 · 变量与配置 | 7 个组件,10 个用例 | 中文变量、整数和列表、深复制内容及引用独立、当前流程 ID、自定义配置命中与未命中、计数 `10 → 12 → 14`。 |
|
||||||
|
| 05 · 等待与随机数 | 5 个组件,7 个用例 | 三个等待组件按独立毫秒时钟测量;毫秒版本设置 `3000`,预期 `2800~6500 ms`;随机整数包含 `min=max=7` 与负数范围,小数应处于 `[0,1)`。 |
|
||||||
|
| 06 · 系统路径与环境变量 | 5 个组件,7 个用例 | 与 Windows 独立查询的临时、用户、桌面路径比较;只读写专用变量 `RPA_APP_SYSTEM_TEST_VALUE`,结束恢复原先读到的值。 |
|
||||||
|
| 07 · 命令行 | 2 个组件,5 个用例 | PowerShell 中文固定输出;DOS、BAT、PowerShell 三种 V1 模式;非零退出码应报错。只校验当前模式的参数,另有含中文和空格 BAT 路径的回归测试。 |
|
||||||
|
| 08 · Windows 凭据【手动】 | 3 个组件,3 个用例 | 仅操作唯一新建测试目标。缺少安全的秘密输入机制,默认阻塞,不能把真实密码填进流程。 |
|
||||||
|
| 09 · 输入法【手动】 | 2 个组件,3 个用例 | 读取、切换到 `en-US`、尝试恢复原状态;必须人工核对前台窗口与任务栏,子进程返回成功不算通过。 |
|
||||||
|
| 10 · 音频【手动】 | 1 个组件,1 个用例 | 使用自制 0.3 秒、440 Hz 低音量 WAV;人工确认确实听到声音。 |
|
||||||
|
| 11 · 屏保【手动】 | 3 个组件,4 个用例 | 两个开启版本分别配对关闭,需观察实际屏保;V1 按 Windows 已有屏保设置运行,已去掉未实现的自定义文字、效果和密码参数。 |
|
||||||
|
| 12 · 锁屏与解锁【手动】 | 2 个组件,2 个用例 | 锁屏仍要求账户等参数,该组保持阻塞。原“屏幕解锁”已明确标为“唤醒屏幕(非登录解锁)”,无需账号密码,不支持 Windows 登录解锁。 |
|
||||||
|
| 13 · 远程桌面【手动】 | 2 个组件,2 个用例 | 填写自己的测试地址,人工完成登录并验证目标;`tsdiscon` 可能断开当前会话,关闭操作有独立开关。 |
|
||||||
|
|
||||||
|
## 手动测试的执行开关
|
||||||
|
|
||||||
|
08~13 组不会由自动脚本执行。任务中名为“手动执行开关”的初始化节点,默认值是布尔 `false`。
|
||||||
|
|
||||||
|
只有确认该组的运行环境、影响和恢复方式后,才把该节点的“值”改为 `${true}`。恢复关闭使用 `${false}`。不要填写普通文本 `true` / `false`,保护条件只接受真正的布尔值。
|
||||||
|
|
||||||
|
- **08 组和 12 组的锁屏节点**:当前没有不会把秘密写入流程或 `run.json` 的输入途径,因此保持阻塞;不要填 Windows 登录密码,不创建、覆盖或删除已有业务凭据。“唤醒屏幕”本身已无需任何凭据,可在单独任务中测试。
|
||||||
|
- **09 组**:先准备一个没有未保存内容的文本编辑窗口,人工记录原输入法,执行后核对是否真的切换并恢复。异常时人工切回原状态。
|
||||||
|
- **10 组**:确认音量和输出设备后,只播放本包的短测试音。
|
||||||
|
- **11 组**:需人在机器旁,有办法手动退出屏保;只调用 Windows 已配置的屏保,不设置自定义解锁密码。旧节点若保留 `set_password=1`,会在桌面操作前给出明确错误,可删除该旧节点后重新添加。
|
||||||
|
- **12 组**:实际锁屏须先明确确认影响和恢复方式,人在机器旁并能正常用 Windows 登录;不能测试自动输入登录密码。
|
||||||
|
- **13 组**:先设置“填写用户自己的测试远程地址”。连接成功须人工确认,启动 `mstsc` 不算已经登录。断连还要求“断连单独开关”为 `${true}`,必须另行确认操作的是哪个会话及如何重新连接。确认前保持 `${false}`。
|
||||||
|
|
||||||
|
人工项目即使组件返回成功,也继续显示 `MANUAL`,本工具不会自动写成通过。人工证据请附实际观察、时间和必要截图,不能仅记录返回标志。
|
||||||
|
|
||||||
|
## 安装、校验与报告命令
|
||||||
|
|
||||||
|
在项目根目录运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 校验 13 组 / 49 项覆盖、连接关系及现有运行前校验
|
||||||
|
node scripts/app-system-tests.mjs verify
|
||||||
|
|
||||||
|
# 安装到当前用户:只新增;同 ID 已存在则跳过,保留用户修改
|
||||||
|
node scripts/app-system-tests.mjs install --user-data "$env:APPDATA\rpa-studio"
|
||||||
|
|
||||||
|
# 重建仓库里的流程 JSON;不会覆盖应用中已安装的任务
|
||||||
|
node scripts/app-system-tests.mjs generate
|
||||||
|
|
||||||
|
# 构建后,在全新的 Electron 用户目录运行非手动任务
|
||||||
|
npm run build
|
||||||
|
node scripts/app-system-tests.mjs run-safe
|
||||||
|
|
||||||
|
# 同时用临时可见的独立 Electron 窗口验证下拉选项、模式切换和唤醒提示
|
||||||
|
node scripts/app-system-tests.mjs run-safe --ui-check
|
||||||
|
|
||||||
|
# 从本次真实证据重新生成报告
|
||||||
|
node scripts/app-system-tests.mjs report
|
||||||
|
```
|
||||||
|
|
||||||
|
安装默认使用 `%APPDATA%\rpa-studio`;非标准用户目录需要显式传入 `--user-data`。重复安装不会覆盖已有测试任务,也不改变用户原有任务。
|
||||||
|
|
||||||
|
`run-safe` 退出码:`0` 表示报告没有失败或阻塞,`2` 表示存在失败或阻塞(包含手动任务缺少前置条件),`1` 表示工具自身异常。报告和原始事件保存在项目的 `test-output/app-system/`,该目录已由现有 `.gitignore` 忽略。
|
||||||
|
|
||||||
|
每次自动运行保留独立 Electron 用户目录及 `runs/<runId>/run.json`,测试不会占用当前日常实例,也不会把隔离验证结果伪装为用户任务列表中的运行历史。任务列表的新任务初始仍显示“尚未运行”。
|
||||||
|
|
||||||
|
## 边界与恢复
|
||||||
|
|
||||||
|
- 测试进程名称和可执行路径唯一,两个关闭组件只操作新建夹具 PID。常规异常后仍执行专用进程清理;中止流程可能跳过收尾,夹具会在 90 秒内自动退出。
|
||||||
|
- 临时文件仅写入系统临时目录下的新建 `RPA-AppSystem-Tests` 子目录,保留供排查,不批量删除文件。
|
||||||
|
- 环境变量组件只操作当前应用进程,不修改 Windows 用户/系统环境变量。本包恢复原读取值;原本不存在时只能恢复为空文本,当前组件不支持删除进程内变量,退出应用后消失。隔离运行预设原始值并验证恢复。
|
||||||
|
- 凭据、输入法、屏保、锁屏、远程会话均未在自动验收中改变。
|
||||||
|
- 缺少类型选项映射、真实桌面观察或测试远程主机时,状态保持阻塞或待人工确认。
|
||||||
|
|
||||||
|
## 修复结果与保留的限制
|
||||||
|
|
||||||
|
- PID:无匹配返回 `[]` / `null`,过滤空行、零和无效 PID。
|
||||||
|
- 类型判断:按目标类型返回布尔值;数值类型与数字文本分开判断。
|
||||||
|
- 类型转换:使用 `number`、`int`、`bool`、`string` 选项,输入无效时报错。历史默认值 `1` 兼容数值类型;其他数字别名为本项目明确规定的兼容值,前端统一使用语义值。
|
||||||
|
- 等待:毫秒组件读取 `mileSecond`,其他秒制等待组件保持秒制;支持取消,拒绝超出计时器范围的值。
|
||||||
|
- 关闭程序:PID/名称模式分别显示并校验参数;无效 PID 不会降级成按名称批量关闭。名称模式尊重“关闭所有会话”选项,并排除 RPA Studio 主进程。
|
||||||
|
- 命令行:DOS/BAT/PowerShell 分别校验,修复中文输出编码和含空格、中文 BAT 路径的引号处理。
|
||||||
|
- 屏保 V1:仅提供实际实现的系统屏保入口,不再要求自定义密码,也不展示未实现的自定义效果。
|
||||||
|
- 原“屏幕解锁”:名称改为“唤醒屏幕(非登录解锁)”,不收集账号密码,返回 `loginUnlockSupported:false`;本人仍需完成 Windows 登录。
|
||||||
|
|
||||||
|
目录修正保存在 `resources/catalog-overrides.json`,重新生成目录也会应用;不会恢复错误的下拉选项或密码字段。
|
||||||
|
|
||||||
|
安装仍只新增、不覆盖已有任务。需要升级本包旧任务时,使用 `upgrade --baseline <升级前的模板目录>`;仅升级仍匹配旧模板的任务,并备份原文件,用户修改过的任务会跳过。
|
||||||
|
|
||||||
|
输入法、音频、屏保、锁屏和远程桌面仍需要人工证据或专门环境,不自动执行高影响操作。输入法子进程实现和 `tsdiscon` 会话语义不在本轮 9 项修复范围内。
|
||||||
|
|
||||||
|
最新逐用例证据见 [REPORT.md](REPORT.md),修复前证据见 [REPORT-before-fix.md](REPORT-before-fix.md)。
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# 应用与系统组件测试报告
|
||||||
|
|
||||||
|
记录时间:2026年10月30日 16:52:04(北京时间)。覆盖 49 项可执行组件,13 组任务。
|
||||||
|
|
||||||
|
按组件统计:通过 29,失败 4,阻塞 11,待人工确认 5,未运行 0。
|
||||||
|
|
||||||
|
状态依据是断言、前置校验和人工证据,节点执行成功不直接代表功能通过。未执行手动系统操作。
|
||||||
|
|
||||||
|
| 组 | 组件 ID | 名称 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 01 | App_Run | 启动应用程序 | 通过 |
|
||||||
|
| 01 | App_GetPid | 获取一个匹配PID | 失败 |
|
||||||
|
| 01 | App_GetPids | 获取全部匹配PID | 失败 |
|
||||||
|
| 01 | App_Kill | 关闭应用程序 | 通过 |
|
||||||
|
| 01 | App_KillV1 | 关闭应用程序 | 通过 |
|
||||||
|
| 02 | Basic_StringToNum | 将文本转换为数字 | 通过 |
|
||||||
|
| 02 | Basic_ConvertNumber | 数据转为数值类型 | 通过 |
|
||||||
|
| 02 | Basic_ConvertInt | 数值转为整数类型 | 通过 |
|
||||||
|
| 02 | Basic_ConvertBool | 数据转为布尔类型 | 通过 |
|
||||||
|
| 02 | Basic_ConvertString | 将数字转换为文本 | 通过 |
|
||||||
|
| 02 | Basic_DataTypeConversion | 转换数据类型 | 阻塞 |
|
||||||
|
| 03 | Basic_IsDictionary | 判断数据是否为字典 | 通过 |
|
||||||
|
| 03 | Basic_IsNumber | 判断数据是否为数值 | 通过 |
|
||||||
|
| 03 | Basic_IsNull | 判断数据是否为空 | 通过 |
|
||||||
|
| 03 | Basic_IsArray | 判断数据是否为数组 | 通过 |
|
||||||
|
| 03 | Basic_Type | 获取数据类型 | 通过 |
|
||||||
|
| 03 | Basic_JudgeDataType | 判断数据类型 | 失败 |
|
||||||
|
| 04 | Basic_SetVariable | 设置变量 | 通过 |
|
||||||
|
| 04 | Basic_SetVariable_V1 | 设置变量 | 通过 |
|
||||||
|
| 04 | Basic_Clone | 复制数据 | 通过 |
|
||||||
|
| 04 | Basic_GetProcessInfo | 获取流程信息 | 通过 |
|
||||||
|
| 04 | Basic_DebugOutput | 调试输出 | 通过 |
|
||||||
|
| 04 | CustomConfig_GetConfig | 自定义配置 | 通过 |
|
||||||
|
| 04 | Basic_BusinessOrderCount | 业务办理计数 | 通过 |
|
||||||
|
| 05 | Basic_Delay_V2 | 等待 | 通过 |
|
||||||
|
| 05 | Basic_Delay_V1 | 等待 | 通过 |
|
||||||
|
| 05 | Basic_Delay | 流程延迟执行 | 失败 |
|
||||||
|
| 05 | Basic_RandomInt | 生成随机数 | 通过 |
|
||||||
|
| 05 | Basic_Rnd | 生成随机数 | 通过 |
|
||||||
|
| 06 | System_GetTempPath | 获取临时文件夹路径 | 通过 |
|
||||||
|
| 06 | System_GetHomePath | 获取用户文件夹路径 | 通过 |
|
||||||
|
| 06 | System_GetSystemPath | 获取系统文件夹路径 | 通过 |
|
||||||
|
| 06 | System_GetEnv | 获取环境变量 | 通过 |
|
||||||
|
| 06 | System_SetEnv | 设置环境变量 | 通过 |
|
||||||
|
| 07 | System_ExecuteCommand | 执行命令行 | 阻塞 |
|
||||||
|
| 07 | System_ExecuteCommandV1 | 执行命令行 | 阻塞 |
|
||||||
|
| 08 | System_AddCredential | 添加Windows凭据 | 阻塞 |
|
||||||
|
| 08 | System_ReadCredential | 获取Windows凭据 | 阻塞 |
|
||||||
|
| 08 | System_DeleteCredential | 删除Windows凭据 | 阻塞 |
|
||||||
|
| 09 | System_GetInputMethodState | 获取输入法状态 | 待人工确认 |
|
||||||
|
| 09 | System_SetInputMethodState | 设置输入法状态 | 待人工确认 |
|
||||||
|
| 10 | System_PlaySound | 播放音频 | 待人工确认 |
|
||||||
|
| 11 | System_ScreenSaverShow | 开启屏保 | 阻塞 |
|
||||||
|
| 11 | System_ScreenSaverShowV1 | 开启屏保 | 阻塞 |
|
||||||
|
| 11 | System_ScreenSaverStop | 关闭屏保 | 阻塞 |
|
||||||
|
| 12 | ScreenControl_Screenlock | 屏幕锁屏 | 阻塞 |
|
||||||
|
| 12 | ScreenControl_ScreenUnlock | 屏幕解锁 | 阻塞 |
|
||||||
|
| 13 | System_ConnectRemoteDesktop | 连接远程桌面 | 待人工确认 |
|
||||||
|
| 13 | System_CloseRemoteDesktop | 关闭远程连接 | 待人工确认 |
|
||||||
|
|
||||||
|
## 用例证据
|
||||||
|
|
||||||
|
| 用例 | 组件 | 状态 | 预期 | 实际/原因 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 01-01 | App_Run | 通过 | 第一个测试进程 PID 大于 0 | 37796 |
|
||||||
|
| 01-02 | App_Run | 通过 | 第二个 PID 大于 0,且与第一个不同 | 48484 |
|
||||||
|
| 01-03 | App_GetPid | 通过 | 返回本次创建的任一 PID | 37796 |
|
||||||
|
| 01-04 | App_GetPids | 通过 | 列表恰含本次创建的两个 PID | [37796,48484] |
|
||||||
|
| 01-05 | App_Kill | 通过 | 结束第一个夹具 PID,第二个仍在运行 | [37796,false,true] |
|
||||||
|
| 01-06 | App_KillV1 | 通过 | 结束第二个夹具 PID | [48484,false] |
|
||||||
|
| 01-07 | App_GetPids | 失败 | 所有夹具结束后应返回空数组 [] | [0] |
|
||||||
|
| 01-08 | App_GetPid | 失败 | 无匹配时应返回 null | 0 |
|
||||||
|
| 02-01 | Basic_StringToNum | 通过 | 数字 123.5 | 123.5 |
|
||||||
|
| 02-02 | Basic_ConvertNumber | 通过 | 数字 -42.25 | -42.25 |
|
||||||
|
| 02-03 | Basic_ConvertInt | 通过 | 整数 -3(向零截断) | -3 |
|
||||||
|
| 02-04 | Basic_ConvertBool | 通过 | 布尔 true | true |
|
||||||
|
| 02-05 | Basic_ConvertBool | 通过 | 布尔 false | false |
|
||||||
|
| 02-06 | Basic_ConvertString | 通过 | 文本 "42" | "42" |
|
||||||
|
| 02-07 | Basic_DataTypeConversion | 阻塞 | 按目标类型转换;先确认目录中目标类型编号映射 | "42";目录没有目标类型选项映射,不能猜测编号;当前实现也未读取 into_type。 |
|
||||||
|
| 03-01 | Basic_IsDictionary | 通过 | true | true |
|
||||||
|
| 03-02 | Basic_IsDictionary | 通过 | false | false |
|
||||||
|
| 03-03 | Basic_IsNumber | 通过 | true | true |
|
||||||
|
| 03-04 | Basic_IsNumber | 通过 | false | false |
|
||||||
|
| 03-05 | Basic_IsNull | 通过 | true | true |
|
||||||
|
| 03-06 | Basic_IsNull | 通过 | true | true |
|
||||||
|
| 03-07 | Basic_IsNull | 通过 | false | false |
|
||||||
|
| 03-08 | Basic_IsArray | 通过 | true | true |
|
||||||
|
| 03-09 | Basic_IsArray | 通过 | false | false |
|
||||||
|
| 03-10 | Basic_Type | 通过 | "list" | "list" |
|
||||||
|
| 03-11 | Basic_Type | 通过 | "null" | "null" |
|
||||||
|
| 03-12 | Basic_JudgeDataType | 失败 | 返回布尔判定结果(true 或 false),不是类型名称 | "number" |
|
||||||
|
| 04-01 | Basic_SetVariable | 通过 | 原值及输出变量均保持中文文本 | "中文变量往返" |
|
||||||
|
| 04-02 | Basic_SetVariable_V1 | 通过 | 数字 42 | 42 |
|
||||||
|
| 04-03 | Basic_SetVariable_V1 | 通过 | 数组 [1,2] | [1,2] |
|
||||||
|
| 04-04 | Basic_Clone | 通过 | 深复制内容相同,外层和嵌套对象均不共用引用 | {"title":"中文","nested":{"n":1}} |
|
||||||
|
| 04-05 | Basic_GetProcessInfo | 通过 | 当前流程 ID、名称、运行 ID 和节点数一致 | {"id":"test_appsystem_04","name":"组件测试 04 · 变量与配置","description":"查看运行日志中的用例结果;有失败时任务最终显示失败,其他用例仍继续。","enabled":true,"trigger":{"type":"manual","label":"手动运行"},"version":"0.1.0","createdAt":"2026-10-30T00:00:00.000Z","updatedAt":"2026-10-30T00:00:00.000Z","nodeCount":99,"runId":"run_mvuqh8oe_gmpo"} |
|
||||||
|
| 04-06 | Basic_DebugOutput | 通过 | 日志及返回值为“中文日志测试:42” | "中文日志测试:42" |
|
||||||
|
| 04-07 | CustomConfig_GetConfig | 通过 | 数字 30 | 30 |
|
||||||
|
| 04-08 | CustomConfig_GetConfig | 通过 | 未命中返回 null | null |
|
||||||
|
| 04-09 | Basic_BusinessOrderCount | 通过 | 首次计数 12 | 12 |
|
||||||
|
| 04-10 | Basic_BusinessOrderCount | 通过 | 同一流程再次计数 14 | 14 |
|
||||||
|
| 05-01 | Basic_Delay_V2 | 通过 | 实际等待耗时 120~1800 ms | 161 |
|
||||||
|
| 05-02 | Basic_Delay_V2 | 通过 | 实际等待耗时 80~1800 ms | 186 |
|
||||||
|
| 05-03 | Basic_Delay_V1 | 通过 | 实际等待耗时 120~1800 ms | 156 |
|
||||||
|
| 05-04 | Basic_Delay | 失败 | 实际等待耗时 2800~6500 ms | 1013 |
|
||||||
|
| 05-05 | Basic_RandomInt | 通过 | 最小值等于最大值时返回整数 7 | 7 |
|
||||||
|
| 05-06 | Basic_RandomInt | 通过 | 返回 -2~2 的整数 | -2 |
|
||||||
|
| 05-07 | Basic_Rnd | 通过 | 随机小数满足 0 ≤ x < 1 | 0.6374054093586852 |
|
||||||
|
| 06-01 | System_GetTempPath | 通过 | 与系统临时目录一致(忽略尾部分隔符) | "C:\\Users\\Administrator\\AppData\\Local\\Temp" |
|
||||||
|
| 06-02 | System_GetHomePath | 通过 | 与 Windows 用户文件夹一致 | "C:\\Users\\Administrator" |
|
||||||
|
| 06-03 | System_GetSystemPath | 通过 | 与 Windows 实际桌面目录一致(包含重定向) | "C:\\Users\\Administrator\\Desktop" |
|
||||||
|
| 06-04 | System_GetEnv | 通过 | 读取测试专用变量的原始值,作为恢复依据 | "隔离环境原始值" |
|
||||||
|
| 06-05 | System_SetEnv | 通过 | 当前应用进程内设置成功 | "中文-测试-42" |
|
||||||
|
| 06-06 | System_GetEnv | 通过 | 能读回中文-测试-42 | "中文-测试-42" |
|
||||||
|
| 06-07 | System_SetEnv | 通过 | 恢复测试前读取值 | "隔离环境原始值" |
|
||||||
|
| 07-01 | System_ExecuteCommand | 阻塞 | RPA-PS-中文-OK | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 07-02 | System_ExecuteCommandV1 | 阻塞 | RPA-DOS-OK | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 07-03 | System_ExecuteCommandV1 | 阻塞 | RPA-PS-V1-OK | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 07-04 | System_ExecuteCommandV1 | 阻塞 | RPA-BAT-OK | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 07-05 | System_ExecuteCommand | 阻塞 | 非零退出码应抛出异常 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 08-01 | System_AddCredential | 阻塞 | 安全输入机制准备好后,在专用目标添加测试凭据 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 08-02 | System_ReadCredential | 阻塞 | 仅验证测试凭据存在,不读取或打印密码 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 08-03 | System_DeleteCredential | 阻塞 | 删除刚创建的测试目标,之后人工确认不存在 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 09-01 | System_GetInputMethodState | 待人工确认 | 读取前台窗口的真实输入法状态,人工对照任务栏 | 手动任务不纳入自动执行 |
|
||||||
|
| 09-02 | System_SetInputMethodState | 待人工确认 | 前台编辑器应切换到英文模式;仅子进程返回 set:true 不能算通过 | 手动任务不纳入自动执行 |
|
||||||
|
| 09-03 | System_SetInputMethodState | 待人工确认 | 恢复原输入法;人工确认前台窗口的实际状态 | 手动任务不纳入自动执行 |
|
||||||
|
| 10-01 | System_PlaySound | 待人工确认 | 听到一次短提示音;没有声音不能算通过 | 手动任务不纳入自动执行 |
|
||||||
|
| 11-01 | System_ScreenSaverShow | 阻塞 | 屏幕实际进入屏保;无配置时记录阻塞 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 11-02 | System_ScreenSaverStop | 阻塞 | 屏保实际关闭,可正常操作 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 11-03 | System_ScreenSaverShowV1 | 阻塞 | 未启用密码保护时无需密码也应能运行 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 11-04 | System_ScreenSaverStop | 阻塞 | 第二种屏保实际关闭 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 12-01 | ScreenControl_Screenlock | 阻塞 | 实际锁屏;需要人在机器旁用 Windows 登录凭据恢复,不改变账户密码 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 12-02 | ScreenControl_ScreenUnlock | 阻塞 | 核对 Windows 登录解锁;当前实现仅尝试唤醒屏幕,不能标记自动解锁通过 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 13-01 | System_ConnectRemoteDesktop | 待人工确认 | 人工完成登录,并确认已连接到正确测试机器;启动 mstsc 不等于连接成功 | 手动任务不纳入自动执行 |
|
||||||
|
| 13-02 | System_CloseRemoteDesktop | 待人工确认 | 确认目标会话及恢复方式后才能执行;当前实现会调用 tsdiscon | 手动任务不纳入自动执行 |
|
||||||
|
|
||||||
|
## 运行前校验阻塞
|
||||||
|
|
||||||
|
- test_appsystem_07:「System_ExecuteCommandV1」缺少必填参数:BAT文件;「System_ExecuteCommandV1」缺少必填参数:PowerShell命令;「System_ExecuteCommandV1」缺少必填参数:DOS命令;「System_ExecuteCommandV1」缺少必填参数:BAT文件;「System_ExecuteCommandV1」缺少必填参数:DOS命令;「System_ExecuteCommandV1」缺少必填参数:PowerShell命令
|
||||||
|
- test_appsystem_08:「System_AddCredential」缺少必填参数:用户名
|
||||||
|
- test_appsystem_11:「System_ScreenSaverShowV1」缺少必填参数:password
|
||||||
|
- test_appsystem_12:「ScreenControl_Screenlock」缺少必填参数:用户名;「ScreenControl_Screenlock」缺少必填参数:密码;「ScreenControl_Screenlock」缺少必填参数:分辨率(宽);「ScreenControl_Screenlock」缺少必填参数:分辨率(高);「ScreenControl_ScreenUnlock」缺少必填参数:用户名;「ScreenControl_ScreenUnlock」缺少必填参数:密码
|
||||||
|
|
||||||
|
## 证据位置
|
||||||
|
|
||||||
|
隔离用户目录:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv
|
||||||
|
|
||||||
|
- test_appsystem_01:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv\runs\run_mvuqh1m8_mpns\run.json
|
||||||
|
- test_appsystem_02:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv\runs\run_mvuqh8fe_enbw\run.json
|
||||||
|
- test_appsystem_03:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv\runs\run_mvuqh8lw_3nsu\run.json
|
||||||
|
- test_appsystem_04:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv\runs\run_mvuqh8oe_gmpo\run.json
|
||||||
|
- test_appsystem_05:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv\runs\run_mvuqh8sp_54xy\run.json
|
||||||
|
- test_appsystem_06:D:\work\code\RPA-only-demo\test-output\app-system\isolated-I8utNv\runs\run_mvuqha0h_4bfu\run.json
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# 应用与系统组件测试报告
|
||||||
|
|
||||||
|
记录时间:2026年10月30日 18:50:29(北京时间)。覆盖 49 项可执行组件,13 组任务。
|
||||||
|
|
||||||
|
按组件统计:通过 36,失败 0,阻塞 5,待人工确认 8,未运行 0。
|
||||||
|
|
||||||
|
状态依据是断言、前置校验和人工证据,节点执行成功不直接代表功能通过。未执行手动系统操作。
|
||||||
|
|
||||||
|
| 组 | 组件 ID | 名称 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 01 | App_Run | 启动应用程序 | 通过 |
|
||||||
|
| 01 | App_GetPid | 获取一个匹配PID | 通过 |
|
||||||
|
| 01 | App_GetPids | 获取全部匹配PID | 通过 |
|
||||||
|
| 01 | App_Kill | 关闭应用程序 | 通过 |
|
||||||
|
| 01 | App_KillV1 | 关闭应用程序 | 通过 |
|
||||||
|
| 02 | Basic_StringToNum | 将文本转换为数字 | 通过 |
|
||||||
|
| 02 | Basic_ConvertNumber | 数据转为数值类型 | 通过 |
|
||||||
|
| 02 | Basic_ConvertInt | 数值转为整数类型 | 通过 |
|
||||||
|
| 02 | Basic_ConvertBool | 数据转为布尔类型 | 通过 |
|
||||||
|
| 02 | Basic_ConvertString | 将数字转换为文本 | 通过 |
|
||||||
|
| 02 | Basic_DataTypeConversion | 转换数据类型 | 通过 |
|
||||||
|
| 03 | Basic_IsDictionary | 判断数据是否为字典 | 通过 |
|
||||||
|
| 03 | Basic_IsNumber | 判断数据是否为数值 | 通过 |
|
||||||
|
| 03 | Basic_IsNull | 判断数据是否为空 | 通过 |
|
||||||
|
| 03 | Basic_IsArray | 判断数据是否为数组 | 通过 |
|
||||||
|
| 03 | Basic_Type | 获取数据类型 | 通过 |
|
||||||
|
| 03 | Basic_JudgeDataType | 判断数据类型 | 通过 |
|
||||||
|
| 04 | Basic_SetVariable | 设置变量 | 通过 |
|
||||||
|
| 04 | Basic_SetVariable_V1 | 设置变量 | 通过 |
|
||||||
|
| 04 | Basic_Clone | 复制数据 | 通过 |
|
||||||
|
| 04 | Basic_GetProcessInfo | 获取流程信息 | 通过 |
|
||||||
|
| 04 | Basic_DebugOutput | 调试输出 | 通过 |
|
||||||
|
| 04 | CustomConfig_GetConfig | 自定义配置 | 通过 |
|
||||||
|
| 04 | Basic_BusinessOrderCount | 业务办理计数 | 通过 |
|
||||||
|
| 05 | Basic_Delay_V2 | 等待 | 通过 |
|
||||||
|
| 05 | Basic_Delay_V1 | 等待 | 通过 |
|
||||||
|
| 05 | Basic_Delay | 流程延迟执行 | 通过 |
|
||||||
|
| 05 | Basic_RandomInt | 生成随机数 | 通过 |
|
||||||
|
| 05 | Basic_Rnd | 生成随机数 | 通过 |
|
||||||
|
| 06 | System_GetTempPath | 获取临时文件夹路径 | 通过 |
|
||||||
|
| 06 | System_GetHomePath | 获取用户文件夹路径 | 通过 |
|
||||||
|
| 06 | System_GetSystemPath | 获取系统文件夹路径 | 通过 |
|
||||||
|
| 06 | System_GetEnv | 获取环境变量 | 通过 |
|
||||||
|
| 06 | System_SetEnv | 设置环境变量 | 通过 |
|
||||||
|
| 07 | System_ExecuteCommand | 执行命令行 | 通过 |
|
||||||
|
| 07 | System_ExecuteCommandV1 | 执行命令行 | 通过 |
|
||||||
|
| 08 | System_AddCredential | 添加Windows凭据 | 阻塞 |
|
||||||
|
| 08 | System_ReadCredential | 获取Windows凭据 | 阻塞 |
|
||||||
|
| 08 | System_DeleteCredential | 删除Windows凭据 | 阻塞 |
|
||||||
|
| 09 | System_GetInputMethodState | 获取输入法状态 | 待人工确认 |
|
||||||
|
| 09 | System_SetInputMethodState | 设置输入法状态 | 待人工确认 |
|
||||||
|
| 10 | System_PlaySound | 播放音频 | 待人工确认 |
|
||||||
|
| 11 | System_ScreenSaverShow | 开启屏保 | 待人工确认 |
|
||||||
|
| 11 | System_ScreenSaverShowV1 | 开启屏保 | 待人工确认 |
|
||||||
|
| 11 | System_ScreenSaverStop | 关闭屏保 | 待人工确认 |
|
||||||
|
| 12 | ScreenControl_Screenlock | 屏幕锁屏 | 阻塞 |
|
||||||
|
| 12 | ScreenControl_ScreenUnlock | 唤醒屏幕(非登录解锁) | 阻塞 |
|
||||||
|
| 13 | System_ConnectRemoteDesktop | 连接远程桌面 | 待人工确认 |
|
||||||
|
| 13 | System_CloseRemoteDesktop | 关闭远程连接 | 待人工确认 |
|
||||||
|
|
||||||
|
## 真实界面验证
|
||||||
|
|
||||||
|
已通过独立 Electron 窗口检查:类型下拉选项、PID/名称参数切换、DOS/PowerShell 参数切换,以及唤醒组件不再索要账号密码。界面快照及 ui-check.json 保存在 test-output/app-system/。
|
||||||
|
|
||||||
|
## 用例证据
|
||||||
|
|
||||||
|
| 用例 | 组件 | 状态 | 预期 | 实际/原因 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 01-01 | App_Run | 通过 | 第一个测试进程 PID 大于 0 | 53688 |
|
||||||
|
| 01-02 | App_Run | 通过 | 第二个 PID 大于 0,且与第一个不同 | 47408 |
|
||||||
|
| 01-03 | App_GetPid | 通过 | 返回本次创建的任一 PID | 47408 |
|
||||||
|
| 01-04 | App_GetPids | 通过 | 列表恰含本次创建的两个 PID | [47408,53688] |
|
||||||
|
| 01-05 | App_Kill | 通过 | 结束第一个夹具 PID,第二个仍在运行 | [53688,false,true] |
|
||||||
|
| 01-06 | App_KillV1 | 通过 | 只填写 PID 即可结束第二个夹具进程 | [47408,false] |
|
||||||
|
| 01-07 | App_GetPids | 通过 | 所有夹具结束后应返回空数组 [] | [] |
|
||||||
|
| 01-08 | App_GetPid | 通过 | 无匹配时应返回 null | null |
|
||||||
|
| 02-01 | Basic_StringToNum | 通过 | 数字 123.5 | 123.5 |
|
||||||
|
| 02-02 | Basic_ConvertNumber | 通过 | 数字 -42.25 | -42.25 |
|
||||||
|
| 02-03 | Basic_ConvertInt | 通过 | 整数 -3(向零截断) | -3 |
|
||||||
|
| 02-04 | Basic_ConvertBool | 通过 | 布尔 true | true |
|
||||||
|
| 02-05 | Basic_ConvertBool | 通过 | 布尔 false | false |
|
||||||
|
| 02-06 | Basic_ConvertString | 通过 | 文本 "42" | "42" |
|
||||||
|
| 02-07 | Basic_DataTypeConversion | 通过 | 数值 42,严格区分文本和数值 | 42 |
|
||||||
|
| 02-08 | Basic_DataTypeConversion | 通过 | 整数 -3 | -3 |
|
||||||
|
| 02-09 | Basic_DataTypeConversion | 通过 | 布尔 false | false |
|
||||||
|
| 02-10 | Basic_DataTypeConversion | 通过 | 文本 "42" | "42" |
|
||||||
|
| 03-01 | Basic_IsDictionary | 通过 | true | true |
|
||||||
|
| 03-02 | Basic_IsDictionary | 通过 | false | false |
|
||||||
|
| 03-03 | Basic_IsNumber | 通过 | true | true |
|
||||||
|
| 03-04 | Basic_IsNumber | 通过 | false | false |
|
||||||
|
| 03-05 | Basic_IsNull | 通过 | true | true |
|
||||||
|
| 03-06 | Basic_IsNull | 通过 | true | true |
|
||||||
|
| 03-07 | Basic_IsNull | 通过 | false | false |
|
||||||
|
| 03-08 | Basic_IsArray | 通过 | true | true |
|
||||||
|
| 03-09 | Basic_IsArray | 通过 | false | false |
|
||||||
|
| 03-10 | Basic_Type | 通过 | "list" | "list" |
|
||||||
|
| 03-11 | Basic_Type | 通过 | "null" | "null" |
|
||||||
|
| 03-12 | Basic_JudgeDataType | 通过 | 数字 42 属于数值类型,返回布尔 true | true |
|
||||||
|
| 04-01 | Basic_SetVariable | 通过 | 原值及输出变量均保持中文文本 | "中文变量往返" |
|
||||||
|
| 04-02 | Basic_SetVariable_V1 | 通过 | 数字 42 | 42 |
|
||||||
|
| 04-03 | Basic_SetVariable_V1 | 通过 | 数组 [1,2] | [1,2] |
|
||||||
|
| 04-04 | Basic_Clone | 通过 | 深复制内容相同,外层和嵌套对象均不共用引用 | {"title":"中文","nested":{"n":1}} |
|
||||||
|
| 04-05 | Basic_GetProcessInfo | 通过 | 当前流程 ID、名称、运行 ID 和节点数一致 | {"id":"test_appsystem_04","name":"组件测试 04 · 变量与配置","description":"查看运行日志中的用例结果;有失败时任务最终显示失败,其他用例仍继续。","enabled":true,"trigger":{"type":"manual","label":"手动运行"},"version":"0.1.0","createdAt":"2026-10-30T00:00:00.000Z","updatedAt":"2026-10-30T00:00:00.000Z","nodeCount":99,"runId":"run_mvuupg9x_yifj"} |
|
||||||
|
| 04-06 | Basic_DebugOutput | 通过 | 日志及返回值为“中文日志测试:42” | "中文日志测试:42" |
|
||||||
|
| 04-07 | CustomConfig_GetConfig | 通过 | 数字 30 | 30 |
|
||||||
|
| 04-08 | CustomConfig_GetConfig | 通过 | 未命中返回 null | null |
|
||||||
|
| 04-09 | Basic_BusinessOrderCount | 通过 | 首次计数 12 | 12 |
|
||||||
|
| 04-10 | Basic_BusinessOrderCount | 通过 | 同一流程再次计数 14 | 14 |
|
||||||
|
| 05-01 | Basic_Delay_V2 | 通过 | 实际等待耗时 120~1800 ms | 165 |
|
||||||
|
| 05-02 | Basic_Delay_V2 | 通过 | 实际等待耗时 80~1800 ms | 122 |
|
||||||
|
| 05-03 | Basic_Delay_V1 | 通过 | 实际等待耗时 120~1800 ms | 154 |
|
||||||
|
| 05-04 | Basic_Delay | 通过 | 实际等待耗时 2800~6500 ms | 3011 |
|
||||||
|
| 05-05 | Basic_RandomInt | 通过 | 最小值等于最大值时返回整数 7 | 7 |
|
||||||
|
| 05-06 | Basic_RandomInt | 通过 | 返回 -2~2 的整数 | 2 |
|
||||||
|
| 05-07 | Basic_Rnd | 通过 | 随机小数满足 0 ≤ x < 1 | 0.18320588816502648 |
|
||||||
|
| 06-01 | System_GetTempPath | 通过 | 与系统临时目录一致(忽略尾部分隔符) | "C:\\Users\\Administrator\\AppData\\Local\\Temp" |
|
||||||
|
| 06-02 | System_GetHomePath | 通过 | 与 Windows 用户文件夹一致 | "C:\\Users\\Administrator" |
|
||||||
|
| 06-03 | System_GetSystemPath | 通过 | 与 Windows 实际桌面目录一致(包含重定向) | "C:\\Users\\Administrator\\Desktop" |
|
||||||
|
| 06-04 | System_GetEnv | 通过 | 读取测试专用变量的原始值,作为恢复依据 | "隔离环境原始值" |
|
||||||
|
| 06-05 | System_SetEnv | 通过 | 当前应用进程内设置成功 | "中文-测试-42" |
|
||||||
|
| 06-06 | System_GetEnv | 通过 | 能读回中文-测试-42 | "中文-测试-42" |
|
||||||
|
| 06-07 | System_SetEnv | 通过 | 恢复测试前读取值 | "隔离环境原始值" |
|
||||||
|
| 07-01 | System_ExecuteCommand | 通过 | RPA-PS-中文-OK | "RPA-PS-中文-OK" |
|
||||||
|
| 07-02 | System_ExecuteCommandV1 | 通过 | RPA-DOS-OK | "RPA-DOS-OK" |
|
||||||
|
| 07-03 | System_ExecuteCommandV1 | 通过 | RPA-PS-V1-OK | "RPA-PS-V1-OK" |
|
||||||
|
| 07-04 | System_ExecuteCommandV1 | 通过 | RPA-BAT-OK | "RPA-BAT-OK" |
|
||||||
|
| 07-05 | System_ExecuteCommand | 通过 | 非零退出码应抛出异常 | "桌面操作失败:Command failed: powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand JABQAHIAbwBnAHIAZQBzAHMAUAByAGUAZgBlAHIAZQBuAGMAZQA9ACcAUwBpAGwAZQBuAHQAbAB5AEMAbwBuAHQAaQBuAHUAZQAnADsAIABbAEMAbwBuAHMAbwBsAGUAXQA6ADoATwB1AHQAcAB1AHQARQBuAGMAbwBkAGkAbgBnAD0AWwBTAHkAcwB0AGUAbQAuAFQAZQB4AHQALgBVAFQARgA4AEUAbgBjAG8AZABpAG4AZwBdADoAOgBuAGUAdwAoACQAZgBhAGwAcwBlACkAOwAgACQATwB1AHQAcAB1AHQARQBuAGMAbwBkAGkAbgBnAD0AWwBDAG8AbgBzAG8AbABlAF0AOgA6AE8AdQB0AHAAdQB0AEUAbgBjAG8AZABpAG4AZwA7ACAAVwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIAAnAFIAUABBAC0ARQBSAFIATwBSAC0AUABSAE8AQgBFACcAOwAgAGUAeABpAHQAIAA5AA==\n" |
|
||||||
|
| 08-01 | System_AddCredential | 阻塞 | 安全输入机制准备好后,在专用目标添加测试凭据 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 08-02 | System_ReadCredential | 阻塞 | 仅验证测试凭据存在,不读取或打印密码 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 08-03 | System_DeleteCredential | 阻塞 | 删除刚创建的测试目标,之后人工确认不存在 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 09-01 | System_GetInputMethodState | 待人工确认 | 读取前台窗口的真实输入法状态,人工对照任务栏 | 手动任务不纳入自动执行 |
|
||||||
|
| 09-02 | System_SetInputMethodState | 待人工确认 | 前台编辑器应切换到英文模式;仅子进程返回 set:true 不能算通过 | 手动任务不纳入自动执行 |
|
||||||
|
| 09-03 | System_SetInputMethodState | 待人工确认 | 恢复原输入法;人工确认前台窗口的实际状态 | 手动任务不纳入自动执行 |
|
||||||
|
| 10-01 | System_PlaySound | 待人工确认 | 听到一次短提示音;没有声音不能算通过 | 手动任务不纳入自动执行 |
|
||||||
|
| 11-01 | System_ScreenSaverShow | 待人工确认 | 屏幕实际进入屏保;无配置时记录阻塞 | 手动任务不纳入自动执行 |
|
||||||
|
| 11-02 | System_ScreenSaverStop | 待人工确认 | 屏保实际关闭,可正常操作 | 手动任务不纳入自动执行 |
|
||||||
|
| 11-03 | System_ScreenSaverShowV1 | 待人工确认 | 开启 Windows 已配置的屏保,不要求输入密码;人工确认屏幕实际变化 | 手动任务不纳入自动执行 |
|
||||||
|
| 11-04 | System_ScreenSaverStop | 待人工确认 | 第二种屏保实际关闭 | 手动任务不纳入自动执行 |
|
||||||
|
| 12-01 | ScreenControl_Screenlock | 阻塞 | 实际锁屏;需要人在机器旁用 Windows 登录凭据恢复,不改变账户密码 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 12-02 | ScreenControl_ScreenUnlock | 阻塞 | 仅尝试唤醒屏幕,loginUnlockSupported 必须为 false;Windows 登录仍需人工完成 | 运行前参数校验拦截,未绕过校验;详见校验问题 |
|
||||||
|
| 13-01 | System_ConnectRemoteDesktop | 待人工确认 | 人工完成登录,并确认已连接到正确测试机器;启动 mstsc 不等于连接成功 | 手动任务不纳入自动执行 |
|
||||||
|
| 13-02 | System_CloseRemoteDesktop | 待人工确认 | 确认目标会话及恢复方式后才能执行;当前实现会调用 tsdiscon | 手动任务不纳入自动执行 |
|
||||||
|
|
||||||
|
## 运行前校验阻塞
|
||||||
|
|
||||||
|
- test_appsystem_08:「System_AddCredential」缺少必填参数:用户名
|
||||||
|
- test_appsystem_12:「ScreenControl_Screenlock」缺少必填参数:用户名;「ScreenControl_Screenlock」缺少必填参数:密码;「ScreenControl_Screenlock」缺少必填参数:分辨率(宽);「ScreenControl_Screenlock」缺少必填参数:分辨率(高)
|
||||||
|
|
||||||
|
## 证据位置
|
||||||
|
|
||||||
|
隔离用户目录:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C
|
||||||
|
|
||||||
|
- test_appsystem_01:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuuparb_ikwv\run.json
|
||||||
|
- test_appsystem_02:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuupg4i_9qr9\run.json
|
||||||
|
- test_appsystem_03:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuupg6q_6ij5\run.json
|
||||||
|
- test_appsystem_04:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuupg9x_yifj\run.json
|
||||||
|
- test_appsystem_05:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuupgcn_2wod\run.json
|
||||||
|
- test_appsystem_06:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuupj2n_b65x\run.json
|
||||||
|
- test_appsystem_07:D:\work\code\RPA-only-demo\test-output\app-system\isolated-PN2R7C\runs\run_mvuupjiv_mh0i\run.json
|
||||||
Binary file not shown.
@@ -0,0 +1,851 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generatedAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_01",
|
||||||
|
"file": "test_appsystem_01.json",
|
||||||
|
"name": "组件测试 01 · 应用与 PID",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"App_Run",
|
||||||
|
"App_GetPid",
|
||||||
|
"App_GetPids",
|
||||||
|
"App_Kill",
|
||||||
|
"App_KillV1"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "01-01",
|
||||||
|
"componentId": "App_Run",
|
||||||
|
"targetNodeId": "t01_8",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"executeable\":\"${fixture.exe}\"}",
|
||||||
|
"expected": "第一个测试进程 PID 大于 0",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-02",
|
||||||
|
"componentId": "App_Run",
|
||||||
|
"targetNodeId": "t01_17",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"executeable\":\"${fixture.exe}\"}",
|
||||||
|
"expected": "第二个 PID 大于 0,且与第一个不同",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-03",
|
||||||
|
"componentId": "App_GetPid",
|
||||||
|
"targetNodeId": "t01_26",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"pattern\":\"${fixture.name}.exe\"}",
|
||||||
|
"expected": "返回本次创建的任一 PID",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-04",
|
||||||
|
"componentId": "App_GetPids",
|
||||||
|
"targetNodeId": "t01_35",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"pattern\":\"${fixture.name}.exe\"}",
|
||||||
|
"expected": "列表恰含本次创建的两个 PID",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-05",
|
||||||
|
"componentId": "App_Kill",
|
||||||
|
"targetNodeId": "t01_44",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{\"pid\":\"${pid1}\"}",
|
||||||
|
"expected": "结束第一个夹具 PID,第二个仍在运行",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-06",
|
||||||
|
"componentId": "App_KillV1",
|
||||||
|
"targetNodeId": "t01_55",
|
||||||
|
"resultVariable": "case_6",
|
||||||
|
"input": "{\"close_type\":\"id\",\"pid\":\"${pid2}\",\"kill_all\":\"0\"}",
|
||||||
|
"expected": "只填写 PID 即可结束第二个夹具进程",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-07",
|
||||||
|
"componentId": "App_GetPids",
|
||||||
|
"targetNodeId": "t01_65",
|
||||||
|
"resultVariable": "case_7",
|
||||||
|
"input": "{\"pattern\":\"${fixture.name}.exe\"}",
|
||||||
|
"expected": "所有夹具结束后应返回空数组 []",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "01-08",
|
||||||
|
"componentId": "App_GetPid",
|
||||||
|
"targetNodeId": "t01_74",
|
||||||
|
"resultVariable": "case_8",
|
||||||
|
"input": "{\"pattern\":\"${fixture.name}.exe\"}",
|
||||||
|
"expected": "无匹配时应返回 null",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_02",
|
||||||
|
"file": "test_appsystem_02.json",
|
||||||
|
"name": "组件测试 02 · 数据转换",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"Basic_StringToNum",
|
||||||
|
"Basic_ConvertNumber",
|
||||||
|
"Basic_ConvertInt",
|
||||||
|
"Basic_ConvertBool",
|
||||||
|
"Basic_ConvertString",
|
||||||
|
"Basic_DataTypeConversion"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "02-01",
|
||||||
|
"componentId": "Basic_StringToNum",
|
||||||
|
"targetNodeId": "t02_6",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"val\":\"123.5\"}",
|
||||||
|
"expected": "数字 123.5",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-02",
|
||||||
|
"componentId": "Basic_ConvertNumber",
|
||||||
|
"targetNodeId": "t02_15",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"val\":\"-42.25\"}",
|
||||||
|
"expected": "数字 -42.25",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-03",
|
||||||
|
"componentId": "Basic_ConvertInt",
|
||||||
|
"targetNodeId": "t02_24",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"val\":-3.9}",
|
||||||
|
"expected": "整数 -3(向零截断)",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-04",
|
||||||
|
"componentId": "Basic_ConvertBool",
|
||||||
|
"targetNodeId": "t02_33",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"val\":\"true\"}",
|
||||||
|
"expected": "布尔 true",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-05",
|
||||||
|
"componentId": "Basic_ConvertBool",
|
||||||
|
"targetNodeId": "t02_42",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{\"val\":\"false\"}",
|
||||||
|
"expected": "布尔 false",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-06",
|
||||||
|
"componentId": "Basic_ConvertString",
|
||||||
|
"targetNodeId": "t02_51",
|
||||||
|
"resultVariable": "case_6",
|
||||||
|
"input": "{\"val\":42}",
|
||||||
|
"expected": "文本 \"42\"",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-07",
|
||||||
|
"componentId": "Basic_DataTypeConversion",
|
||||||
|
"targetNodeId": "t02_60",
|
||||||
|
"resultVariable": "case_7",
|
||||||
|
"input": "{\"val\":\"42\",\"into_type\":\"number\"}",
|
||||||
|
"expected": "数值 42,严格区分文本和数值",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-08",
|
||||||
|
"componentId": "Basic_DataTypeConversion",
|
||||||
|
"targetNodeId": "t02_69",
|
||||||
|
"resultVariable": "case_8",
|
||||||
|
"input": "{\"val\":\"-3.9\",\"into_type\":\"int\"}",
|
||||||
|
"expected": "整数 -3",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-09",
|
||||||
|
"componentId": "Basic_DataTypeConversion",
|
||||||
|
"targetNodeId": "t02_78",
|
||||||
|
"resultVariable": "case_9",
|
||||||
|
"input": "{\"val\":\"false\",\"into_type\":\"bool\"}",
|
||||||
|
"expected": "布尔 false",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02-10",
|
||||||
|
"componentId": "Basic_DataTypeConversion",
|
||||||
|
"targetNodeId": "t02_87",
|
||||||
|
"resultVariable": "case_10",
|
||||||
|
"input": "{\"val\":42,\"into_type\":\"string\"}",
|
||||||
|
"expected": "文本 \"42\"",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_03",
|
||||||
|
"file": "test_appsystem_03.json",
|
||||||
|
"name": "组件测试 03 · 数据判断",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"Basic_IsDictionary",
|
||||||
|
"Basic_IsNumber",
|
||||||
|
"Basic_IsNull",
|
||||||
|
"Basic_IsArray",
|
||||||
|
"Basic_Type",
|
||||||
|
"Basic_JudgeDataType"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "03-01",
|
||||||
|
"componentId": "Basic_IsDictionary",
|
||||||
|
"targetNodeId": "t03_6",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"val\":{\"name\":\"测试\"}}",
|
||||||
|
"expected": "true",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-02",
|
||||||
|
"componentId": "Basic_IsDictionary",
|
||||||
|
"targetNodeId": "t03_15",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"val\":[]}",
|
||||||
|
"expected": "false",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-03",
|
||||||
|
"componentId": "Basic_IsNumber",
|
||||||
|
"targetNodeId": "t03_24",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"val\":\"12.5\"}",
|
||||||
|
"expected": "true",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-04",
|
||||||
|
"componentId": "Basic_IsNumber",
|
||||||
|
"targetNodeId": "t03_33",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"val\":\"中文\"}",
|
||||||
|
"expected": "false",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-05",
|
||||||
|
"componentId": "Basic_IsNull",
|
||||||
|
"targetNodeId": "t03_42",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{\"val\":null}",
|
||||||
|
"expected": "true",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-06",
|
||||||
|
"componentId": "Basic_IsNull",
|
||||||
|
"targetNodeId": "t03_51",
|
||||||
|
"resultVariable": "case_6",
|
||||||
|
"input": "{\"val\":\"\"}",
|
||||||
|
"expected": "true",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-07",
|
||||||
|
"componentId": "Basic_IsNull",
|
||||||
|
"targetNodeId": "t03_60",
|
||||||
|
"resultVariable": "case_7",
|
||||||
|
"input": "{\"val\":0}",
|
||||||
|
"expected": "false",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-08",
|
||||||
|
"componentId": "Basic_IsArray",
|
||||||
|
"targetNodeId": "t03_69",
|
||||||
|
"resultVariable": "case_8",
|
||||||
|
"input": "{\"val\":[]}",
|
||||||
|
"expected": "true",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-09",
|
||||||
|
"componentId": "Basic_IsArray",
|
||||||
|
"targetNodeId": "t03_78",
|
||||||
|
"resultVariable": "case_9",
|
||||||
|
"input": "{\"val\":\"[]\"}",
|
||||||
|
"expected": "false",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-10",
|
||||||
|
"componentId": "Basic_Type",
|
||||||
|
"targetNodeId": "t03_87",
|
||||||
|
"resultVariable": "case_10",
|
||||||
|
"input": "{\"val\":[1,2]}",
|
||||||
|
"expected": "\"list\"",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-11",
|
||||||
|
"componentId": "Basic_Type",
|
||||||
|
"targetNodeId": "t03_96",
|
||||||
|
"resultVariable": "case_11",
|
||||||
|
"input": "{\"val\":null}",
|
||||||
|
"expected": "\"null\"",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03-12",
|
||||||
|
"componentId": "Basic_JudgeDataType",
|
||||||
|
"targetNodeId": "t03_105",
|
||||||
|
"resultVariable": "case_12",
|
||||||
|
"input": "{\"val\":42,\"into_type\":\"number\"}",
|
||||||
|
"expected": "数字 42 属于数值类型,返回布尔 true",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_04",
|
||||||
|
"file": "test_appsystem_04.json",
|
||||||
|
"name": "组件测试 04 · 变量与配置",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"Basic_SetVariable",
|
||||||
|
"Basic_SetVariable_V1",
|
||||||
|
"Basic_Clone",
|
||||||
|
"Basic_GetProcessInfo",
|
||||||
|
"Basic_DebugOutput",
|
||||||
|
"CustomConfig_GetConfig",
|
||||||
|
"Basic_BusinessOrderCount"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "04-01",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"targetNodeId": "t04_6",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"value\":\"中文变量往返\"}",
|
||||||
|
"expected": "原值及输出变量均保持中文文本",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-02",
|
||||||
|
"componentId": "Basic_SetVariable_V1",
|
||||||
|
"targetNodeId": "t04_15",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"variableType\":\"rpa_int\",\"value\":\"42\"}",
|
||||||
|
"expected": "数字 42",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-03",
|
||||||
|
"componentId": "Basic_SetVariable_V1",
|
||||||
|
"targetNodeId": "t04_24",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"variableType\":\"rpa_list\",\"value\":\"[1,2]\"}",
|
||||||
|
"expected": "数组 [1,2]",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-04",
|
||||||
|
"componentId": "Basic_Clone",
|
||||||
|
"targetNodeId": "t04_34",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"val\":\"${original}\"}",
|
||||||
|
"expected": "深复制内容相同,外层和嵌套对象均不共用引用",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-05",
|
||||||
|
"componentId": "Basic_GetProcessInfo",
|
||||||
|
"targetNodeId": "t04_43",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "当前流程 ID、名称、运行 ID 和节点数一致",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-06",
|
||||||
|
"componentId": "Basic_DebugOutput",
|
||||||
|
"targetNodeId": "t04_52",
|
||||||
|
"resultVariable": "case_6",
|
||||||
|
"input": "{\"msg\":\"中文日志测试:${actual_2}\"}",
|
||||||
|
"expected": "日志及返回值为“中文日志测试:42”",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-07",
|
||||||
|
"componentId": "CustomConfig_GetConfig",
|
||||||
|
"targetNodeId": "t04_61",
|
||||||
|
"resultVariable": "case_7",
|
||||||
|
"input": "{\"jsonlist\":\"[{\\\"name\\\":\\\"timeout\\\",\\\"value\\\":30}]\",\"configName\":\"timeout\"}",
|
||||||
|
"expected": "数字 30",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-08",
|
||||||
|
"componentId": "CustomConfig_GetConfig",
|
||||||
|
"targetNodeId": "t04_70",
|
||||||
|
"resultVariable": "case_8",
|
||||||
|
"input": "{\"jsonlist\":\"[]\",\"configName\":\"missing\"}",
|
||||||
|
"expected": "未命中返回 null",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-09",
|
||||||
|
"componentId": "Basic_BusinessOrderCount",
|
||||||
|
"targetNodeId": "t04_79",
|
||||||
|
"resultVariable": "case_9",
|
||||||
|
"input": "{\"start_count\":10,\"count\":2}",
|
||||||
|
"expected": "首次计数 12",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04-10",
|
||||||
|
"componentId": "Basic_BusinessOrderCount",
|
||||||
|
"targetNodeId": "t04_88",
|
||||||
|
"resultVariable": "case_10",
|
||||||
|
"input": "{\"start_count\":10,\"count\":2}",
|
||||||
|
"expected": "同一流程再次计数 14",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_05",
|
||||||
|
"file": "test_appsystem_05.json",
|
||||||
|
"name": "组件测试 05 · 等待与随机数",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"Basic_Delay_V2",
|
||||||
|
"Basic_Delay_V1",
|
||||||
|
"Basic_Delay",
|
||||||
|
"Basic_RandomInt",
|
||||||
|
"Basic_Rnd"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "05-01",
|
||||||
|
"componentId": "Basic_Delay_V2",
|
||||||
|
"targetNodeId": "t05_7",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"is_random\":\"0\",\"second\":\"0.15\",\"min_second\":\"0.1\",\"max_second\":\"0.2\"}",
|
||||||
|
"expected": "实际等待耗时 120~1800 ms",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-02",
|
||||||
|
"componentId": "Basic_Delay_V2",
|
||||||
|
"targetNodeId": "t05_18",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"is_random\":\"1\",\"second\":\"0.15\",\"min_second\":\"0.1\",\"max_second\":\"0.2\"}",
|
||||||
|
"expected": "实际等待耗时 80~1800 ms",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-03",
|
||||||
|
"componentId": "Basic_Delay_V1",
|
||||||
|
"targetNodeId": "t05_29",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"second\":\"0.15\"}",
|
||||||
|
"expected": "实际等待耗时 120~1800 ms",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-04",
|
||||||
|
"componentId": "Basic_Delay",
|
||||||
|
"targetNodeId": "t05_40",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"mileSecond\":\"3000\"}",
|
||||||
|
"expected": "实际等待耗时 2800~6500 ms",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-05",
|
||||||
|
"componentId": "Basic_RandomInt",
|
||||||
|
"targetNodeId": "t05_50",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{\"min\":7,\"max\":7}",
|
||||||
|
"expected": "最小值等于最大值时返回整数 7",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-06",
|
||||||
|
"componentId": "Basic_RandomInt",
|
||||||
|
"targetNodeId": "t05_59",
|
||||||
|
"resultVariable": "case_6",
|
||||||
|
"input": "{\"min\":-2,\"max\":2}",
|
||||||
|
"expected": "返回 -2~2 的整数",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05-07",
|
||||||
|
"componentId": "Basic_Rnd",
|
||||||
|
"targetNodeId": "t05_68",
|
||||||
|
"resultVariable": "case_7",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "随机小数满足 0 ≤ x < 1",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_06",
|
||||||
|
"file": "test_appsystem_06.json",
|
||||||
|
"name": "组件测试 06 · 系统路径与环境变量",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"System_GetTempPath",
|
||||||
|
"System_GetHomePath",
|
||||||
|
"System_GetSystemPath",
|
||||||
|
"System_GetEnv",
|
||||||
|
"System_SetEnv"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "06-01",
|
||||||
|
"componentId": "System_GetTempPath",
|
||||||
|
"targetNodeId": "t06_8",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "与系统临时目录一致(忽略尾部分隔符)",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-02",
|
||||||
|
"componentId": "System_GetHomePath",
|
||||||
|
"targetNodeId": "t06_17",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "与 Windows 用户文件夹一致",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-03",
|
||||||
|
"componentId": "System_GetSystemPath",
|
||||||
|
"targetNodeId": "t06_26",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"path_name\":\"desktop\"}",
|
||||||
|
"expected": "与 Windows 实际桌面目录一致(包含重定向)",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-04",
|
||||||
|
"componentId": "System_GetEnv",
|
||||||
|
"targetNodeId": "t06_35",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"name\":\"RPA_APP_SYSTEM_TEST_VALUE\"}",
|
||||||
|
"expected": "读取测试专用变量的原始值,作为恢复依据",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-05",
|
||||||
|
"componentId": "System_SetEnv",
|
||||||
|
"targetNodeId": "t06_44",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{\"name\":\"RPA_APP_SYSTEM_TEST_VALUE\",\"value\":\"中文-测试-42\"}",
|
||||||
|
"expected": "当前应用进程内设置成功",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-06",
|
||||||
|
"componentId": "System_GetEnv",
|
||||||
|
"targetNodeId": "t06_53",
|
||||||
|
"resultVariable": "case_6",
|
||||||
|
"input": "{\"name\":\"RPA_APP_SYSTEM_TEST_VALUE\"}",
|
||||||
|
"expected": "能读回中文-测试-42",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06-07",
|
||||||
|
"componentId": "System_SetEnv",
|
||||||
|
"targetNodeId": "t06_62",
|
||||||
|
"resultVariable": "case_7",
|
||||||
|
"input": "{\"name\":\"RPA_APP_SYSTEM_TEST_VALUE\",\"value\":\"${originalEnv}\"}",
|
||||||
|
"expected": "恢复测试前读取值",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_07",
|
||||||
|
"file": "test_appsystem_07.json",
|
||||||
|
"name": "组件测试 07 · 命令行",
|
||||||
|
"manual": false,
|
||||||
|
"components": [
|
||||||
|
"System_ExecuteCommand",
|
||||||
|
"System_ExecuteCommandV1"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "07-01",
|
||||||
|
"componentId": "System_ExecuteCommand",
|
||||||
|
"targetNodeId": "t07_6",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"action\":\"Write-Output 'RPA-PS-中文-OK'\"}",
|
||||||
|
"expected": "RPA-PS-中文-OK",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "07-02",
|
||||||
|
"componentId": "System_ExecuteCommandV1",
|
||||||
|
"targetNodeId": "t07_15",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"command_type\":\"1\",\"dos_command\":\"echo RPA-DOS-OK\",\"get_ret\":\"1\"}",
|
||||||
|
"expected": "RPA-DOS-OK",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "07-03",
|
||||||
|
"componentId": "System_ExecuteCommandV1",
|
||||||
|
"targetNodeId": "t07_24",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"command_type\":\"3\",\"ps_command\":\"Write-Output 'RPA-PS-V1-OK'\",\"get_ret\":\"1\"}",
|
||||||
|
"expected": "RPA-PS-V1-OK",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "07-04",
|
||||||
|
"componentId": "System_ExecuteCommandV1",
|
||||||
|
"targetNodeId": "t07_34",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{\"command_type\":\"2\",\"bat_file\":\"${batPath}\",\"get_ret\":\"1\"}",
|
||||||
|
"expected": "RPA-BAT-OK",
|
||||||
|
"observe": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "07-05",
|
||||||
|
"componentId": "System_ExecuteCommand",
|
||||||
|
"targetNodeId": "t07_43",
|
||||||
|
"resultVariable": "case_5",
|
||||||
|
"input": "{\"action\":\"Write-Output 'RPA-ERROR-PROBE'; exit 9\"}",
|
||||||
|
"expected": "非零退出码应抛出异常",
|
||||||
|
"observe": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_08",
|
||||||
|
"file": "test_appsystem_08.json",
|
||||||
|
"name": "组件测试 08 · Windows 凭据【手动】",
|
||||||
|
"manual": true,
|
||||||
|
"components": [
|
||||||
|
"System_AddCredential",
|
||||||
|
"System_ReadCredential",
|
||||||
|
"System_DeleteCredential"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "08-01",
|
||||||
|
"componentId": "System_AddCredential",
|
||||||
|
"targetNodeId": "t08_10",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"application\":\"${credentialTarget}\",\"name\":\"\",\"password\":\"\"}",
|
||||||
|
"expected": "安全输入机制准备好后,在专用目标添加测试凭据",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "08-02",
|
||||||
|
"componentId": "System_ReadCredential",
|
||||||
|
"targetNodeId": "t08_21",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"application\":\"${credentialTarget}\"}",
|
||||||
|
"expected": "仅验证测试凭据存在,不读取或打印密码",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "08-03",
|
||||||
|
"componentId": "System_DeleteCredential",
|
||||||
|
"targetNodeId": "t08_32",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"application\":\"${credentialTarget}\"}",
|
||||||
|
"expected": "删除刚创建的测试目标,之后人工确认不存在",
|
||||||
|
"observe": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_09",
|
||||||
|
"file": "test_appsystem_09.json",
|
||||||
|
"name": "组件测试 09 · 输入法【手动】",
|
||||||
|
"manual": true,
|
||||||
|
"components": [
|
||||||
|
"System_GetInputMethodState",
|
||||||
|
"System_SetInputMethodState"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "09-01",
|
||||||
|
"componentId": "System_GetInputMethodState",
|
||||||
|
"targetNodeId": "t09_9",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "读取前台窗口的真实输入法状态,人工对照任务栏",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "09-02",
|
||||||
|
"componentId": "System_SetInputMethodState",
|
||||||
|
"targetNodeId": "t09_20",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{\"mode\":\"en-US\"}",
|
||||||
|
"expected": "前台编辑器应切换到英文模式;仅子进程返回 set:true 不能算通过",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "09-03",
|
||||||
|
"componentId": "System_SetInputMethodState",
|
||||||
|
"targetNodeId": "t09_31",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{\"mode\":\"${originalIme.culture}\"}",
|
||||||
|
"expected": "恢复原输入法;人工确认前台窗口的实际状态",
|
||||||
|
"observe": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_10",
|
||||||
|
"file": "test_appsystem_10.json",
|
||||||
|
"name": "组件测试 10 · 音频【手动】",
|
||||||
|
"manual": true,
|
||||||
|
"components": [
|
||||||
|
"System_PlaySound"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "10-01",
|
||||||
|
"componentId": "System_PlaySound",
|
||||||
|
"targetNodeId": "t10_10",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"audio\":\"${audioPath}\"}",
|
||||||
|
"expected": "听到一次短提示音;没有声音不能算通过",
|
||||||
|
"observe": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_11",
|
||||||
|
"file": "test_appsystem_11.json",
|
||||||
|
"name": "组件测试 11 · 屏保【手动】",
|
||||||
|
"manual": true,
|
||||||
|
"components": [
|
||||||
|
"System_ScreenSaverShow",
|
||||||
|
"System_ScreenSaverShowV1",
|
||||||
|
"System_ScreenSaverStop"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "11-01",
|
||||||
|
"componentId": "System_ScreenSaverShow",
|
||||||
|
"targetNodeId": "t11_9",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"text\":\"RPA 测试\",\"status\":\"1\"}",
|
||||||
|
"expected": "屏幕实际进入屏保;无配置时记录阻塞",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11-02",
|
||||||
|
"componentId": "System_ScreenSaverStop",
|
||||||
|
"targetNodeId": "t11_20",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "屏保实际关闭,可正常操作",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11-03",
|
||||||
|
"componentId": "System_ScreenSaverShowV1",
|
||||||
|
"targetNodeId": "t11_31",
|
||||||
|
"resultVariable": "case_3",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "开启 Windows 已配置的屏保,不要求输入密码;人工确认屏幕实际变化",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11-04",
|
||||||
|
"componentId": "System_ScreenSaverStop",
|
||||||
|
"targetNodeId": "t11_42",
|
||||||
|
"resultVariable": "case_4",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "第二种屏保实际关闭",
|
||||||
|
"observe": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_12",
|
||||||
|
"file": "test_appsystem_12.json",
|
||||||
|
"name": "组件测试 12 · 锁屏与解锁【手动】",
|
||||||
|
"manual": true,
|
||||||
|
"components": [
|
||||||
|
"ScreenControl_Screenlock",
|
||||||
|
"ScreenControl_ScreenUnlock"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "12-01",
|
||||||
|
"componentId": "ScreenControl_Screenlock",
|
||||||
|
"targetNodeId": "t12_9",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"username\":\"\",\"pwd\":\"\",\"width\":\"\",\"height\":\"\",\"scale_factor\":\"100\"}",
|
||||||
|
"expected": "实际锁屏;需要人在机器旁用 Windows 登录凭据恢复,不改变账户密码",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12-02",
|
||||||
|
"componentId": "ScreenControl_ScreenUnlock",
|
||||||
|
"targetNodeId": "t12_20",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "仅尝试唤醒屏幕,loginUnlockSupported 必须为 false;Windows 登录仍需人工完成",
|
||||||
|
"observe": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test_appsystem_13",
|
||||||
|
"file": "test_appsystem_13.json",
|
||||||
|
"name": "组件测试 13 · 远程桌面【手动】",
|
||||||
|
"manual": true,
|
||||||
|
"components": [
|
||||||
|
"System_ConnectRemoteDesktop",
|
||||||
|
"System_CloseRemoteDesktop"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "13-01",
|
||||||
|
"componentId": "System_ConnectRemoteDesktop",
|
||||||
|
"targetNodeId": "t13_11",
|
||||||
|
"resultVariable": "case_1",
|
||||||
|
"input": "{\"ip\":\"${remoteAddress}\",\"port\":\"3389\"}",
|
||||||
|
"expected": "人工完成登录,并确认已连接到正确测试机器;启动 mstsc 不等于连接成功",
|
||||||
|
"observe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "13-02",
|
||||||
|
"componentId": "System_CloseRemoteDesktop",
|
||||||
|
"targetNodeId": "t13_22",
|
||||||
|
"resultVariable": "case_2",
|
||||||
|
"input": "{}",
|
||||||
|
"expected": "确认目标会话及恢复方式后才能执行;当前实现会调用 tsdiscon",
|
||||||
|
"observe": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,897 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_appsystem_08",
|
||||||
|
"name": "组件测试 08 · Windows 凭据【手动】",
|
||||||
|
"description": "手动测试:先阅读测试说明。默认不开启执行开关,不能把组件返回成功当作功能通过。",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger": {
|
||||||
|
"type": "manual",
|
||||||
|
"label": "手动运行"
|
||||||
|
},
|
||||||
|
"variables": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "t08_1",
|
||||||
|
"componentId": "Trigger.Manual",
|
||||||
|
"label": "人工发起",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 80
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"entryName": "开始组件测试"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_2",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化失败计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 222
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_3",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化待确认计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 364
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_4",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化阻塞计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 506
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "blocked"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_5",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "手动执行开关:确认环境后才可改为 true",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 648
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "manualEnabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_6",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "只允许使用新的测试凭据目标",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 790
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "RPA-AppSystem-Tests-${now()}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "credentialTarget"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_7",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "08-01 · System_AddCredential",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 932
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_8",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_7",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_9",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_8",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_10",
|
||||||
|
"componentId": "System_AddCredential",
|
||||||
|
"label": "System_AddCredential",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"application": "${credentialTarget}",
|
||||||
|
"name": "",
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_1"
|
||||||
|
},
|
||||||
|
"parentId": "t08_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_11",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"08-01\", \"System_AddCredential\", \"MANUAL\", \"{\\\"application\\\":\\\"${credentialTarget}\\\",\\\"name\\\":\\\"\\\",\\\"password\\\":\\\"\\\"}\", \"安全输入机制准备好后,在专用目标添加测试凭据\", actual_1]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t08_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_12",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t08_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_13",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t08_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_14",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"08-01\", \"System_AddCredential\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"application\\\":\\\"${credentialTarget}\\\",\\\"name\\\":\\\"\\\",\\\"password\\\":\\\"\\\"}\", \"安全输入机制准备好后,在专用目标添加测试凭据\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t08_7",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_15",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t08_7",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_16",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t08_7",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_17",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1436
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_1)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_18",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "08-02 · System_ReadCredential",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1578
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_19",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_18",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_20",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_19",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_21",
|
||||||
|
"componentId": "System_ReadCredential",
|
||||||
|
"label": "System_ReadCredential",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"application": "${credentialTarget}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_2"
|
||||||
|
},
|
||||||
|
"parentId": "t08_19",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_22",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"08-02\", \"System_ReadCredential\", \"MANUAL\", \"{\\\"application\\\":\\\"${credentialTarget}\\\"}\", \"仅验证测试凭据存在,不读取或打印密码\", actual_2]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t08_19",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_23",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t08_19",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_24",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t08_19",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_25",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"08-02\", \"System_ReadCredential\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"application\\\":\\\"${credentialTarget}\\\"}\", \"仅验证测试凭据存在,不读取或打印密码\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t08_18",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_26",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t08_18",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_27",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1700
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t08_18",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_28",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2082
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_2)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_29",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "08-03 · System_DeleteCredential",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2224
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_30",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_29",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_31",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_30",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_32",
|
||||||
|
"componentId": "System_DeleteCredential",
|
||||||
|
"label": "System_DeleteCredential",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"application": "${credentialTarget}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_3"
|
||||||
|
},
|
||||||
|
"parentId": "t08_30",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_33",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"08-03\", \"System_DeleteCredential\", \"MANUAL\", \"{\\\"application\\\":\\\"${credentialTarget}\\\"}\", \"删除刚创建的测试目标,之后人工确认不存在\", actual_3]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_3"
|
||||||
|
},
|
||||||
|
"parentId": "t08_30",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_34",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_3[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t08_30",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_35",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_3[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t08_30",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_36",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"08-03\", \"System_DeleteCredential\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"application\\\":\\\"${credentialTarget}\\\"}\", \"删除刚创建的测试目标,之后人工确认不存在\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_3"
|
||||||
|
},
|
||||||
|
"parentId": "t08_29",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_37",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_3[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t08_29",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_38",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_3[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t08_29",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_39",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2728
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_3)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_40",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2870
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_41",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "汇总判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 3012
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${failures > 0 || pending > 0 || blocked > 0}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_42",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "如实标记未通过",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2600
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_41",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t08_43",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2600
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组全部通过。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t08_41",
|
||||||
|
"slot": "statement_else"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "e_t08_2",
|
||||||
|
"source": "t08_1",
|
||||||
|
"target": "t08_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_3",
|
||||||
|
"source": "t08_2",
|
||||||
|
"target": "t08_3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_4",
|
||||||
|
"source": "t08_3",
|
||||||
|
"target": "t08_4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_5",
|
||||||
|
"source": "t08_4",
|
||||||
|
"target": "t08_5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_6",
|
||||||
|
"source": "t08_5",
|
||||||
|
"target": "t08_6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_7",
|
||||||
|
"source": "t08_6",
|
||||||
|
"target": "t08_7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_17",
|
||||||
|
"source": "t08_7",
|
||||||
|
"target": "t08_17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_18",
|
||||||
|
"source": "t08_17",
|
||||||
|
"target": "t08_18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_28",
|
||||||
|
"source": "t08_18",
|
||||||
|
"target": "t08_28"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_29",
|
||||||
|
"source": "t08_28",
|
||||||
|
"target": "t08_29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_39",
|
||||||
|
"source": "t08_29",
|
||||||
|
"target": "t08_39"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_40",
|
||||||
|
"source": "t08_39",
|
||||||
|
"target": "t08_40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t08_41",
|
||||||
|
"source": "t08_40",
|
||||||
|
"target": "t08_41"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"createdAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"appVersion": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_appsystem_09",
|
||||||
|
"name": "组件测试 09 · 输入法【手动】",
|
||||||
|
"description": "手动测试:先阅读测试说明。默认不开启执行开关,不能把组件返回成功当作功能通过。",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger": {
|
||||||
|
"type": "manual",
|
||||||
|
"label": "手动运行"
|
||||||
|
},
|
||||||
|
"variables": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "t09_1",
|
||||||
|
"componentId": "Trigger.Manual",
|
||||||
|
"label": "人工发起",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 80
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"entryName": "开始组件测试"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_2",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化失败计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 222
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_3",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化待确认计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 364
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_4",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化阻塞计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 506
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "blocked"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_5",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "手动执行开关:确认环境后才可改为 true",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 648
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "manualEnabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_6",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "09-01 · System_GetInputMethodState",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 790
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_7",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_6",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_8",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_7",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_9",
|
||||||
|
"componentId": "System_GetInputMethodState",
|
||||||
|
"label": "System_GetInputMethodState",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "originalIme"
|
||||||
|
},
|
||||||
|
"parentId": "t09_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_10",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"09-01\", \"System_GetInputMethodState\", \"MANUAL\", \"{}\", \"读取前台窗口的真实输入法状态,人工对照任务栏\", originalIme]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t09_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_11",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t09_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_12",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t09_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_13",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"09-01\", \"System_GetInputMethodState\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{}\", \"读取前台窗口的真实输入法状态,人工对照任务栏\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t09_6",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_14",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t09_6",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_15",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t09_6",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_16",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1294
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_1)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_17",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "09-02 · System_SetInputMethodState",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1436
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_18",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_17",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_19",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_18",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_20",
|
||||||
|
"componentId": "System_SetInputMethodState",
|
||||||
|
"label": "System_SetInputMethodState",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"mode": "en-US"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_2"
|
||||||
|
},
|
||||||
|
"parentId": "t09_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_21",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"09-02\", \"System_SetInputMethodState\", \"MANUAL\", \"{\\\"mode\\\":\\\"en-US\\\"}\", \"前台编辑器应切换到英文模式;仅子进程返回 set:true 不能算通过\", actual_2]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t09_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_22",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t09_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_23",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t09_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_24",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"09-02\", \"System_SetInputMethodState\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"mode\\\":\\\"en-US\\\"}\", \"前台编辑器应切换到英文模式;仅子进程返回 set:true 不能算通过\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t09_17",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_25",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t09_17",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_26",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t09_17",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_27",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1940
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_2)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_28",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "09-03 · System_SetInputMethodState",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2082
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_29",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_28",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_30",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_29",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_31",
|
||||||
|
"componentId": "System_SetInputMethodState",
|
||||||
|
"label": "System_SetInputMethodState",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"mode": "${originalIme.culture}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_3"
|
||||||
|
},
|
||||||
|
"parentId": "t09_29",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_32",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"09-03\", \"System_SetInputMethodState\", \"MANUAL\", \"{\\\"mode\\\":\\\"${originalIme.culture}\\\"}\", \"恢复原输入法;人工确认前台窗口的实际状态\", actual_3]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_3"
|
||||||
|
},
|
||||||
|
"parentId": "t09_29",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_33",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_3[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t09_29",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_34",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_3[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t09_29",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_35",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"09-03\", \"System_SetInputMethodState\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"mode\\\":\\\"${originalIme.culture}\\\"}\", \"恢复原输入法;人工确认前台窗口的实际状态\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_3"
|
||||||
|
},
|
||||||
|
"parentId": "t09_28",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_36",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_3[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t09_28",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_37",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_3[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t09_28",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_38",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2586
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_3)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_39",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2728
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_40",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "汇总判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2870
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${failures > 0 || pending > 0 || blocked > 0}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_41",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "如实标记未通过",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2420
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_40",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t09_42",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2420
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组全部通过。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t09_40",
|
||||||
|
"slot": "statement_else"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "e_t09_2",
|
||||||
|
"source": "t09_1",
|
||||||
|
"target": "t09_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_3",
|
||||||
|
"source": "t09_2",
|
||||||
|
"target": "t09_3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_4",
|
||||||
|
"source": "t09_3",
|
||||||
|
"target": "t09_4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_5",
|
||||||
|
"source": "t09_4",
|
||||||
|
"target": "t09_5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_6",
|
||||||
|
"source": "t09_5",
|
||||||
|
"target": "t09_6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_16",
|
||||||
|
"source": "t09_6",
|
||||||
|
"target": "t09_16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_17",
|
||||||
|
"source": "t09_16",
|
||||||
|
"target": "t09_17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_27",
|
||||||
|
"source": "t09_17",
|
||||||
|
"target": "t09_27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_28",
|
||||||
|
"source": "t09_27",
|
||||||
|
"target": "t09_28"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_38",
|
||||||
|
"source": "t09_28",
|
||||||
|
"target": "t09_38"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_39",
|
||||||
|
"source": "t09_38",
|
||||||
|
"target": "t09_39"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t09_40",
|
||||||
|
"source": "t09_39",
|
||||||
|
"target": "t09_40"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"createdAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"appVersion": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_appsystem_10",
|
||||||
|
"name": "组件测试 10 · 音频【手动】",
|
||||||
|
"description": "手动测试:先阅读测试说明。默认不开启执行开关,不能把组件返回成功当作功能通过。",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger": {
|
||||||
|
"type": "manual",
|
||||||
|
"label": "手动运行"
|
||||||
|
},
|
||||||
|
"variables": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "t10_1",
|
||||||
|
"componentId": "Trigger.Manual",
|
||||||
|
"label": "人工发起",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 80
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"entryName": "开始组件测试"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_2",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化失败计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 222
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_3",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化待确认计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 364
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_4",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化阻塞计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 506
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "blocked"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_5",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "手动执行开关:确认环境后才可改为 true",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 648
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "manualEnabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_6",
|
||||||
|
"componentId": "System_ExecuteCommand",
|
||||||
|
"label": "准备 0.3 秒低音量测试音",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 790
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"action": "$dir=Join-Path ([IO.Path]::GetTempPath()) ('RPA-AppSystem-Tests\\'+[Guid]::NewGuid().ToString('N')); [IO.Directory]::CreateDirectory($dir) | Out-Null; $wav=Join-Path $dir 'tone.wav'; [IO.File]::WriteAllBytes($wav,[Convert]::FromBase64String('UklGRuQSAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YcASAAAAAAEAAwAFAAgACgALAAoABgABAPr/8v/r/+X/4//k/+n/8v/+/wsAGQAkAC0AMAAtACUAFwAFAPH/3v/N/8L/vf/B/8z/3//3/xEAKwBBAFAAVgBSAEQALAAPAO7/zv+z/6D/l/+c/6z/yP/r/xIAOABZAHEAewB3AGUARgAdAO//wv+b/3//cv91/4r/rf/b/w4AQgBvAJAAoACeAIkAYgAvAPX/u/+I/2L/Tv9P/2X/jv/G/wYARwCAAKwAxADFAK4AggBGAAAAuf94/0f/K/8n/z7/bP+t//n/RwCOAMUA5gDsANYApgBhAA8Au/9t/y//Cv8B/xb/SP+Q/+f/QgCXANsABgETAf4AzACAACQAwv9m/xv/6/7b/u3+If9w/9H/OQCcAO0AJAE4ASgB9ACiADwAzv9k/wv/z/62/sT++P5M/7b/KwCcAPsAPgFcAVEBHgHIAFkA3v9m///+tf6S/pr+zf4k/5f/GACXAAUBVgF/AXoBSQHwAHoA9P9u//j+oP5x/nH+of77/nT/AACNAAsBagGfAaMBdgEbAZ4ADgB6//X+jv5S/kn+dP7O/k7/5P9+AAsBegG8AcoBogFIAccALACL//b+gP42/iL+R/6g/iT/w/9rAAcBhQHWAfABzwF3AfIATwCh//3+d/4e/v79Gv5x/vf+nv9SAP0AjAHtARQC/AGoASABdgC8/wn/cv4J/tv97v1A/sj+df81AO8AjwEAAjUCKALZAVEBoQDc/xr/cv74/bz9w/0P/pb+Sf8TANwAjAEOAlMCUgIKAoQBzwAAADD/d/7s/Z/9mv3d/WL+Gf/s/8MAhAEZAm4CegI7ArgBAAEpAEv/gf7k/Yb9cv2s/S3+5v7B/6YAdwEeAoUCoAJsAu4BNAFVAGv/kf7h/XH9Tv18/ff9sf6T/4MAZQEeApgCxAKcAiQCawGGAJD/pv7j/WD9LP1N/cH9ef5g/1wATgEaAqcC5ALKAlsCpAG6ALn/wP7r/VT9Df0g/Yr9P/4q/zAAMQEQArACAAP2ApEC3gHyAOf/3/73/U398/z1/FT9BP7y/gAAEAEAArUCGQMfA8cCGQIsARoAA/8J/kr93PzN/B/9yf22/sz/6QDrAbUCLQNGA/sCVQJpAVAALf8h/k39yvyo/Oz8jP14/pT/vQDRAa8CPQNpAy0DkgKnAYoAW/8+/lb9vfyH/Lr8UP05/lj/jACxAaQCRwOIA14DzQLnAccAjv9h/mT9tfxq/Iv8Ff34/Rr/VwCMAZMCTQOjA4wDCAMpAgcBxf+I/nj9s/xR/F/82/y2/dj+HgBiAXwCTQO6A7YDQgNrAkkBAAC2/pH9tfw9/Db8ovx0/ZX+4f8yAWACRwPMA90DegOtAo4BPwDo/rD9vvwu/BH8a/wy/U/+oP/+AD4CPAPYAwAErwPuAtQBgQAe/9X9zPwk/PD7N/zx/Aj+XP/EABcCKgPfAx8E4gMvAxsCxwBa///94Pwf/NP7Bvyx/MH9Ff+HAOoBEwPhAzkEEQRvA2MCDwGZ/y/++/wh/Lz72Pty/Hn9zP5FALcB9gLcA04EPQSsA6sCWQHd/2T+G/0o/Kr7r/s2/DH9gf4AAIAB0wLSA10EZQTnA/MCpQEkAJ7+Qf02/J37ifv8++n8Nf63/0MBqgLBA2cEiAQfBDoD8gFuAN3+bP1J/Jb7afvG+6P85/1s/wIBfAKqA2sEpgRUBH8DQAK7ACD/nv1j/JX7TfuT+1/8mv0e/70ARwKOA2kEvwSFBMIDjQIKAWf/1f2D/Jv7N/tk+x38TP3N/nQADgJrA2AE0gSyBAME2wJbAbL/Ef6p/Kb7J/s6+937//x8/igA0AFCA1IE3wTaBEEEJwOuAQAAUv7V/Lj7HPsU+6H7s/wp/tj/jAETAz0E5wT9BHwEcwMBAlEAl/4H/dD7GPv0+mj7aPzW/YX/RAHeAiEE6AQaBbIEvANUAqUA4f4//e/7GvvZ+jT7IPyC/TH/+ACjAv8D4wQyBeUEAwSnAvoAL/98/RT8IvvE+gT72/sw/dv+qQBkAtcD1wREBRIFRwT5AlEBgP++/T/8Mvu1+tn6mfve/IP+VgAfAqkDxARQBTsFhwRKA6kB1f8F/nH8R/ut+rP6WvuO/Cv+AADWAXUDqwRVBV4FxASZAwECLABR/qj8ZPur+pP6IPtA/NP9qP+IAToDjARUBXsF/ATmA1kChQCg/uX8hvuv+nn66vr1+3v9Tv82AfsCZgRMBZIFLwUvBLEC4AD0/if9sPu7+mX6ufqt+yT98v7hALYCOQQ9BaMFXQV2BAcDPAFL/2793/vN+lj6jvpo+8/8lv6JAGwCBwQnBa0FhgW4BFsDmAGk/7r9Ffzm+lH6aPoo+3v8Ov4uAB4CzgMLBbAFqQX2BK0D9AEAAAv+UfwG+1H6SPrs+iv83v3R/8sBjwPoBK0FxQUwBfwDUAJeAF/+kvws+1j6L/q2+t37gv1z/3UBSwO+BKIF3AVkBUgEqwK9ALf+2fxZ+2b6HPqE+pP7KP0U/xsBAgOOBJEF6wWSBZAEBAMcARL/JP2M+3r6EPpZ+k370Py0/r8AtAJYBHkF9AW7BdQEWwN8AXD/dP3F+5b6Cvoz+gz7evxU/mAAYgIcBFoF9gXdBRMFrwPcAdD/yf0F/Ln6DPoU+s/6KPz1/QAADALaAzQF8AX5BU0FAQQ6AjEAIf5J/OL6Ffr7+Zj62PuX/Z7/sgGSAwcF5AUOBoEFTgSYApMAfP6T/BH7Jfrq+Wf6jfs6/Tz/VQFGA9QE0AUdBrAFlwTzAvUA2v7i/Ej7PPrf+Tv6Rvvh/Nr+9gD1ApsEtgUkBtgF2wRLA1cBO/81/YT7Wvrb+Rb6BPuJ/Hj+lACgAlwElQUkBvoFGwWhA7kBnf+M/cX7f/re+fj5yPo2/Bf+MgBHAhgEbQUdBhUGVQXzAxkCAADn/Q38qvrp+eD5kfrm+7f9zv/rAc4DPgUPBikGiQVBBHgCZABE/ln83Pr6+c/5X/qa+1r9av+MAX8DCQX6BTcGtwWKBNQCyACk/qn8FPsT+sb5NfpT+//8Bv8rAS0DzgTeBT0G3gXOBC0DKwEG//78Uvsz+sP5EPoR+6j8o/7IANYCjQS7BTsG/wUNBYMDjgFq/1f9lftZ+sj58/nV+lT8Qv5kAHsCRwSRBTMGGQZHBdUD7gHO/7P93vuG+tT53Pmf+gT84v0AAB4C/ANhBSQGLAZ6BSIETQIyABL+K/y5+uf5zflv+rn7hf2c/74BrAMrBQ0GOAanBWsEqQKWAHL+ffzz+gH6xflF+nP7Kv04/10BWAPvBPAFPQbNBa4EAgP6ANX+0/wy+yL6w/ki+jL70/zV/voAAQOtBMsFOgbtBewEVwNcATj/LP12+0n6yfkG+vf6gfx0/pYApgJmBKEFMQYGBiQFpwO8AZz/iP2/+3f61/nx+cL6MvwV/jIASQIaBG8FIAYXBlYF8wMZAgAA5/0N/Kv66/nj+ZP66Pu5/c7/6QHKAzgFCAYiBoEFOwR0AmMAR/5f/OX6Bvrc+Wv6pPtg/Wz/iAF3A/wE6gUlBqYFfATLAsUAqf61/CX7KPrc+Ur6ZfsL/Qr/JgEfA7oExQUhBsQFuAQeAyYBC/8N/Wn7UPrj+TD6LPu6/Kv+xADGAnMEmQUWBtsF7wRtA4QBbf9o/bL7f/ry+Rz6+fpu/E7+YgBpAigEaAUFBusFHgW3A98Bz//G/f/7s/oH+hD6zPom/PT9AAALAtgDMQXsBfQFRwX7AzcCMAAk/lH87foj+gr6pvrk+579oP+sAYYD9ATNBfYFagU7BIwCkACE/qX8LPtF+gz6h/qo+0z9Qf9MATADswSnBfAFhgV0BNwC7gDk/vz8cPtu+hX6b/py+/785f7sANgCbQR8BeQFmgWnBCcDSQFD/1X9uPuc+iT6XvpC+7X8i/6NAH4CIwRKBdEFqAXUBG4DoQGi/7D9BPzQ+jv6U/oY+3H8Nf4vACIC1QMUBbgFrwX6BK8D9QEAAAz+U/wK+1f6UPr1+jL84v3S/8YBhQPYBJgFrwUaBesDRgJcAGj+pfxI+3r6U/rZ+vn7lP13/2oBMQOYBHIFqAUzBSEEkgK1AMT++fyK+6P6XfrD+sf7Sv0f/w4B3AJTBEcFmwVFBVAE2QIMASD/T/3R+9H6bvq0+pr7Bf3K/rIAhQILBBYFhwVRBXoEGwNgAXv/p/0a/AT7hfqs+nT7xvx4/lgALQLAA+AEbQVVBZwEWAOvAdT///1n/Dz7ovqr+lX7i/wq/gAA1QFyA6YETQVTBbkEjwP7ASsAV/62/Hn7xfqw+jz7V/zh/ar/fQEiA2cEJwVLBc4EwQNCAoAAr/4H/bn77vq8+in7Kfyc/Vf/JQHQAiUE/AQ8Bd4E7AOEAtEABv9Z/f37G/vO+h37Afxd/Qj/zwB+AuADzAQnBeYEEQTBAh8BW/+s/UT8Tvvm+hj73/si/bz+ewAqApgDmAQMBegEMAT5AmkBr////Y38hPsD+xn7w/vt/HT+KADXAU0DXwTsBOQESAQrA64BAABS/tn8v/sm+yH7rvu+/DD+2P+EAQEDIwTGBNkEWgRXA+8BTgCl/iX9/ftO+y77oPuV/PL9jP8zAbQC4wOcBMkEZQR9AysCmQD2/nP9Pvx7+0H7l/ty/Ln9Q//iAGYCoQNtBLMEawSdA2IC4ALine truncated
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "audioPath"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_7",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "10-01 · System_PlaySound",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 932
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_8",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t10_7",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_9",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t10_8",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_10",
|
||||||
|
"componentId": "System_PlaySound",
|
||||||
|
"label": "System_PlaySound",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"audio": "${audioPath}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_1"
|
||||||
|
},
|
||||||
|
"parentId": "t10_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_11",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"10-01\", \"System_PlaySound\", \"MANUAL\", \"{\\\"audio\\\":\\\"${audioPath}\\\"}\", \"听到一次短提示音;没有声音不能算通过\", actual_1]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t10_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_12",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t10_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_13",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t10_8",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_14",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"10-01\", \"System_PlaySound\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"audio\\\":\\\"${audioPath}\\\"}\", \"听到一次短提示音;没有声音不能算通过\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t10_7",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_15",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t10_7",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_16",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1340
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t10_7",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_17",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1436
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_1)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_18",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1578
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_19",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "汇总判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1720
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${failures > 0 || pending > 0 || blocked > 0}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_20",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "如实标记未通过",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t10_19",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t10_21",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组全部通过。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t10_19",
|
||||||
|
"slot": "statement_else"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "e_t10_2",
|
||||||
|
"source": "t10_1",
|
||||||
|
"target": "t10_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_3",
|
||||||
|
"source": "t10_2",
|
||||||
|
"target": "t10_3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_4",
|
||||||
|
"source": "t10_3",
|
||||||
|
"target": "t10_4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_5",
|
||||||
|
"source": "t10_4",
|
||||||
|
"target": "t10_5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_6",
|
||||||
|
"source": "t10_5",
|
||||||
|
"target": "t10_6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_7",
|
||||||
|
"source": "t10_6",
|
||||||
|
"target": "t10_7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_17",
|
||||||
|
"source": "t10_7",
|
||||||
|
"target": "t10_17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_18",
|
||||||
|
"source": "t10_17",
|
||||||
|
"target": "t10_18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t10_19",
|
||||||
|
"source": "t10_18",
|
||||||
|
"target": "t10_19"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"createdAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"appVersion": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,654 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_appsystem_12",
|
||||||
|
"name": "组件测试 12 · 锁屏与解锁【手动】",
|
||||||
|
"description": "手动测试:先阅读测试说明。默认不开启执行开关,不能把组件返回成功当作功能通过。",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger": {
|
||||||
|
"type": "manual",
|
||||||
|
"label": "手动运行"
|
||||||
|
},
|
||||||
|
"variables": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "t12_1",
|
||||||
|
"componentId": "Trigger.Manual",
|
||||||
|
"label": "人工发起",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 80
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"entryName": "开始组件测试"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_2",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化失败计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 222
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_3",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化待确认计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 364
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_4",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化阻塞计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 506
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "blocked"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_5",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "手动执行开关:确认环境后才可改为 true",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 648
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "manualEnabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_6",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "12-01 · ScreenControl_Screenlock",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 790
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_7",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t12_6",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_8",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t12_7",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_9",
|
||||||
|
"componentId": "ScreenControl_Screenlock",
|
||||||
|
"label": "ScreenControl_Screenlock",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"username": "",
|
||||||
|
"pwd": "",
|
||||||
|
"width": "",
|
||||||
|
"height": "",
|
||||||
|
"scale_factor": "100"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_1"
|
||||||
|
},
|
||||||
|
"parentId": "t12_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_10",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"12-01\", \"ScreenControl_Screenlock\", \"MANUAL\", \"{\\\"username\\\":\\\"\\\",\\\"pwd\\\":\\\"\\\",\\\"width\\\":\\\"\\\",\\\"height\\\":\\\"\\\",\\\"scale_factor\\\":\\\"100\\\"}\", \"实际锁屏;需要人在机器旁用 Windows 登录凭据恢复,不改变账户密码\", actual_1]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t12_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_11",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t12_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_12",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t12_7",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_13",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"12-01\", \"ScreenControl_Screenlock\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"username\\\":\\\"\\\",\\\"pwd\\\":\\\"\\\",\\\"width\\\":\\\"\\\",\\\"height\\\":\\\"\\\",\\\"scale_factor\\\":\\\"100\\\"}\", \"实际锁屏;需要人在机器旁用 Windows 登录凭据恢复,不改变账户密码\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t12_6",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_14",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t12_6",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_15",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1160
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t12_6",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_16",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1294
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_1)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_17",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "12-02 · ScreenControl_ScreenUnlock",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1436
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_18",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t12_17",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_19",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t12_18",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_20",
|
||||||
|
"componentId": "ScreenControl_ScreenUnlock",
|
||||||
|
"label": "ScreenControl_ScreenUnlock",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_2"
|
||||||
|
},
|
||||||
|
"parentId": "t12_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_21",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"12-02\", \"ScreenControl_ScreenUnlock\", \"MANUAL\", \"{}\", \"仅尝试唤醒屏幕,loginUnlockSupported 必须为 false;Windows 登录仍需人工完成\", actual_2]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t12_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_22",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t12_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_23",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t12_18",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_24",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"12-02\", \"ScreenControl_ScreenUnlock\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{}\", \"仅尝试唤醒屏幕,loginUnlockSupported 必须为 false;Windows 登录仍需人工完成\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t12_17",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_25",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t12_17",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_26",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t12_17",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_27",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1940
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_2)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_28",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2082
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_29",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "汇总判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2224
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${failures > 0 || pending > 0 || blocked > 0}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_30",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "如实标记未通过",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t12_29",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t12_31",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组全部通过。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t12_29",
|
||||||
|
"slot": "statement_else"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "e_t12_2",
|
||||||
|
"source": "t12_1",
|
||||||
|
"target": "t12_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_3",
|
||||||
|
"source": "t12_2",
|
||||||
|
"target": "t12_3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_4",
|
||||||
|
"source": "t12_3",
|
||||||
|
"target": "t12_4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_5",
|
||||||
|
"source": "t12_4",
|
||||||
|
"target": "t12_5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_6",
|
||||||
|
"source": "t12_5",
|
||||||
|
"target": "t12_6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_16",
|
||||||
|
"source": "t12_6",
|
||||||
|
"target": "t12_16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_17",
|
||||||
|
"source": "t12_16",
|
||||||
|
"target": "t12_17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_27",
|
||||||
|
"source": "t12_17",
|
||||||
|
"target": "t12_27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_28",
|
||||||
|
"source": "t12_27",
|
||||||
|
"target": "t12_28"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t12_29",
|
||||||
|
"source": "t12_28",
|
||||||
|
"target": "t12_29"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"createdAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"appVersion": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_appsystem_13",
|
||||||
|
"name": "组件测试 13 · 远程桌面【手动】",
|
||||||
|
"description": "手动测试:先阅读测试说明。默认不开启执行开关,不能把组件返回成功当作功能通过。",
|
||||||
|
"enabled": true,
|
||||||
|
"trigger": {
|
||||||
|
"type": "manual",
|
||||||
|
"label": "手动运行"
|
||||||
|
},
|
||||||
|
"variables": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "t13_1",
|
||||||
|
"componentId": "Trigger.Manual",
|
||||||
|
"label": "人工发起",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 80
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"entryName": "开始组件测试"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_2",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化失败计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 222
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_3",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化待确认计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 364
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_4",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "初始化阻塞计数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 506
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "blocked"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_5",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "手动执行开关:确认环境后才可改为 true",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 648
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "manualEnabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_6",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "填写用户自己的测试远程地址",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 790
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "remoteAddress"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_7",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "断连单独开关:可能断开当前 Windows 会话,必须另行确认",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 932
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "allowDisconnect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_8",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "13-01 · System_ConnectRemoteDesktop",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1074
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_9",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_8",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_10",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_9",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_11",
|
||||||
|
"componentId": "System_ConnectRemoteDesktop",
|
||||||
|
"label": "System_ConnectRemoteDesktop",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"ip": "${remoteAddress}",
|
||||||
|
"port": "3389"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_1"
|
||||||
|
},
|
||||||
|
"parentId": "t13_9",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_12",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"13-01\", \"System_ConnectRemoteDesktop\", \"MANUAL\", \"{\\\"ip\\\":\\\"${remoteAddress}\\\",\\\"port\\\":\\\"3389\\\"}\", \"人工完成登录,并确认已连接到正确测试机器;启动 mstsc 不等于连接成功\", actual_1]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t13_9",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_13",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t13_9",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_14",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t13_9",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_15",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"13-01\", \"System_ConnectRemoteDesktop\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{\\\"ip\\\":\\\"${remoteAddress}\\\",\\\"port\\\":\\\"3389\\\"}\", \"人工完成登录,并确认已连接到正确测试机器;启动 mstsc 不等于连接成功\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_1"
|
||||||
|
},
|
||||||
|
"parentId": "t13_8",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_16",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_1[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t13_8",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_17",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1520
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_1[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t13_8",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_18",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1578
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_1)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_19",
|
||||||
|
"componentId": "logic_control_try_catch",
|
||||||
|
"label": "13-02 · System_CloseRemoteDesktop",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1720
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_20",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "手动执行保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${manualEnabled === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_19",
|
||||||
|
"slot": "try_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_21",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "默认不执行系统状态操作",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "待人工确认:执行开关未开启"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_20",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_30",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "断连操作单独保护",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${allowDisconnect === true}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_20",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_22",
|
||||||
|
"componentId": "System_CloseRemoteDesktop",
|
||||||
|
"label": "System_CloseRemoteDesktop",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "actual_2"
|
||||||
|
},
|
||||||
|
"parentId": "t13_30",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_23",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录实际值与判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"13-02\", \"System_CloseRemoteDesktop\", \"MANUAL\", \"{}\", \"确认目标会话及恢复方式后才能执行;当前实现会调用 tsdiscon\", actual_2]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t13_20",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_24",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t13_20",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_25",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "累计待人工确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t13_20",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_26",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "记录异常结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${[\"13-02\", \"System_CloseRemoteDesktop\", manualEnabled === true ? \"FAIL\" : \"MANUAL\", \"{}\", \"确认目标会话及恢复方式后才能执行;当前实现会调用 tsdiscon\", error.reason]}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "case_2"
|
||||||
|
},
|
||||||
|
"parentId": "t13_19",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_27",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计失败数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${failures + (case_2[2] === \"FAIL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "failures"
|
||||||
|
},
|
||||||
|
"parentId": "t13_19",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_28",
|
||||||
|
"componentId": "Basic_SetVariable",
|
||||||
|
"label": "异常累计待确认数",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 1880
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"value": "${pending + (case_2[2] === \"MANUAL\" ? 1 : 0)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false,
|
||||||
|
"outputVar": "pending"
|
||||||
|
},
|
||||||
|
"parentId": "t13_19",
|
||||||
|
"slot": "except_fn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_29",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2224
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "用例结果:${json(case_2)}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_31",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "阻止未授权断连",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2060
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "阻塞:断连开关未开启;执行可能中断当前会话。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_30",
|
||||||
|
"slot": "statement_else"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_32",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2366
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_33",
|
||||||
|
"componentId": "logic_control_if_else",
|
||||||
|
"label": "汇总判定",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2508
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"condition": "${failures > 0 || pending > 0 || blocked > 0}"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_34",
|
||||||
|
"componentId": "ExceptionUtil_Throws",
|
||||||
|
"label": "如实标记未通过",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2420
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_33",
|
||||||
|
"slot": "statement_if"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t13_35",
|
||||||
|
"componentId": "Log_Info",
|
||||||
|
"label": "输出测试结果",
|
||||||
|
"position": {
|
||||||
|
"x": 220,
|
||||||
|
"y": 2420
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"msg": "本组全部通过。"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"retryCount": 0,
|
||||||
|
"logException": false
|
||||||
|
},
|
||||||
|
"parentId": "t13_33",
|
||||||
|
"slot": "statement_else"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "e_t13_2",
|
||||||
|
"source": "t13_1",
|
||||||
|
"target": "t13_2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_3",
|
||||||
|
"source": "t13_2",
|
||||||
|
"target": "t13_3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_4",
|
||||||
|
"source": "t13_3",
|
||||||
|
"target": "t13_4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_5",
|
||||||
|
"source": "t13_4",
|
||||||
|
"target": "t13_5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_6",
|
||||||
|
"source": "t13_5",
|
||||||
|
"target": "t13_6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_7",
|
||||||
|
"source": "t13_6",
|
||||||
|
"target": "t13_7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_8",
|
||||||
|
"source": "t13_7",
|
||||||
|
"target": "t13_8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_18",
|
||||||
|
"source": "t13_8",
|
||||||
|
"target": "t13_18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_19",
|
||||||
|
"source": "t13_18",
|
||||||
|
"target": "t13_19"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_29",
|
||||||
|
"source": "t13_19",
|
||||||
|
"target": "t13_29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_32",
|
||||||
|
"source": "t13_29",
|
||||||
|
"target": "t13_32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e_t13_33",
|
||||||
|
"source": "t13_32",
|
||||||
|
"target": "t13_33"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"createdAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-10-30T00:00:00.000Z",
|
||||||
|
"appVersion": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
export const bundleRoot = path.join(projectRoot, "resources/test-flows/app-system");
|
||||||
|
const ref = (expression) => `\${${expression}}`;
|
||||||
|
const literal = (value) => JSON.stringify(value);
|
||||||
|
const epoch = "2026-10-30T00:00:00.000Z";
|
||||||
|
|
||||||
|
// 只生成现有引擎支持的节点,不注册测试专用执行器。
|
||||||
|
export class TestFlow {
|
||||||
|
constructor(number, title, components, manual = false) {
|
||||||
|
this.number = String(number).padStart(2, "0");
|
||||||
|
this.flow = {
|
||||||
|
schemaVersion: 1, id: `test_appsystem_${this.number}`,
|
||||||
|
name: `组件测试 ${this.number} · ${title}${manual ? "【手动】" : ""}`,
|
||||||
|
description: manual ? "手动测试:先阅读测试说明。默认不开启执行开关,不能把组件返回成功当作功能通过。" : "查看运行日志中的用例结果;有失败时任务最终显示失败,其他用例仍继续。",
|
||||||
|
enabled: true, trigger: { type: "manual", label: "手动运行" }, variables: [], nodes: [], edges: [],
|
||||||
|
meta: { createdAt: epoch, updatedAt: epoch, appVersion: "0.1.0" }
|
||||||
|
};
|
||||||
|
this.entry = { id: this.flow.id, file: `${this.flow.id}.json`, name: this.flow.name, manual, components, cases: [] };
|
||||||
|
this.seq = 0;
|
||||||
|
this.top = [];
|
||||||
|
this.add("Trigger.Manual", { entryName: "开始组件测试" }, undefined, "人工发起");
|
||||||
|
this.set("failures", 0, "初始化失败计数");
|
||||||
|
this.set("pending", 0, "初始化待确认计数");
|
||||||
|
this.set("blocked", 0, "初始化阻塞计数");
|
||||||
|
if (manual) this.set("manualEnabled", false, "手动执行开关:确认环境后才可改为 true");
|
||||||
|
}
|
||||||
|
|
||||||
|
add(componentId, values = {}, outputVar, label, parent) {
|
||||||
|
const id = `t${this.number}_${++this.seq}`;
|
||||||
|
const node = { id, componentId, label: label || componentId, position: { x: 220, y: 80 + this.top.length * 180 }, values,
|
||||||
|
options: { timeoutMs: 15000, retryCount: 0, logException: false, ...(outputVar ? { outputVar } : {}) } };
|
||||||
|
if (parent) { node.parentId = parent.id; node.slot = parent.slot; }
|
||||||
|
else {
|
||||||
|
if (this.top.length) this.flow.edges.push({ id: `e_${id}`, source: this.top.at(-1), target: id });
|
||||||
|
this.top.push(id);
|
||||||
|
}
|
||||||
|
this.flow.nodes.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(name, value, label, parent) { return this.add("Basic_SetVariable", { value }, name, label, parent); }
|
||||||
|
log(message, parent) { return this.add("Log_Info", { msg: message }, undefined, "输出测试结果", parent); }
|
||||||
|
command(script, outputVar, label, parent) { return this.add("System_ExecuteCommand", { action: script }, outputVar, label, parent); }
|
||||||
|
|
||||||
|
case(componentId, values, expected, predicate, options = {}) {
|
||||||
|
const index = this.entry.cases.length + 1;
|
||||||
|
const caseId = `${this.number}-${String(index).padStart(2, "0")}`;
|
||||||
|
const out = options.output || `actual_${index}`;
|
||||||
|
const result = `case_${index}`;
|
||||||
|
const container = this.add("logic_control_try_catch", {}, undefined, `${caseId} · ${options.title || componentId}`);
|
||||||
|
const run = { id: container.id, slot: "try_fn" };
|
||||||
|
const caught = { id: container.id, slot: "except_fn" };
|
||||||
|
let work = run;
|
||||||
|
if (this.entry.manual) {
|
||||||
|
const gate = this.add("logic_control_if_else", { condition: "${manualEnabled === true}" }, undefined, "手动执行保护", run);
|
||||||
|
work = { id: gate.id, slot: "statement_if" };
|
||||||
|
this.add("ExceptionUtil_Throws", { msg: "待人工确认:执行开关未开启" }, undefined, "默认不执行系统状态操作", { id: gate.id, slot: "statement_else" });
|
||||||
|
}
|
||||||
|
for (const step of options.before || []) this.add(step.componentId, step.values, step.output, step.label, work);
|
||||||
|
const target = this.add(componentId, values, out, options.title, work);
|
||||||
|
if (options.timeoutMs) target.options.timeoutMs = options.timeoutMs;
|
||||||
|
for (const step of options.after || []) this.add(step.componentId, step.values, step.output, step.label, work);
|
||||||
|
const actual = options.actual || out;
|
||||||
|
const manualResult = options.observe === true;
|
||||||
|
const status = options.blocked ? '"BLOCKED"' : manualResult ? '"MANUAL"' : `(${predicate}) ? "PASS" : "FAIL"`;
|
||||||
|
// 数组是表达式引擎原生支持的结构;对象交给 json/parseJson 处理。
|
||||||
|
const prefix = [caseId, componentId];
|
||||||
|
const input = options.input || JSON.stringify(values);
|
||||||
|
this.set(result, ref(`[${literal(prefix[0])}, ${literal(prefix[1])}, ${status}, ${literal(input)}, ${literal(expected)}, ${actual}]`), "记录实际值与判定", work);
|
||||||
|
this.set("failures", ref(`failures + (${result}[2] === "FAIL" ? 1 : 0)`), "累计失败数", work);
|
||||||
|
this.set("pending", ref(`pending + (${result}[2] === "MANUAL" ? 1 : 0)`), "累计待人工确认数", work);
|
||||||
|
if (options.blocked) this.set("blocked", ref("blocked + 1"), options.blocked, work);
|
||||||
|
const errorStatus = this.entry.manual ? 'manualEnabled === true ? "FAIL" : "MANUAL"' : '"FAIL"';
|
||||||
|
this.set(result, ref(`[${literal(caseId)}, ${literal(componentId)}, ${errorStatus}, ${literal(input)}, ${literal(expected)}, error.reason]`), "记录异常结果", caught);
|
||||||
|
this.set("failures", ref(`failures + (${result}[2] === "FAIL" ? 1 : 0)`), "异常累计失败数", caught);
|
||||||
|
this.set("pending", ref(`pending + (${result}[2] === "MANUAL" ? 1 : 0)`), "异常累计待确认数", caught);
|
||||||
|
this.log(`用例结果:\${json(${result})}`);
|
||||||
|
this.entry.cases.push({ id: caseId, componentId, targetNodeId: target.id, resultVariable: result, input, expected, observe: manualResult, ...(options.blocked ? { blockedReason: options.blocked } : {}) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish() {
|
||||||
|
this.log("本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。");
|
||||||
|
const gate = this.add("logic_control_if_else", { condition: "${failures > 0 || pending > 0 || blocked > 0}" }, undefined, "汇总判定");
|
||||||
|
this.add("ExceptionUtil_Throws", { msg: "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。" }, undefined, "如实标记未通过", { id: gate.id, slot: "statement_if" });
|
||||||
|
this.log("本组全部通过。", { id: gate.id, slot: "statement_else" });
|
||||||
|
let y = 80;
|
||||||
|
for (const id of this.top) {
|
||||||
|
const node = this.flow.nodes.find(item => item.id === id);
|
||||||
|
node.position.y = y;
|
||||||
|
const children = this.flow.nodes.filter(item => item.parentId === id);
|
||||||
|
y += children.length ? 320 + children.length * 46 : 142;
|
||||||
|
}
|
||||||
|
return { flow: this.flow, entry: this.entry };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSuite() {
|
||||||
|
const groups = [];
|
||||||
|
groups.push(processFlow());
|
||||||
|
const conversion = new TestFlow(2, "数据转换", ["Basic_StringToNum", "Basic_ConvertNumber", "Basic_ConvertInt", "Basic_ConvertBool", "Basic_ConvertString", "Basic_DataTypeConversion"]);
|
||||||
|
conversion.case("Basic_StringToNum", { val: "123.5" }, "数字 123.5", "actual_1 === 123.5");
|
||||||
|
conversion.case("Basic_ConvertNumber", { val: "-42.25" }, "数字 -42.25", "actual_2 === -42.25");
|
||||||
|
conversion.case("Basic_ConvertInt", { val: -3.9 }, "整数 -3(向零截断)", "actual_3 === -3");
|
||||||
|
conversion.case("Basic_ConvertBool", { val: "true" }, "布尔 true", "actual_4 === true");
|
||||||
|
conversion.case("Basic_ConvertBool", { val: "false" }, "布尔 false", "actual_5 === false");
|
||||||
|
conversion.case("Basic_ConvertString", { val: 42 }, '文本 "42"', 'actual_6 === "42"');
|
||||||
|
conversion.case("Basic_DataTypeConversion", { val: "42", into_type: "number" }, "数值 42,严格区分文本和数值", "actual_7 === 42");
|
||||||
|
conversion.case("Basic_DataTypeConversion", { val: "-3.9", into_type: "int" }, "整数 -3", "actual_8 === -3");
|
||||||
|
conversion.case("Basic_DataTypeConversion", { val: "false", into_type: "bool" }, "布尔 false", "actual_9 === false");
|
||||||
|
conversion.case("Basic_DataTypeConversion", { val: 42, into_type: "string" }, '文本 "42"', 'actual_10 === "42"');
|
||||||
|
groups.push(conversion.finish());
|
||||||
|
const judge = new TestFlow(3, "数据判断", ["Basic_IsDictionary", "Basic_IsNumber", "Basic_IsNull", "Basic_IsArray", "Basic_Type", "Basic_JudgeDataType"]);
|
||||||
|
for (const [id, val, expected] of [
|
||||||
|
["Basic_IsDictionary", { name: "测试" }, true], ["Basic_IsDictionary", [], false],
|
||||||
|
["Basic_IsNumber", "12.5", true], ["Basic_IsNumber", "中文", false],
|
||||||
|
["Basic_IsNull", null, true], ["Basic_IsNull", "", true], ["Basic_IsNull", 0, false],
|
||||||
|
["Basic_IsArray", [], true], ["Basic_IsArray", "[]", false],
|
||||||
|
["Basic_Type", [1, 2], "list"], ["Basic_Type", null, "null"]
|
||||||
|
]) {
|
||||||
|
const out = `actual_${judge.entry.cases.length + 1}`;
|
||||||
|
judge.case(id, { val }, JSON.stringify(expected), `${out} === ${literal(expected)}`);
|
||||||
|
}
|
||||||
|
judge.case("Basic_JudgeDataType", { val: 42, into_type: "number" }, "数字 42 属于数值类型,返回布尔 true", "actual_12 === true");
|
||||||
|
groups.push(judge.finish());
|
||||||
|
|
||||||
|
const vars = new TestFlow(4, "变量与配置", ["Basic_SetVariable", "Basic_SetVariable_V1", "Basic_Clone", "Basic_GetProcessInfo", "Basic_DebugOutput", "CustomConfig_GetConfig", "Basic_BusinessOrderCount"]);
|
||||||
|
vars.case("Basic_SetVariable", { value: "中文变量往返" }, "原值及输出变量均保持中文文本", 'actual_1 === "中文变量往返"');
|
||||||
|
vars.case("Basic_SetVariable_V1", { variableType: "rpa_int", value: "42" }, "数字 42", "actual_2 === 42");
|
||||||
|
vars.case("Basic_SetVariable_V1", { variableType: "rpa_list", value: "[1,2]" }, "数组 [1,2]", 'json(actual_3) === "[1,2]"');
|
||||||
|
vars.set("original", { title: "中文", nested: { n: 1 } }, "初始化复制源");
|
||||||
|
vars.case("Basic_Clone", { val: "${original}" }, "深复制内容相同,外层和嵌套对象均不共用引用", "json(actual_4) === json(original) && actual_4 !== original && actual_4.nested !== original.nested");
|
||||||
|
vars.case("Basic_GetProcessInfo", {}, "当前流程 ID、名称、运行 ID 和节点数一致", `actual_5.id === ${literal(vars.flow.id)} && actual_5.name === ${literal(vars.flow.name)} && len(actual_5.runId) > 0 && actual_5.nodeCount > 0`);
|
||||||
|
vars.case("Basic_DebugOutput", { msg: "中文日志测试:${actual_2}" }, '日志及返回值为“中文日志测试:42”', 'actual_6 === "中文日志测试:42"');
|
||||||
|
vars.case("CustomConfig_GetConfig", { jsonlist: '[{"name":"timeout","value":30}]', configName: "timeout" }, "数字 30", "actual_7 === 30");
|
||||||
|
vars.case("CustomConfig_GetConfig", { jsonlist: "[]", configName: "missing" }, "未命中返回 null", "actual_8 === null");
|
||||||
|
vars.case("Basic_BusinessOrderCount", { start_count: 10, count: 2 }, "首次计数 12", "actual_9 === 12");
|
||||||
|
vars.case("Basic_BusinessOrderCount", { start_count: 10, count: 2 }, "同一流程再次计数 14", "actual_10 === 14");
|
||||||
|
groups.push(vars.finish());
|
||||||
|
|
||||||
|
groups.push(timingFlow());
|
||||||
|
const system = new TestFlow(6, "系统路径与环境变量", ["System_GetTempPath", "System_GetHomePath", "System_GetSystemPath", "System_GetEnv", "System_SetEnv"]);
|
||||||
|
system.command("@{temp=[IO.Path]::GetTempPath().TrimEnd('\\'); home=[Environment]::GetFolderPath('UserProfile'); desktop=[Environment]::GetFolderPath('Desktop')} | ConvertTo-Json -Compress", "pathOracle", "独立读取 Windows 系统路径");
|
||||||
|
system.set("paths", "${parseJson(pathOracle)}", "解析独立预期路径");
|
||||||
|
system.case("System_GetTempPath", {}, "与系统临时目录一致(忽略尾部分隔符)", 'actual_1 === paths.temp || actual_1 === paths.temp + "\\\\"');
|
||||||
|
system.case("System_GetHomePath", {}, "与 Windows 用户文件夹一致", "actual_2 === paths.home");
|
||||||
|
system.case("System_GetSystemPath", { path_name: "desktop" }, "与 Windows 实际桌面目录一致(包含重定向)", "actual_3 === paths.desktop");
|
||||||
|
system.case("System_GetEnv", { name: "RPA_APP_SYSTEM_TEST_VALUE" }, "读取测试专用变量的原始值,作为恢复依据", "true", { output: "originalEnv" });
|
||||||
|
system.case("System_SetEnv", { name: "RPA_APP_SYSTEM_TEST_VALUE", value: "中文-测试-42" }, "当前应用进程内设置成功", 'actual_5 === "中文-测试-42"');
|
||||||
|
system.case("System_GetEnv", { name: "RPA_APP_SYSTEM_TEST_VALUE" }, "能读回中文-测试-42", 'actual_6 === "中文-测试-42"');
|
||||||
|
system.case("System_SetEnv", { name: "RPA_APP_SYSTEM_TEST_VALUE", value: "${originalEnv}" }, "恢复测试前读取值", "actual_7 === originalEnv");
|
||||||
|
groups.push(system.finish());
|
||||||
|
groups.push(commandFlow());
|
||||||
|
groups.push(...manualFlows());
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFlow() {
|
||||||
|
const f = new TestFlow(1, "应用与 PID", ["App_Run", "App_GetPid", "App_GetPids", "App_Kill", "App_KillV1"]);
|
||||||
|
const csharp = "using System; using System.Threading; public static class RpaFixture { public static void Main() { Thread.Sleep(90000); } }";
|
||||||
|
const setup = `$ErrorActionPreference='Stop'; $tag=[Guid]::NewGuid().ToString('N'); $dir=Join-Path ([IO.Path]::GetTempPath()) ('RPA-AppSystem-Tests\\'+$tag); [IO.Directory]::CreateDirectory($dir) | Out-Null; $name='rpatest_'+$tag.Substring(0,12); $exe=Join-Path $dir ($name+'.exe'); Add-Type -TypeDefinition '${csharp}' -OutputAssembly $exe -OutputType ConsoleApplication; @{folder=$dir;exe=$exe;name=$name} | ConvertTo-Json -Compress`;
|
||||||
|
f.command(setup, "fixtureJson", "准备唯一测试程序(90 秒自动退出)");
|
||||||
|
f.set("fixture", "${parseJson(fixtureJson)}", "读取夹具路径与专用进程名");
|
||||||
|
f.case("App_Run", { executeable: "${fixture.exe}" }, "第一个测试进程 PID 大于 0", "pid1 > 0", { output: "pid1" });
|
||||||
|
f.case("App_Run", { executeable: "${fixture.exe}" }, "第二个 PID 大于 0,且与第一个不同", "pid2 > 0 && pid2 !== pid1", { output: "pid2" });
|
||||||
|
f.case("App_GetPid", { pattern: "${fixture.name}.exe" }, "返回本次创建的任一 PID", "actual_3 === pid1 || actual_3 === pid2");
|
||||||
|
f.case("App_GetPids", { pattern: "${fixture.name}.exe" }, "列表恰含本次创建的两个 PID", "len(actual_4) === 2 && ((actual_4[0] === pid1 && actual_4[1] === pid2) || (actual_4[0] === pid2 && actual_4[1] === pid1))");
|
||||||
|
f.case("App_Kill", { pid: "${pid1}" }, "结束第一个夹具 PID,第二个仍在运行", "parseJson(alive1) === false && parseJson(alive2) === true", {
|
||||||
|
after: [processProbe("pid1", "alive1"), processProbe("pid2", "alive2")], actual: "[actual_5, parseJson(alive1), parseJson(alive2)]"
|
||||||
|
});
|
||||||
|
f.case("App_KillV1", { close_type: "id", pid: "${pid2}", kill_all: "0" }, "只填写 PID 即可结束第二个夹具进程", "parseJson(aliveAfterV1) === false", {
|
||||||
|
after: [processProbe("pid2", "aliveAfterV1")], actual: "[actual_6, parseJson(aliveAfterV1)]"
|
||||||
|
});
|
||||||
|
f.case("App_GetPids", { pattern: "${fixture.name}.exe" }, "所有夹具结束后应返回空数组 []", "len(actual_7) === 0");
|
||||||
|
f.case("App_GetPid", { pattern: "${fixture.name}.exe" }, "无匹配时应返回 null", "actual_8 === null");
|
||||||
|
const cleanup = "$ErrorActionPreference='Stop'; $fixture='${fixture.exe}'; if ((Split-Path $fixture -Leaf) -notmatch '^rpatest_[0-9a-f]{12}\\.exe$') { throw '拒绝清理非测试进程' }; Get-Process -Name '${fixture.name}' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $fixture } | Stop-Process -Force; '测试进程清理完成'";
|
||||||
|
f.command(cleanup, "cleanupResult", "收尾:仅清理本次专用测试进程");
|
||||||
|
return f.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
function processProbe(pid, output) {
|
||||||
|
return { componentId: "System_ExecuteCommand", values: { action: `if (Get-Process -Id \${${pid}} -ErrorAction SilentlyContinue) { 'true' } else { 'false' }` }, output, label: "独立核对 PID 是否存活" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingFlow() {
|
||||||
|
const f = new TestFlow(5, "等待与随机数", ["Basic_Delay_V2", "Basic_Delay_V1", "Basic_Delay", "Basic_RandomInt", "Basic_Rnd"]);
|
||||||
|
for (const [id, values, low, high] of [
|
||||||
|
["Basic_Delay_V2", { is_random: "0", second: "0.15", min_second: "0.1", max_second: "0.2" }, 120, 1800],
|
||||||
|
["Basic_Delay_V2", { is_random: "1", second: "0.15", min_second: "0.1", max_second: "0.2" }, 80, 1800],
|
||||||
|
["Basic_Delay_V1", { second: "0.15" }, 120, 1800],
|
||||||
|
["Basic_Delay", { mileSecond: "3000" }, 2800, 6500]
|
||||||
|
]) {
|
||||||
|
const step = (output) => ({ componentId: "Time_GetDatetimeObj", values: { date_input: "${now()}" }, output, label: "独立采集毫秒时间" });
|
||||||
|
f.case(id, values, `实际等待耗时 ${low}~${high} ms`, `endTime.timestamp - startTime.timestamp >= ${low} && endTime.timestamp - startTime.timestamp <= ${high}`, { before: [step("startTime")], after: [step("endTime")], actual: "endTime.timestamp - startTime.timestamp" });
|
||||||
|
}
|
||||||
|
f.case("Basic_RandomInt", { min: 7, max: 7 }, "最小值等于最大值时返回整数 7", "actual_5 === 7");
|
||||||
|
f.case("Basic_RandomInt", { min: -2, max: 2 }, "返回 -2~2 的整数", "actual_6 >= -2 && actual_6 <= 2 && actual_6 % 1 === 0");
|
||||||
|
f.case("Basic_Rnd", {}, "随机小数满足 0 ≤ x < 1", "actual_7 >= 0 && actual_7 < 1");
|
||||||
|
return f.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFlow() {
|
||||||
|
const f = new TestFlow(7, "命令行", ["System_ExecuteCommand", "System_ExecuteCommandV1"]);
|
||||||
|
f.case("System_ExecuteCommand", { action: "Write-Output 'RPA-PS-中文-OK'" }, "RPA-PS-中文-OK", 'actual_1 === "RPA-PS-中文-OK"');
|
||||||
|
f.case("System_ExecuteCommandV1", { command_type: "1", dos_command: "echo RPA-DOS-OK", get_ret: "1" }, "RPA-DOS-OK", 'actual_2 === "RPA-DOS-OK"');
|
||||||
|
f.case("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output 'RPA-PS-V1-OK'", get_ret: "1" }, "RPA-PS-V1-OK", 'actual_3 === "RPA-PS-V1-OK"');
|
||||||
|
f.command("$dir=Join-Path ([IO.Path]::GetTempPath()) ('RPA-AppSystem-Tests\\'+[Guid]::NewGuid().ToString('N')); [IO.Directory]::CreateDirectory($dir) | Out-Null; $bat=Join-Path $dir 'echo-test.bat'; [IO.File]::WriteAllText($bat, '@echo RPA-BAT-OK', [Text.Encoding]::ASCII); $bat", "batPath", "创建仅输出标记的 BAT 夹具");
|
||||||
|
f.case("System_ExecuteCommandV1", { command_type: "2", bat_file: "${batPath}", get_ret: "1" }, "RPA-BAT-OK", 'actual_4 === "RPA-BAT-OK"');
|
||||||
|
f.case("System_ExecuteCommand", { action: "Write-Output 'RPA-ERROR-PROBE'; exit 9" }, "非零退出码应抛出异常", "false", { title: "非零返回码检查" });
|
||||||
|
// 最后一例是预期异常:由正常 Catch 记录 PASS,仍保留实际异常证据。
|
||||||
|
const last = f.entry.cases.at(-1);
|
||||||
|
const caught = f.flow.nodes.find(node => node.options.outputVar === last.resultVariable && node.slot === "except_fn");
|
||||||
|
caught.values.value = ref(`[${literal(last.id)}, ${literal(last.componentId)}, "PASS", ${literal(last.input)}, ${literal(last.expected)}, error.reason]`);
|
||||||
|
return f.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
function manualFlows() {
|
||||||
|
const result = [];
|
||||||
|
const credential = new TestFlow(8, "Windows 凭据", ["System_AddCredential", "System_ReadCredential", "System_DeleteCredential"], true);
|
||||||
|
credential.set("credentialTarget", "RPA-AppSystem-Tests-${now()}", "只允许使用新的测试凭据目标");
|
||||||
|
credential.case("System_AddCredential", { application: "${credentialTarget}", name: "", password: "" }, "安全输入机制准备好后,在专用目标添加测试凭据", "false", { observe: true });
|
||||||
|
credential.case("System_ReadCredential", { application: "${credentialTarget}" }, "仅验证测试凭据存在,不读取或打印密码", "false", { observe: true });
|
||||||
|
credential.case("System_DeleteCredential", { application: "${credentialTarget}" }, "删除刚创建的测试目标,之后人工确认不存在", "false", { observe: true });
|
||||||
|
result.push(credential.finish());
|
||||||
|
|
||||||
|
const ime = new TestFlow(9, "输入法", ["System_GetInputMethodState", "System_SetInputMethodState"], true);
|
||||||
|
ime.case("System_GetInputMethodState", {}, "读取前台窗口的真实输入法状态,人工对照任务栏", "false", { observe: true, output: "originalIme" });
|
||||||
|
ime.case("System_SetInputMethodState", { mode: "en-US" }, "前台编辑器应切换到英文模式;仅子进程返回 set:true 不能算通过", "false", { observe: true });
|
||||||
|
ime.case("System_SetInputMethodState", { mode: "${originalIme.culture}" }, "恢复原输入法;人工确认前台窗口的实际状态", "false", { observe: true });
|
||||||
|
result.push(ime.finish());
|
||||||
|
|
||||||
|
const sound = new TestFlow(10, "音频", ["System_PlaySound"], true);
|
||||||
|
const wav = fs.readFileSync(path.join(bundleRoot, "fixtures/tone.wav")).toString("base64");
|
||||||
|
sound.command(`$dir=Join-Path ([IO.Path]::GetTempPath()) ('RPA-AppSystem-Tests\\'+[Guid]::NewGuid().ToString('N')); [IO.Directory]::CreateDirectory($dir) | Out-Null; $wav=Join-Path $dir 'tone.wav'; [IO.File]::WriteAllBytes($wav,[Convert]::FromBase64String('${wav}')); $wav`, "audioPath", "准备 0.3 秒低音量测试音");
|
||||||
|
sound.case("System_PlaySound", { audio: "${audioPath}" }, "听到一次短提示音;没有声音不能算通过", "false", { observe: true });
|
||||||
|
result.push(sound.finish());
|
||||||
|
|
||||||
|
const saver = new TestFlow(11, "屏保", ["System_ScreenSaverShow", "System_ScreenSaverShowV1", "System_ScreenSaverStop"], true);
|
||||||
|
saver.case("System_ScreenSaverShow", { text: "RPA 测试", status: "1" }, "屏幕实际进入屏保;无配置时记录阻塞", "false", { observe: true });
|
||||||
|
saver.case("System_ScreenSaverStop", {}, "屏保实际关闭,可正常操作", "false", { observe: true });
|
||||||
|
saver.case("System_ScreenSaverShowV1", {}, "开启 Windows 已配置的屏保,不要求输入密码;人工确认屏幕实际变化", "false", { observe: true });
|
||||||
|
saver.case("System_ScreenSaverStop", {}, "第二种屏保实际关闭", "false", { observe: true });
|
||||||
|
result.push(saver.finish());
|
||||||
|
|
||||||
|
const lock = new TestFlow(12, "锁屏与解锁", ["ScreenControl_Screenlock", "ScreenControl_ScreenUnlock"], true);
|
||||||
|
lock.case("ScreenControl_Screenlock", { username: "", pwd: "", width: "", height: "", scale_factor: "100" }, "实际锁屏;需要人在机器旁用 Windows 登录凭据恢复,不改变账户密码", "false", { observe: true });
|
||||||
|
lock.case("ScreenControl_ScreenUnlock", {}, "仅尝试唤醒屏幕,loginUnlockSupported 必须为 false;Windows 登录仍需人工完成", "false", { observe: true });
|
||||||
|
result.push(lock.finish());
|
||||||
|
|
||||||
|
const remote = new TestFlow(13, "远程桌面", ["System_ConnectRemoteDesktop", "System_CloseRemoteDesktop"], true);
|
||||||
|
remote.set("remoteAddress", "", "填写用户自己的测试远程地址");
|
||||||
|
remote.set("allowDisconnect", false, "断连单独开关:可能断开当前 Windows 会话,必须另行确认");
|
||||||
|
remote.case("System_ConnectRemoteDesktop", { ip: "${remoteAddress}", port: "3389" }, "人工完成登录,并确认已连接到正确测试机器;启动 mstsc 不等于连接成功", "false", { observe: true });
|
||||||
|
const disconnect = remote.case("System_CloseRemoteDesktop", {}, "确认目标会话及恢复方式后才能执行;当前实现会调用 tsdiscon", "false", { observe: true });
|
||||||
|
const target = remote.flow.nodes.find(node => node.options.outputVar === disconnect);
|
||||||
|
const parent = { id: target.parentId, slot: target.slot };
|
||||||
|
const guard = remote.add("logic_control_if_else", { condition: "${allowDisconnect === true}" }, undefined, "断连操作单独保护", parent);
|
||||||
|
// 子节点按数组顺序执行,保护节点必须在目标前。
|
||||||
|
remote.flow.nodes.splice(remote.flow.nodes.indexOf(guard), 1);
|
||||||
|
remote.flow.nodes.splice(remote.flow.nodes.indexOf(target), 0, guard);
|
||||||
|
target.parentId = guard.id; target.slot = "statement_if";
|
||||||
|
remote.add("ExceptionUtil_Throws", { msg: "阻塞:断连开关未开启;执行可能中断当前会话。" }, undefined, "阻止未授权断连", { id: guard.id, slot: "statement_else" });
|
||||||
|
result.push(remote.finish());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSuite() {
|
||||||
|
const groups = buildSuite();
|
||||||
|
fs.mkdirSync(bundleRoot, { recursive: true });
|
||||||
|
for (const { flow, entry } of groups) fs.writeFileSync(path.join(bundleRoot, entry.file), JSON.stringify(flow, null, 2) + "\n");
|
||||||
|
const manifest = { schemaVersion: 1, generatedAt: epoch, groups: groups.map(item => item.entry) };
|
||||||
|
fs.writeFileSync(path.join(bundleRoot, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import net from "node:net";
|
||||||
|
import { spawn, execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { projectRoot, bundleRoot, writeSuite } from "./app-system-test-flows.mjs";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
const containers = new Set(["logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_dictionary_for_v1", "logic_control_while", "infinite_loop", "logic_control_try_catch"]);
|
||||||
|
const statusNames = { PASS: "通过", FAIL: "失败", BLOCKED: "阻塞", MANUAL: "待人工确认", UNRUN: "未运行" };
|
||||||
|
|
||||||
|
export function readBundle() {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(bundleRoot, "manifest.json"), "utf8"));
|
||||||
|
return { manifest, groups: manifest.groups.map(entry => ({ entry, flow: JSON.parse(fs.readFileSync(path.join(bundleRoot, entry.file), "utf8")) })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态清单供离线校验;真实运行前再与 Electron 返回的 implemented 清单逐项比较。
|
||||||
|
export function staticCatalog() {
|
||||||
|
const registered = new Set();
|
||||||
|
const directory = path.join(projectRoot, "src/main/capabilities");
|
||||||
|
for (const name of fs.readdirSync(directory).filter(name => name.endsWith(".ts") && !name.endsWith(".test.ts"))) {
|
||||||
|
const source = ts.createSourceFile(name, fs.readFileSync(path.join(directory, name), "utf8"), ts.ScriptTarget.Latest, true);
|
||||||
|
const visit = node => {
|
||||||
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "register") {
|
||||||
|
const first = node.arguments[0];
|
||||||
|
if (first && ts.isStringLiteral(first)) registered.add(first.text);
|
||||||
|
if (first && ts.isArrayLiteralExpression(first)) for (const item of first.elements) if (ts.isStringLiteral(item)) registered.add(item.text);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(source);
|
||||||
|
}
|
||||||
|
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "resources/catalog.json"), "utf8"));
|
||||||
|
for (const c of catalog.components) c.implemented = containers.has(c.id) || registered.has(c.id) || registered.has(c.functionName);
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validator() {
|
||||||
|
const source = fs.readFileSync(path.join(projectRoot, "src/renderer/src/validation/flowValidation.ts"), "utf8");
|
||||||
|
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||||
|
return (await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`)).validateFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBundle(catalog = staticCatalog()) {
|
||||||
|
const { manifest, groups } = readBundle();
|
||||||
|
const expected = catalog.components.filter(c => c.group === "app" && c.implemented).map(c => c.id).sort();
|
||||||
|
const actual = groups.flatMap(g => g.entry.components).sort();
|
||||||
|
if (groups.length !== 13 || actual.length !== 49 || new Set(actual).size !== 49 || JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("流程包与当前 49 项可执行清单不一致,请重新生成并核对覆盖范围。");
|
||||||
|
const validateFlow = await validator();
|
||||||
|
const validation = {};
|
||||||
|
for (const { flow, entry } of groups) {
|
||||||
|
const nodes = new Map(flow.nodes.map(n => [n.id, n]));
|
||||||
|
if (nodes.size !== flow.nodes.length || flow.nodes[0].componentId !== "Trigger.Manual") throw new Error(`${flow.id} 节点 ID 重复或缺少入口`);
|
||||||
|
const covered = new Set(entry.cases.map(c => c.componentId));
|
||||||
|
if (entry.components.some(id => !covered.has(id)) || covered.size !== entry.components.length) throw new Error(`${flow.id} 用例覆盖不完整`);
|
||||||
|
for (const c of entry.cases) if (nodes.get(c.targetNodeId)?.componentId !== c.componentId) throw new Error(`${c.id} 目标节点不匹配`);
|
||||||
|
const reached = new Set([flow.nodes[0].id]);
|
||||||
|
for (let i = 0; i < nodes.size; i++) {
|
||||||
|
for (const edge of flow.edges) {
|
||||||
|
if (!nodes.has(edge.source) || !nodes.has(edge.target)) throw new Error(`${flow.id} 连线指向不存在的节点`);
|
||||||
|
if (reached.has(edge.source)) reached.add(edge.target);
|
||||||
|
}
|
||||||
|
for (const node of flow.nodes) if (reached.has(node.parentId)) reached.add(node.id);
|
||||||
|
}
|
||||||
|
if (reached.size !== nodes.size) throw new Error(`${flow.id} 存在断链或孤立节点`);
|
||||||
|
validation[entry.id] = validateFlow(flow, catalog);
|
||||||
|
const unexpected = validation[entry.id].filter(i => i.code !== "required");
|
||||||
|
if (unexpected.length) throw new Error(`${flow.id} 存在意外校验问题:${JSON.stringify(unexpected)}`);
|
||||||
|
}
|
||||||
|
return { manifest, groups, validation, executableCount: expected.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installBundle(userData) {
|
||||||
|
const { groups } = readBundle();
|
||||||
|
const directory = path.join(path.resolve(userData), "flows");
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
const installed = [], skipped = [];
|
||||||
|
for (const { flow } of groups) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const target = path.join(directory, `${flow.id}.json`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(target, JSON.stringify({ ...flow, meta: { ...flow.meta, createdAt: now, updatedAt: now } }, null, 2) + "\n", { flag: "wx" });
|
||||||
|
installed.push(flow.name);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "EEXIST") throw error;
|
||||||
|
skipped.push(flow.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { userData: path.resolve(userData), installed, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅升级仍与指定旧模板一致的测试任务;用户修改过的内容保持原样。 */
|
||||||
|
export function upgradeBundle(userData, baselineDirectory, backupDirectory) {
|
||||||
|
const { groups } = readBundle();
|
||||||
|
const directory = path.join(path.resolve(userData), "flows");
|
||||||
|
const normalize = flow => { const { meta, ...content } = flow; return JSON.stringify(content); };
|
||||||
|
const upgraded = [], skipped = [];
|
||||||
|
for (const { flow, entry } of groups) {
|
||||||
|
const target = path.join(directory, entry.file);
|
||||||
|
const baseline = path.join(path.resolve(baselineDirectory), entry.file);
|
||||||
|
if (!fs.existsSync(target) || !fs.existsSync(baseline)) { skipped.push({ id: entry.id, reason: "缺少现有任务或旧模板" }); continue; }
|
||||||
|
let current;
|
||||||
|
try { current = JSON.parse(fs.readFileSync(target, "utf8")); }
|
||||||
|
catch { skipped.push({ id: entry.id, reason: "任务文件无法解析,保留原文件" }); continue; }
|
||||||
|
if (normalize(current) === normalize(flow)) { skipped.push({ id: entry.id, reason: "已是当前版本" }); continue; }
|
||||||
|
if (normalize(current) !== normalize(JSON.parse(fs.readFileSync(baseline, "utf8")))) { skipped.push({ id: entry.id, reason: "用户已修改,保留现有版本" }); continue; }
|
||||||
|
fs.mkdirSync(backupDirectory, { recursive: true });
|
||||||
|
fs.copyFileSync(target, path.join(backupDirectory, entry.file), fs.constants.COPYFILE_EXCL);
|
||||||
|
const updated = { ...flow, meta: { ...flow.meta, createdAt: current.meta?.createdAt || flow.meta.createdAt, updatedAt: new Date().toISOString() } };
|
||||||
|
const temp = `${target}.tmp`;
|
||||||
|
fs.writeFileSync(temp, JSON.stringify(updated, null, 2) + "\n", { flag: "wx" });
|
||||||
|
fs.renameSync(temp, target);
|
||||||
|
upgraded.push(entry.id);
|
||||||
|
}
|
||||||
|
return { upgraded, skipped, backupDirectory: path.resolve(backupDirectory) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
||||||
|
const port = server.address().port;
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cdpCommand(page, method, params, timeout = 180000) {
|
||||||
|
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error("CDP 连接超时")), 10000); socket.onopen = resolve; socket.onerror = reject; });
|
||||||
|
clearTimeout(timer);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`隔离检查 ${method} 超时`)), timeout);
|
||||||
|
socket.onerror = reject;
|
||||||
|
socket.onclose = () => reject(new Error("隔离应用连接提前关闭"));
|
||||||
|
socket.onmessage = event => {
|
||||||
|
const response = JSON.parse(event.data);
|
||||||
|
if (response.id !== 1) return;
|
||||||
|
if (response.error || response.result?.exceptionDetails) reject(new Error(JSON.stringify(response.error || response.result.exceptionDetails)));
|
||||||
|
else resolve(response.result);
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify({ id: 1, method, params }));
|
||||||
|
});
|
||||||
|
} finally { clearTimeout(timer); socket.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(page, expression, timeout = 180000) {
|
||||||
|
const response = await cdpCommand(page, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, timeout);
|
||||||
|
return response?.result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRenderer(page, output) {
|
||||||
|
const specs = [
|
||||||
|
["conversion", "Basic_DataTypeConversion", { val: "42", into_type: "number" }],
|
||||||
|
["judgment", "Basic_JudgeDataType", { val: 42, into_type: "number" }],
|
||||||
|
["kill", "App_KillV1", { close_type: "id", pid: "", p_name: "" }],
|
||||||
|
["command", "System_ExecuteCommandV1", { command_type: "1", dos_command: "echo ui-check" }],
|
||||||
|
["wake", "ScreenControl_ScreenUnlock", {}]
|
||||||
|
];
|
||||||
|
const flow = { schemaVersion: 1, id: "test_appsystem_ui", name: "界面验证:组件参数", variables: [],
|
||||||
|
nodes: [{ id: "ui_start", componentId: "Trigger.Manual", values: {}, options: {}, position: { x: 220, y: 60 } }, ...specs.map(([id, componentId, values], index) => ({ id: `ui_${id}`, componentId, values, options: {}, position: { x: 220, y: 200 + index * 142 } }))],
|
||||||
|
edges: [], meta: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), appVersion: "test" } };
|
||||||
|
await evaluate(page, `window.rpa.flows.save(${JSON.stringify(flow)})`);
|
||||||
|
await evaluate(page, "document.querySelector('.refresh-btn').click()");
|
||||||
|
const waitFor = async expression => {
|
||||||
|
for (let n = 0; n < 100; n++) { if (await evaluate(page, expression)) return; await pause(50); }
|
||||||
|
throw new Error(`前端状态等待超时:${expression}`);
|
||||||
|
};
|
||||||
|
await waitFor(`Array.from(document.querySelectorAll('.task-row')).some(row => row.textContent.includes(${JSON.stringify(flow.name)}))`);
|
||||||
|
await evaluate(page, `Array.from(document.querySelectorAll('.task-row')).find(row => row.textContent.includes(${JSON.stringify(flow.name)})).querySelector('button[title="编辑流程"]').click()`);
|
||||||
|
const select = async (id, componentId) => {
|
||||||
|
await waitFor(`Boolean(document.querySelector('[data-id="ui_${id}"]'))`);
|
||||||
|
await evaluate(page, `document.querySelector('[data-id="ui_${id}"]').click()`);
|
||||||
|
await waitFor(`document.querySelector('.props-id')?.textContent === ${JSON.stringify(componentId)}`);
|
||||||
|
};
|
||||||
|
const labels = () => evaluate(page, "Array.from(document.querySelectorAll('.props .field-label')).map(el => el.textContent.trim())");
|
||||||
|
const clickOption = text => evaluate(page, `Array.from(document.querySelectorAll('.props .option-chip')).find(el => el.textContent === ${JSON.stringify(text)}).click()`);
|
||||||
|
const screenshot = async name => {
|
||||||
|
const result = await cdpCommand(page, "Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
||||||
|
fs.writeFileSync(path.join(output, name), Buffer.from(result.data, "base64"));
|
||||||
|
};
|
||||||
|
const observations = {};
|
||||||
|
await select("conversion", "Basic_DataTypeConversion");
|
||||||
|
observations.conversionOptions = await evaluate(page, "Array.from(document.querySelector('.props select').options).map(o => ({label:o.text,value:o.value}))");
|
||||||
|
if (!["number", "int", "bool", "string"].every(value => observations.conversionOptions.some(o => o.value === value))) throw new Error("转换类型下拉选项缺失");
|
||||||
|
await screenshot("ui-conversion.png");
|
||||||
|
await select("judgment", "Basic_JudgeDataType");
|
||||||
|
observations.judgmentOptions = await evaluate(page, "Array.from(document.querySelector('.props select').options).map(o => o.value)");
|
||||||
|
if (!["number", "int", "null", "dict", "list", "string", "bool"].every(value => observations.judgmentOptions.includes(value))) throw new Error("判断类型下拉选项缺失");
|
||||||
|
await select("kill", "App_KillV1");
|
||||||
|
observations.killPidFields = await labels();
|
||||||
|
if (!observations.killPidFields.some(s => s.startsWith("进程PID")) || observations.killPidFields.some(s => s.startsWith("进程名称"))) throw new Error("PID 模式显示了错误字段");
|
||||||
|
await clickOption("按进程名称"); await pause(80);
|
||||||
|
observations.killNameFields = await labels();
|
||||||
|
if (!observations.killNameFields.some(s => s.startsWith("进程名称")) || observations.killNameFields.some(s => s.startsWith("进程PID"))) throw new Error("名称模式显示了错误字段");
|
||||||
|
await screenshot("ui-kill-name.png");
|
||||||
|
await select("command", "System_ExecuteCommandV1");
|
||||||
|
observations.commandDosFields = await labels();
|
||||||
|
if (!observations.commandDosFields.some(s => s.startsWith("DOS命令")) || observations.commandDosFields.some(s => s.startsWith("BAT文件") || s.startsWith("PowerShell命令"))) throw new Error("DOS 模式显示了错误字段");
|
||||||
|
await clickOption("PowerShell 命令"); await pause(80);
|
||||||
|
observations.commandPsFields = await labels();
|
||||||
|
if (!observations.commandPsFields.some(s => s.startsWith("PowerShell命令")) || observations.commandPsFields.some(s => s.startsWith("DOS命令") || s.startsWith("BAT文件"))) throw new Error("PowerShell 模式显示了错误字段");
|
||||||
|
await screenshot("ui-command-ps.png");
|
||||||
|
await select("wake", "ScreenControl_ScreenUnlock");
|
||||||
|
observations.wakeTitle = await evaluate(page, "document.querySelector('.props-title').textContent");
|
||||||
|
observations.wakeFields = await labels();
|
||||||
|
if (!observations.wakeTitle.includes("非登录解锁") || observations.wakeFields.some(s => s === "密码" || s === "用户名")) throw new Error("唤醒组件仍在宣称登录解锁或索要凭据");
|
||||||
|
await screenshot("ui-wake.png");
|
||||||
|
fs.writeFileSync(path.join(output, "ui-check.json"), JSON.stringify({ passed: true, observations }, null, 2) + "\n");
|
||||||
|
return observations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSafe(outputDirectory, checkUi = false) {
|
||||||
|
const output = path.resolve(outputDirectory);
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const profile = fs.mkdtempSync(path.join(output, "isolated-"));
|
||||||
|
const debugPort = await freePort();
|
||||||
|
const candidates = [process.env.RPA_ELECTRON_DIST, "C:/electron-rpa/dist", path.join(projectRoot, "node_modules/electron/dist")].filter(Boolean);
|
||||||
|
const dist = candidates.find(dir => fs.existsSync(path.join(dir, "electron.exe")));
|
||||||
|
if (!dist) throw new Error("找不到 Electron,请先安装项目依赖。");
|
||||||
|
if (!fs.existsSync(path.join(projectRoot, "out/main/index.js"))) throw new Error("请先执行 npm run build。");
|
||||||
|
// Chromium 在隐藏窗口中可能停止后续绘制;界面截图检查使用独立可见窗口。
|
||||||
|
const env = { ...process.env, RPA_E2E: checkUi ? "0" : "1", RPA_SKIP_SINGLE_INSTANCE: "1", RPA_FIXTURE_PORT: "0", RPA_APP_SYSTEM_TEST_VALUE: "隔离环境原始值" };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
const child = spawn(path.join(dist, "electron.exe"), [projectRoot, `--user-data-dir=${profile}`, `--remote-debugging-port=${debugPort}`, "--remote-debugging-address=127.0.0.1", "--no-sandbox"], { cwd: projectRoot, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let diagnostics = "";
|
||||||
|
for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-16000); });
|
||||||
|
child.on("error", error => { diagnostics += error.message; });
|
||||||
|
const runs = [];
|
||||||
|
try {
|
||||||
|
let page;
|
||||||
|
const deadline = Date.now() + 45000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) throw new Error(`隔离应用提前退出:${diagnostics}`);
|
||||||
|
try {
|
||||||
|
const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`, { signal: AbortSignal.timeout(1500) })).json();
|
||||||
|
page = targets.find(p => p.type === "page" && p.title === "RPA Studio");
|
||||||
|
if (page && await evaluate(page, "Boolean(window.rpa)", 5000)) break;
|
||||||
|
} catch { /* Electron 和 preload 尚未就绪,继续有界等待。 */ }
|
||||||
|
page = undefined;
|
||||||
|
await pause(200);
|
||||||
|
}
|
||||||
|
if (!page) throw new Error(`等待隔离应用超时:${diagnostics}`);
|
||||||
|
const info = await evaluate(page, "window.rpa.app.info()");
|
||||||
|
if (path.resolve(info.userData).toLowerCase() !== profile.toLowerCase()) throw new Error("隔离用户目录不匹配,已拒绝执行测试。");
|
||||||
|
const catalog = await evaluate(page, "window.rpa.catalog.get()");
|
||||||
|
const checked = await verifyBundle(catalog);
|
||||||
|
for (const { flow, entry } of checked.groups) {
|
||||||
|
const issues = checked.validation[entry.id];
|
||||||
|
if (entry.manual || issues.some(i => i.severity === "error")) {
|
||||||
|
const status = issues.some(i => i.severity === "error") ? "BLOCKED" : "MANUAL";
|
||||||
|
runs.push({ flowId: flow.id, status, validation: issues, reason: status === "BLOCKED" ? "运行前参数校验拦截,未绕过校验;详见校验问题" : "手动任务不纳入自动执行" });
|
||||||
|
console.log(`${flow.name}:${statusNames[status]}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = await evaluate(page, `(async () => {
|
||||||
|
const flow = ${JSON.stringify(flow)};
|
||||||
|
await window.rpa.flows.save(flow);
|
||||||
|
const events = [];
|
||||||
|
const off = window.rpa.run.onEvent(event => events.push(event));
|
||||||
|
try {
|
||||||
|
const started = await window.rpa.run.start(flow);
|
||||||
|
const finished = await new Promise((resolve, reject) => {
|
||||||
|
const end = Date.now() + 150000;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const item = events.find(e => e.runId === started.runId && e.type === 'run.finished');
|
||||||
|
if (item) { clearInterval(timer); resolve(item); }
|
||||||
|
else if (Date.now() > end) { clearInterval(timer); void window.rpa.run.stop(started.runId); reject(new Error('流程执行超时')); }
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
return { flowId: flow.id, runId: started.runId, status: finished.status, durationMs: finished.durationMs, events };
|
||||||
|
} finally { off(); }
|
||||||
|
})()`);
|
||||||
|
// run.finished 先通过 IPC 发出,主进程随后落盘,等待真正持久化再读取证据。
|
||||||
|
const persisted = path.join(profile, "runs", record.runId, "run.json");
|
||||||
|
for (let n = 0; n < 30 && !fs.existsSync(persisted); n++) await pause(100);
|
||||||
|
if (!fs.existsSync(persisted)) throw new Error(`${record.runId} 没有持久化运行证据`);
|
||||||
|
record.persistedFile = persisted;
|
||||||
|
record.result = JSON.parse(fs.readFileSync(persisted, "utf8"));
|
||||||
|
runs.push(record);
|
||||||
|
console.log(`${flow.name}:${record.status}(${record.durationMs} ms)`);
|
||||||
|
}
|
||||||
|
const report = { generatedAt: new Date().toISOString(), profile, executableCount: checked.executableCount, runs };
|
||||||
|
fs.writeFileSync(path.join(output, "latest.json"), JSON.stringify(report, null, 2) + "\n");
|
||||||
|
if (checkUi) {
|
||||||
|
try { report.ui = { passed: true, observations: await verifyRenderer(page, output) }; }
|
||||||
|
catch (error) { report.ui = { passed: false, reason: error.message }; throw error; }
|
||||||
|
finally { fs.writeFileSync(path.join(output, "latest.json"), JSON.stringify(report, null, 2) + "\n"); }
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
} finally {
|
||||||
|
if (child.pid && child.exitCode === null) {
|
||||||
|
try { await execFileAsync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, timeout: 10000 }); }
|
||||||
|
catch (error) { if (child.exitCode === null) console.warn(`隔离进程退出检查:${error.message}`); }
|
||||||
|
}
|
||||||
|
// 保留隔离目录中的 run.json 作为可审阅证据,不清理用户或仓库目录。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseResults(entry, record) {
|
||||||
|
return entry.cases.map(c => {
|
||||||
|
const value = record?.result?.variables?.[c.resultVariable];
|
||||||
|
return { ...c, status: value?.[2] || (record?.status === "BLOCKED" ? "BLOCKED" : entry.manual ? "MANUAL" : "UNRUN"), actual: value?.[5], reason: c.blockedReason || record?.reason };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeReport(evidence) {
|
||||||
|
const { groups } = readBundle();
|
||||||
|
const records = new Map((evidence.runs || []).map(r => [r.flowId, r]));
|
||||||
|
const cases = groups.flatMap(g => caseResults(g.entry, records.get(g.entry.id)));
|
||||||
|
const priority = ["FAIL", "BLOCKED", "MANUAL", "UNRUN", "PASS"];
|
||||||
|
const catalog = staticCatalog();
|
||||||
|
const components = new Map(catalog.components.map(c => [c.id, c]));
|
||||||
|
const rows = groups.flatMap(({ entry }) => entry.components.map(id => {
|
||||||
|
const matches = cases.filter(c => c.componentId === id);
|
||||||
|
const status = priority.find(s => matches.some(c => c.status === s)) || "UNRUN";
|
||||||
|
return { id, name: components.get(id)?.name || id, group: entry.id.slice(-2), status };
|
||||||
|
}));
|
||||||
|
const counts = Object.fromEntries(priority.map(s => [s, rows.filter(r => r.status === s).length]));
|
||||||
|
const escape = value => String(value ?? "无执行记录").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ").slice(0, 1200);
|
||||||
|
const recordedAt = evidence.generatedAt ? new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", dateStyle: "medium", timeStyle: "medium", hour12: false }).format(new Date(evidence.generatedAt)) + "(北京时间)" : "未执行";
|
||||||
|
const lines = ["# 应用与系统组件测试报告", "", `记录时间:${recordedAt}。覆盖 49 项可执行组件,13 组任务。`, "",
|
||||||
|
`按组件统计:通过 ${counts.PASS},失败 ${counts.FAIL},阻塞 ${counts.BLOCKED},待人工确认 ${counts.MANUAL},未运行 ${counts.UNRUN}。`, "",
|
||||||
|
"状态依据是断言、前置校验和人工证据,节点执行成功不直接代表功能通过。未执行手动系统操作。", "",
|
||||||
|
"| 组 | 组件 ID | 名称 | 状态 |", "|---|---|---|---|"];
|
||||||
|
for (const r of rows) lines.push(`| ${r.group} | ${r.id} | ${r.name} | ${statusNames[r.status]} |`);
|
||||||
|
if (evidence.ui) lines.push("", "## 真实界面验证", "", evidence.ui.passed ? "已通过独立 Electron 窗口检查:类型下拉选项、PID/名称参数切换、DOS/PowerShell 参数切换,以及唤醒组件不再索要账号密码。界面快照及 ui-check.json 保存在 test-output/app-system/。" : `界面检查未通过:${escape(evidence.ui.reason)}`);
|
||||||
|
lines.push("", "## 用例证据", "", "| 用例 | 组件 | 状态 | 预期 | 实际/原因 |", "|---|---|---|---|---|");
|
||||||
|
for (const c of cases) lines.push(`| ${c.id} | ${c.componentId} | ${statusNames[c.status]} | ${escape(c.expected)} | ${escape([c.actual === undefined ? undefined : JSON.stringify(c.actual), c.reason].filter(Boolean).join(";") || undefined)} |`);
|
||||||
|
lines.push("", "## 运行前校验阻塞", "");
|
||||||
|
for (const r of evidence.runs || []) {
|
||||||
|
if (!r.validation?.length) continue;
|
||||||
|
lines.push(`- ${r.flowId}:${r.validation.map(i => i.message).join(";")}`);
|
||||||
|
}
|
||||||
|
lines.push("", "## 证据位置", "", `隔离用户目录:${evidence.profile || "未执行"}`, "");
|
||||||
|
for (const r of evidence.runs || []) if (r.persistedFile) lines.push(`- ${r.flowId}:${r.persistedFile}`);
|
||||||
|
return { markdown: lines.join("\n") + "\n", counts, components: rows, cases };
|
||||||
|
}
|
||||||
|
|
||||||
|
function option(name, fallback) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
if (index < 0) return fallback;
|
||||||
|
if (!process.argv[index + 1] || process.argv[index + 1].startsWith("--")) throw new Error(`${name} 缺少参数`);
|
||||||
|
return process.argv[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const action = process.argv[2];
|
||||||
|
const output = option("--output", path.join(projectRoot, "test-output/app-system"));
|
||||||
|
if (action === "generate") { const m = writeSuite(); console.log(`已生成 ${m.groups.length} 组任务。`); }
|
||||||
|
else if (action === "verify") {
|
||||||
|
const checked = await verifyBundle();
|
||||||
|
console.log(JSON.stringify({ groups: checked.groups.length, components: checked.executableCount, validation: checked.validation }, null, 2));
|
||||||
|
} else if (action === "install") {
|
||||||
|
await verifyBundle();
|
||||||
|
const userData = option("--user-data", path.join(process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"), "rpa-studio"));
|
||||||
|
console.log(JSON.stringify(installBundle(userData), null, 2));
|
||||||
|
} else if (action === "upgrade") {
|
||||||
|
await verifyBundle();
|
||||||
|
const baseline = option("--baseline", undefined);
|
||||||
|
if (!baseline) throw new Error("upgrade 需要 --baseline 指定升级前的旧流程模板目录。");
|
||||||
|
const userData = option("--user-data", path.join(process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"), "rpa-studio"));
|
||||||
|
const backup = path.join(output, `installed-backup-${Date.now()}`);
|
||||||
|
console.log(JSON.stringify(upgradeBundle(userData, baseline, backup), null, 2));
|
||||||
|
} else if (action === "run-safe") {
|
||||||
|
await verifyBundle();
|
||||||
|
const evidence = await runSafe(output, process.argv.includes("--ui-check"));
|
||||||
|
const report = makeReport(evidence);
|
||||||
|
fs.writeFileSync(path.join(output, "report.md"), report.markdown);
|
||||||
|
console.log(JSON.stringify(report.counts));
|
||||||
|
// 2 表示组件存在失败/阻塞;1 才表示测试工具自身无法完成。
|
||||||
|
if (report.counts.FAIL || report.counts.BLOCKED) process.exitCode = 2;
|
||||||
|
} else if (action === "report") {
|
||||||
|
const evidence = JSON.parse(fs.readFileSync(option("--evidence", path.join(output, "latest.json")), "utf8"));
|
||||||
|
const report = makeReport(evidence);
|
||||||
|
const destination = option("--file", path.join(output, "report.md"));
|
||||||
|
fs.writeFileSync(destination, report.markdown);
|
||||||
|
console.log(JSON.stringify({ file: destination, counts: report.counts }, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("用法:node scripts/app-system-tests.mjs generate|verify|install|upgrade|run-safe|report\n安装:install --user-data <应用用户目录>\n升级:upgrade --baseline <旧模板目录> --user-data <应用用户目录>\n运行:run-safe --output <证据目录>\n报告:report --evidence <latest.json> --file <报告.md>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; });
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
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 { validateFlow } from "../src/renderer/src/validation/flowValidation.ts";
|
||||||
|
|
||||||
|
const containers = new Set(["logic_control_try_catch", "logic_control_if_else"]);
|
||||||
|
const catalog = JSON.parse(fs.readFileSync("resources/catalog.json", "utf8"));
|
||||||
|
for (const c of catalog.components) c.implemented = containers.has(c.id) || hasCapability(c.id) || hasCapability(c.functionName);
|
||||||
|
const componentMap = new Map(catalog.components.map(c => [c.id, c]));
|
||||||
|
const temporary = [];
|
||||||
|
afterEach(() => { for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
async function execute(number, change) {
|
||||||
|
const group = buildSuite()[number - 1];
|
||||||
|
change?.(group.flow);
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-appsystem-unit-")); temporary.push(dir);
|
||||||
|
const logs = [];
|
||||||
|
const result = await runFlow(group.flow, {
|
||||||
|
runId: `unit-${group.flow.id}`, evidenceDir: dir, signal: new AbortController().signal,
|
||||||
|
emit: () => {}, log: (message) => logs.push(message), componentOf: node => componentMap.get(node.componentId)
|
||||||
|
});
|
||||||
|
return { group, result, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("应用与系统测试流程包", () => {
|
||||||
|
it("覆盖真实注册的全部 49 项,13 组无重复归属,不覆盖待接入组件", () => {
|
||||||
|
const suite = buildSuite();
|
||||||
|
expect(suite).toHaveLength(13);
|
||||||
|
const covered = suite.flatMap(group => group.entry.components).sort();
|
||||||
|
const executable = catalog.components.filter(c => c.group === "app" && c.implemented).map(c => c.id).sort();
|
||||||
|
expect(covered).toHaveLength(49);
|
||||||
|
expect(new Set(covered).size).toBe(49);
|
||||||
|
expect(covered).toEqual(executable);
|
||||||
|
for (const { flow, entry } of suite) {
|
||||||
|
expect(new Set(flow.nodes.map(node => node.id)).size).toBe(flow.nodes.length);
|
||||||
|
expect(new Set(entry.cases.map(c => c.componentId))).toEqual(new Set(entry.components));
|
||||||
|
const reached = new Set([flow.nodes[0].id]);
|
||||||
|
for (let n = 0; n < flow.nodes.length; n++) {
|
||||||
|
for (const edge of flow.edges) if (reached.has(edge.source)) reached.add(edge.target);
|
||||||
|
for (const node of flow.nodes) if (reached.has(node.parentId)) reached.add(node.id);
|
||||||
|
}
|
||||||
|
expect(reached.size, flow.name).toBe(flow.nodes.length);
|
||||||
|
expect(entry.cases.every(c => flow.nodes.some(n => n.id === c.targetNodeId && n.componentId === c.componentId))).toBe(true);
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(bundleRoot, entry.file), "utf8"))).toEqual(flow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("目录修正可重复应用,前端类型选项都有实际判断与转换能力", async () => {
|
||||||
|
expect(applyCatalogOverrides(structuredClone(catalog))).toEqual(catalog);
|
||||||
|
for (const id of ["Basic_JudgeDataType", "Basic_DataTypeConversion"]) {
|
||||||
|
const component = componentMap.get(id);
|
||||||
|
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));
|
||||||
|
if (id === "Basic_JudgeDataType") expect(typeof output).toBe("boolean");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wake = componentMap.get("ScreenControl_ScreenUnlock");
|
||||||
|
expect(wake.name).toContain("非登录解锁");
|
||||||
|
expect(wake.fields).toHaveLength(0);
|
||||||
|
expect(componentMap.get("System_ScreenSaverShowV1").fields).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只保留真实参数校验阻塞,不出现断链、未知变量或未接入节点", () => {
|
||||||
|
const blocked = [];
|
||||||
|
for (const { flow } of buildSuite()) {
|
||||||
|
const issues = validateFlow(flow, catalog);
|
||||||
|
expect(issues.every(issue => issue.code === "required"), flow.name).toBe(true);
|
||||||
|
if (issues.length) blocked.push(flow.id);
|
||||||
|
}
|
||||||
|
expect(blocked).toEqual(["test_appsystem_08", "test_appsystem_12"]);
|
||||||
|
const pidOnly = buildSuite()[0].flow;
|
||||||
|
const killV1 = pidOnly.nodes.find(n => n.componentId === "App_KillV1");
|
||||||
|
killV1.values.p_name = "";
|
||||||
|
expect(validateFlow(pidOnly, catalog).filter(issue => issue.nodeId === killV1.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("真实执行转换流程,显式类型选项下完整核对全部用例", async () => {
|
||||||
|
const { group, result, logs } = await execute(2);
|
||||||
|
expect(result.status).toBe("succeeded");
|
||||||
|
expect(result.variables.failures).toBe(0);
|
||||||
|
expect(result.variables.blocked).toBe(0);
|
||||||
|
expect(group.entry.cases.map(c => result.variables[c.resultVariable][2])).toEqual(Array(10).fill("PASS"));
|
||||||
|
expect(logs.filter(log => log.startsWith("用例结果:"))).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("注入错误返回类型时断言会失败,且前面的用例继续留有证据", async () => {
|
||||||
|
// 只改变内存中的测试副本,不把现有组件缺陷固化成测试的预期行为。
|
||||||
|
const { result } = await execute(3, flow => {
|
||||||
|
const target = flow.nodes.find(n => n.componentId === "Basic_JudgeDataType");
|
||||||
|
target.componentId = "Basic_SetVariable";
|
||||||
|
target.values = { value: "number" };
|
||||||
|
});
|
||||||
|
expect(result.variables.failures).toBe(1);
|
||||||
|
expect(result.variables.case_12[2]).toBe("FAIL");
|
||||||
|
expect(result.variables.case_12[5]).toBe("number");
|
||||||
|
expect(result.variables.case_11[2]).toBe("PASS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("变量、深复制、配置、流程信息和计数能够真实通过", async () => {
|
||||||
|
const { result } = await execute(4);
|
||||||
|
expect(result.status).toBe("succeeded");
|
||||||
|
expect(result.variables.failures).toBe(0);
|
||||||
|
expect(result.variables.case_10[5]).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("手动任务默认保护有效,不调用输入法、远程连接或断连操作", async () => {
|
||||||
|
for (const number of [9, 13]) {
|
||||||
|
const { group, result } = await execute(number);
|
||||||
|
expect(result.status).toBe("failed");
|
||||||
|
expect(result.variables.pending).toBe(group.entry.cases.length);
|
||||||
|
for (const c of group.entry.cases) {
|
||||||
|
expect(result.nodes.some(n => n.nodeId === c.targetNodeId)).toBe(false);
|
||||||
|
expect(result.variables[c.resultVariable][2]).toBe("MANUAL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("字符串 false 或 true 不会误开启手动保护", async () => {
|
||||||
|
for (const value of ["false", "true"]) {
|
||||||
|
const { group, result } = await execute(13, flow => {
|
||||||
|
flow.nodes.find(n => n.options.outputVar === "manualEnabled").values.value = value;
|
||||||
|
});
|
||||||
|
for (const c of group.entry.cases) expect(result.nodes.some(n => n.nodeId === c.targetNodeId)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只新增 13 个任务,重复安装保留用户已有修改且不改无关文件", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-appsystem-install-")); temporary.push(dir);
|
||||||
|
const first = installBundle(dir);
|
||||||
|
expect(first.installed).toHaveLength(13);
|
||||||
|
const file = path.join(dir, "flows/test_appsystem_01.json");
|
||||||
|
fs.writeFileSync(file, "用户自行编辑的内容");
|
||||||
|
fs.writeFileSync(path.join(dir, "flows/other.json"), "其他任务");
|
||||||
|
const second = installBundle(dir);
|
||||||
|
expect(second.installed).toHaveLength(0);
|
||||||
|
expect(second.skipped).toHaveLength(13);
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("用户自行编辑的内容");
|
||||||
|
expect(fs.readFileSync(path.join(dir, "flows/other.json"), "utf8")).toBe("其他任务");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("升级仅覆盖匹配旧模板的测试任务,并保存原文件备份", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-appsystem-upgrade-")); temporary.push(dir);
|
||||||
|
installBundle(dir);
|
||||||
|
const baseline = path.join(dir, "baseline"), backup = path.join(dir, "backup");
|
||||||
|
fs.mkdirSync(baseline);
|
||||||
|
for (const { entry, flow } of buildSuite()) {
|
||||||
|
const old = { ...flow, description: "旧测试模板" };
|
||||||
|
fs.writeFileSync(path.join(baseline, entry.file), JSON.stringify(old));
|
||||||
|
fs.writeFileSync(path.join(dir, "flows", entry.file), JSON.stringify(old));
|
||||||
|
}
|
||||||
|
const changed = path.join(dir, "flows/test_appsystem_01.json");
|
||||||
|
const userFlow = JSON.parse(fs.readFileSync(changed, "utf8")); userFlow.description = "用户已修改";
|
||||||
|
fs.writeFileSync(changed, JSON.stringify(userFlow));
|
||||||
|
const result = upgradeBundle(dir, baseline, backup);
|
||||||
|
expect(result.upgraded).toHaveLength(12);
|
||||||
|
expect(result.skipped).toContainEqual({ id: "test_appsystem_01", reason: "用户已修改,保留现有版本" });
|
||||||
|
expect(fs.readFileSync(changed, "utf8")).toBe(JSON.stringify(userFlow));
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(backup, "test_appsystem_02.json"), "utf8")).description).toBe("旧测试模板");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("报告按最差用例聚合,不把任务成功或缺失的记录冒充组件通过", () => {
|
||||||
|
const group = buildSuite()[0];
|
||||||
|
const variables = Object.fromEntries(group.entry.cases.map(c => [c.resultVariable, [c.id, c.componentId, "PASS", c.input, c.expected, 42]]));
|
||||||
|
variables.case_8[2] = "FAIL";
|
||||||
|
const report = makeReport({ runs: [{ flowId: group.flow.id, status: "succeeded", result: { variables } }] });
|
||||||
|
expect(report.components.find(c => c.id === "App_GetPid").status).toBe("FAIL");
|
||||||
|
expect(report.components.find(c => c.id === "Basic_ConvertInt").status).toBe("UNRUN");
|
||||||
|
expect(report.components.find(c => c.id === "System_PlaySound").status).toBe("MANUAL");
|
||||||
|
expect(caseResults(group.entry, { status: "BLOCKED" }).every(c => c.status === "BLOCKED")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { applyCatalogOverrides } from "./catalog-overrides.mjs";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = path.resolve(__dirname, "..");
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
@@ -336,7 +337,7 @@ function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||||
fs.writeFileSync(OUT_FILE, JSON.stringify(catalog), "utf8");
|
fs.writeFileSync(OUT_FILE, JSON.stringify(applyCatalogOverrides(catalog)), "utf8");
|
||||||
const size = fs.statSync(OUT_FILE).size;
|
const size = fs.statSync(OUT_FILE).size;
|
||||||
console.log(`catalog: ${counts.total} components (${counts.vendor} vendor + ${counts.native} native + ${counts.triggers} triggers)`);
|
console.log(`catalog: ${counts.total} components (${counts.vendor} vendor + ${counts.native} native + ${counts.triggers} triggers)`);
|
||||||
console.log(`fields: ${counts.fields}; groups: ${catalog.groups.map((g) => `${g.id}:${g.count}`).join(",")}`);
|
console.log(`fields: ${counts.fields}; groups: ${catalog.groups.map((g) => `${g.id}:${g.count}`).join(",")}`);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const overrides = JSON.parse(fs.readFileSync(new URL("../resources/catalog-overrides.json", import.meta.url), "utf8"));
|
||||||
|
|
||||||
|
/** 把本项目已实现的参数契约覆盖到派生目录,避免重新生成时恢复旧的错误元数据。 */
|
||||||
|
export function applyCatalogOverrides(catalog) {
|
||||||
|
for (const component of catalog.components) {
|
||||||
|
const override = overrides[component.id];
|
||||||
|
if (!override) continue;
|
||||||
|
const { fields = {}, removeFields = [], ...metadata } = override;
|
||||||
|
Object.assign(component, metadata);
|
||||||
|
component.fields = component.fields.filter(field => !removeFields.includes(field.key))
|
||||||
|
.map(field => ({ ...field, ...fields[field.key] }));
|
||||||
|
}
|
||||||
|
catalog.counts.fields = catalog.components.reduce((count, component) => count + component.fields.length, 0);
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { PNG } from "pngjs";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const output = path.join(root, "build", "icon.ico");
|
||||||
|
const size = 256;
|
||||||
|
const png = new PNG({ width: size, height: size });
|
||||||
|
const glyph = [
|
||||||
|
"0111000000",
|
||||||
|
"1000100000",
|
||||||
|
"1000100000",
|
||||||
|
"1000100000",
|
||||||
|
"1111100000",
|
||||||
|
"1000100000",
|
||||||
|
"1000100000",
|
||||||
|
"1000100000",
|
||||||
|
"1000100000",
|
||||||
|
"1000100000"
|
||||||
|
];
|
||||||
|
|
||||||
|
function insideRoundedSquare(x, y, radius) {
|
||||||
|
const clampX = Math.min(Math.max(x, radius), size - 1 - radius);
|
||||||
|
const clampY = Math.min(Math.max(y, radius), size - 1 - radius);
|
||||||
|
return (x - clampX) ** 2 + (y - clampY) ** 2 <= radius ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
const index = (size * y + x) << 2;
|
||||||
|
const visible = insideRoundedSquare(x, y, 34);
|
||||||
|
const gradient = (x + y) / (2 * (size - 1));
|
||||||
|
png.data[index] = visible ? Math.round(37 + (30 - 37) * gradient) : 0;
|
||||||
|
png.data[index + 1] = visible ? Math.round(99 + (58 - 99) * gradient) : 0;
|
||||||
|
png.data[index + 2] = visible ? Math.round(235 + (138 - 235) * gradient) : 0;
|
||||||
|
png.data[index + 3] = visible ? 255 : 0;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
const glyphX = Math.round((x - 78) / 10);
|
||||||
|
const glyphY = Math.round((y - 68) / 12);
|
||||||
|
if (glyphY >= 0 && glyphY < glyph.length && glyphX >= 0 && glyphX < glyph[glyphY].length && glyph[glyphY][glyphX] === "1") {
|
||||||
|
png.data[index] = 255;
|
||||||
|
png.data[index + 1] = 255;
|
||||||
|
png.data[index + 2] = 255;
|
||||||
|
png.data[index + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
png.pack()
|
||||||
|
.on("data", (chunk) => chunks.push(chunk))
|
||||||
|
.on("end", () => {
|
||||||
|
const image = Buffer.concat(chunks);
|
||||||
|
const directory = Buffer.alloc(6);
|
||||||
|
directory.writeUInt16LE(0, 0);
|
||||||
|
directory.writeUInt16LE(1, 2);
|
||||||
|
directory.writeUInt16LE(1, 4);
|
||||||
|
|
||||||
|
const entry = Buffer.alloc(16);
|
||||||
|
entry[0] = 0;
|
||||||
|
entry[1] = 0;
|
||||||
|
entry[2] = 0;
|
||||||
|
entry[3] = 0;
|
||||||
|
entry.writeUInt16LE(1, 4);
|
||||||
|
entry.writeUInt16LE(32, 6);
|
||||||
|
entry.writeUInt32LE(image.length, 8);
|
||||||
|
entry.writeUInt32LE(22, 12);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||||
|
fs.writeFileSync(output, Buffer.concat([directory, entry, image]));
|
||||||
|
console.log(`[icon] generated ${output} (${image.length + 22} bytes)`);
|
||||||
|
})
|
||||||
|
.on("error", (error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+156
-41
@@ -1,18 +1,22 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import packager from "@electron/packager";
|
|
||||||
|
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
const releaseRoot = path.resolve(root, "release");
|
const releaseRoot = path.resolve(root, "release", "dist");
|
||||||
|
const builderWork = path.join(os.tmpdir(), "RPA-Studio-electron-builder");
|
||||||
|
const builderUnpacked = path.join(builderWork, "win-unpacked");
|
||||||
const productFolderName = `RPA-Studio-${version}-win-x64`;
|
const productFolderName = `RPA-Studio-${version}-win-x64`;
|
||||||
const productFolder = path.join(releaseRoot, productFolderName);
|
const productFolder = path.join(releaseRoot, productFolderName);
|
||||||
const zipPath = path.join(releaseRoot, `${productFolderName}.zip`);
|
const zipPath = path.join(releaseRoot, `${productFolderName}.zip`);
|
||||||
|
const setupPath = path.join(releaseRoot, `RPA-Studio-Setup-${version}-win-x64.exe`);
|
||||||
|
const builderSetupPath = path.join(builderWork, `RPA-Studio-Setup-${version}-win-x64.exe`);
|
||||||
|
|
||||||
function assertInsideRelease(target) {
|
function assertInsideRelease(target) {
|
||||||
const relative = path.relative(releaseRoot, path.resolve(target));
|
const relative = path.relative(releaseRoot, path.resolve(target));
|
||||||
@@ -26,56 +30,101 @@ function run(command, args) {
|
|||||||
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}`);
|
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function killProcessTree(pid) {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTree(target) {
|
||||||
|
fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyLaunch(executable, label) {
|
||||||
|
const userData = path.join(os.tmpdir(), `RPA-Studio-UserData-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
|
const child = spawn(executable, [`--user-data-dir=${userData}`], {
|
||||||
|
cwd: path.dirname(executable),
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: false,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
RPA_SKIP_SINGLE_INSTANCE: "1",
|
||||||
|
RPA_E2E: "1",
|
||||||
|
RPA_FIXTURE_PORT: String(18000 + Math.floor(Math.random() * 1000))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await delay(8000);
|
||||||
|
const exited = child.exitCode !== null || child.signalCode !== null;
|
||||||
|
if (!exited) {
|
||||||
|
killProcessTree(child.pid);
|
||||||
|
await delay(2000);
|
||||||
|
}
|
||||||
|
removeTree(userData);
|
||||||
|
if (exited) throw new Error(`${label} 启动后提前退出,退出码 ${child.exitCode}`);
|
||||||
|
console.log(`[package] ${label} launch verified`);
|
||||||
|
}
|
||||||
|
|
||||||
fs.mkdirSync(releaseRoot, { recursive: true });
|
fs.mkdirSync(releaseRoot, { recursive: true });
|
||||||
for (const target of [productFolder, zipPath]) {
|
for (const entry of fs.readdirSync(releaseRoot)) {
|
||||||
|
if (entry.startsWith(".installer")) {
|
||||||
|
const legacyTarget = path.join(releaseRoot, entry);
|
||||||
|
assertInsideRelease(legacyTarget);
|
||||||
|
fs.rmSync(legacyTarget, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const target of [productFolder, zipPath, setupPath]) {
|
||||||
assertInsideRelease(target);
|
assertInsideRelease(target);
|
||||||
fs.rmSync(target, { recursive: true, force: true });
|
fs.rmSync(target, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
removeTree(builderWork);
|
||||||
|
|
||||||
console.log("[package] typecheck");
|
console.log("[package] typecheck");
|
||||||
run("npm", ["run", "typecheck"]);
|
run("npm", ["run", "typecheck"]);
|
||||||
console.log("[package] tests");
|
console.log("[package] tests");
|
||||||
run("npm", ["test"]);
|
run("npm", ["test"]);
|
||||||
|
console.log("[package] browser e2e");
|
||||||
|
run("npm", ["run", "test:e2e"]);
|
||||||
console.log("[package] build");
|
console.log("[package] build");
|
||||||
run("npm", ["run", "build"]);
|
run("npm", ["run", "build"]);
|
||||||
|
|
||||||
console.log("[package] electron packager");
|
const electronDist = path.join(root, "node_modules", "electron", "dist");
|
||||||
const outputs = await packager({
|
if (!fs.existsSync(path.join(electronDist, "electron.exe"))) {
|
||||||
dir: root,
|
throw new Error(`本机 Electron 运行时不完整:${electronDist}`);
|
||||||
name: "RPA Studio",
|
}
|
||||||
platform: "win32",
|
|
||||||
arch: "x64",
|
|
||||||
out: releaseRoot,
|
|
||||||
overwrite: true,
|
|
||||||
prune: true,
|
|
||||||
asar: true,
|
|
||||||
electronVersion: packageJson.devDependencies.electron,
|
|
||||||
appVersion: version,
|
|
||||||
win32metadata: {
|
|
||||||
CompanyName: "RPA Studio",
|
|
||||||
FileDescription: "RPA Studio Desktop Automation",
|
|
||||||
ProductName: "RPA Studio",
|
|
||||||
InternalName: "RPA Studio"
|
|
||||||
},
|
|
||||||
ignore: [
|
|
||||||
/^[/\\]\.git($|[/\\])/,
|
|
||||||
/^[/\\]release($|[/\\])/,
|
|
||||||
/^[/\\]_analysis($|[/\\])/,
|
|
||||||
/^[/\\]docs($|[/\\])/,
|
|
||||||
/^[/\\]scripts($|[/\\])/,
|
|
||||||
/^[/\\]src($|[/\\])/,
|
|
||||||
/\.test\.[cm]?[jt]sx?$/
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const packaged = outputs[0];
|
console.log("[package] icon");
|
||||||
if (!packaged || !fs.existsSync(path.join(packaged, "RPA Studio.exe"))) {
|
run("node", ["scripts/generate-icon.mjs"]);
|
||||||
throw new Error(`打包产物无效:${packaged || "未返回目录"}`);
|
|
||||||
|
console.log("[package] electron-builder nsis");
|
||||||
|
run(path.join(root, "node_modules", ".bin", "electron-builder.cmd"), [
|
||||||
|
"--win",
|
||||||
|
"--x64",
|
||||||
|
"--config",
|
||||||
|
path.join(root, "electron-builder.config.mjs")
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!fs.existsSync(builderSetupPath)) {
|
||||||
|
throw new Error(`electron-builder 未生成安装包:${builderSetupPath}`);
|
||||||
}
|
}
|
||||||
if (path.resolve(packaged) !== path.resolve(productFolder)) {
|
if (!fs.existsSync(path.join(builderUnpacked, "RPA Studio.exe"))) {
|
||||||
assertInsideRelease(packaged);
|
throw new Error(`electron-builder 未生成应用目录:${builderUnpacked}`);
|
||||||
fs.renameSync(packaged, productFolder);
|
|
||||||
}
|
}
|
||||||
|
const packagedCatalogPath = path.join(builderUnpacked, "resources", "catalog.json");
|
||||||
|
const packagedFixturePath = path.join(builderUnpacked, "resources", "fixture", "index.html");
|
||||||
|
if (!fs.existsSync(packagedCatalogPath) || !fs.existsSync(packagedFixturePath)) {
|
||||||
|
throw new Error("打包产物缺少运行资源:resources/catalog.json 或 resources/fixture/index.html");
|
||||||
|
}
|
||||||
|
const packagedCatalog = JSON.parse(fs.readFileSync(packagedCatalogPath, "utf8"));
|
||||||
|
if (!Array.isArray(packagedCatalog.components) || packagedCatalog.components.length === 0) {
|
||||||
|
throw new Error("打包产物 resources/catalog.json 无效:组件目录为空");
|
||||||
|
}
|
||||||
|
fs.cpSync(builderUnpacked, productFolder, { recursive: true });
|
||||||
|
fs.copyFileSync(builderSetupPath, setupPath);
|
||||||
|
removeTree(builderWork);
|
||||||
|
|
||||||
const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" });
|
const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" });
|
||||||
const revision = git.status === 0 ? git.stdout.trim() : "unknown";
|
const revision = git.status === 0 ? git.stdout.trim() : "unknown";
|
||||||
@@ -97,17 +146,83 @@ fs.writeFileSync(path.join(productFolder, "使用说明.txt"), [
|
|||||||
"3. 任务、流程和运行记录默认保存在当前 Windows 用户的 AppData\\Roaming\\rpa-studio。",
|
"3. 任务、流程和运行记录默认保存在当前 Windows 用户的 AppData\\Roaming\\rpa-studio。",
|
||||||
"4. Word 自动化需要本机安装 Microsoft Word;其他文件级能力无需安装 Office。",
|
"4. Word 自动化需要本机安装 Microsoft Word;其他文件级能力无需安装 Office。",
|
||||||
"5. 首次启动如被 Windows SmartScreen 提示,请核对同目录 SHA256SUMS.txt;当前构建未进行商业代码签名。",
|
"5. 首次启动如被 Windows SmartScreen 提示,请核对同目录 SHA256SUMS.txt;当前构建未进行商业代码签名。",
|
||||||
|
"6. 如果双击没有窗口,请运行 启动诊断.cmd,并把自动打开的 startup.log 发给开发人员。",
|
||||||
"",
|
"",
|
||||||
`版本:${version}`,
|
`版本:${version}`,
|
||||||
`源码提交:${revision}`,
|
`源码提交:${revision}`,
|
||||||
`构建时间:${builtAt}`
|
`构建时间:${builtAt}`
|
||||||
].join("\r\n"), "utf8");
|
].join("\r\n"), "utf8");
|
||||||
|
fs.writeFileSync(path.join(productFolder, "系统要求.txt"), [
|
||||||
|
"RPA Studio 系统要求",
|
||||||
|
"",
|
||||||
|
"- Windows 10 64 位或 Windows 11 64 位",
|
||||||
|
"- 建议至少 4 GB 内存和 500 MB 可用磁盘空间",
|
||||||
|
"- 必须先完整解压,不能直接在 ZIP 压缩包中双击 exe",
|
||||||
|
"- Word 自动化需要本机安装 Microsoft Word",
|
||||||
|
"- 当前版本未购买商业代码签名证书,SmartScreen 可能显示未知发布者",
|
||||||
|
"- 软件默认关闭 GPU 硬件加速,以兼容远程桌面、虚拟机和旧显卡驱动"
|
||||||
|
].join("\r\n"), "utf8");
|
||||||
|
fs.writeFileSync(path.join(productFolder, "启动诊断.cmd"), [
|
||||||
|
"@echo off",
|
||||||
|
"chcp 65001 >nul",
|
||||||
|
"set ELECTRON_ENABLE_LOGGING=1",
|
||||||
|
"set LOG=%APPDATA%\\rpa-studio\\logs\\startup.log",
|
||||||
|
"start \"RPA Studio\" \"%~dp0RPA Studio.exe\" --enable-logging --v=1",
|
||||||
|
"timeout /t 8 /nobreak >nul",
|
||||||
|
"if exist \"%LOG%\" (start notepad.exe \"%LOG%\") else (echo 未生成启动日志,请检查安全软件拦截记录。 & pause)"
|
||||||
|
].join("\r\n"), "utf8");
|
||||||
|
fs.writeFileSync(path.join(productFolder, "安装到本机.cmd"), [
|
||||||
|
"@echo off",
|
||||||
|
"chcp 65001 >nul",
|
||||||
|
"set \"TARGET=%LOCALAPPDATA%\\Programs\\RPA Studio\"",
|
||||||
|
"if not exist \"%TARGET%\" mkdir \"%TARGET%\"",
|
||||||
|
"robocopy \"%~dp0\" \"%TARGET%\" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP >nul",
|
||||||
|
"powershell -NoProfile -ExecutionPolicy Bypass -Command \"$w=New-Object -ComObject WScript.Shell;$s=$w.CreateShortcut([IO.Path]::Combine([Environment]::GetFolderPath('Desktop'),'RPA Studio.lnk'));$s.TargetPath=[IO.Path]::Combine($env:LOCALAPPDATA,'Programs','RPA Studio','RPA Studio.exe');$s.WorkingDirectory=[IO.Path]::Combine($env:LOCALAPPDATA,'Programs','RPA Studio');$s.Save()\"",
|
||||||
|
"start \"RPA Studio\" \"%TARGET%\\RPA Studio.exe\"",
|
||||||
|
"exit /b 0"
|
||||||
|
].join("\r\n"), "utf8");
|
||||||
|
|
||||||
|
console.log("[package] portable smoke");
|
||||||
|
const portableSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-Portable-Smoke-"));
|
||||||
|
const portableSmokeFolder = path.join(portableSmokeRoot, productFolderName);
|
||||||
|
fs.cpSync(productFolder, portableSmokeFolder, { recursive: true });
|
||||||
|
await verifyLaunch(path.join(portableSmokeFolder, "RPA Studio.exe"), "portable");
|
||||||
|
removeTree(portableSmokeRoot);
|
||||||
|
|
||||||
console.log("[package] zip");
|
console.log("[package] zip");
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addLocalFolder(productFolder, productFolderName);
|
zip.addLocalFolder(productFolder, productFolderName);
|
||||||
zip.writeZip(zipPath);
|
zip.writeZip(zipPath);
|
||||||
const sha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
|
const zipSha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
|
||||||
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sha256} ${path.basename(zipPath)}\r\n`, "utf8");
|
const setupSha256 = createHash("sha256").update(fs.readFileSync(setupPath)).digest("hex");
|
||||||
|
|
||||||
console.log(JSON.stringify({ productFolder, zipPath, sha256 }, null, 2));
|
console.log("[package] nsis smoke");
|
||||||
|
const installSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-NSIS-Smoke-"));
|
||||||
|
const installSmokeSetup = path.join(installSmokeRoot, path.basename(setupPath));
|
||||||
|
const installSmokeTarget = path.join(installSmokeRoot, "RPAStudio");
|
||||||
|
fs.copyFileSync(setupPath, installSmokeSetup);
|
||||||
|
const installed = spawnSync(installSmokeSetup, ["/S", `/D=${installSmokeTarget}`], {
|
||||||
|
cwd: installSmokeRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 180000
|
||||||
|
});
|
||||||
|
if (installed.status !== 0 || installed.error) {
|
||||||
|
throw new Error(`NSIS 安装包冒烟失败,退出码 ${installed.status}:${installed.error?.message || "unknown error"}`);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(installSmokeTarget, "RPA Studio.exe"))) {
|
||||||
|
throw new Error("NSIS 安装包冒烟失败:未生成主程序");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(installSmokeTarget, "resources", "catalog.json"))) {
|
||||||
|
throw new Error("NSIS 安装包冒烟失败:catalog.json 未安装");
|
||||||
|
}
|
||||||
|
await verifyLaunch(path.join(installSmokeTarget, "RPA Studio.exe"), "installed");
|
||||||
|
removeTree(installSmokeRoot);
|
||||||
|
|
||||||
|
const sums = [
|
||||||
|
`${zipSha256} ${path.basename(zipPath)}`,
|
||||||
|
`${setupSha256} ${path.basename(setupPath)}`
|
||||||
|
];
|
||||||
|
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sums.join("\r\n")}\r\n`, "utf8");
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ productFolder, zipPath, setupPath, zipSha256, setupSha256 }, null, 2));
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const executable = process.argv[2];
|
||||||
|
if (!executable) {
|
||||||
|
console.error("usage: node scripts/spawn-test.mjs <exe> [...args]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
|
||||||
|
const child = spawn(executable, process.argv.slice(3), {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
windowsHide: false
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", (data) => process.stdout.write(`[out] ${data}`));
|
||||||
|
child.stderr.on("data", (data) => process.stderr.write(`[err] ${data}`));
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log(`STILL_RUNNING pid=${child.pid}`);
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setTimeout(() => process.exit(0), 1000);
|
||||||
|
}, 9000);
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
console.log(`EXIT code=${code} signal=${signal}`);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
console.error(`SPAWN_ERROR ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { RuntimeError } from "../engine/types";
|
||||||
|
import { ExternalTransport } from "./transport";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部浏览器(Edge / Chrome)进程管理与 CDP 连接。
|
||||||
|
*
|
||||||
|
* 关键约束(Chromium 安全限制):
|
||||||
|
* - 未带 --remote-debugging-port 启动的浏览器实例无法连接,必须由本模块带参拉起;
|
||||||
|
* - 拉起时必须带独立 --user-data-dir,否则端口参数会被用户已开的实例吞掉(连不上且无报错的头号坑)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExternalBrowserType = "chrome" | "edge";
|
||||||
|
|
||||||
|
export interface ExternalLaunchOptions {
|
||||||
|
mode: "launch";
|
||||||
|
browserType: ExternalBrowserType;
|
||||||
|
/** 首选调试端口,被占用时自动 +1 重试;默认 9222 */
|
||||||
|
port?: number;
|
||||||
|
/** 透传给浏览器命令行的额外参数(如 --headless) */
|
||||||
|
extraArgs?: string[];
|
||||||
|
/** 独立用户数据目录;缺省使用系统临时目录下的独立 profile */
|
||||||
|
userDataDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalRemoteOptions {
|
||||||
|
mode: "remote";
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalBrowserOptions = ExternalLaunchOptions | ExternalRemoteOptions;
|
||||||
|
|
||||||
|
export interface ExternalConnection {
|
||||||
|
transport: ExternalTransport;
|
||||||
|
/** 会话缓存键,close 时用于移出缓存 */
|
||||||
|
sessionKey: string;
|
||||||
|
/** 人类可读标识,用于日志(如 "Edge (127.0.0.1:9222)") */
|
||||||
|
label: string;
|
||||||
|
/** 调试 HTTP 端点根地址(如 http://127.0.0.1:9222),用于 /json/list、/json/new 多标签管理 */
|
||||||
|
httpBase: string;
|
||||||
|
/** 初始接管的页面 targetId */
|
||||||
|
targetId: string;
|
||||||
|
/** 由本模块拉起的浏览器进程;远程连接时为空 */
|
||||||
|
child?: ChildProcess;
|
||||||
|
/** close 时是否结束浏览器进程(本模块拉起的默认关闭,远程连接绝不关闭) */
|
||||||
|
killOnClose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BROWSER_LABEL: Record<ExternalBrowserType, string> = { chrome: "Chrome", edge: "Edge" };
|
||||||
|
|
||||||
|
const BROWSER_CANDIDATE_PATHS: Record<ExternalBrowserType, string[]> = {
|
||||||
|
chrome: [
|
||||||
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
|
path.join(localAppDataDir(), "Google", "Chrome", "Application", "chrome.exe")
|
||||||
|
],
|
||||||
|
edge: [
|
||||||
|
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||||
|
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 用户级 AppData\Local 目录;LOCALAPPDATA 缺失(如受限启动环境)时退回 USERPROFILE 拼接 */
|
||||||
|
function localAppDataDir(): string {
|
||||||
|
if (process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
|
||||||
|
return path.join(os.homedir(), "AppData", "Local");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册表 App Paths 兜底:HKLM(64 位 + WOW6432Node)与 HKCU(用户级安装)都查 */
|
||||||
|
const BROWSER_APP_PATHS_REGISTRIES: Record<ExternalBrowserType, string[]> = {
|
||||||
|
chrome: [
|
||||||
|
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe",
|
||||||
|
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe",
|
||||||
|
"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe"
|
||||||
|
],
|
||||||
|
edge: [
|
||||||
|
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe",
|
||||||
|
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe",
|
||||||
|
"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
|
if (!p || p === "undefined") return false;
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryRegistry(command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile("reg", ["query", command, "/ve"], { windowsHide: true }, (error, stdout) => {
|
||||||
|
if (error) return reject(error);
|
||||||
|
resolve(String(stdout));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 探测本机浏览器安装路径:常见目录优先,注册表 App Paths(HKLM/HKCU)兜底 */
|
||||||
|
export async function detectBrowserPath(browserType: ExternalBrowserType): Promise<string> {
|
||||||
|
for (const candidate of BROWSER_CANDIDATE_PATHS[browserType]) {
|
||||||
|
if (await fileExists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
for (const registryKey of BROWSER_APP_PATHS_REGISTRIES[browserType]) {
|
||||||
|
try {
|
||||||
|
const stdout = await queryRegistry(registryKey);
|
||||||
|
const match = stdout.match(/REG_SZ\s+(\S[^\r\n]*)/);
|
||||||
|
if (match) {
|
||||||
|
const registryPath = match[1].trim();
|
||||||
|
if (await fileExists(registryPath)) return registryPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 该键不存在继续查下一个
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeError(
|
||||||
|
"browser_not_found",
|
||||||
|
`未找到本机 ${BROWSER_LABEL[browserType]},请确认已安装或在节点参数中填写正确端口进行远程连接`,
|
||||||
|
{ recommendation: "安装 Chrome/Edge,或改用「内置运行浏览器」" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CdpVersionInfo {
|
||||||
|
Browser?: string;
|
||||||
|
webSocketDebuggerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CdpTargetInfo {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
webSocketDebuggerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string, timeoutMs = 5000, method = "GET"): Promise<T> {
|
||||||
|
const response = await fetch(url, { method, signal: AbortSignal.timeout(timeoutMs) });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RuntimeError("browser_disconnected", `调试端点响应异常 HTTP ${response.status}:${url}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fetchJson<CdpVersionInfo>(`http://${host}:${port}/json/version`, 1200);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 /json/list 里挑一个可用的页面级 target */
|
||||||
|
function pickPageTarget(targets: CdpTargetInfo[], preferUrl?: string): CdpTargetInfo {
|
||||||
|
const pages = targets.filter((t) => t.type === "page" && t.webSocketDebuggerUrl);
|
||||||
|
if (pages.length === 0) {
|
||||||
|
throw new RuntimeError("browser_disconnected", "外部浏览器上没有可接管的页面", {
|
||||||
|
recommendation: "关闭无头/后台模式限制,或重新运行「打开网页」"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (preferUrl) {
|
||||||
|
const preferred = pages.find((t) => (t.url ?? "") === preferUrl);
|
||||||
|
if (preferred) return preferred;
|
||||||
|
}
|
||||||
|
const blank = pages.find((t) => /^about:blank|^chrome:\/\/newtab|^edge:\/\/newtab/.test(t.url ?? ""));
|
||||||
|
return blank ?? pages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接一个已在监听调试端口的实例(远程场景,绝不关进程) */
|
||||||
|
export async function connectRemoteBrowser(options: ExternalRemoteOptions): Promise<ExternalConnection> {
|
||||||
|
const host = options.host.trim() || "127.0.0.1";
|
||||||
|
const port = Math.trunc(options.port);
|
||||||
|
if (!port || port < 1 || port > 65535) {
|
||||||
|
throw new RuntimeError("validation_error", `远程调试端口不合法:${options.port}`, { recommendation: "填写 1~65535 之间的端口号" });
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9.\-]+$/.test(host)) {
|
||||||
|
throw new RuntimeError("validation_error", `远程地址不合法:${host}`, { recommendation: "填写 IP 或主机名,如 127.0.0.1" });
|
||||||
|
}
|
||||||
|
const base = `http://${host}:${port}`;
|
||||||
|
let version: CdpVersionInfo;
|
||||||
|
try {
|
||||||
|
version = await fetchJson<CdpVersionInfo>(`${base}/json/version`, 5000);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RuntimeError) throw error;
|
||||||
|
throw new RuntimeError("browser_disconnected", `无法连接远程浏览器 ${host}:${port}`, {
|
||||||
|
detail: (error as Error).message,
|
||||||
|
recommendation: "确认目标浏览器已带 --remote-debugging-port 启动,且防火墙已放行该端口"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const targets = await fetchJson<CdpTargetInfo[]>(`${base}/json/list`, 5000);
|
||||||
|
const target = pickPageTarget(targets);
|
||||||
|
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `${host}:${port}`);
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
sessionKey: `remote|${host}|${port}`,
|
||||||
|
label: `远程浏览器 ${host}:${port} (${version.Browser ?? "Chromium"})`,
|
||||||
|
httpBase: base,
|
||||||
|
targetId: String(target.id ?? ""),
|
||||||
|
killOnClose: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉起本机 Edge/Chrome(带独立 profile + 调试端口),并接管其页面 */
|
||||||
|
export async function launchExternalBrowser(options: ExternalLaunchOptions): Promise<ExternalConnection> {
|
||||||
|
const browserType = options.browserType === "edge" ? "edge" : "chrome";
|
||||||
|
const label = BROWSER_LABEL[browserType];
|
||||||
|
const browserPath = await detectBrowserPath(browserType);
|
||||||
|
|
||||||
|
// 端口管理:首选端口被占用(存在调试端点)则 +1 重试
|
||||||
|
let port = Math.trunc(options.port ?? 0) > 0 ? Math.trunc(options.port as number) : 9222;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
if (await isDebugPortReady(port)) port += 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDataDir =
|
||||||
|
options.userDataDir?.trim() || path.join(os.tmpdir(), `rpa-studio-external-${browserType}`);
|
||||||
|
await fs.mkdir(userDataDir, { recursive: true });
|
||||||
|
|
||||||
|
const extraArgs = (options.extraArgs ?? []).filter(Boolean);
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"about:blank",
|
||||||
|
...extraArgs
|
||||||
|
];
|
||||||
|
|
||||||
|
const child = spawn(browserPath, args, { detached: false, stdio: "ignore", windowsHide: true });
|
||||||
|
child.on("error", () => {
|
||||||
|
// spawn 失败由下方就绪轮询超时统一报错
|
||||||
|
});
|
||||||
|
|
||||||
|
// 就绪轮询:等 /json/version 可访问(最长 15s)
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
let ready = false;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
throw new RuntimeError("browser_error", `${label} 启动失败(进程已退出,代码 ${child.exitCode})`, {
|
||||||
|
detail: browserPath,
|
||||||
|
recommendation: "检查浏览器安装是否完整,或参数是否合法"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (await isDebugPortReady(port)) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
try {
|
||||||
|
child.kill();
|
||||||
|
} catch {
|
||||||
|
// 进程可能已退出
|
||||||
|
}
|
||||||
|
throw new RuntimeError("timeout", `${label} 已启动但调试端口 ${port} 未就绪(15s 超时)`, {
|
||||||
|
recommendation: "重启电脑后重试,或换一个端口;也可能是安全软件拦截了调试端口"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const targets = await fetchJson<CdpTargetInfo[]>(`${base}/json/list`, 5000);
|
||||||
|
const target = pickPageTarget(targets, "about:blank");
|
||||||
|
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`);
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
sessionKey: `launch|${browserType}|${port}`,
|
||||||
|
label: `${label} (127.0.0.1:${port})`,
|
||||||
|
httpBase: base,
|
||||||
|
targetId: String(target.id ?? ""),
|
||||||
|
child,
|
||||||
|
killOnClose: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出调试端点上的全部页面级 target(多标签枚举用);排除 chrome://、edge:// 内部页 */
|
||||||
|
export async function listPageTargets(httpBase: string): Promise<Array<{ id: string; title: string; url: string; webSocketDebuggerUrl: string }>> {
|
||||||
|
const targets = await fetchJson<CdpTargetInfo[]>(`${httpBase}/json/list`, 5000);
|
||||||
|
return targets
|
||||||
|
.filter((t) => t.type === "page" && t.webSocketDebuggerUrl)
|
||||||
|
.map((t) => ({ id: String(t.id ?? ""), title: String(t.title ?? ""), url: String(t.url ?? ""), webSocketDebuggerUrl: String(t.webSocketDebuggerUrl) }))
|
||||||
|
.filter((t) => !/^chrome:\/\/|^edge:\/\/|^devtools:\/\//.test(t.url) || /^chrome:\/\/newtab|^edge:\/\/newtab/.test(t.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在外部浏览器上新建标签页并返回其调试端点(PUT /json/new) */
|
||||||
|
export async function openNewPageTarget(httpBase: string, url = "about:blank"): Promise<{ id: string; webSocketDebuggerUrl: string; url: string }> {
|
||||||
|
const endpoint = `${httpBase}/json/new?${encodeURIComponent(url)}`;
|
||||||
|
const target = await fetchJson<{ id?: string; webSocketDebuggerUrl?: string; url?: string }>(endpoint, 5000, "PUT");
|
||||||
|
if (!target.webSocketDebuggerUrl) {
|
||||||
|
throw new RuntimeError("browser_error", "新建标签页未返回调试端点", { detail: JSON.stringify(target) });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(target.id ?? ""),
|
||||||
|
webSocketDebuggerUrl: target.webSocketDebuggerUrl,
|
||||||
|
url: String(target.url ?? url)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openExternalBrowser(options: ExternalBrowserOptions): Promise<ExternalConnection> {
|
||||||
|
return options.mode === "remote" ? connectRemoteBrowser(options) : launchExternalBrowser(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionKeyOf(options: ExternalBrowserOptions): string {
|
||||||
|
return options.mode === "remote"
|
||||||
|
? `remote|${options.host.trim()}|${Math.trunc(options.port)}`
|
||||||
|
: `launch|${options.browserType === "edge" ? "edge" : "chrome"}|${Math.trunc(options.port ?? 9222)}`;
|
||||||
|
}
|
||||||
+635
-49
@@ -1,6 +1,26 @@
|
|||||||
import { BrowserWindow } from "electron";
|
import { BrowserWindow } from "electron";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { ElementPickResult } from "../../shared/types";
|
||||||
|
import { EmbeddedTransport, ExternalTransport, type CdpTransport, type CdpEventHandler } from "./transport";
|
||||||
|
import {
|
||||||
|
listPageTargets,
|
||||||
|
openExternalBrowser,
|
||||||
|
openNewPageTarget,
|
||||||
|
sessionKeyOf,
|
||||||
|
type ExternalBrowserOptions,
|
||||||
|
type ExternalConnection
|
||||||
|
} from "./external";
|
||||||
|
|
||||||
|
const DEFAULT_ELEMENT_WAIT_MS = 5000;
|
||||||
|
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
|
||||||
|
|
||||||
|
export function setBrowserElementWaitMs(milliseconds: number): void {
|
||||||
|
const value = Number(milliseconds);
|
||||||
|
globalElementWaitMs = Number.isFinite(value)
|
||||||
|
? Math.min(600000, Math.max(0, Math.round(value)))
|
||||||
|
: DEFAULT_ELEMENT_WAIT_MS;
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
RuntimeError,
|
RuntimeError,
|
||||||
type BrowserElementInfo,
|
type BrowserElementInfo,
|
||||||
@@ -8,19 +28,10 @@ import {
|
|||||||
type BrowserElementRegion,
|
type BrowserElementRegion,
|
||||||
type BrowserLike,
|
type BrowserLike,
|
||||||
type BrowserSelectState,
|
type BrowserSelectState,
|
||||||
type BrowserSimilarElementOptions
|
type BrowserSimilarElementOptions,
|
||||||
|
type BrowserTabInfo
|
||||||
} from "../engine/types";
|
} from "../engine/types";
|
||||||
|
|
||||||
const DEFAULT_DOMAINS = ["127.0.0.1", "localhost", "example.com"];
|
|
||||||
|
|
||||||
function allowedDomains(): string[] {
|
|
||||||
const extra = (process.env.RPA_ALLOWED_DOMAINS || "")
|
|
||||||
.split(",")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
return [...DEFAULT_DOMAINS, ...extra];
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUrl(raw: string): URL {
|
function assertUrl(raw: string): URL {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -31,13 +42,6 @@ function assertUrl(raw: string): URL {
|
|||||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
throw new RuntimeError("blocked_url", `只允许 http/https,当前为 ${url.protocol}`);
|
throw new RuntimeError("blocked_url", `只允许 http/https,当前为 ${url.protocol}`);
|
||||||
}
|
}
|
||||||
const host = url.hostname;
|
|
||||||
const allowed = allowedDomains().some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
||||||
if (!allowed) {
|
|
||||||
throw new RuntimeError("blocked_url", `域名不在白名单:${host}`, {
|
|
||||||
recommendation: `在设置中把 ${host} 加入白名单(或设置 RPA_ALLOWED_DOMAINS)`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,15 +94,37 @@ function selectorSpec(selector: unknown): { kind: string; value: string; role?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BrowserSession implements BrowserLike {
|
export class BrowserSession implements BrowserLike {
|
||||||
private win: BrowserWindow;
|
private win: BrowserWindow | null;
|
||||||
|
private transport: CdpTransport;
|
||||||
|
private external: ExternalConnection | null;
|
||||||
|
private externalKey: string;
|
||||||
|
/** 外部浏览器调试 HTTP 根地址(多标签管理用),内置为空 */
|
||||||
|
private httpBase = "";
|
||||||
|
/** 外部浏览器各标签页的 ws 会话缓存(targetId → transport),this.transport 指向当前激活页 */
|
||||||
|
private tabTransports = new Map<string, ExternalTransport>();
|
||||||
|
private activeTargetId = "";
|
||||||
|
// 实例级监听器状态(自动关弹窗 / 网络监听)——仅对当前激活页生效
|
||||||
|
private dialogAutoClose = false;
|
||||||
|
private dialogHandler: CdpEventHandler | null = null;
|
||||||
|
private netListening = false;
|
||||||
|
private netHandler: CdpEventHandler | null = null;
|
||||||
|
private netCaptures: Array<{ requestId: string; url: string; status: number; mimeType: string }> = [];
|
||||||
|
|
||||||
private constructor(win: BrowserWindow) {
|
private constructor(win: BrowserWindow | null, transport: CdpTransport, external: ExternalConnection | null = null) {
|
||||||
this.win = win;
|
this.win = win;
|
||||||
|
this.transport = transport;
|
||||||
|
this.external = external;
|
||||||
|
this.externalKey = external?.sessionKey ?? "";
|
||||||
|
this.httpBase = external?.httpBase ?? "";
|
||||||
|
if (external) {
|
||||||
|
this.activeTargetId = external.targetId;
|
||||||
|
this.tabTransports.set(external.targetId, external.transport);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(): Promise<BrowserSession> {
|
static async create(): Promise<BrowserSession> {
|
||||||
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
|
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
|
||||||
return new BrowserSession(globalThis.__rpaBrowserWindow);
|
return new BrowserSession(globalThis.__rpaBrowserWindow, new EmbeddedTransport(globalThis.__rpaBrowserWindow));
|
||||||
}
|
}
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
@@ -120,22 +146,37 @@ export class BrowserSession implements BrowserLike {
|
|||||||
if (!win.webContents.debugger.isAttached()) {
|
if (!win.webContents.debugger.isAttached()) {
|
||||||
win.webContents.debugger.attach("1.3");
|
win.webContents.debugger.attach("1.3");
|
||||||
}
|
}
|
||||||
return new BrowserSession(win);
|
return new BrowserSession(win, new EmbeddedTransport(win));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建外部浏览器会话(Edge/Chrome 或远程调试端口连接)。
|
||||||
|
* 会话按 (mode, browserType/host, port) 缓存复用,同一流程多次「打开网页」不会重复拉起。
|
||||||
|
*/
|
||||||
|
static async createExternal(options: ExternalBrowserOptions): Promise<BrowserSession> {
|
||||||
|
const key = sessionKeyOf(options);
|
||||||
|
const cache = externalSessionCache();
|
||||||
|
const existing = cache.get(key);
|
||||||
|
if (existing && !existing.transport.closed) return existing;
|
||||||
|
if (existing) cache.delete(key);
|
||||||
|
const connection = await openExternalBrowser(options);
|
||||||
|
const session = new BrowserSession(null, connection.transport, connection);
|
||||||
|
cache.set(key, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前会话是否为外部浏览器 */
|
||||||
|
get isExternal(): boolean {
|
||||||
|
return this.external !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前会话标识(用于日志,如 "Edge (127.0.0.1:9222)") */
|
||||||
|
get label(): string {
|
||||||
|
return this.external?.label ?? "内置运行浏览器";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||||
if (this.win.isDestroyed()) {
|
return this.transport.send<T>(method, params);
|
||||||
throw new RuntimeError("tab_unavailable", "运行浏览器已关闭", { recommendation: "重新运行流程" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (!this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
|
|
||||||
return (await this.win.webContents.debugger.sendCommand(method, params)) as T;
|
|
||||||
} catch (error) {
|
|
||||||
if (/destroyed|Target closed|not attached/i.test((error as Error).message)) {
|
|
||||||
throw new RuntimeError("tab_unavailable", "浏览器连接已失效", { detail: (error as Error).message });
|
|
||||||
}
|
|
||||||
throw new RuntimeError("browser_error", (error as Error).message, { recommendation: "重试或检查页面状态" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async evaluate<T = unknown>(expression: string): Promise<T> {
|
private async evaluate<T = unknown>(expression: string): Promise<T> {
|
||||||
@@ -153,9 +194,25 @@ export class BrowserSession implements BrowserLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
|
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
|
||||||
|
const deadline = Date.now() + globalElementWaitMs;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return await this.withElementOnce<T>(selector, operation);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error;
|
||||||
|
await this.wait(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withElementOnce<T>(selector: unknown, operation: string): Promise<T> {
|
||||||
const spec = selectorSpec(selector);
|
const spec = selectorSpec(selector);
|
||||||
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
|
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
|
||||||
const spec = ${JSON.stringify(spec)};
|
const spec = ${JSON.stringify(spec)};
|
||||||
|
const escapeSelectorValue = (value) => {
|
||||||
|
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||||||
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
|
||||||
|
};
|
||||||
const find = () => {
|
const find = () => {
|
||||||
if (spec.kind === 'xpath') {
|
if (spec.kind === 'xpath') {
|
||||||
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||||
@@ -169,7 +226,7 @@ export class BrowserSession implements BrowserLike {
|
|||||||
return matches.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length)[0] || null;
|
return matches.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length)[0] || null;
|
||||||
}
|
}
|
||||||
if (spec.kind === 'aria') {
|
if (spec.kind === 'aria') {
|
||||||
const role = spec.role || spec.value;
|
const role = escapeSelectorValue(spec.role || spec.value);
|
||||||
const name = spec.name || '';
|
const name = spec.name || '';
|
||||||
return [...document.querySelectorAll('[role="' + role + '"], ' + role)].find((node) => {
|
return [...document.querySelectorAll('[role="' + role + '"], ' + role)].find((node) => {
|
||||||
const accessibleName = node.getAttribute('aria-label') || node.innerText || '';
|
const accessibleName = node.getAttribute('aria-label') || node.innerText || '';
|
||||||
@@ -253,11 +310,67 @@ export class BrowserSession implements BrowserLike {
|
|||||||
async navigate(url: string): Promise<{ url: string; title: string }> {
|
async navigate(url: string): Promise<{ url: string; title: string }> {
|
||||||
const target = assertUrl(url);
|
const target = assertUrl(url);
|
||||||
await this.cdp("Page.enable");
|
await this.cdp("Page.enable");
|
||||||
await this.win.loadURL(target.toString());
|
if (this.win) {
|
||||||
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
await this.win.loadURL(target.toString());
|
||||||
|
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
||||||
|
}
|
||||||
|
// 外部浏览器:Page.navigate + 等待 loadEventFired(与内置 loadURL 等加载完成的语义一致)
|
||||||
|
const loaded = new Promise<void>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const onLoad = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.transport.off("Page.loadEventFired", onLoad);
|
||||||
|
// 留 150ms 余量让 load 事件后的首帧渲染稳定
|
||||||
|
setTimeout(resolve, 150);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
this.transport.off("Page.loadEventFired", onLoad);
|
||||||
|
resolve();
|
||||||
|
}, 30000);
|
||||||
|
this.transport.on("Page.loadEventFired", onLoad);
|
||||||
|
});
|
||||||
|
const result = await this.cdp<{ errorText?: string }>("Page.navigate", { url: target.toString() });
|
||||||
|
if (result?.errorText) {
|
||||||
|
throw new RuntimeError("browser_error", `页面导航失败:${result.errorText}`, {
|
||||||
|
detail: target.toString(),
|
||||||
|
recommendation: "确认网址可达"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loaded;
|
||||||
|
const currentUrl = await this.evaluate<string>("location.href");
|
||||||
|
const title = await this.evaluate<string>("document.title");
|
||||||
|
return { url: currentUrl, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
async locate(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
private async wait(milliseconds: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async locate(selector: unknown, timeoutMs?: number): Promise<ElementInfo & { selector: string }> {
|
||||||
|
const waitMs = Math.max(0, timeoutMs ?? globalElementWaitMs);
|
||||||
|
const deadline = Date.now() + waitMs;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return await this.locateOnce(selector);
|
||||||
|
} catch (error) {
|
||||||
|
const canRetry = error instanceof RuntimeError && error.code === "not_found" && Date.now() < deadline;
|
||||||
|
if (!canRetry) {
|
||||||
|
if (error instanceof RuntimeError && error.code === "not_found" && waitMs > 0) {
|
||||||
|
error.detail = `已等待页面渲染 ${waitMs}ms 后仍未找到元素`;
|
||||||
|
error.recommendation = "确认页面已加载完成;如果元素在 iframe 或 Shadow DOM 中,请用拾取器重新采集";
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await this.wait(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async locateOnce(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
||||||
const spec = selectorSpec(selector);
|
const spec = selectorSpec(selector);
|
||||||
const expression = `(() => {
|
const expression = `(() => {
|
||||||
const spec = ${JSON.stringify(spec)};
|
const spec = ${JSON.stringify(spec)};
|
||||||
@@ -328,8 +441,8 @@ export class BrowserSession implements BrowserLike {
|
|||||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
|
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async click(selector: unknown): Promise<unknown> {
|
async click(selector: unknown, timeoutMs?: number): Promise<unknown> {
|
||||||
const info = await this.locate(selector);
|
const info = await this.locate(selector, timeoutMs);
|
||||||
if (!info.visible) {
|
if (!info.visible) {
|
||||||
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
|
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
|
||||||
}
|
}
|
||||||
@@ -359,9 +472,21 @@ export class BrowserSession implements BrowserLike {
|
|||||||
return { selector: info.selector, tag: info.tag, text: info.text };
|
return { selector: info.selector, tag: info.tag, text: info.text };
|
||||||
}
|
}
|
||||||
|
|
||||||
async type(selector: unknown, text: string, clearFirst = true): Promise<unknown> {
|
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
|
||||||
const info = await this.locate(selector);
|
const info = await this.locate(selector, timeoutMs);
|
||||||
|
if (this.win) {
|
||||||
|
this.win.show();
|
||||||
|
this.win.focus();
|
||||||
|
}
|
||||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||||
|
const focused = await this.withElement<boolean>(selector, `
|
||||||
|
el.focus({ focusVisible: true });
|
||||||
|
return document.activeElement === el || el.contains(document.activeElement);
|
||||||
|
`);
|
||||||
|
if (!focused) {
|
||||||
|
const direct = await this.setValueDirect(selector, clearFirst ? text : `${info.value || ""}${text}`);
|
||||||
|
return { selector: info.selector, value: direct };
|
||||||
|
}
|
||||||
if (clearFirst) {
|
if (clearFirst) {
|
||||||
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
||||||
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
||||||
@@ -370,11 +495,32 @@ export class BrowserSession implements BrowserLike {
|
|||||||
await this.cdp("Input.insertText", { text });
|
await this.cdp("Input.insertText", { text });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
const expected = clearFirst ? text : `${info.value || ""}${text}`;
|
const expected = clearFirst ? text : `${info.value || ""}${text}`;
|
||||||
const actual = await this.evaluate<string>(`(() => { const el = document.activeElement; return el ? (el.value ?? el.textContent ?? '') : ''; })()`);
|
const actual = await this.withElement<string>(selector, `return String(el.value ?? el.textContent ?? '')`);
|
||||||
if (actual !== expected) throw new RuntimeError("evaluate_error", `输入校验失败:期望「${expected}」,实际「${actual}」`, { recommendation: "检查输入框状态或重新拾取元素" });
|
if (actual !== expected) {
|
||||||
|
const fallback = await this.setValueDirect(selector, clearFirst ? text : expected);
|
||||||
|
if (fallback !== expected) {
|
||||||
|
throw new RuntimeError("evaluate_error", `输入校验失败:期望「${expected}」,实际「${fallback}」`, { recommendation: "检查输入框状态或重新拾取元素" });
|
||||||
|
}
|
||||||
|
return { selector: info.selector, value: fallback };
|
||||||
|
}
|
||||||
return { selector: info.selector, value: actual };
|
return { selector: info.selector, value: actual };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async setValueDirect(selector: unknown, text: string): Promise<string> {
|
||||||
|
return this.withElement<string>(selector, `
|
||||||
|
if (el.isContentEditable) {
|
||||||
|
el.textContent = ${JSON.stringify(text)};
|
||||||
|
} else {
|
||||||
|
const prototype = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||||||
|
descriptor?.set?.call(el, ${JSON.stringify(text)});
|
||||||
|
}
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return String(el.value ?? el.textContent ?? '');
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
async getText(selector: unknown, attribute?: string): Promise<string> {
|
async getText(selector: unknown, attribute?: string): Promise<string> {
|
||||||
const info = await this.locate(selector);
|
const info = await this.locate(selector);
|
||||||
if (attribute) {
|
if (attribute) {
|
||||||
@@ -390,7 +536,7 @@ export class BrowserSession implements BrowserLike {
|
|||||||
|
|
||||||
async exists(selector: unknown): Promise<boolean> {
|
async exists(selector: unknown): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.locate(selector);
|
await this.locate(selector, 0);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -418,11 +564,13 @@ export class BrowserSession implements BrowserLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
|
async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
|
||||||
|
if (!this.win) return this.downloadExternal(selector, folder, filename, timeoutMs);
|
||||||
|
const win = this.win;
|
||||||
const targetFolder = path.resolve(folder);
|
const targetFolder = path.resolve(folder);
|
||||||
await fs.mkdir(targetFolder, { recursive: true });
|
await fs.mkdir(targetFolder, { recursive: true });
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const browserSession = this.win.webContents.session;
|
const browserSession = win.webContents.session;
|
||||||
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
|
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
|
||||||
const target = path.join(targetFolder, filename || item.getFilename());
|
const target = path.join(targetFolder, filename || item.getFilename());
|
||||||
item.setSavePath(target);
|
item.setSavePath(target);
|
||||||
@@ -444,6 +592,49 @@ export class BrowserSession implements BrowserLike {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外部浏览器下载:Browser.setDownloadBehavior + downloadProgress 事件(guid 命名,完成后重命名为期望文件名) */
|
||||||
|
private async downloadExternal(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
|
||||||
|
const targetFolder = path.resolve(folder);
|
||||||
|
await fs.mkdir(targetFolder, { recursive: true });
|
||||||
|
await this.cdp("Browser.setDownloadBehavior", {
|
||||||
|
behavior: "allowAndName",
|
||||||
|
downloadPath: targetFolder,
|
||||||
|
eventsEnabled: true
|
||||||
|
});
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let guid = "";
|
||||||
|
let suggestedName = "";
|
||||||
|
const finish = (error?: Error, guidFile?: string) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.transport.off("Browser.downloadWillBegin", onBegin);
|
||||||
|
this.transport.off("Browser.downloadProgress", onProgress);
|
||||||
|
if (error || !guidFile) return reject(error ?? new RuntimeError("browser_error", "下载未完成"));
|
||||||
|
const desired = filename || suggestedName;
|
||||||
|
const desiredPath = desired ? path.join(targetFolder, desired) : guidFile;
|
||||||
|
fs
|
||||||
|
.rename(guidFile, desiredPath)
|
||||||
|
.then(() => resolve(desiredPath))
|
||||||
|
.catch(() => resolve(guidFile));
|
||||||
|
};
|
||||||
|
const onBegin = (params: Record<string, unknown>) => {
|
||||||
|
guid = String(params.guid ?? "");
|
||||||
|
suggestedName = String(params.suggestedFilename ?? "");
|
||||||
|
};
|
||||||
|
const onProgress = (params: Record<string, unknown>) => {
|
||||||
|
if (guid && params.guid !== guid) return;
|
||||||
|
if (params.state === "completed") finish(undefined, path.join(targetFolder, guid));
|
||||||
|
else if (params.state === "canceled") finish(new RuntimeError("browser_error", "下载被取消"));
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
|
||||||
|
this.transport.on("Browser.downloadWillBegin", onBegin);
|
||||||
|
this.transport.on("Browser.downloadProgress", onProgress);
|
||||||
|
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async screenshot(filePath: string): Promise<string> {
|
async screenshot(filePath: string): Promise<string> {
|
||||||
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
|
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
|
||||||
const target = path.resolve(filePath);
|
const target = path.resolve(filePath);
|
||||||
@@ -707,25 +898,420 @@ export class BrowserSession implements BrowserLike {
|
|||||||
return result.items;
|
return result.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maximize(): void {
|
||||||
|
if (this.win && !this.win.isDestroyed()) this.win.maximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
minimize(): void {
|
||||||
|
if (this.win && !this.win.isDestroyed()) this.win.minimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoading(): void {
|
||||||
|
if (this.win) {
|
||||||
|
if (!this.win.isDestroyed()) this.win.webContents.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.cdp("Page.stopLoading").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFileToInput(selector: string, filePath: string): Promise<number> {
|
||||||
|
const doc = await this.cdp<{ root?: { nodeId?: number } }>("DOM.getDocument");
|
||||||
|
const rootNodeId = doc.root?.nodeId;
|
||||||
|
if (!rootNodeId) throw new RuntimeError("browser_error", "无法定位页面 DOM 根节点");
|
||||||
|
const css = selector.replace(/^css:/, "").trim();
|
||||||
|
const found = await this.cdp<{ nodeId?: number }>("DOM.querySelector", { nodeId: rootNodeId, selector: css });
|
||||||
|
if (!found.nodeId) {
|
||||||
|
throw new RuntimeError("element_not_found", "未找到文件输入元素:" + css, { recommendation: "确认 input[type=file] 存在且已渲染" });
|
||||||
|
}
|
||||||
|
await this.cdp("DOM.setFileInputFiles", { files: [filePath], nodeId: found.nodeId });
|
||||||
|
return found.nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
startAutoCloseDialogs(): boolean {
|
||||||
|
if (this.dialogAutoClose) return false;
|
||||||
|
this.dialogAutoClose = true;
|
||||||
|
this.cdp("Page.enable").catch(() => {});
|
||||||
|
const handler: CdpEventHandler = () => {
|
||||||
|
this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
this.dialogHandler = handler;
|
||||||
|
this.transport.on("Page.javascriptDialogOpening", handler);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoCloseDialogs(): boolean {
|
||||||
|
if (!this.dialogAutoClose) return false;
|
||||||
|
if (this.dialogHandler) {
|
||||||
|
this.transport.off("Page.javascriptDialogOpening", this.dialogHandler);
|
||||||
|
}
|
||||||
|
this.dialogAutoClose = false;
|
||||||
|
this.dialogHandler = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
startNetworkListening(): boolean {
|
||||||
|
if (this.netListening) return false;
|
||||||
|
this.netListening = true;
|
||||||
|
this.netCaptures = [];
|
||||||
|
this.cdp("Network.enable").catch(() => {});
|
||||||
|
const handler: CdpEventHandler = (params) => {
|
||||||
|
const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined;
|
||||||
|
const request = params.requestId as string | undefined;
|
||||||
|
if (!response || !request) return;
|
||||||
|
this.netCaptures.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" });
|
||||||
|
if (this.netCaptures.length > 2000) this.netCaptures.shift();
|
||||||
|
};
|
||||||
|
this.netHandler = handler;
|
||||||
|
this.transport.on("Network.responseReceived", handler);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise<Array<Record<string, unknown>>> {
|
||||||
|
let captures = this.netCaptures;
|
||||||
|
if (options.resourceType) {
|
||||||
|
const want = options.resourceType.toLowerCase();
|
||||||
|
captures = captures.filter(c => c.mimeType.toLowerCase().includes(want));
|
||||||
|
}
|
||||||
|
if (options.url) {
|
||||||
|
const keyword = options.url;
|
||||||
|
captures = options.wildcard
|
||||||
|
? captures.filter(c => new RegExp("^" + keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")).test(c.url))
|
||||||
|
: captures.filter(c => c.url.includes(keyword));
|
||||||
|
}
|
||||||
|
const result: Array<Record<string, unknown>> = [];
|
||||||
|
for (const c of captures.slice(-200)) {
|
||||||
|
const item: Record<string, unknown> = { url: c.url, status: c.status, mimeType: c.mimeType };
|
||||||
|
if (options.body && /json|text|javascript/i.test(c.mimeType)) {
|
||||||
|
try {
|
||||||
|
const body = await this.cdp<{ body?: string }>("Network.getResponseBody", { requestId: c.requestId });
|
||||||
|
item.body = body.body ?? "";
|
||||||
|
} catch { item.body = ""; }
|
||||||
|
}
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopNetworkListening(): boolean {
|
||||||
|
if (!this.netListening) return false;
|
||||||
|
if (this.netHandler) {
|
||||||
|
this.transport.off("Network.responseReceived", this.netHandler);
|
||||||
|
}
|
||||||
|
this.netListening = false;
|
||||||
|
this.netHandler = null;
|
||||||
|
this.cdp("Network.disable").catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async currentUrl(): Promise<string> {
|
async currentUrl(): Promise<string> {
|
||||||
return this.win.webContents.getURL();
|
if (this.win) return this.win.webContents.getURL();
|
||||||
|
return this.evaluate<string>("location.href");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出标签页:内置浏览器返回当前单页;外部浏览器枚举全部页面 target */
|
||||||
|
async listTabs(): Promise<BrowserTabInfo[]> {
|
||||||
|
if (this.win) {
|
||||||
|
if (this.win.isDestroyed()) return [];
|
||||||
|
return [{ index: 0, targetId: "", title: this.win.webContents.getTitle(), url: this.win.webContents.getURL(), active: true }];
|
||||||
|
}
|
||||||
|
const pages = await listPageTargets(this.httpBase);
|
||||||
|
return pages.map((page, index) => ({
|
||||||
|
index,
|
||||||
|
targetId: page.id,
|
||||||
|
title: page.title,
|
||||||
|
url: page.url,
|
||||||
|
active: page.id === this.activeTargetId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换当前操作的标签页(外部浏览器按 targetId 复用或新建 ws 会话;标题支持正则) */
|
||||||
|
async switchTab(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise<BrowserTabInfo> {
|
||||||
|
if (this.win) {
|
||||||
|
const tabs = await this.listTabs();
|
||||||
|
if (tabs.length === 0) throw new RuntimeError("tab_unavailable", "运行浏览器已关闭");
|
||||||
|
return tabs[0];
|
||||||
|
}
|
||||||
|
const tabs = await this.listTabs();
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
throw new RuntimeError("browser_disconnected", "外部浏览器上没有可切换的页面", { recommendation: "重新运行「打开网页」" });
|
||||||
|
}
|
||||||
|
let matched: BrowserTabInfo | undefined;
|
||||||
|
if (match.targetId) {
|
||||||
|
matched = tabs.find((tab) => tab.targetId === match.targetId);
|
||||||
|
} else if (match.index !== undefined) {
|
||||||
|
matched = tabs[match.index] ?? (match.index < 0 ? tabs[tabs.length + match.index] : undefined);
|
||||||
|
} else if (match.title) {
|
||||||
|
let pattern: RegExp;
|
||||||
|
try {
|
||||||
|
pattern = new RegExp(match.title);
|
||||||
|
} catch {
|
||||||
|
pattern = new RegExp(match.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||||
|
}
|
||||||
|
matched = tabs.find((tab) => pattern.test(tab.title));
|
||||||
|
} else if (match.url) {
|
||||||
|
matched = tabs.find((tab) => tab.url.includes(match.url as string));
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
throw new RuntimeError("tab_not_found", "没有匹配的标签页", {
|
||||||
|
detail: JSON.stringify(match),
|
||||||
|
recommendation: "用「获取标签页」查看当前打开的页面标题与网址"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.activateTab(matched.targetId);
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在外部浏览器新建标签页并切换过去;内置浏览器退化为当前页导航 */
|
||||||
|
async openNewTab(url: string): Promise<BrowserTabInfo> {
|
||||||
|
if (this.win) {
|
||||||
|
const result = await this.navigate(url);
|
||||||
|
return { index: 0, targetId: "", title: result.title, url: result.url, active: true };
|
||||||
|
}
|
||||||
|
const target = await openNewPageTarget(this.httpBase, assertUrl(url).toString());
|
||||||
|
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl, `tab:${target.id}`);
|
||||||
|
this.tabTransports.set(target.id, transport);
|
||||||
|
this.activeTargetId = target.id;
|
||||||
|
this.transport = transport;
|
||||||
|
const info: BrowserTabInfo = { index: -1, targetId: target.id, title: "", url: target.url, active: true };
|
||||||
|
try {
|
||||||
|
const tabs = await this.listTabs();
|
||||||
|
const found = tabs.find((tab) => tab.targetId === target.id);
|
||||||
|
if (found) return found;
|
||||||
|
} catch {
|
||||||
|
// 枚举失败时返回基本信息
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接(或复用)指定页面的 ws 会话并设为当前操作页;监听器状态重置以跟随新页面 */
|
||||||
|
private async activateTab(targetId: string): Promise<void> {
|
||||||
|
if (targetId === this.activeTargetId && this.tabTransports.has(targetId) && !this.tabTransports.get(targetId)?.closed) return;
|
||||||
|
let transport = this.tabTransports.get(targetId);
|
||||||
|
if (!transport || transport.closed) {
|
||||||
|
const pages = await listPageTargets(this.httpBase);
|
||||||
|
const page = pages.find((item) => item.id === targetId);
|
||||||
|
if (!page) throw new RuntimeError("tab_not_found", "标签页已关闭", { detail: targetId });
|
||||||
|
transport = await ExternalTransport.connect(page.webSocketDebuggerUrl, `tab:${targetId}`);
|
||||||
|
this.tabTransports.set(targetId, transport);
|
||||||
|
}
|
||||||
|
// 监听器(弹窗/网络)绑定在旧 transport 上,切页后重新建立
|
||||||
|
const previousHandler = { dialog: this.dialogAutoClose, net: this.netListening };
|
||||||
|
if (previousHandler.dialog) this.stopAutoCloseDialogs();
|
||||||
|
if (previousHandler.net) this.stopNetworkListening();
|
||||||
|
this.transport = transport;
|
||||||
|
this.activeTargetId = targetId;
|
||||||
|
this.netCaptures = [];
|
||||||
|
if (previousHandler.dialog) this.startAutoCloseDialogs();
|
||||||
|
if (previousHandler.net) this.startNetworkListening();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量 Cookie(含 HttpOnly),内置/外部统一走 Network.getCookies */
|
||||||
|
async getCookies(): Promise<Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>> {
|
||||||
|
const result = await this.cdp<{ cookies?: Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }> }>(
|
||||||
|
"Network.getCookies"
|
||||||
|
);
|
||||||
|
return result.cookies ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
|
||||||
|
// 拾取脚本经 Runtime.evaluate 注入并等待用户点击,内置/外部浏览器行为一致
|
||||||
|
const url = await this.currentUrl();
|
||||||
|
if (!url || url === "about:blank") {
|
||||||
|
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
|
||||||
|
}
|
||||||
|
if (this.win) {
|
||||||
|
this.win.show();
|
||||||
|
this.win.focus();
|
||||||
|
}
|
||||||
|
const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => {
|
||||||
|
const timeout = ${JSON.stringify(timeoutMs)};
|
||||||
|
if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel();
|
||||||
|
|
||||||
|
let host = null;
|
||||||
|
let root = null;
|
||||||
|
let box = null;
|
||||||
|
let badge = null;
|
||||||
|
let target = null;
|
||||||
|
let timer = 0;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const cssEscape = (value) => {
|
||||||
|
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||||||
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
|
||||||
|
};
|
||||||
|
const isUnique = (candidate) => {
|
||||||
|
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
|
||||||
|
};
|
||||||
|
const cssPath = (node) => {
|
||||||
|
if (node.id) {
|
||||||
|
const candidate = '#' + cssEscape(node.id);
|
||||||
|
if (isUnique(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
const testId = node.getAttribute('data-testid');
|
||||||
|
if (testId) {
|
||||||
|
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||||||
|
if (isUnique(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
let current = node;
|
||||||
|
while (current && current.nodeType === 1) {
|
||||||
|
let part = current.tagName.toLowerCase();
|
||||||
|
if (current.id) {
|
||||||
|
part += '#' + cssEscape(current.id);
|
||||||
|
parts.unshift(part);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parent = current.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
||||||
|
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return parts.join(' > ');
|
||||||
|
};
|
||||||
|
const describe = (node) => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
selector: 'css:' + cssPath(node),
|
||||||
|
tag: node.tagName.toLowerCase(),
|
||||||
|
id: node.id || undefined,
|
||||||
|
className: node.getAttribute('class') || undefined,
|
||||||
|
name: node.getAttribute('name') || undefined,
|
||||||
|
type: node.getAttribute('type') || undefined,
|
||||||
|
placeholder: node.getAttribute('placeholder') || undefined,
|
||||||
|
label: node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || (node.innerText || '').trim().slice(0, 100) || undefined,
|
||||||
|
text: (node.innerText || node.textContent || '').trim().slice(0, 200) || undefined,
|
||||||
|
value: String(node.value ?? '') || undefined,
|
||||||
|
url: location.href
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const updateBox = () => {
|
||||||
|
if (!target || !box || !badge) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
box.style.left = Math.max(0, rect.left - 3) + 'px';
|
||||||
|
box.style.top = Math.max(0, rect.top - 3) + 'px';
|
||||||
|
box.style.width = Math.max(4, rect.width + 6) + 'px';
|
||||||
|
box.style.height = Math.max(4, rect.height + 6) + 'px';
|
||||||
|
const tag = target.tagName.toLowerCase();
|
||||||
|
const name = target.getAttribute('aria-label') || target.getAttribute('placeholder') || target.getAttribute('name') || target.id || '';
|
||||||
|
badge.textContent = '拾取:' + tag + (name ? ' · ' + name.slice(0, 60) : '');
|
||||||
|
badge.style.left = Math.max(0, rect.left) + 'px';
|
||||||
|
badge.style.top = Math.max(0, rect.top - 30) + 'px';
|
||||||
|
};
|
||||||
|
const firstElement = (event) => {
|
||||||
|
for (const item of event.composedPath()) if (item instanceof Element) return item;
|
||||||
|
return event.target instanceof Element ? event.target : null;
|
||||||
|
};
|
||||||
|
const move = (event) => {
|
||||||
|
if (finished) return;
|
||||||
|
target = firstElement(event);
|
||||||
|
updateBox();
|
||||||
|
};
|
||||||
|
const click = (event) => {
|
||||||
|
if (finished) return;
|
||||||
|
const selected = firstElement(event);
|
||||||
|
if (!selected) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
cleanup();
|
||||||
|
resolve(describe(selected));
|
||||||
|
};
|
||||||
|
const key = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('已取消元素拾取'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener('mousemove', move, true);
|
||||||
|
window.removeEventListener('mousedown', click, true);
|
||||||
|
window.removeEventListener('click', click, true);
|
||||||
|
window.removeEventListener('scroll', updateBox, true);
|
||||||
|
window.removeEventListener('resize', updateBox);
|
||||||
|
window.removeEventListener('keydown', key, true);
|
||||||
|
host?.remove();
|
||||||
|
delete window.__rpaElementPicker;
|
||||||
|
};
|
||||||
|
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none;';
|
||||||
|
root = host.attachShadow({ mode: 'closed' });
|
||||||
|
box = document.createElement('div');
|
||||||
|
badge = document.createElement('div');
|
||||||
|
box.style.cssText = 'position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.16);border-radius:3px;pointer-events:none;';
|
||||||
|
badge.style.cssText = 'position:fixed;z-index:2147483647;padding:4px 7px;border-radius:6px;background:#2563eb;color:#fff;font:600 12px/16px system-ui,sans-serif;pointer-events:none;white-space:nowrap;';
|
||||||
|
root.append(box, badge);
|
||||||
|
document.documentElement.append(host);
|
||||||
|
window.addEventListener('mousemove', move, true);
|
||||||
|
window.addEventListener('mousedown', click, true);
|
||||||
|
window.addEventListener('click', click, true);
|
||||||
|
window.addEventListener('scroll', updateBox, true);
|
||||||
|
window.addEventListener('resize', updateBox);
|
||||||
|
window.addEventListener('keydown', key, true);
|
||||||
|
window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } };
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('拾取超时,请重新点击拾取'));
|
||||||
|
}, timeout);
|
||||||
|
}))()`);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
if (!this.win.isDestroyed()) this.win.close();
|
if (this.win) {
|
||||||
globalThis.__rpaBrowserWindow = null;
|
if (!this.win.isDestroyed()) this.win.close();
|
||||||
|
globalThis.__rpaBrowserWindow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 外部浏览器:断开全部标签页 ws 会话;本模块拉起的进程按 killOnClose 决定是否关闭
|
||||||
|
if (this.externalKey) externalSessionCache().delete(this.externalKey);
|
||||||
|
const all = new Set<CdpTransport>([this.transport, ...this.tabTransports.values()]);
|
||||||
|
await Promise.all([...all].map((transport) => transport.close().catch(() => {})));
|
||||||
|
this.tabTransports.clear();
|
||||||
|
const child = this.external?.child;
|
||||||
|
if (this.external?.killOnClose && child && child.exitCode === null) {
|
||||||
|
try {
|
||||||
|
child.kill();
|
||||||
|
} catch {
|
||||||
|
// 进程可能已自行退出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.external = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
var __rpaBrowserWindow: BrowserWindow | null | undefined;
|
var __rpaBrowserWindow: BrowserWindow | null | undefined;
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __rpaExternalSessions: Map<string, BrowserSession> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalSessionCache(): Map<string, BrowserSession> {
|
||||||
|
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
|
||||||
|
return globalThis.__rpaExternalSessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSession(): Promise<BrowserSession> {
|
export async function browserSession(): Promise<BrowserSession> {
|
||||||
return BrowserSession.create();
|
return BrowserSession.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function externalBrowserSession(options: ExternalBrowserOptions): Promise<BrowserSession> {
|
||||||
|
return BrowserSession.createExternal(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBrowserSession(): boolean {
|
||||||
|
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
|
||||||
|
}
|
||||||
|
|
||||||
export function assertAllowedUrl(url: string): URL {
|
export function assertAllowedUrl(url: string): URL {
|
||||||
return assertUrl(url);
|
return assertUrl(url);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { BrowserWindow } from "electron";
|
||||||
|
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: "重试或检查页面状态" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置运行浏览器:包装 webContents.debugger */
|
||||||
|
export class EmbeddedTransport implements CdpTransport {
|
||||||
|
readonly kind = "embedded" as const;
|
||||||
|
private wrappers = new Map<CdpEventHandler, (event: unknown, method: string, params?: Record<string, unknown>) => void>();
|
||||||
|
|
||||||
|
constructor(private win: BrowserWindow) {}
|
||||||
|
|
||||||
|
get closed(): boolean {
|
||||||
|
return this.win.isDestroyed();
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
if (this.win.isDestroyed()) {
|
||||||
|
throw new RuntimeError("tab_unavailable", "运行浏览器已关闭", { recommendation: "重新运行流程" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
|
||||||
|
return (await this.win.webContents.debugger.sendCommand(method, params)) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw mapCdpError(error, "embedded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: CdpEventHandler): void {
|
||||||
|
if (this.win.isDestroyed()) return;
|
||||||
|
const wrapper = (_event: unknown, evtMethod: string, evtParams?: Record<string, unknown>) => {
|
||||||
|
if (evtMethod === method) handler(evtParams ?? {});
|
||||||
|
};
|
||||||
|
this.wrappers.set(handler, wrapper);
|
||||||
|
this.win.webContents.debugger.on("message", wrapper as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: CdpEventHandler): void {
|
||||||
|
const wrapper = this.wrappers.get(handler);
|
||||||
|
if (wrapper) {
|
||||||
|
this.win.webContents.debugger.removeListener("message", wrapper as never);
|
||||||
|
this.wrappers.delete(handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (!this.win.isDestroyed() && this.win.webContents.debugger.isAttached()) {
|
||||||
|
try {
|
||||||
|
this.win.webContents.debugger.detach();
|
||||||
|
} catch {
|
||||||
|
// 窗口关闭过程中 detach 可能失败,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError } from "../engine/types";
|
||||||
|
import { chatCompletion, requireAiConfig } from "./ai-service";
|
||||||
|
|
||||||
|
function inputText(args: Record<string, unknown>, nodeId: string): string {
|
||||||
|
for (const key of ["input", "text", "content", "sentence", "prompt", "sourceFilePath", "filePath", "source", "document"]) {
|
||||||
|
const v = String(args[key] ?? "").trim();
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
throw new RuntimeError("validation_error", "输入文本为空", { nodeId, componentId: nodeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMaybeFile(value: string): Promise<string> {
|
||||||
|
const looksLikeFile = /\.(txt|md|csv|json|log)$/i.test(value) && value.length < 260 && !value.includes("\n");
|
||||||
|
if (!looksLikeFile) return value;
|
||||||
|
try {
|
||||||
|
const fs = await import("node:fs/promises");
|
||||||
|
const stat = await fs.stat(value);
|
||||||
|
if (stat.isFile()) return await fs.readFile(value, "utf8");
|
||||||
|
} catch { /* treat the value as literal text */ }
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete(prompt: string, input: string, temperature = 0.3): Promise<string> {
|
||||||
|
requireAiConfig();
|
||||||
|
return chatCompletion([
|
||||||
|
{ role: "system", content: prompt },
|
||||||
|
{ role: "user", content: input }
|
||||||
|
], { temperature });
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_TASKS: Array<{ ids: string[]; system: string; task: string }> = [
|
||||||
|
{ ids: ["DataService_AIGenTxt"], system: "你是写作助手,根据用户要求生成高质量中文内容。", task: "AI 生成文本" },
|
||||||
|
{ ids: ["DataService_LargeModel", "DataService_ZBrain", "DataService_AgentCall", "NLP_XunFei_NLPBaseServer"], system: "你是智能助手,尽力完成用户的任务。", task: "通用模型调用" },
|
||||||
|
{ ids: ["DataService_ChatBot"], system: "你是对话机器人,用中文自然对话。", task: "对话机器人" },
|
||||||
|
{ ids: ["DataService_KeyWord", "NLP_KeyWordsAnalysis", "NLP_SplitText"], system: "从文本中提取关键词,输出 JSON 数组(字符串列表),不要输出其他内容。", task: "关键词抽取" },
|
||||||
|
{ ids: ["DataService_Correct"], system: "修正文本中的错别字与语病,直接输出修正后的全文。", task: "文本纠错" },
|
||||||
|
{ ids: ["DataService_Review"], system: '审核文本内容是否合规,输出 JSON:{\"pass\":true/false,\"reason\":\"原因\"}。', task: "文本审核" },
|
||||||
|
{ ids: ["DataService_TextComparison", "Scenario_TextComparison"], system: "比较两段文本差异,输出简明差异摘要。", task: "文档比对" },
|
||||||
|
{ ids: ["NLP_SplitWords"], system: '对文本分词,输出 JSON 数组(字符串列表)。', task: "分词" },
|
||||||
|
{ ids: ["NLP_EmotionalAnalysis"], system: '分析文本情感倾向,输出 JSON:{\"label\":\"正面/负面/中性\",\"score\":0.99}。', task: "情感分析" },
|
||||||
|
{ ids: ["NLP_SemanticSimilarity"], system: '判断两段文本语义相似度,输出 JSON:{\"score\":0.99}(0-1)。输入格式:文本1\n---\n文本2。', task: "语义相似度" },
|
||||||
|
{ ids: ["NLP_PerspectiveAnalysis"], system: '分析文本中的观点与立场,输出 JSON:{\"perspectives\":[{\"view\":\"...\",\"stance\":\"支持/反对/中立\"}]}。', task: "观点分析" },
|
||||||
|
{ ids: ["NLP_AnalyseProductReview", "NLP_BatchAnalyseProductReview"], system: '分析商品评论,输出 JSON:{\"summary\":\"...\",\"sentiment\":\"...\",\"issues\":[]}。', task: "商品评论分析" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const task of TEXT_TASKS) {
|
||||||
|
register(task.ids, async (ctx, node, args) => {
|
||||||
|
const rawInput = inputText(args, node.id);
|
||||||
|
const input = await readMaybeFile(rawInput);
|
||||||
|
const result = await complete(task.system, input);
|
||||||
|
ctx.log(task.task + "完成", "info", node.id);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { loadSettings } from "../settings";
|
||||||
|
import { RuntimeError } from "../engine/types";
|
||||||
|
|
||||||
|
export interface ChatMessage { role: "system" | "user" | "assistant"; content: string }
|
||||||
|
|
||||||
|
export function getAiConfig(): { baseUrl: string; apiKey: string; model: string } {
|
||||||
|
return loadSettings().aiConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAiConfig() {
|
||||||
|
const config = getAiConfig();
|
||||||
|
if (!config.baseUrl || !config.model) {
|
||||||
|
throw new RuntimeError("ai_not_configured", "AI 服务未配置", { recommendation: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)" });
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatCompletion(messages: ChatMessage[], options: { temperature?: number } = {}): Promise<string> {
|
||||||
|
const config = requireAiConfig();
|
||||||
|
const base = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
const response = await fetch(base + "/chat/completions", {
|
||||||
|
signal: AbortSignal.timeout(170000),
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
||||||
|
body: JSON.stringify({ model: config.model, messages, temperature: options.temperature ?? 0.3 })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = (await response.text()).slice(0, 300);
|
||||||
|
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail);
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
return data.choices?.[0]?.message?.content ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CatalogComponent, FlowNode } from "../../shared/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("node:child_process", () => ({ execFile: mocks.execFile }));
|
||||||
|
import "./data";
|
||||||
|
import "./control";
|
||||||
|
import "./system-extended";
|
||||||
|
import "./windows-system";
|
||||||
|
|
||||||
|
function run(id: string, args: Record<string, unknown> = {}, signal = new AbortController().signal) {
|
||||||
|
const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||||
|
return getHandler(id)!({ signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); });
|
||||||
|
|
||||||
|
describe("应用与系统组件缺陷回归", () => {
|
||||||
|
it("没有匹配进程时返回 [] / null,忽略空行和非法 PID", async () => {
|
||||||
|
mocks.powershell.mockResolvedValue("");
|
||||||
|
await expect(run("App_GetPids", { pattern: "missing.exe" })).resolves.toEqual([]);
|
||||||
|
await expect(run("App_GetPid", { pattern: "missing.exe" })).resolves.toBeNull();
|
||||||
|
mocks.powershell.mockResolvedValue("\r\n0\r\n123\r\nabc\r\n456\r\n");
|
||||||
|
await expect(run("App_GetPids", { pattern: "fixture.exe" })).resolves.toEqual([123, 456]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("毫秒等待读取 mileSecond,计时和取消均正确", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const waiting = Promise.resolve(run("Basic_Delay", { mileSecond: "250" }));
|
||||||
|
let done = false;
|
||||||
|
void waiting.then(() => { done = true; });
|
||||||
|
await vi.advanceTimersByTimeAsync(249);
|
||||||
|
expect(done).toBe(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(done).toBe(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cancelled = Promise.resolve(run("Basic_Delay", { mileSecond: "3000" }, controller.signal));
|
||||||
|
const rejection = expect(cancelled).rejects.toMatchObject({ code: "cancelled" });
|
||||||
|
controller.abort();
|
||||||
|
await rejection;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[42, "number", true], ["42", "number", false], [42, "1", true],
|
||||||
|
[[], "list", true], [{ key: 1 }, "dict", true], [null, "null", true],
|
||||||
|
[false, "bool", true], ["中文", "string", true], [3.5, "int", false]
|
||||||
|
])("按目标类型判断 %j → %s", (val, into_type, expected) => {
|
||||||
|
expect(run("Basic_JudgeDataType", { val, into_type })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["42.5", "number", 42.5], ["42", "1", 42], ["-3.9", "int", -3],
|
||||||
|
["false", "bool", false], ["true", "bool", true], [42, "string", "42"]
|
||||||
|
])("执行类型转换 %j → %s", (val, into_type, expected) => {
|
||||||
|
expect(run("Basic_DataTypeConversion", { val, into_type })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非法数值或未知目标类型给出定位错误,避免静默返回原值", () => {
|
||||||
|
expect(() => run("Basic_DataTypeConversion", { val: "abc", into_type: "int" })).toThrow();
|
||||||
|
expect(() => run("Basic_DataTypeConversion", { val: 1, into_type: "unknown" })).toThrow();
|
||||||
|
expect(() => run("Basic_JudgeDataType", { val: 1, into_type: "unknown" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("关闭组件按所选模式执行,非法 PID 不降级为按名称批量关闭", async () => {
|
||||||
|
mocks.powershell.mockResolvedValue("");
|
||||||
|
await expect(run("App_KillV1", { close_type: "id", pid: "invalid", p_name: "fixture.exe" })).rejects.toMatchObject({ code: "validation_error" });
|
||||||
|
expect(mocks.powershell).not.toHaveBeenCalled();
|
||||||
|
await run("App_KillV1", { close_type: "name", pid: 123, p_name: "fixture.exe", kill_all: "0" });
|
||||||
|
expect(mocks.powershell.mock.calls[0][0]).toContain("SessionId");
|
||||||
|
expect(mocks.powershell.mock.calls[0][0]).not.toContain("Stop-Process -Id 123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("唤醒不声称已通过 Windows 登录解锁", async () => {
|
||||||
|
mocks.execFile.mockImplementation((_file, _args, _opts, callback) => callback(null, { stdout: "", stderr: "" }));
|
||||||
|
await expect(run("ScreenControl_ScreenUnlock")).resolves.toMatchObject({ awakened: true, loginUnlockSupported: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("屏保自定义密码不受支持时,在触发桌面操作前明确报错", async () => {
|
||||||
|
await expect(run("System_ScreenSaverShowV1", { set_password: "1", password: "" })).rejects.toMatchObject({ code: "validation_error" });
|
||||||
|
expect(mocks.execFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||||
|
import type { BrowserLike, RunContext } from "../engine/types";
|
||||||
|
import { getHandler } from "../engine/registry";
|
||||||
|
import { BROWSER_CONTROL_IDS } from "./browser-control";
|
||||||
|
|
||||||
|
const component = {} as CatalogComponent; const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||||
|
function fake(): BrowserLike { return { navigate: vi.fn(async (url) => ({ url, title: "title" })), click: vi.fn(), type: vi.fn(), getText: vi.fn(), exists: vi.fn(async () => true), select: vi.fn(), runJs: vi.fn(async (script) => script.includes("document.title") ? "Title" : script.includes("document.cookie") ? "a=1" : script.includes("readyState") ? true : true), download: vi.fn(async (_s, folder, file) => `${folder}/${file}`), screenshot: vi.fn(async (target) => target), currentUrl: vi.fn(async () => "http://127.0.0.1/"), close: vi.fn() }; }
|
||||||
|
function context(browser: BrowserLike): RunContext { return { runId: "r", flowId: "f", flow: {} as any, vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "C:/evidence", browser, emit: vi.fn(), log: vi.fn(), componentOf: vi.fn() }; }
|
||||||
|
async function run(ctx: RunContext, id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(ctx, node(id), args, component); }
|
||||||
|
|
||||||
|
describe("browser control capabilities", () => {
|
||||||
|
let browser: BrowserLike; let ctx: RunContext;
|
||||||
|
beforeEach(() => { browser = fake(); ctx = context(browser); });
|
||||||
|
it("registers only real catalog IDs", () => { const catalog = JSON.parse(readFileSync(resolve("resources/catalog.json"), "utf8")); const ids = new Set(catalog.components.map((item: any) => item.id)); for (const id of BROWSER_CONTROL_IDS) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); } });
|
||||||
|
it("routes navigation metadata, scrolling, cookies and screenshots", async () => { await expect(run(ctx, "WebBrowser_GetTitle")).resolves.toBe("Title"); await expect(run(ctx, "WebBrowser_GetUrl")).resolves.toBe("http://127.0.0.1/"); await run(ctx, "WebBrowser_SetScrollPos", { x: 1, y: 2 }); expect(browser.runJs).toHaveBeenCalled(); await expect(run(ctx, "WebBrowser_GetCookie", { name: "a" })).resolves.toBe("1"); await run(ctx, "WebBrowser_GetBrowserPic", { path: "C:/evidence/page.png" }); expect(browser.screenshot).toHaveBeenCalled(); });
|
||||||
|
it("routes waits, exists, close and direct downloads", async () => { await expect(run(ctx, "Element_ExistsV1", { uiSelector: "css:#a" })).resolves.toBe(true); await expect(run(ctx, "Element_WaitElement_v5", { uiSelector: "css:#a", time_out: 0 })).resolves.toBe(true); await run(ctx, "WebBrowser_DownFile", { url: "http://127.0.0.1/file.csv", savepath: "C:/downloads", filename: "file.csv" }); expect(browser.download).toHaveBeenCalledWith(expect.stringContaining("rpa-download"), "C:/downloads", "file.csv", 10000); await run(ctx, "WebBrowser_CloseWeb"); expect(browser.close).toHaveBeenCalled(); });
|
||||||
|
it("rejects unsupported request headers for direct downloads", async () => { await expect(run(ctx, "WebBrowser_DownFile", { url: "http://127.0.0.1/file", savepath: "C:/d", headers: '{"x":"y"}' })).rejects.toMatchObject({ code: "validation_error" }); });
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { browserSession } from "../browser/session";
|
||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
|
||||||
|
|
||||||
|
async function session(ctx: RunContext): Promise<BrowserLike> { if (!ctx.browser) ctx.browser = await browserSession(); return ctx.browser; }
|
||||||
|
function selector(args: Record<string, unknown>): unknown { const value = args.uiSelector ?? args.download_selector ?? args.selector; if (value === undefined || value === null || value === "") throw new RuntimeError("validation_error", "元素选择器为空"); return value; }
|
||||||
|
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); } return fallback; }
|
||||||
|
|
||||||
|
register("WebBrowser_Reload", async (ctx) => { const browser = await session(ctx); return browser.navigate(await browser.currentUrl()); });
|
||||||
|
register("WebBrowser_GoBack", async (ctx) => { const browser = await session(ctx); await browser.runJs("history.back(); true"); return true; });
|
||||||
|
register("WebBrowser_GoForward", async (ctx) => { const browser = await session(ctx); await browser.runJs("history.forward(); true"); return true; });
|
||||||
|
register("WebBrowser_GetTitle", async (ctx) => session(ctx).then((browser) => browser.runJs("document.title")));
|
||||||
|
register("WebBrowser_GetUrl", async (ctx) => session(ctx).then((browser) => browser.currentUrl()));
|
||||||
|
register("WebBrowser_GetDocument", async (ctx) => session(ctx).then((browser) => browser.runJs("document.documentElement.outerHTML")));
|
||||||
|
register(["WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1"], async (ctx) => session(ctx).then((browser) => browser.runJs("({x:window.scrollX,y:window.scrollY})")));
|
||||||
|
register(["WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1"], async (ctx, _node, args) => {
|
||||||
|
const browser = await session(ctx);
|
||||||
|
const raw = String(args.scrollpos ?? "").trim();
|
||||||
|
let x = Number(args.x ?? args.left ?? 0);
|
||||||
|
let y = Number(args.y ?? args.top ?? args.position ?? 0);
|
||||||
|
if (raw) {
|
||||||
|
if (raw.includes(",")) { const parts = raw.split(","); x = Number(parts[0]) || 0; y = Number(parts[1]) || 0; }
|
||||||
|
else { y = Number(raw) || 0; }
|
||||||
|
}
|
||||||
|
await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 全量 Cookie:优先走 CDP Network.getCookies(含 HttpOnly),不支持时退回 document.cookie */
|
||||||
|
async function getCookies(ctx: RunContext): Promise<Record<string, string>> {
|
||||||
|
const browser = await session(ctx);
|
||||||
|
if (browser.getCookies) {
|
||||||
|
const cookies = await browser.getCookies();
|
||||||
|
return Object.fromEntries(cookies.map((cookie) => [cookie.name, cookie.value]));
|
||||||
|
}
|
||||||
|
const raw = String(await browser.runJs("document.cookie"));
|
||||||
|
return Object.fromEntries(raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const [name, ...rest] = part.split("="); return [name, decodeURIComponent(rest.join("="))]; }));
|
||||||
|
}
|
||||||
|
|
||||||
|
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx, node) => {
|
||||||
|
const cookies = await getCookies(ctx);
|
||||||
|
ctx.log(`已获取 ${Object.keys(cookies).length} 个 Cookie(含 HttpOnly)`, "info", node.id);
|
||||||
|
return cookies;
|
||||||
|
});
|
||||||
|
register("WebBrowser_GetCookie", async (ctx, _node, args) => {
|
||||||
|
const name = text(args, ["name"]); const cookies = await getCookies(ctx); return cookies[name] ?? "";
|
||||||
|
});
|
||||||
|
register("WebBrowser_SetCookies", async (ctx, node, args) => {
|
||||||
|
let cookies: unknown = args.cookies; if (typeof cookies === "string") { try { cookies = JSON.parse(cookies); } catch { throw new RuntimeError("validation_error", "Cookie JSON无效", { nodeId: node.id, componentId: node.componentId }); } }
|
||||||
|
if (!cookies || typeof cookies !== "object") throw new RuntimeError("validation_error", "Cookie对象为空", { nodeId: node.id, componentId: node.componentId }); const browser = await session(ctx);
|
||||||
|
for (const [name, value] of Object.entries(cookies as Record<string, unknown>)) await browser.runJs(`document.cookie=${JSON.stringify(`${name}=${encodeURIComponent(String(value))}; path=/`)}; true`); return getCookies(ctx);
|
||||||
|
});
|
||||||
|
register("WebBrowser_SetCookies_V1", async (ctx, node, args) => {
|
||||||
|
const name = text(args, ["cookie_name"]); if (!name) throw new RuntimeError("validation_error", "Cookie name为空", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
const value = encodeURIComponent(text(args, ["cookie_value"])); const cookiePath = text(args, ["cookie_path"], "/"); const domain = text(args, ["cookie_domain"]); const expires = text(args, ["cookie_expires"]); const secure = String(args.secure ?? "0") === "1";
|
||||||
|
const cookie = `${name}=${value}; path=${cookiePath}${domain ? `; domain=${domain}` : ""}${expires ? `; expires=${expires}` : ""}${secure ? "; secure" : ""}`; await (await session(ctx)).runJs(`document.cookie=${JSON.stringify(cookie)}; true`); return cookie;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_Wait", async (ctx) => {
|
||||||
|
const browser = await session(ctx); const deadline = Date.now() + 15000; do { try { if (await browser.runJs("document.readyState === 'complete'")) return true; } catch {} await new Promise((resolve) => setTimeout(resolve, 100)); } while (Date.now() < deadline); return false;
|
||||||
|
});
|
||||||
|
register(["WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1"], async (ctx) => { const browser = await session(ctx); await browser.close(); ctx.browser = undefined; return true; });
|
||||||
|
register(["WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1"], async (ctx, node, args) => { const browser = await session(ctx); const target = path.resolve(text(args, ["path", "file_path", "image_path"], path.join(ctx.evidenceDir, `${node.id}.png`))); return browser.screenshot(target); });
|
||||||
|
register("Element_ExistsV1", async (ctx, _node, args) => (await session(ctx)).exists(selector(args)));
|
||||||
|
register("Element_WaitElement_v5", async (ctx, _node, args) => { const browser = await session(ctx); const target = selector(args); const expected = String(args.choose ?? "1") !== "3"; const deadline = Date.now() + Math.max(0, Number(args.time_out ?? args.timeout ?? 10)) * 1000; do { if ((await browser.exists(target)) === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline); return false; });
|
||||||
|
|
||||||
|
register(["WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"], async (ctx, node, args) => {
|
||||||
|
const browser = await session(ctx); const headers = text(args, ["headers"], "{}"); const body = text(args, ["body", "param"], "{}"); if (!["", "{}"].includes(headers) || !["", "{}"].includes(body)) throw new RuntimeError("validation_error", "当前下载能力暂不支持自定义Headers或请求体", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
const folder = text(args, ["savepath", "save_path"]); if (!folder) throw new RuntimeError("validation_error", "下载目录为空", { nodeId: node.id, componentId: node.componentId }); const filename = text(args, ["filename", "file_name"]); const seconds = Number(args.download_time ?? 0); const timeout = seconds > 0 ? seconds * 1000 : Number(args.timeout ?? 10000) * (node.componentId.endsWith("_V1") && node.componentId.includes("DownFile") ? 1000 : 1);
|
||||||
|
const directUrl = text(args, ["url"]); if (!directUrl) return browser.download(selector(args), folder, filename, timeout);
|
||||||
|
const temporaryId = `rpa-download-${Date.now().toString(36)}`; await browser.runJs(`(()=>{const a=document.createElement('a');a.id=${JSON.stringify(temporaryId)};a.href=${JSON.stringify(directUrl)};a.style.display='none';document.body.appendChild(a);return true})()`);
|
||||||
|
try { return await browser.download(`css:#${temporaryId}`, folder, filename, timeout); } finally { try { await browser.runJs(`document.getElementById(${JSON.stringify(temporaryId)})?.remove();true`); } catch {} }
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BROWSER_CONTROL_IDS = ["WebBrowser_Reload", "WebBrowser_GoBack", "WebBrowser_GoForward", "WebBrowser_GetTitle", "WebBrowser_GetUrl", "WebBrowser_GetDocument", "WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1", "WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1", "WebBrowser_GetCookies", "WebBrowser_GetCookieNew", "WebBrowser_GetCookie", "WebBrowser_SetCookies", "WebBrowser_SetCookies_V1", "WebBrowser_Wait", "WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1", "WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1", "Element_ExistsV1", "Element_WaitElement_v5", "WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"] as const;
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { register } from "../engine/registry";
|
import { register } from "../engine/registry";
|
||||||
import { RuntimeError } from "../engine/types";
|
import { RuntimeError } from "../engine/types";
|
||||||
import { browserSession } from "../browser/session";
|
import { browserSession, externalBrowserSession } from "../browser/session";
|
||||||
import type { BrowserLike, RunContext } from "../engine/types";
|
import type { BrowserLike, RunContext } from "../engine/types";
|
||||||
|
|
||||||
async function session(ctx: RunContext): Promise<BrowserLike> {
|
async function session(ctx: RunContext): Promise<BrowserLike> {
|
||||||
@@ -8,6 +8,61 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
|
|||||||
return ctx.browser;
|
return ctx.browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTruthyFlag(value: unknown): boolean {
|
||||||
|
const text = String(value ?? "").trim().toLowerCase();
|
||||||
|
return text === "1" || text === "true" || text === "yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「打开网页」浏览器选择:
|
||||||
|
* - type 填 chrome / edge → 拉起本机对应浏览器(带独立 profile + 调试端口)并接管
|
||||||
|
* - isRemote 填 1/true → 直连 ipAddress:port 上已监听调试端口的实例(不拉起进程)
|
||||||
|
* - type 缺省或其他值 → 内置运行浏览器(现状行为,兜底)
|
||||||
|
*/
|
||||||
|
async function resolveSession(ctx: RunContext, args: Record<string, unknown>): Promise<{ browser: BrowserLike; label: string }> {
|
||||||
|
if (ctx.browser) return { browser: ctx.browser, label: "" };
|
||||||
|
const type = String(args.type ?? "").trim().toLowerCase();
|
||||||
|
const isRemote = isTruthyFlag(args.isRemote);
|
||||||
|
if (isRemote) {
|
||||||
|
const host = String(args.ipAddress ?? "127.0.0.1");
|
||||||
|
const port = Number(args.port ?? 9222);
|
||||||
|
const browser = await externalBrowserSession({ mode: "remote", host, port });
|
||||||
|
ctx.browser = browser;
|
||||||
|
return { browser, label: browser.label };
|
||||||
|
}
|
||||||
|
if (type === "chrome" || type === "edge") {
|
||||||
|
const extraArgs = String(args.arguments ?? "")
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const browser = await externalBrowserSession({
|
||||||
|
mode: "launch",
|
||||||
|
browserType: type === "edge" ? "edge" : "chrome",
|
||||||
|
port: Number(args.port ?? 9222) || 9222,
|
||||||
|
extraArgs
|
||||||
|
});
|
||||||
|
ctx.browser = browser;
|
||||||
|
return { browser, label: browser.label };
|
||||||
|
}
|
||||||
|
return { browser: await session(ctx), label: "内置运行浏览器" };
|
||||||
|
}
|
||||||
|
|
||||||
|
register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
|
||||||
|
const { browser, label } = await resolveSession(ctx, args);
|
||||||
|
const url = String(args.url ?? "");
|
||||||
|
if (url && String(args.openNewTab ?? "0") === "1" && browser.openNewTab) {
|
||||||
|
const tab = await browser.openNewTab(url);
|
||||||
|
ctx.log(`已使用${label}在新标签页打开 ${tab.url}`, "info", node.id);
|
||||||
|
return { url: tab.url, title: tab.title };
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
const result = await browser.navigate(url);
|
||||||
|
ctx.log(`已使用${label}打开 ${result.url}`, "info", node.id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return { url: await browser.currentUrl(), title: "" };
|
||||||
|
});
|
||||||
|
|
||||||
function selectorOf(args: Record<string, unknown>): unknown {
|
function selectorOf(args: Record<string, unknown>): unknown {
|
||||||
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
|
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
|
||||||
if (selector === undefined || selector === null || selector === "") {
|
if (selector === undefined || selector === null || selector === "") {
|
||||||
@@ -18,17 +73,6 @@ function selectorOf(args: Record<string, unknown>): unknown {
|
|||||||
return selector;
|
return selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(["WebBrowser_CreateV3", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
|
|
||||||
const browser = await session(ctx);
|
|
||||||
const url = String(args.url ?? "");
|
|
||||||
if (url) {
|
|
||||||
const result = await browser.navigate(url);
|
|
||||||
ctx.log(`已打开 ${result.url}`, "info", node.id);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return { url: await browser.currentUrl(), title: "" };
|
|
||||||
});
|
|
||||||
|
|
||||||
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
|
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
|
||||||
const browser = await session(ctx);
|
const browser = await session(ctx);
|
||||||
const url = String(args.url ?? "");
|
const url = String(args.url ?? "");
|
||||||
@@ -44,8 +88,15 @@ register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) =>
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
register("WebBrowser_Switch", async (ctx, node) => {
|
register("WebBrowser_Switch", async (ctx, node, args) => {
|
||||||
await session(ctx);
|
const browser = await session(ctx);
|
||||||
|
const title = String(args.title ?? "").trim();
|
||||||
|
const url = String(args.url ?? "").trim();
|
||||||
|
if (browser.switchTab && (title || url)) {
|
||||||
|
const tab = await browser.switchTab({ title: title || undefined, url: url || undefined });
|
||||||
|
ctx.log(`已切换到标签页:${tab.title || tab.url}`, "info", node.id);
|
||||||
|
return { switched: true, targetId: tab.targetId, title: tab.title, url: tab.url };
|
||||||
|
}
|
||||||
ctx.log("网页切换已由统一运行浏览器接管", "info", node.id);
|
ctx.log("网页切换已由统一运行浏览器接管", "info", node.id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ describe("capability registration smoke", () => {
|
|||||||
const execute = getHandler("System_ExecuteCommand");
|
const execute = getHandler("System_ExecuteCommand");
|
||||||
expect(execute).toBeTruthy();
|
expect(execute).toBeTruthy();
|
||||||
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
|
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
|
||||||
|
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output '中文输出测试'" }, component)).resolves.toBe("中文输出测试");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds an exact PNG template inside a larger screenshot", async () => {
|
it("finds an exact PNG template inside a larger screenshot", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
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 { RunContext } from "../engine/types";
|
||||||
|
import { getHandler } from "../engine/registry";
|
||||||
|
import "./desktop"; import "./pdf"; import "./system-extended"; import "./window-extended"; import "./common-final";
|
||||||
|
|
||||||
|
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: {} });
|
||||||
|
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||||
|
afterEach(async () => { context.vars = {}; await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
|
||||||
|
|
||||||
|
describe("final common capabilities", () => {
|
||||||
|
it("registers window, keyboard, clipboard, system, PDF and webhook components", () => {
|
||||||
|
for (const id of ["Window_ActiveWindow", "Window_CloseWindow_v1", "Window_GetScreenSize", "Window_WindowList", "Window_GetRect", "Window_MaximumSize", "Window_SetWindowShow", "Window_WaitWindow", "Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2", "Clipboard_SetImage", "Clipboard_SetImageV1", "Clipboard_SaveImage", "Clipboard_SaveImageV1", "System_PlaySound", "CustomConfig_GetConfig", "Basic_BusinessOrderCount", "PDF_ConcatPdfV1", "PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1", "MessageNotification_DingTalk_Message", "MessageNotification_Feishu_Message", "MessageNotification_WXWork_Message"]) expect(getHandler(id), id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads custom config and increments business counters", async () => {
|
||||||
|
await expect(run("CustomConfig_GetConfig", { jsonlist: [{ name: "timeout", value: 30 }], configName: "timeout" })).resolves.toBe(30);
|
||||||
|
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(12);
|
||||||
|
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an ASCII PDF watermark", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-common-final-")); dirs.push(directory); const input = path.join(directory, "input.pdf"); const pdf = await PDFDocument.create(); pdf.addPage([300, 300]); await writeFile(input, await pdf.save());
|
||||||
|
const output = await run("PDF_SetPdfWaterMark", { fileInPath: input, markTextInfo: "RPA", newPdfName: "watermark.pdf" }) as string;
|
||||||
|
expect((await readFile(output)).byteLength).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts the three webhook formats to a local server", async () => {
|
||||||
|
const bodies: unknown[] = []; const server = createServer((request, response) => { let raw = ""; request.on("data", (chunk) => raw += chunk); request.on("end", () => { bodies.push(JSON.parse(raw)); response.writeHead(200, { "Content-Type": "application/json" }); response.end('{"ok":true}'); }); });
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); const webhook = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/hook`;
|
||||||
|
try { await run("MessageNotification_DingTalk_Message", { webhook, content_text: "ding" }); await run("MessageNotification_Feishu_Message", { webhook, content_text: "fei" }); await run("MessageNotification_WXWork_Message", { webhook, content_text: "wx" }); expect(bodies).toHaveLength(3); } finally { await new Promise<void>((resolve) => server.close(() => resolve())); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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); }
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
function delegate(baseId: string): CapabilityHandler {
|
||||||
|
return (ctx, node, args, component) => { const base = getHandler(baseId); if (!base) throw new RuntimeError("capability_not_implemented", `基础能力不可用:${baseId}`, { nodeId: node.id, componentId: node.componentId }); return base(ctx, node, args, component); };
|
||||||
|
}
|
||||||
|
|
||||||
|
register("Window_ActiveWindow", delegate("Window_SetActive"));
|
||||||
|
register("Window_CloseWindow_v1", delegate("Window_CloseWindow"));
|
||||||
|
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_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];
|
||||||
|
});
|
||||||
|
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`);
|
||||||
|
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);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setClipboardImage: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const file = path.resolve(text(args, ["path"])); await fs.access(file).catch(() => { throw new RuntimeError("validation_error", `图片不存在:${file}`, { nodeId: node.id, componentId: node.componentId }); });
|
||||||
|
await powershell(`Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(file)});[System.Windows.Forms.Clipboard]::SetImage($image);$image.Dispose()`); return file;
|
||||||
|
};
|
||||||
|
register(["Clipboard_SetImage", "Clipboard_SetImageV1"], setClipboardImage);
|
||||||
|
const saveClipboardImage: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const folder = path.resolve(text(args, ["path"])); const name = text(args, ["filename"], "clipboard.png"); const target = path.join(folder, path.extname(name) ? name : `${name}.png`); await fs.mkdir(folder, { recursive: true });
|
||||||
|
await powershell(`Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;if(-not [System.Windows.Forms.Clipboard]::ContainsImage()){throw '剪贴板中没有图片'};$image=[System.Windows.Forms.Clipboard]::GetImage();$image.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png);$image.Dispose()`).catch((error) => { throw new RuntimeError("desktop_command_failed", (error as Error).message, { nodeId: node.id, componentId: node.componentId }); }); return target;
|
||||||
|
};
|
||||||
|
register(["Clipboard_SaveImage", "Clipboard_SaveImageV1"], saveClipboardImage);
|
||||||
|
|
||||||
|
register("System_PlaySound", async (_ctx, node, args) => {
|
||||||
|
const audio = text(args, ["audio"]); if (audio) { const file = path.resolve(audio); await fs.access(file); await powershell(`$player=New-Object System.Media.SoundPlayer ${psString(file)};$player.PlaySync()`); return file; }
|
||||||
|
await powershell("[System.Media.SystemSounds]::Beep.Play()"); return true;
|
||||||
|
});
|
||||||
|
register("CustomConfig_GetConfig", (_ctx, _node, args) => {
|
||||||
|
let configs: unknown = args.jsonlist; if (typeof configs === "string") { try { configs = JSON.parse(configs); } catch { configs = []; } }
|
||||||
|
const name = text(args, ["configName"]); if (Array.isArray(configs)) { const found = configs.find((item) => item && typeof item === "object" && [String((item as any).name ?? ""), String((item as any).configName ?? ""), String((item as any).key ?? "")].includes(name)); return found && typeof found === "object" && "value" in found ? (found as any).value : found ?? null; }
|
||||||
|
return configs && typeof configs === "object" ? (configs as Record<string, unknown>)[name] : null;
|
||||||
|
});
|
||||||
|
register("Basic_BusinessOrderCount", (ctx, _node, args) => {
|
||||||
|
const key = "__businessOrderCount"; const start = Number(args.start_count ?? 0); const step = Number(args.count ?? 1); const current = Number(ctx.vars[key] ?? start); const next = current + step; ctx.vars[key] = next; return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("PDF_ConcatPdfV1", delegate("PDF_ConcatPdf"));
|
||||||
|
register(["PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1"], async (_ctx, node, args) => {
|
||||||
|
const input = path.resolve(text(args, ["fileInPath"])); const output = path.join(path.dirname(input), text(args, ["newPdfName"], "PDF加水印.pdf")); const content = text(args, ["markTextInfo"], "Watermark");
|
||||||
|
if (!/^[\x20-\x7E]*$/.test(content)) throw new RuntimeError("validation_error", "当前文本水印仅支持 ASCII;中文水印需嵌入字体", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
const document = await PDFDocument.load(await fs.readFile(input), { ignoreEncryption: true }); const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
for (const page of document.getPages()) { const { width, height } = page.getSize(); page.drawText(content, { x: width * 0.2, y: height * 0.5, size: 32, font, color: rgb(0.65, 0.65, 0.65), opacity: 0.35, rotate: degrees(-30) }); }
|
||||||
|
await fs.writeFile(output, await document.save()); return output;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function webhook(kind: "dingtalk" | "feishu" | "wecom", args: Record<string, unknown>, nodeId: string, componentId: string) {
|
||||||
|
let url = text(args, ["webhook"]); if (!/^https?:\/\//i.test(url)) throw new RuntimeError("validation_error", "Webhook 地址无效", { nodeId, componentId });
|
||||||
|
const secret = text(args, ["key"]); if (kind === "dingtalk" && secret) { const timestamp = Date.now(); const sign = encodeURIComponent(createHmac("sha256", secret).update(`${timestamp}\n${secret}`).digest("base64")); url += `${url.includes("?") ? "&" : "?"}timestamp=${timestamp}&sign=${sign}`; }
|
||||||
|
const custom = text(args, ["content_custom"]); const messageType = text(args, ["message_type"], "0"); const plain = text(args, ["content_text", "test_message"]); const markdown = text(args, ["content_markdown"]);
|
||||||
|
let body: unknown;
|
||||||
|
if (custom) { try { body = JSON.parse(custom); } catch { throw new RuntimeError("validation_error", "自定义消息 JSON 无效", { nodeId, componentId }); } }
|
||||||
|
else if (kind === "feishu") body = messageType === "1" ? { msg_type: "interactive", card: { elements: [{ tag: "markdown", content: markdown || plain }] } } : { msg_type: "text", content: { text: plain } };
|
||||||
|
else if (kind === "dingtalk") body = messageType === "1" ? { msgtype: "markdown", markdown: { title: "RPA通知", text: markdown || plain } } : { msgtype: "text", text: { content: plain }, at: { atMobiles: text(args, ["mentioned_mobile_list"]).split(/[,,]/).filter(Boolean) } };
|
||||||
|
else body = messageType === "1" ? { msgtype: "markdown", markdown: { content: markdown || plain } } : { msgtype: "text", text: { content: plain, mentioned_mobile_list: text(args, ["mentioned_mobile_list"]).split(/[,,]/).filter(Boolean) } };
|
||||||
|
const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const responseText = await response.text(); if (!response.ok) throw new RuntimeError("http_error", `Webhook 返回 ${response.status}: ${responseText}`, { nodeId, componentId });
|
||||||
|
try { return JSON.parse(responseText); } catch { return responseText; }
|
||||||
|
}
|
||||||
|
register("MessageNotification_DingTalk_Message", (_ctx, node, args) => webhook("dingtalk", args, node.id, node.componentId));
|
||||||
|
register("MessageNotification_Feishu_Message", (_ctx, node, args) => webhook("feishu", args, node.id, node.componentId));
|
||||||
|
register("MessageNotification_WXWork_Message", (_ctx, node, args) => webhook("wecom", args, node.id, node.componentId));
|
||||||
@@ -30,12 +30,21 @@ register("Basic_Delay_V2", async (ctx, node, args) => {
|
|||||||
return seconds;
|
return seconds;
|
||||||
});
|
});
|
||||||
|
|
||||||
register(["Basic_Delay_V1", "Basic_Delay"], async (ctx, node, args) => {
|
register("Basic_Delay_V1", async (ctx, node, args) => {
|
||||||
const seconds = toNumber(args.second ?? args.time ?? 1, 1);
|
const seconds = toNumber(args.second ?? args.time ?? 1, 1);
|
||||||
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
||||||
return seconds;
|
return seconds;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
register("Basic_Delay", async (ctx, node, args) => {
|
||||||
|
const milliseconds = Number(args.mileSecond ?? 1000);
|
||||||
|
if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > 2_147_483_647) {
|
||||||
|
throw new RuntimeError("validation_error", "延时时间必须是 0~2147483647 范围内的毫秒数", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
}
|
||||||
|
await sleep(milliseconds, ctx.signal);
|
||||||
|
return milliseconds / 1000;
|
||||||
|
});
|
||||||
|
|
||||||
register("Basic_DebugOutput", (ctx, node, args) => {
|
register("Basic_DebugOutput", (ctx, node, args) => {
|
||||||
const message = String(args.msg ?? args.message ?? "");
|
const message = String(args.msg ?? args.message ?? "");
|
||||||
ctx.log(message || "(空)", "info", node.id);
|
ctx.log(message || "(空)", "info", node.id);
|
||||||
|
|||||||
@@ -90,9 +90,21 @@ register(["Bivartable_ColumnOperationDf", "Bivartable_DoCal"], (_ctx, _node, arg
|
|||||||
const result: Table = [[...headers, name]]; for (const row of source.slice(1)) result.push([...row, calculate(row[left], row[right], operator)]); return result;
|
const result: Table = [[...headers, name]]; for (const row of source.slice(1)) result.push([...row, calculate(row[left], row[right], operator)]); return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function columnIndexOf(candidate: unknown): number {
|
||||||
|
const text = String(candidate ?? "").trim();
|
||||||
|
if (/^\d+$/.test(text)) return Number(text) - 1;
|
||||||
|
let result = 0;
|
||||||
|
for (const char of text.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
|
||||||
|
return result - 1;
|
||||||
|
}
|
||||||
|
|
||||||
register(["Bivartable_SpliceColData", "Bivartable_DoJoint"], (_ctx, _node, args) => {
|
register(["Bivartable_SpliceColData", "Bivartable_DoJoint"], (_ctx, _node, args) => {
|
||||||
const source = table(args.df); if (!source.length) return source; const headers = source[0];
|
const source = table(args.df); if (!source.length) return source; const headers = source[0];
|
||||||
const left = index(headers, args.col1 ?? args.cola); const right = index(headers, args.col2 ?? args.colb); const name = String(args.name ?? args.colnew ?? "拼接结果"); const separator = String(args.separator ?? "");
|
// catalog 字段:change_type(name/num) + col_name_1/2 或 col_num_1/2;兼容旧键 col1/col2
|
||||||
|
const byName = String(args.change_type ?? "name") !== "num";
|
||||||
|
const left = byName ? index(headers, args.col_name_1 ?? args.col1 ?? args.cola) : columnIndexOf(args.col_num_1);
|
||||||
|
const right = byName ? index(headers, args.col_name_2 ?? args.col2 ?? args.colb) : columnIndexOf(args.col_num_2);
|
||||||
|
const name = String(args.new_col_name ?? args.name ?? args.colnew ?? "拼接结果"); const separator = String(args.connector ?? args.separator ?? "");
|
||||||
return [[...headers, name], ...source.slice(1).map((row) => [...row, `${row[left] ?? ""}${separator}${row[right] ?? ""}`])];
|
return [[...headers, name], ...source.slice(1).map((row) => [...row, `${row[left] ?? ""}${separator}${row[right] ?? ""}`])];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -498,12 +498,30 @@ register("Basic_Type", (_ctx, _node, args) => {
|
|||||||
if (Array.isArray(value)) return "list";
|
if (Array.isArray(value)) return "list";
|
||||||
return typeof value;
|
return typeof value;
|
||||||
});
|
});
|
||||||
register("Basic_JudgeDataType", (_ctx, _node, args) => {
|
// 原始目录缺少选项映射。新界面使用语义值;数字别名兼容此前默认的 "1"。
|
||||||
|
const JUDGE_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "null", "3": "dict", "4": "string", "5": "list", "6": "bool", "7": "int" };
|
||||||
|
const CONVERT_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "int", "3": "bool", "4": "string" };
|
||||||
|
|
||||||
|
function targetType(raw: unknown, aliases: Record<string, string>, supported: string[], nodeId: string, componentId: string): string {
|
||||||
|
const text = String(raw ?? "number").trim().toLowerCase();
|
||||||
|
const synonyms: Record<string, string> = { float: "number", boolean: "bool", str: "string", text: "string", array: "list", dictionary: "dict", object: "dict", empty: "null" };
|
||||||
|
const type = aliases[text] || synonyms[text] || text;
|
||||||
|
if (!supported.includes(type)) throw new RuntimeError("validation_error", "目标数据类型无效,请重新选择类型", { nodeId, componentId });
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
register("Basic_JudgeDataType", (_ctx, node, args) => {
|
||||||
const value = first(args, ["val"]);
|
const value = first(args, ["val"]);
|
||||||
if (value === null || value === undefined) return "null";
|
const type = targetType(args.into_type, JUDGE_TYPE_ALIASES, ["number", "null", "dict", "string", "list", "bool", "int"], node.id, node.componentId);
|
||||||
if (Array.isArray(value)) return "list";
|
switch (type) {
|
||||||
if (typeof value === "object") return "dict";
|
case "number": return typeof value === "number" && Number.isFinite(value);
|
||||||
return typeof value;
|
case "int": return typeof value === "number" && Number.isInteger(value);
|
||||||
|
case "null": return value === null || value === undefined || value === "";
|
||||||
|
case "dict": return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
case "string": return typeof value === "string";
|
||||||
|
case "list": return Array.isArray(value);
|
||||||
|
case "bool": return typeof value === "boolean";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"])));
|
register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"])));
|
||||||
register("Basic_RandomInt", (_ctx, _node, args) => {
|
register("Basic_RandomInt", (_ctx, _node, args) => {
|
||||||
@@ -512,4 +530,23 @@ register("Basic_RandomInt", (_ctx, _node, args) => {
|
|||||||
return Math.floor(min + Math.random() * (max - min + 1));
|
return Math.floor(min + Math.random() * (max - min + 1));
|
||||||
});
|
});
|
||||||
register("Basic_Rnd", () => Math.random());
|
register("Basic_Rnd", () => Math.random());
|
||||||
register("Basic_DataTypeConversion", (_ctx, _node, args) => first(args, ["val"]));
|
register("Basic_DataTypeConversion", (_ctx, node, args) => {
|
||||||
|
const value = first(args, ["val"]);
|
||||||
|
const type = targetType(args.into_type, CONVERT_TYPE_ALIASES, ["number", "int", "bool", "string"], node.id, node.componentId);
|
||||||
|
if (type === "string") return str(value);
|
||||||
|
if (type === "bool") {
|
||||||
|
if (value == null || value === "") return false;
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value !== 0;
|
||||||
|
const text = String(value).trim().toLowerCase();
|
||||||
|
if (["true", "1", "是", "开启"].includes(text)) return true;
|
||||||
|
if (["false", "0", "否", "关闭"].includes(text)) return false;
|
||||||
|
throw new RuntimeError("validation_error", "输入值无法转换为布尔类型", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
}
|
||||||
|
if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && !value.trim()) || !Number.isFinite(Number(value))) {
|
||||||
|
throw new RuntimeError("validation_error", "输入值不是有效数字", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
}
|
||||||
|
const result = type === "int" ? Math.trunc(Number(value)) : Number(value);
|
||||||
|
if (type === "int" && !Number.isSafeInteger(result)) throw new RuntimeError("validation_error", "转换后的整数超出安全范围", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getHandler } from "../engine/registry";
|
||||||
|
import { parseUiaSelector } from "./desktop-uia";
|
||||||
|
|
||||||
|
describe("desktop UIA ground capabilities", () => {
|
||||||
|
it.each([
|
||||||
|
"Element_ClickGround",
|
||||||
|
"Element_SetValueGround",
|
||||||
|
"Element_ExistsGround",
|
||||||
|
"Element_GetValueGround",
|
||||||
|
"Element_WaitElementGround"
|
||||||
|
])("registers %s", (id) => {
|
||||||
|
expect(getHandler(id), id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses name selector", () => {
|
||||||
|
const s = parseUiaSelector("name=添加供应商");
|
||||||
|
expect(s.name).toBe("添加供应商");
|
||||||
|
expect(s.index).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses window, id, type, class and index", () => {
|
||||||
|
const s = parseUiaSelector("window=CC Switch;id=btn-save;type=button;class=btn;index=2");
|
||||||
|
expect(s.window).toBe("CC Switch");
|
||||||
|
expect(s.id).toBe("btn-save");
|
||||||
|
expect(s.type).toBe("Button");
|
||||||
|
expect(s.className).toBe("btn");
|
||||||
|
expect(s.index).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips uia: prefix and accepts object form", () => {
|
||||||
|
const s = parseUiaSelector({ value: "uia:name=确定" });
|
||||||
|
expect(s.name).toBe("确定");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects selector without any condition", () => {
|
||||||
|
expect(() => parseUiaSelector("window=只有窗口")).toThrow();
|
||||||
|
expect(() => parseUiaSelector("")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
|
import { powershell, psString } from "./desktop";
|
||||||
|
|
||||||
|
interface UiaSelector {
|
||||||
|
window?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
className?: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUiaSelector(raw: unknown): UiaSelector {
|
||||||
|
let text = "";
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
if (typeof record.value === "string") text = record.value;
|
||||||
|
else if (typeof record.selector === "string") text = record.selector;
|
||||||
|
else if (record.commonProps && typeof record.commonProps === "object") {
|
||||||
|
const common = record.commonProps as Record<string, unknown>;
|
||||||
|
if (typeof common.uiSelector === "string") text = common.uiSelector;
|
||||||
|
}
|
||||||
|
} else if (raw !== undefined && raw !== null) {
|
||||||
|
text = String(raw);
|
||||||
|
}
|
||||||
|
text = text.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new RuntimeError("validation_error", "桌面元素选择器为空", { recommendation: "使用 name=元素名称 或 id=AutomationId;window=窗口名称" });
|
||||||
|
}
|
||||||
|
if (text.startsWith("uia:")) text = text.slice(4);
|
||||||
|
const selector: UiaSelector = { index: 1 };
|
||||||
|
for (const part of text.split(";")) {
|
||||||
|
const eq = part.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = part.slice(0, eq).trim().toLowerCase();
|
||||||
|
const value = part.slice(eq + 1).trim();
|
||||||
|
if (!value) continue;
|
||||||
|
if (key === "window" || key === "win") selector.window = value;
|
||||||
|
else if (key === "name" || key === "n") selector.name = value;
|
||||||
|
else if (key === "id" || key === "automationid") selector.id = value;
|
||||||
|
else if (key === "type" || key === "controltype") selector.type = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||||
|
else if (key === "class" || key === "classname") selector.className = value;
|
||||||
|
else if (key === "index") {
|
||||||
|
const n = Number(value);
|
||||||
|
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selector.name && !selector.id && !selector.className && !selector.type) {
|
||||||
|
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
|
||||||
|
}
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIA_USING = [
|
||||||
|
"Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes;",
|
||||||
|
"try { Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);' -Name RpaDpi -Namespace WinRpaDpi | Out-Null; [WinRpaDpi.RpaDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}"
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const FIND_HELPER = `
|
||||||
|
function Find-RpaWindow([string]$winName) {
|
||||||
|
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||||
|
if (-not $winName) { return $root }
|
||||||
|
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
|
||||||
|
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||||
|
if ($win) { return $win }
|
||||||
|
Add-RpaUser32
|
||||||
|
$script:hitHwnd = [IntPtr]::Zero
|
||||||
|
$cb = [WinRpa.RpaUser32Click+EnumProc]{ param($h, $l)
|
||||||
|
$sb = New-Object System.Text.StringBuilder 256
|
||||||
|
[WinRpa.RpaUser32Click]::GetWindowTextW($h, $sb, 256) | Out-Null
|
||||||
|
if ($sb.ToString() -eq $winName) { $script:hitHwnd = $h; return $false }
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
[WinRpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null
|
||||||
|
if ($script:hitHwnd -ne [IntPtr]::Zero) {
|
||||||
|
[WinRpa.RpaUser32Click]::ShowWindow($script:hitHwnd, 9) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$sw2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
|
||||||
|
$sh2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
|
||||||
|
$w2 = [Math]::Min(1400, $sw2 - 120)
|
||||||
|
$h2 = [Math]::Min(820, $sh2 - 120)
|
||||||
|
[WinRpa.RpaUser32Click]::SetWindowPos($script:hitHwnd, [IntPtr]::Zero, 60, 60, $w2, $h2, 0x0040) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||||
|
if ($win) { return $win }
|
||||||
|
}
|
||||||
|
throw "window_not_found:$winName"
|
||||||
|
}
|
||||||
|
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index) {
|
||||||
|
$conds = @()
|
||||||
|
if ($name) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $name) }
|
||||||
|
if ($id) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::AutomationIdProperty, $id) }
|
||||||
|
if ($className) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty, $className) }
|
||||||
|
if ($type) {
|
||||||
|
try { $field = [System.Windows.Automation.ControlType].GetField($type); if ($field) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $field.GetValue($null)) } } catch {}
|
||||||
|
if ($ct) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ct) }
|
||||||
|
}
|
||||||
|
if ($conds.Count -eq 0) { throw "no_condition" }
|
||||||
|
$final = if ($conds.Count -eq 1) { $conds[0] } else { New-Object System.Windows.Automation.AndCondition($conds) }
|
||||||
|
$all = $parent.FindAll([System.Windows.Automation.TreeScope]::Descendants, $final)
|
||||||
|
if ($all.Count -eq 0) { return $null }
|
||||||
|
$pick = [Math]::Min([Math]::Max($index, 1), $all.Count)
|
||||||
|
return $all[$pick - 1]
|
||||||
|
}
|
||||||
|
function Add-RpaUser32 {
|
||||||
|
Add-Type -MemberDefinition '[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 bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int X, int Y, int cx, int cy, uint flags); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp); public delegate bool EnumProc(IntPtr hWnd, IntPtr lp); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder sb, int max);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface UiaTarget {
|
||||||
|
selector: UiaSelector;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
node: { id: string; componentId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function target(args: Record<string, unknown>, node: { id: string; componentId: string }): UiaTarget {
|
||||||
|
const raw = args.uiSelector ?? args.target ?? args.selector;
|
||||||
|
return { selector: parseUiaSelector(raw), args, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findScript(t: UiaTarget, timeoutMs: number, gapMs: number): string {
|
||||||
|
const s = t.selector;
|
||||||
|
return `${UIA_USING}
|
||||||
|
${FIND_HELPER}
|
||||||
|
$deadline = [DateTime]::UtcNow.AddMilliseconds(${Math.max(0, Math.round(timeoutMs))})
|
||||||
|
$found = $null
|
||||||
|
while ($null -eq $found) {
|
||||||
|
try {
|
||||||
|
$parent = Find-RpaWindow ${psString(s.window ?? "")}
|
||||||
|
$found = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
|
||||||
|
} catch {
|
||||||
|
$err = $_.Exception.Message
|
||||||
|
if ($err -like "window_not_found:*") { $found = $null }
|
||||||
|
}
|
||||||
|
if ($null -eq $found -and [DateTime]::UtcNow -ge $deadline) { break }
|
||||||
|
if ($null -eq $found) { Start-Sleep -Milliseconds ${Math.max(50, Math.round(gapMs))} }
|
||||||
|
}
|
||||||
|
if ($null -eq $found) { Write-Output "JSON_NOT_FOUND" } else {
|
||||||
|
$rect = $found.Current.BoundingRectangle
|
||||||
|
$info = @{ found = $true; name = $found.Current.Name; id = $found.Current.AutomationId; type = $found.Current.ControlType.ProgrammaticName; enabled = $found.Current.IsEnabled; x = [int]$rect.X; y = [int]$rect.Y; w = [int]$rect.Width; h = [int]$rect.Height }
|
||||||
|
Write-Output ($info | ConvertTo-Json -Compress)
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findElement(t: UiaTarget, timeoutMs = 5000, gapMs = 200): Promise<Record<string, unknown> | null> {
|
||||||
|
const out = await powershell(findScript(t, timeoutMs, gapMs), Math.max(30000, timeoutMs + 30000));
|
||||||
|
const line = out.split(/\r?\n/).filter(Boolean).pop() || "";
|
||||||
|
if (line.includes("JSON_NOT_FOUND")) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeError("desktop_command_failed", `桌面元素查找输出异常:${out.slice(0, 200)}`, { nodeId: t.node.id, componentId: t.node.componentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickScript(t: UiaTarget): string {
|
||||||
|
const s = t.selector;
|
||||||
|
const clickway = String(t.args.clickway ?? "0");
|
||||||
|
const doubleClick = clickway === "1" || /double/i.test(clickway);
|
||||||
|
return `${UIA_USING}
|
||||||
|
${FIND_HELPER}
|
||||||
|
$el = $null
|
||||||
|
try {
|
||||||
|
$parent = Find-RpaWindow ${psString(s.window ?? "")}
|
||||||
|
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
|
||||||
|
} catch { $el = $null }
|
||||||
|
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
|
||||||
|
$rect = $el.Current.BoundingRectangle
|
||||||
|
Add-RpaUser32
|
||||||
|
$vsX = [WinRpa.RpaUser32Click]::GetSystemMetrics(76)
|
||||||
|
$vsY = [WinRpa.RpaUser32Click]::GetSystemMetrics(77)
|
||||||
|
$vsW = [WinRpa.RpaUser32Click]::GetSystemMetrics(78)
|
||||||
|
$vsH = [WinRpa.RpaUser32Click]::GetSystemMetrics(79)
|
||||||
|
$inVs = ($rect.X -ge $vsX) -and ($rect.Y -ge $vsY) -and (($rect.X + $rect.Width) -le ($vsX + $vsW)) -and (($rect.Y + $rect.Height) -le ($vsY + $vsH))
|
||||||
|
$offscreen = $el.Current.IsOffscreen -or $rect.Width -le 0 -or $rect.Height -le 0 -or (-not $inVs)
|
||||||
|
if ($offscreen) {
|
||||||
|
# 窗口可能被最小化或移出屏幕:还原并移回可视区后重取坐标
|
||||||
|
$walker0 = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||||
|
$anc0 = $el
|
||||||
|
$topHwnd = [IntPtr]::Zero
|
||||||
|
while ($null -ne $anc0) {
|
||||||
|
$h0 = $anc0.Current.NativeWindowHandle
|
||||||
|
if ($h0 -ne 0) { $topHwnd = [IntPtr]$h0; break }
|
||||||
|
$anc0 = $walker0.GetParent($anc0)
|
||||||
|
}
|
||||||
|
if ($topHwnd -ne [IntPtr]::Zero) {
|
||||||
|
[WinRpa.RpaUser32Click]::ShowWindow($topHwnd, 9) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$sw3 = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
|
||||||
|
$sh3 = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
|
||||||
|
$w3 = [Math]::Min(1400, $sw3 - 120)
|
||||||
|
$h3 = [Math]::Min(820, $sh3 - 120)
|
||||||
|
[WinRpa.RpaUser32Click]::SetWindowPos($topHwnd, [IntPtr]::Zero, 60, 60, $w3, $h3, 0x0040) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
$rect = $el.Current.BoundingRectangle
|
||||||
|
$inVs = ($rect.X -ge $vsX) -and ($rect.Y -ge $vsY) -and (($rect.X + $rect.Width) -le ($vsX + $vsW)) -and (($rect.Y + $rect.Height) -le ($vsY + $vsH))
|
||||||
|
$offscreen = $el.Current.IsOffscreen -or $rect.Width -le 0 -or $rect.Height -le 0 -or (-not $inVs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$method = "coordinate_click"
|
||||||
|
$ok = $false
|
||||||
|
if (-not $offscreen) {
|
||||||
|
Add-RpaUser32
|
||||||
|
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||||
|
$ancestor = $el
|
||||||
|
$hwnd = [IntPtr]::Zero
|
||||||
|
while ($null -ne $ancestor) {
|
||||||
|
$h = $ancestor.Current.NativeWindowHandle
|
||||||
|
if ($h -ne 0) { $hwnd = [IntPtr]$h; break }
|
||||||
|
$ancestor = $walker.GetParent($ancestor)
|
||||||
|
}
|
||||||
|
if ($hwnd -ne [IntPtr]::Zero) {
|
||||||
|
$fgHwnd = [WinRpa.RpaUser32Click]::GetForegroundWindow()
|
||||||
|
if ($fgHwnd -ne $hwnd) {
|
||||||
|
$fgPid = [uint32]0
|
||||||
|
$fgThread = [WinRpa.RpaUser32Click]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
|
||||||
|
$thisThread = [WinRpa.RpaUser32Click]::GetCurrentThreadId()
|
||||||
|
[WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $true) | Out-Null
|
||||||
|
[WinRpa.RpaUser32Click]::SetForegroundWindow($hwnd) | Out-Null
|
||||||
|
[WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $false) | Out-Null
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
$x = [int]($rect.X + $rect.Width / 2)
|
||||||
|
$y = [int]($rect.Y + $rect.Height / 2)
|
||||||
|
[WinRpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
[WinRpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 40
|
||||||
|
[WinRpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||||||
|
if (${doubleClick}) {
|
||||||
|
Start-Sleep -Milliseconds 60
|
||||||
|
[WinRpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds 40
|
||||||
|
[WinRpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||||||
|
}
|
||||||
|
$ok = $true
|
||||||
|
} else {
|
||||||
|
$method = "invoke_pattern"
|
||||||
|
foreach ($entry in @(@("Invoke", [System.Windows.Automation.InvokePattern]::Pattern), @("Toggle", [System.Windows.Automation.TogglePattern]::Pattern), @("Expand", [System.Windows.Automation.ExpandCollapsePattern]::Pattern), @("Select", [System.Windows.Automation.SelectionItemPattern]::Pattern))) {
|
||||||
|
try {
|
||||||
|
$p = $el.GetCurrentPattern($entry[1])
|
||||||
|
switch ($entry[0]) {
|
||||||
|
"Invoke" { $p.Invoke(); $ok = $true }
|
||||||
|
"Toggle" { $p.Toggle(); $ok = $true }
|
||||||
|
"Expand" { $p.Expand(); $ok = $true }
|
||||||
|
"Select" { $p.Select(); $ok = $true }
|
||||||
|
}
|
||||||
|
if ($ok) { break }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rect2 = $el.Current.BoundingRectangle
|
||||||
|
Write-Output (@{ clicked = $ok; method = $method; name = $el.Current.Name; x = [int]$rect2.X; y = [int]$rect2.Y; w = [int]$rect2.Width; h = [int]$rect2.Height } | ConvertTo-Json -Compress)`;
|
||||||
|
}
|
||||||
|
export function setValueScript(t: UiaTarget, value: string): string {
|
||||||
|
const s = t.selector;
|
||||||
|
return `${UIA_USING}
|
||||||
|
${FIND_HELPER}
|
||||||
|
try {
|
||||||
|
$parent = Find-RpaWindow ${psString(s.window ?? "")}
|
||||||
|
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
|
||||||
|
} catch { $el = $null }
|
||||||
|
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
|
||||||
|
$value = ${psString(value)}
|
||||||
|
$method = "value_pattern"
|
||||||
|
$ok = $false
|
||||||
|
try {
|
||||||
|
$vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
|
||||||
|
$vp.SetValue($value)
|
||||||
|
$ok = $true
|
||||||
|
} catch {
|
||||||
|
$method = "clipboard_paste"
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$saved = $null
|
||||||
|
try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}
|
||||||
|
try { $el.SetFocus() } catch {}
|
||||||
|
Start-Sleep -Milliseconds 150
|
||||||
|
try { [System.Windows.Forms.Clipboard]::SetText($value) } catch {}
|
||||||
|
Start-Sleep -Milliseconds 80
|
||||||
|
[System.Windows.Forms.SendKeys]::SendWait("^a")
|
||||||
|
Start-Sleep -Milliseconds 60
|
||||||
|
[System.Windows.Forms.SendKeys]::SendWait("^v")
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
$ok = $true
|
||||||
|
try {
|
||||||
|
if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) }
|
||||||
|
elseif ($saved -ne $null) { [System.Windows.Forms.Clipboard]::Clear() }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
Write-Output (@{ set = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)`;
|
||||||
|
}
|
||||||
|
function getValueScript(t: UiaTarget): string {
|
||||||
|
const s = t.selector;
|
||||||
|
return `${UIA_USING}
|
||||||
|
${FIND_HELPER}
|
||||||
|
try {
|
||||||
|
$parent = Find-RpaWindow ${psString(s.window ?? "")}
|
||||||
|
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
|
||||||
|
} catch { $el = $null }
|
||||||
|
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
|
||||||
|
$value = $el.Current.Name
|
||||||
|
try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {}
|
||||||
|
Write-Output (@{ value = $value; name = $el.Current.Name } | ConvertTo-Json -Compress)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScript(script: string, t: UiaTarget, timeoutMs = 60000): Promise<Record<string, unknown> | null> {
|
||||||
|
const out = await powershell(script, timeoutMs);
|
||||||
|
const line = out.split(/\r?\n/).filter(Boolean).pop() || "";
|
||||||
|
if (line.includes("JSON_NOT_FOUND")) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeError("desktop_command_failed", `桌面元素操作输出异常:${out.slice(0, 200)}`, { nodeId: t.node.id, componentId: t.node.componentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepMs(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithImplicitWait(
|
||||||
|
t: UiaTarget,
|
||||||
|
action: string,
|
||||||
|
script: string,
|
||||||
|
exec: (script: string, t: UiaTarget, timeoutMs?: number) => Promise<Record<string, unknown> | null>,
|
||||||
|
args: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const waitMs = Number(args.waitMs ?? args.wait_ms ?? 4000) || 4000;
|
||||||
|
const deadline = Date.now() + Math.max(0, waitMs);
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return requireFound(await exec(script, t), t, action);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: string }).code;
|
||||||
|
if (code !== "desktop_element_not_found" || Date.now() >= deadline) throw error;
|
||||||
|
await sleepMs(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requireFound(result: Record<string, unknown> | null, t: UiaTarget, action: string): Record<string, unknown> {
|
||||||
|
if (!result) {
|
||||||
|
const s = t.selector;
|
||||||
|
throw new RuntimeError("desktop_element_not_found", `桌面元素未找到(${action}):${JSON.stringify({ window: s.window, name: s.name, id: s.id, type: s.type, className: s.className, index: s.index })}。若指定了窗口,请确认应用已启动且主窗口未最小化到托盘`, { nodeId: t.node.id, componentId: t.node.componentId });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickGround: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const t = target(args, node);
|
||||||
|
return runWithImplicitWait(t, "click", clickScript(t), runScript, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setValueGround: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const t = target(args, node);
|
||||||
|
const value = String(args.value ?? args.text ?? "");
|
||||||
|
return runWithImplicitWait(t, "setValue", setValueScript(t, value), runScript, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const existsGround: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const t = target(args, node);
|
||||||
|
const found = await findElement(t, 1000, 150);
|
||||||
|
return found !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueGround: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const t = target(args, node);
|
||||||
|
const result = await runWithImplicitWait(t, "getValue", getValueScript(t), runScript, args);
|
||||||
|
return result.value ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitElementGround: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const t = target(args, node);
|
||||||
|
const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000;
|
||||||
|
const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200;
|
||||||
|
const found = await findElement(t, timeout, gap);
|
||||||
|
return found !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
register("Element_ClickGround", clickGround);
|
||||||
|
register("Element_SetValueGround", setValueGround);
|
||||||
|
register("Element_ExistsGround", existsGround);
|
||||||
|
register("Element_GetValueGround", getValueGround);
|
||||||
|
register("Element_WaitElementGround", waitElementGround);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +45,9 @@ function encoded(command: string): string {
|
|||||||
|
|
||||||
export async function powershell(command: string, timeout = 30000): Promise<string> {
|
export async function powershell(command: string, timeout = 30000): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], {
|
// 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,
|
windowsHide: true,
|
||||||
timeout,
|
timeout,
|
||||||
maxBuffer: 4 * 1024 * 1024
|
maxBuffer: 4 * 1024 * 1024
|
||||||
@@ -122,7 +124,8 @@ const runApp: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
if (!executable) throw new RuntimeError("validation_error", "应用路径为空", { nodeId: node.id, componentId: node.componentId });
|
if (!executable) throw new RuntimeError("validation_error", "应用路径为空", { nodeId: node.id, componentId: node.componentId });
|
||||||
const params = text(args, ["params", "arguments"]);
|
const params = text(args, ["params", "arguments"]);
|
||||||
const cwd = text(args, ["cwd"]);
|
const cwd = text(args, ["cwd"]);
|
||||||
const result = await powershell(`$p = Start-Process -FilePath ${psString(executable)} -ArgumentList ${psString(params)} ${cwd ? `-WorkingDirectory ${psString(cwd)}` : ""} -PassThru; $p.Id`);
|
const argPart = params ? ` -ArgumentList ${psString(params)}` : "";
|
||||||
|
const result = await powershell(`$p = Start-Process -FilePath ${psString(executable)}${argPart} ${cwd ? `-WorkingDirectory ${psString(cwd)}` : ""} -PassThru; $p.Id`);
|
||||||
return Number(result) || result;
|
return Number(result) || result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +149,7 @@ const executeCommand: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
return powershell(command, Math.max(1000, number(args, ["timeoutMs"], 120000)));
|
return powershell(command, Math.max(1000, number(args, ["timeoutMs"], 120000)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
|
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
|
||||||
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
|
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
|
||||||
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
|
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
|
||||||
@@ -158,6 +162,57 @@ register(["Window_CloseWindow", "Window_Close"], closeWindow);
|
|||||||
register(["App_Run", "App_Start"], runApp);
|
register(["App_Run", "App_Start"], runApp);
|
||||||
register("Clipboard_GetText", clipboardGet);
|
register("Clipboard_GetText", clipboardGet);
|
||||||
register("Clipboard_SetText", clipboardSet);
|
register("Clipboard_SetText", clipboardSet);
|
||||||
|
register("Dialog_Notice_V1", async (_ctx, node, args) => {
|
||||||
|
const msg = text(args, ["text", "message"]);
|
||||||
|
const title = text(args, ["title"], "通知");
|
||||||
|
const timeout = Math.max(1, number(args, ["timeout", "time_out"], 10));
|
||||||
|
return powershell("(New-Object -ComObject WScript.Shell).Popup(" + psString(msg) + ", " + timeout + ", " + psString(title) + ", 0) | Out-Null; $true");
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Dialog_Notice_V2", async (ctx, node, args) => {
|
||||||
|
const msg = text(args, ["text", "message"]);
|
||||||
|
const title = text(args, ["title"], "通知");
|
||||||
|
const timeout = Math.max(1, number(args, ["time_out", "timeout"], 10));
|
||||||
|
return powershell("(New-Object -ComObject WScript.Shell).Popup(" + psString(msg) + ", " + timeout + ", " + psString(title) + ", 0) | Out-Null; $true");
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Dialog_CsMsgBox", async (_ctx, _node, args) => {
|
||||||
|
const msg = text(args, ["text", "message"]);
|
||||||
|
const title = text(args, ["title"], "提示");
|
||||||
|
const buttons = Number(args.msg_button ?? 0);
|
||||||
|
const autoClose = Number(args.autoClose ?? 0);
|
||||||
|
const time = Math.max(1, Number(args.time ?? 10));
|
||||||
|
const popup = "(New-Object -ComObject WScript.Shell).Popup(" + psString(msg) + ", " + (autoClose ? time : -1) + ", " + psString(title) + ", " + buttons + ")";
|
||||||
|
const choice = await powershell(popup + " | Out-Null; if (" + (autoClose ? "$true" : "$false") + ") {}");
|
||||||
|
return powershell(popup);
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Dialog_OpenFile", async (_ctx, _node, args) => {
|
||||||
|
const title = text(args, ["title"], "选择文件");
|
||||||
|
const wildcard = text(args, ["wildcard"], "所有文件|*.*");
|
||||||
|
return powershell("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $dlg = New-Object System.Windows.Forms.OpenFileDialog; $dlg.Filter = " + psString(wildcard) + "; $dlg.Title = " + psString(title) + "; if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '' }");
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Dialog_OpenFiles", async (_ctx, _node, args) => {
|
||||||
|
const title = text(args, ["title"], "选择文件");
|
||||||
|
const wildcard = text(args, ["wildcard"], "所有文件|*.*");
|
||||||
|
return powershell("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $dlg = New-Object System.Windows.Forms.OpenFileDialog; $dlg.Filter = " + psString(wildcard) + "; $dlg.Title = " + psString(title) + "; $dlg.Multiselect = $true; if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileNames -join ';' } else { '' }");
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Dialog_SaveFile", async (_ctx, _node, args) => {
|
||||||
|
const title = text(args, ["title"], "保存文件");
|
||||||
|
const wildcard = text(args, ["wildcard"], "所有文件|*.*");
|
||||||
|
return powershell("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $dlg = New-Object System.Windows.Forms.SaveFileDialog; $dlg.Filter = " + psString(wildcard) + "; $dlg.Title = " + psString(title) + "; if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '' }");
|
||||||
|
});
|
||||||
|
|
||||||
|
register(["Dialog_InputBox", "Dialog_CsInputBox"], async (_ctx, _node, args) => {
|
||||||
|
const title = text(args, ["title", "input_title"], "请输入");
|
||||||
|
const description = text(args, ["description", "txt_content"], "");
|
||||||
|
const defaultValue = text(args, ["text"], "");
|
||||||
|
return powershell("Add-Type -AssemblyName Microsoft.VisualBasic | Out-Null; [Microsoft.VisualBasic.Interaction]::InputBox(" + psString(description) + ", " + psString(title) + ", " + psString(defaultValue) + ")");
|
||||||
|
});
|
||||||
|
|
||||||
register("Dialog_Notice", notice);
|
register("Dialog_Notice", notice);
|
||||||
register("Dialog_MsgBox", notice);
|
register("Dialog_MsgBox", notice);
|
||||||
register("System_ExecuteCommand", executeCommand);
|
register("System_ExecuteCommand", executeCommand);
|
||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||||
|
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 { RunContext } from "../engine/types";
|
||||||
|
import { getHandler } from "../engine/registry";
|
||||||
|
import "./index";
|
||||||
|
import { EXCEL_COMMON_IDS } from "./excel-common";
|
||||||
|
|
||||||
|
const directories: string[] = [];
|
||||||
|
const context = {} as RunContext;
|
||||||
|
const component = {} as CatalogComponent;
|
||||||
|
const node = (componentId: string): FlowNode => ({
|
||||||
|
id: `excel_common_${componentId}`,
|
||||||
|
componentId,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
values: {},
|
||||||
|
options: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function temporaryWorkbook(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-common-"));
|
||||||
|
directories.push(directory);
|
||||||
|
const filePath = path.join(directory, "common.xlsx");
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet("Data");
|
||||||
|
sheet.addRows([
|
||||||
|
["name", "score", "group", "parts"],
|
||||||
|
["Bob", 2, "B", "x,y"],
|
||||||
|
["Alice", 3, "A", "m,n"],
|
||||||
|
["Bob", 2, "B", "p,q"]
|
||||||
|
]);
|
||||||
|
await workbook.xlsx.writeFile(filePath);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const handler = getHandler(componentId);
|
||||||
|
expect(handler, `${componentId} should be registered`).toBeTruthy();
|
||||||
|
return handler!(context, node(componentId), args, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(filePath);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("common Excel capabilities", () => {
|
||||||
|
it("registers at least 25 real catalog IDs without conflicting with existing modules", async () => {
|
||||||
|
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
||||||
|
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||||
|
expect(EXCEL_COMMON_IDS.length).toBeGreaterThanOrEqual(25);
|
||||||
|
for (const id of EXCEL_COMMON_IDS) {
|
||||||
|
expect(catalogIds.has(id), `${id} should exist in catalog.json`).toBe(true);
|
||||||
|
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFiles = (await readdir("src/main/capabilities"))
|
||||||
|
.filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && file !== "excel-common.ts");
|
||||||
|
const existingSource = (await Promise.all(sourceFiles.map((file) => readFile(path.join("src/main/capabilities", file), "utf8")))).join("\n");
|
||||||
|
for (const id of EXCEL_COMMON_IDS) expect(existingSource.includes(`"${id}"`), `${id} must not already be registered`).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes and reads rows, columns, and ranges in a real workbook", async () => {
|
||||||
|
const filePath = await temporaryWorkbook();
|
||||||
|
await run("Excel_Cs_WriteRow", { workbook: filePath, sheet: "Data", row: 6, column: "A", contents: ["Carol", 9, "C"], save: 1 });
|
||||||
|
await run("Excel_Cs_WriteColumn", { workbook: filePath, sheet: "Data", row: 2, column: "E", contents: [10, 20, 30], save: 1 });
|
||||||
|
await run("Excel_Cs_WriteRangeV2", { workbook: filePath, sheet: "Data", row: 8, column: "A", contents: [["x", 1], ["y", 2]], save: 1 });
|
||||||
|
|
||||||
|
await expect(run("Excel_Cs_ReadRow", { workbook: filePath, sheet: "Data", rowIndex: 6 })).resolves.toEqual(["Carol", 9, "C", "", ""]);
|
||||||
|
await expect(run("Excel_GetColData", { objExcel: filePath, sheet: "Data", cellPos: "E2" })).resolves.toEqual([10, 20, 30, "", "", "", "", ""]);
|
||||||
|
await expect(run("Excel_Cs_ReadRange", { workbook: filePath, sheet: "Data", startRow: 8, startColumn: "A", lastRow: 9, lastColumn: "B" }))
|
||||||
|
.resolves.toEqual([["x", 1], ["y", 2]]);
|
||||||
|
await expect(run("Excel_Cs_ReadColumnCount", { workbook: filePath, sheet: "Data", row: 2 })).resolves.toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds, replaces, sorts, filters, clears filters, and removes duplicates", async () => {
|
||||||
|
const filePath = await temporaryWorkbook();
|
||||||
|
await expect(run("Excel_Cs_Find", { workbook: filePath, sheet: "Data", what: "Alice", matchFirst: 1 }))
|
||||||
|
.resolves.toEqual(["A3"]);
|
||||||
|
await run("Excel_Cs_Search", { workbook: filePath, sheet: "Data", mode: "替换", input: "Alice", replace: "Alicia", wholeWord: 1, save: 1 });
|
||||||
|
await run("Excel_Cs_Sort", { workbook: filePath, sheet: "Data", sortRange: "A1:D4", sortOrder: "2", header: 1, save: 1 });
|
||||||
|
await run("Excel_Cs_Filter", { workbook: filePath, sheet: "Data", column: "C", criteria: "B", condition: "equal", startRow: 1, endRow: 4, onHeader: 1, save: 1 });
|
||||||
|
|
||||||
|
let workbook = await readWorkbook(filePath);
|
||||||
|
let sheet = workbook.getWorksheet("Data")!;
|
||||||
|
expect(sheet.getCell("A2").value).toBe("Bob");
|
||||||
|
expect(sheet.getRow(3).hidden || sheet.getRow(4).hidden).toBe(true);
|
||||||
|
|
||||||
|
await run("Excel_Cs_ClearExcelFilter", { workbook: filePath, sheet: "Data", save: 1 });
|
||||||
|
await run("Excel_Cs_DeleteDuplicates", { workbook: filePath, sheet: "Data", columns: ["A", "B", "C"], header: 1, save: 1 });
|
||||||
|
workbook = await readWorkbook(filePath);
|
||||||
|
sheet = workbook.getWorksheet("Data")!;
|
||||||
|
expect(sheet.getRows(1, sheet.rowCount)!.some((row) => row.hidden)).toBe(false);
|
||||||
|
expect(sheet.rowCount).toBe(3);
|
||||||
|
expect(sheet.getColumn("A").values).toContain("Alicia");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies, pastes, fills, splits, and deletes real ranges", async () => {
|
||||||
|
const filePath = await temporaryWorkbook();
|
||||||
|
await run("Excel_CopyRange", { objExcel: filePath, sheet: "Data", cellPosRange: "A2:B3" });
|
||||||
|
await run("Excel_Cs_PastRange", { workbook: filePath, sheet: "Data", column: "F", row: 2, save: 1 });
|
||||||
|
await run("Excel_Cs_AutoFill", { workbook: filePath, sheet: "Data", sourceRange: "B2:B3", destination: "B2:B6", save: 1 });
|
||||||
|
await run("Excel_Cs_ColumnSplitV2", { workbook: filePath, sheet: "Data", column: "D", destination: "J1", comma: 1, tab: 0, startRow: 2, endRow: 4, save: 1 });
|
||||||
|
await run("Excel_Cs_DeleteRangeV3", { workbook: filePath, sheet: "Data", excelRng: "C2:C4", save: 1 });
|
||||||
|
|
||||||
|
const workbook = await readWorkbook(filePath);
|
||||||
|
const sheet = workbook.getWorksheet("Data")!;
|
||||||
|
expect(sheet.getCell("F2").value).toBe("Bob");
|
||||||
|
expect(sheet.getCell("G3").value).toBe(3);
|
||||||
|
expect(sheet.getCell("B6").value).toBe(6);
|
||||||
|
expect(sheet.getCell("J2").value).toBe("x");
|
||||||
|
expect(sheet.getCell("K2").value).toBe("y");
|
||||||
|
expect(sheet.getCell("C2").value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages sheets and persists active sheet state", async () => {
|
||||||
|
const filePath = await temporaryWorkbook();
|
||||||
|
await run("Excel_CreateSheet", { objExcel: filePath, sheet: "Report", isSave: true });
|
||||||
|
await expect(run("Excel_Cs_GetName", { workbook: filePath })).resolves.toEqual(["Data", "Report"]);
|
||||||
|
await expect(run("Excel_Cs_Active", { workbook: filePath, mode: "按名称", sheet: "Report", save: 1 })).resolves.toBe("Report");
|
||||||
|
await run("Excel_DeleteSheet", { objExcel: filePath, sheet: "Data", isSave: true });
|
||||||
|
await run("Excel_Cs_Close", { workbook: filePath, save: 1 });
|
||||||
|
|
||||||
|
const workbook = await readWorkbook(filePath);
|
||||||
|
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual(["Report"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets and reads cell and range colors", async () => {
|
||||||
|
const filePath = await temporaryWorkbook();
|
||||||
|
await run("Excel_Cs_SetCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2, color: "#12AB34", save: 1 });
|
||||||
|
await run("Excel_Cs_SetRangeFontV2", { workbook: filePath, sheet: "Data", range: "A2:B3", color: "#445566", save: 1 });
|
||||||
|
await expect(run("Excel_Cs_ReadCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2 })).resolves.toBe("FF12AB34");
|
||||||
|
|
||||||
|
const workbook = await readWorkbook(filePath);
|
||||||
|
const sheet = workbook.getWorksheet("Data")!;
|
||||||
|
expect((sheet.getCell("A2").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FF12AB34");
|
||||||
|
expect(sheet.getCell("B3").font.color?.argb).toBe("FF445566");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
|
import { acquireWorkbook, releaseWorkbook } from "./excel-store";
|
||||||
|
|
||||||
|
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||||||
|
type CopiedRange = { values: unknown[][]; styles: Partial<ExcelJS.Style>[][] };
|
||||||
|
|
||||||
|
let copiedRange: CopiedRange | undefined;
|
||||||
|
|
||||||
|
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
const candidate = args[key];
|
||||||
|
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||||
|
return String(value(args, keys, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
function truthy(candidate: unknown, fallback = false): boolean {
|
||||||
|
if (candidate === undefined || candidate === null || candidate === "") return fallback;
|
||||||
|
return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(candidate as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(candidate: unknown, fallback: number): number {
|
||||||
|
const result = Number(candidate);
|
||||||
|
return Number.isFinite(result) ? result : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredPath(candidate: unknown, nodeId: string, componentId: string): string {
|
||||||
|
const raw = typeof candidate === "object" && candidate !== null ? (candidate as { path?: unknown }).path : candidate;
|
||||||
|
const filePath = String(raw ?? "").trim();
|
||||||
|
if (!filePath) {
|
||||||
|
throw new RuntimeError("validation_error", "Excel path is empty", {
|
||||||
|
nodeId,
|
||||||
|
componentId,
|
||||||
|
recommendation: "Configure an Excel workbook path"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return path.resolve(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
|
||||||
|
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), nodeId, componentId);
|
||||||
|
const workbook = await acquireWorkbook(filePath);
|
||||||
|
return { workbook, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function worksheetOf(workbook: ExcelJS.Workbook, args: Record<string, unknown>): ExcelJS.Worksheet {
|
||||||
|
const name = text(args, ["sheet", "sheetName"]);
|
||||||
|
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||||||
|
if (!sheet) throw new Error(`Sheet not found: ${name || "first sheet"}`);
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWhenRequested(source: WorkbookSource, args: Record<string, unknown>, fallback = false): Promise<void> {
|
||||||
|
if (truthy(value(args, ["save", "isSave"]), fallback)) await source.workbook.xlsx.writeFile(source.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnNumber(candidate: unknown): number {
|
||||||
|
const raw = String(candidate ?? "A").trim().toUpperCase();
|
||||||
|
if (/^\d+$/.test(raw)) return Math.max(1, Number(raw));
|
||||||
|
if (!/^[A-Z]+$/.test(raw)) throw new Error(`Invalid column: ${raw}`);
|
||||||
|
let result = 0;
|
||||||
|
for (const character of raw) result = result * 26 + character.charCodeAt(0) - 64;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnLetter(index: number): string {
|
||||||
|
let current = Math.max(1, Math.trunc(index));
|
||||||
|
let result = "";
|
||||||
|
while (current > 0) {
|
||||||
|
current -= 1;
|
||||||
|
result = String.fromCharCode(65 + current % 26) + result;
|
||||||
|
current = Math.floor(current / 26);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellValue(candidate: ExcelJS.CellValue): unknown {
|
||||||
|
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result ?? "";
|
||||||
|
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
|
||||||
|
return candidate ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function listValue(candidate: unknown): unknown[] {
|
||||||
|
if (Array.isArray(candidate)) return candidate;
|
||||||
|
const raw = String(candidate ?? "").trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw.replace(/'/g, '"'));
|
||||||
|
return Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
} catch {
|
||||||
|
return raw.split(",").map((item) => item.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableValue(candidate: unknown): unknown[][] {
|
||||||
|
return listValue(candidate).map((item) => Array.isArray(item) ? item : [item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeBounds(sheet: ExcelJS.Worksheet, range: string): { top: number; left: number; bottom: number; right: number } {
|
||||||
|
const normalized = range.trim() || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`;
|
||||||
|
const [start, end = start] = normalized.split(":").map((part) => part.trim());
|
||||||
|
const first = sheet.getCell(start);
|
||||||
|
const last = sheet.getCell(end);
|
||||||
|
return {
|
||||||
|
top: Math.min(Number(first.row), Number(last.row)),
|
||||||
|
left: Math.min(Number(first.col), Number(last.col)),
|
||||||
|
bottom: Math.max(Number(first.row), Number(last.row)),
|
||||||
|
right: Math.max(Number(first.col), Number(last.col))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitRange(args: Record<string, unknown>, sheet: ExcelJS.Worksheet): string {
|
||||||
|
const direct = text(args, ["excelRng", "range", "cellPosRange", "cellPos", "sortRange"]);
|
||||||
|
if (direct) return direct;
|
||||||
|
const top = numberValue(value(args, ["startRow", "row"]), 1);
|
||||||
|
const left = columnNumber(value(args, ["startColumn", "column"]));
|
||||||
|
const bottom = numberValue(value(args, ["lastRow", "endRow"]), top);
|
||||||
|
const right = columnNumber(value(args, ["lastColumn", "endColumn"], left));
|
||||||
|
return `${columnLetter(left)}${top}:${columnLetter(right)}${bottom || Math.max(1, sheet.rowCount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellsInRange(sheet: ExcelJS.Worksheet, range: string): ExcelJS.Cell[] {
|
||||||
|
const bounds = rangeBounds(sheet, range);
|
||||||
|
const cells: ExcelJS.Cell[] = [];
|
||||||
|
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
|
||||||
|
for (let column = bounds.left; column <= bounds.right; column += 1) cells.push(sheet.getCell(row, column));
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneStyle(style: Partial<ExcelJS.Style>): Partial<ExcelJS.Style> {
|
||||||
|
return JSON.parse(JSON.stringify(style)) as Partial<ExcelJS.Style>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorArgb(candidate: unknown): string {
|
||||||
|
if (Array.isArray(candidate)) {
|
||||||
|
const [red = 0, green = 0, blue = 0] = candidate.map((item) => Math.max(0, Math.min(255, Number(item))));
|
||||||
|
return `FF${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`.toUpperCase();
|
||||||
|
}
|
||||||
|
const raw = String(candidate ?? "#000000").trim();
|
||||||
|
if (raw.startsWith("[")) {
|
||||||
|
try { return colorArgb(JSON.parse(raw)); } catch { return "FF000000"; }
|
||||||
|
}
|
||||||
|
const hex = raw.replace(/^#/, "").toUpperCase();
|
||||||
|
if (/^[0-9A-F]{8}$/.test(hex)) return hex;
|
||||||
|
return `FF${/^[0-9A-F]{6}$/.test(hex) ? hex : "000000"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparison(left: unknown, right: unknown): number {
|
||||||
|
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||||
|
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN", { numeric: true, sensitivity: "base" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerMany(ids: string[], handler: CapabilityHandler): void {
|
||||||
|
register(ids, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeRow: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const start = text(args, ["cellPos"]);
|
||||||
|
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row", "rowIndex"]), 1);
|
||||||
|
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||||
|
listValue(value(args, ["contents", "value"])).forEach((item, index) => { sheet.getCell(row, column + index).value = item as ExcelJS.CellValue; });
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return `${columnLetter(column)}${row}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const start = text(args, ["cellPos"]);
|
||||||
|
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row"]), 1);
|
||||||
|
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||||
|
listValue(value(args, ["contents", "value"])).forEach((item, index) => { sheet.getCell(row + index, column).value = item as ExcelJS.CellValue; });
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return `${columnLetter(column)}${row}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const row = numberValue(value(args, ["row", "startRow"]), 1);
|
||||||
|
const column = columnNumber(value(args, ["column", "startColumn"]));
|
||||||
|
tableValue(value(args, ["contents", "value"])).forEach((items, rowOffset) => {
|
||||||
|
items.forEach((item, columnOffset) => { sheet.getCell(row + rowOffset, column + columnOffset).value = item as ExcelJS.CellValue; });
|
||||||
|
});
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return `${columnLetter(column)}${row}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRow: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const start = text(args, ["cellPos"]);
|
||||||
|
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row", "rowIndex"]), 1);
|
||||||
|
const firstColumn = start ? Number(sheet.getCell(start).col) : 1;
|
||||||
|
const result: unknown[] = [];
|
||||||
|
for (let column = firstColumn; column <= Math.max(firstColumn, sheet.columnCount); column += 1) result.push(cellValue(sheet.getCell(row, column).value));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const start = text(args, ["cellPos"]);
|
||||||
|
const row = start ? Number(sheet.getCell(start).row) : 1;
|
||||||
|
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||||
|
const result: unknown[] = [];
|
||||||
|
for (let current = row; current <= Math.max(row, sheet.rowCount); current += 1) result.push(cellValue(sheet.getCell(current, column).value));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const bounds = rangeBounds(sheet, explicitRange(args, sheet));
|
||||||
|
const result: unknown[][] = [];
|
||||||
|
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
|
||||||
|
const values: unknown[] = [];
|
||||||
|
for (let column = bounds.left; column <= bounds.right; column += 1) values.push(cellValue(sheet.getCell(row, column).value));
|
||||||
|
result.push(values);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readColumnCount: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const row = numberValue(value(args, ["row"]), 1);
|
||||||
|
let last = 0;
|
||||||
|
for (let column = 1; column <= sheet.columnCount; column += 1) {
|
||||||
|
if (cellValue(sheet.getCell(row, column).value) !== "") last = column;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOrReplace: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const needle = text(args, ["what", "input"]);
|
||||||
|
const replacement = text(args, ["replacement", "replace"]);
|
||||||
|
const mode = text(args, ["mode"], "0");
|
||||||
|
const replaceMode = mode === "1" || mode.includes("替换") || replacement !== "";
|
||||||
|
const exact = truthy(value(args, ["wholeWord"])) || text(args, ["lookAt"]) === "1";
|
||||||
|
const matchCase = truthy(value(args, ["matchCase"]));
|
||||||
|
const range = text(args, ["range"]);
|
||||||
|
const cells = cellsInRange(sheet, range || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`);
|
||||||
|
const matches: string[] = [];
|
||||||
|
for (const cell of cells) {
|
||||||
|
const original = String(cellValue(cell.value));
|
||||||
|
const haystack = matchCase ? original : original.toLowerCase();
|
||||||
|
const target = matchCase ? needle : needle.toLowerCase();
|
||||||
|
const matched = exact ? haystack === target : haystack.includes(target);
|
||||||
|
if (!matched) continue;
|
||||||
|
matches.push(cell.address);
|
||||||
|
if (replaceMode) {
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
cell.value = exact ? replacement : original.replace(new RegExp(escaped, matchCase ? "g" : "gi"), replacement);
|
||||||
|
}
|
||||||
|
if (truthy(value(args, ["matchFirst"]))) break;
|
||||||
|
}
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const range = text(args, ["sortRange", "range"]) || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`;
|
||||||
|
const bounds = rangeBounds(sheet, range);
|
||||||
|
const hasHeader = truthy(value(args, ["header"]));
|
||||||
|
const dataStart = bounds.top + (hasHeader ? 1 : 0);
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
for (let row = dataStart; row <= bounds.bottom; row += 1) {
|
||||||
|
const values: unknown[] = [];
|
||||||
|
for (let column = bounds.left; column <= bounds.right; column += 1) values.push(cellValue(sheet.getCell(row, column).value));
|
||||||
|
rows.push(values);
|
||||||
|
}
|
||||||
|
let sortOffset = 0;
|
||||||
|
const rule = value(args, ["rule"]);
|
||||||
|
if (rule) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof rule === "string" ? JSON.parse(rule) as Record<string, unknown> : rule as Record<string, unknown>;
|
||||||
|
sortOffset = Math.max(0, columnNumber(value(parsed, ["column", "key"], bounds.left)) - bounds.left);
|
||||||
|
} catch { /* use first column */ }
|
||||||
|
}
|
||||||
|
const descending = ["2", "-1", "desc", "descending", "降序"].includes(text(args, ["sortOrder"]).toLowerCase());
|
||||||
|
rows.sort((left, right) => comparison(left[sortOffset], right[sortOffset]) * (descending ? -1 : 1));
|
||||||
|
rows.forEach((values, rowOffset) => values.forEach((item, columnOffset) => {
|
||||||
|
sheet.getCell(dataStart + rowOffset, bounds.left + columnOffset).value = item as ExcelJS.CellValue;
|
||||||
|
}));
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
function criterionMatches(candidate: unknown, criterion: unknown, condition: string): boolean {
|
||||||
|
const left = String(candidate ?? "");
|
||||||
|
const right = String(criterion ?? "");
|
||||||
|
if (["contains", "包含", "1"].includes(condition)) return left.includes(right);
|
||||||
|
if (["notContains", "不包含", "2"].includes(condition)) return !left.includes(right);
|
||||||
|
if (["greater", ">", "3"].includes(condition)) return Number(candidate) > Number(criterion);
|
||||||
|
if (["less", "<", "4"].includes(condition)) return Number(candidate) < Number(criterion);
|
||||||
|
if (["notEqual", "!=", "5"].includes(condition)) return left !== right;
|
||||||
|
return left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterRows: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
let column = columnNumber(value(args, ["column"], "A"));
|
||||||
|
let criterion = value(args, ["criteria"], "");
|
||||||
|
let condition = text(args, ["condition"], "equal");
|
||||||
|
const rule = value(args, ["rule"]);
|
||||||
|
if (rule) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof rule === "string" ? JSON.parse(rule) as Record<string, unknown> : rule as Record<string, unknown>;
|
||||||
|
column = columnNumber(value(parsed, ["column"], column));
|
||||||
|
criterion = value(parsed, ["criteria", "value"], criterion);
|
||||||
|
condition = text(parsed, ["condition", "operator"], condition);
|
||||||
|
} catch { /* use direct fields */ }
|
||||||
|
}
|
||||||
|
const startRow = numberValue(value(args, ["startRow"]), 1);
|
||||||
|
const endRowRaw = numberValue(value(args, ["endRow"]), sheet.rowCount);
|
||||||
|
const endRow = endRowRaw < 0 ? sheet.rowCount : endRowRaw;
|
||||||
|
const headerRow = truthy(value(args, ["onHeader"])) ? startRow : 0;
|
||||||
|
for (let row = startRow; row <= endRow; row += 1) {
|
||||||
|
if (row === headerRow) continue;
|
||||||
|
sheet.getRow(row).hidden = !criterionMatches(cellValue(sheet.getCell(row, column).value), criterion, condition);
|
||||||
|
}
|
||||||
|
sheet.autoFilter = { from: { row: startRow, column: 1 }, to: { row: endRow, column: Math.max(1, sheet.columnCount) } };
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return { column: columnLetter(column), criterion, condition };
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilter: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
for (let row = 1; row <= sheet.rowCount; row += 1) sheet.getRow(row).hidden = false;
|
||||||
|
sheet.autoFilter = undefined;
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDuplicates: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const columns = listValue(value(args, ["columns"], "A")).map(columnNumber);
|
||||||
|
const hasHeader = truthy(value(args, ["header"]));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const duplicates: number[] = [];
|
||||||
|
for (let row = hasHeader ? 2 : 1; row <= sheet.rowCount; row += 1) {
|
||||||
|
const key = JSON.stringify(columns.map((column) => cellValue(sheet.getCell(row, column).value)));
|
||||||
|
if (seen.has(key)) duplicates.push(row);
|
||||||
|
else seen.add(key);
|
||||||
|
}
|
||||||
|
duplicates.reverse().forEach((row) => sheet.spliceRows(row, 1));
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return duplicates.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
function copyCells(sheet: ExcelJS.Worksheet, range: string): CopiedRange {
|
||||||
|
const bounds = rangeBounds(sheet, range);
|
||||||
|
const values: unknown[][] = [];
|
||||||
|
const styles: Partial<ExcelJS.Style>[][] = [];
|
||||||
|
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
|
||||||
|
const valueRow: unknown[] = [];
|
||||||
|
const styleRow: Partial<ExcelJS.Style>[] = [];
|
||||||
|
for (let column = bounds.left; column <= bounds.right; column += 1) {
|
||||||
|
const cell = sheet.getCell(row, column);
|
||||||
|
valueRow.push(cell.value);
|
||||||
|
styleRow.push(cloneStyle(cell.style));
|
||||||
|
}
|
||||||
|
values.push(valueRow);
|
||||||
|
styles.push(styleRow);
|
||||||
|
}
|
||||||
|
return { values, styles };
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const range = explicitRange(args, sheet);
|
||||||
|
copiedRange = copyCells(sheet, range);
|
||||||
|
const destinationColumn = value(args, ["startColumn2"]);
|
||||||
|
const destinationRow = value(args, ["startRow2"]);
|
||||||
|
if (destinationColumn !== "" && destinationRow !== "") {
|
||||||
|
const startColumn = columnNumber(destinationColumn);
|
||||||
|
const startRow = numberValue(destinationRow, 1);
|
||||||
|
copiedRange.values.forEach((items, rowOffset) => items.forEach((item, columnOffset) => {
|
||||||
|
const cell = sheet.getCell(startRow + rowOffset, startColumn + columnOffset);
|
||||||
|
cell.value = item as ExcelJS.CellValue;
|
||||||
|
cell.style = cloneStyle(copiedRange!.styles[rowOffset][columnOffset]);
|
||||||
|
}));
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
}
|
||||||
|
return copiedRange.values;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
if (!copiedRange) throw new RuntimeError("validation_error", "No copied Excel range is available", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const start = text(args, ["Start"]);
|
||||||
|
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row"]), 1);
|
||||||
|
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||||
|
const transpose = truthy(value(args, ["transpose"]));
|
||||||
|
const values = transpose ? copiedRange.values[0].map((_, index) => copiedRange!.values.map((items) => items[index])) : copiedRange.values;
|
||||||
|
values.forEach((items, rowOffset) => items.forEach((item, columnOffset) => {
|
||||||
|
const cell = sheet.getCell(row + rowOffset, column + columnOffset);
|
||||||
|
cell.value = item as ExcelJS.CellValue;
|
||||||
|
if (!transpose) cell.style = cloneStyle(copiedRange!.styles[rowOffset][columnOffset]);
|
||||||
|
}));
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return `${columnLetter(column)}${row}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const name = text(args, ["sheet", "sheetName", "name"], `Sheet${source.workbook.worksheets.length + 1}`);
|
||||||
|
if (source.workbook.getWorksheet(name)) throw new Error(`Sheet already exists: ${name}`);
|
||||||
|
source.workbook.addWorksheet(name);
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
if (source.workbook.worksheets.length <= 1) throw new Error("Cannot delete the only worksheet");
|
||||||
|
source.workbook.removeWorksheet(sheet.id);
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return sheet.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const mode = text(args, ["mode"]);
|
||||||
|
const index = mode.includes("序号") ? numberValue(value(args, ["index"]), 1) : 0;
|
||||||
|
const sheet = index ? source.workbook.worksheets[index - 1] : worksheetOf(source.workbook, args);
|
||||||
|
if (!sheet) throw new Error(`Sheet not found: ${index}`);
|
||||||
|
source.workbook.views = [{
|
||||||
|
activeTab: source.workbook.worksheets.indexOf(sheet),
|
||||||
|
firstSheet: 0,
|
||||||
|
visibility: "visible",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 12000,
|
||||||
|
height: 8000
|
||||||
|
}];
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return sheet.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSheetNames: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
return source.workbook.worksheets.map((sheet) => sheet.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
function setFontColor(cell: ExcelJS.Cell, argb: string): void {
|
||||||
|
cell.font = { ...(cell.font || {}), color: { argb } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBackground(cell: ExcelJS.Cell, argb: string): void {
|
||||||
|
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const setColor = (kind: "font" | "background", rangeMode: boolean): CapabilityHandler => async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const range = rangeMode
|
||||||
|
? explicitRange(args, sheet)
|
||||||
|
: text(args, ["cellPos"]) || `${text(args, ["column"], "A")}${numberValue(value(args, ["row"]), 1)}`;
|
||||||
|
const argb = colorArgb(value(args, ["color", "cellColor"]));
|
||||||
|
for (const cell of cellsInRange(sheet, range)) {
|
||||||
|
if (kind === "font") setFontColor(cell, argb);
|
||||||
|
else setBackground(cell, argb);
|
||||||
|
}
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return argb;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBackground: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const cell = sheet.getCell(numberValue(value(args, ["row"]), 1), columnNumber(value(args, ["column"])));
|
||||||
|
const fill = cell.fill as ExcelJS.FillPattern | undefined;
|
||||||
|
return fill?.type === "pattern" ? fill.fgColor?.argb ?? "" : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoFill: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const sourceRange = text(args, ["sourceRange", "excelRng"]);
|
||||||
|
const destination = text(args, ["destination"]);
|
||||||
|
const sourceBounds = rangeBounds(sheet, sourceRange);
|
||||||
|
const destinationBounds = rangeBounds(sheet, destination);
|
||||||
|
const sourceValues = copyCells(sheet, sourceRange).values;
|
||||||
|
const vertical = sourceBounds.left === sourceBounds.right;
|
||||||
|
for (let row = destinationBounds.top; row <= destinationBounds.bottom; row += 1) {
|
||||||
|
for (let column = destinationBounds.left; column <= destinationBounds.right; column += 1) {
|
||||||
|
const sourceRow = (row - destinationBounds.top) % sourceValues.length;
|
||||||
|
const sourceColumn = (column - destinationBounds.left) % sourceValues[0].length;
|
||||||
|
let next = sourceValues[sourceRow][sourceColumn];
|
||||||
|
const sequence = vertical ? sourceValues.map((items) => items[0]) : sourceValues[0];
|
||||||
|
const offset = vertical ? row - sourceBounds.top : column - sourceBounds.left;
|
||||||
|
if (sequence.length >= 2 && sequence.every((item) => typeof item === "number")) {
|
||||||
|
next = Number(sequence[0]) + (Number(sequence[1]) - Number(sequence[0])) * offset;
|
||||||
|
}
|
||||||
|
sheet.getCell(row, column).value = next as ExcelJS.CellValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return destination;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const sourceColumn = columnNumber(value(args, ["column"]));
|
||||||
|
const destination = text(args, ["destination"]);
|
||||||
|
const destinationColumn = destination ? Number(sheet.getCell(destination).col) : sourceColumn;
|
||||||
|
const startRow = numberValue(value(args, ["startRow"]), 1);
|
||||||
|
const endRow = numberValue(value(args, ["endRow"]), sheet.rowCount);
|
||||||
|
const delimiters: string[] = [];
|
||||||
|
if (truthy(value(args, ["tab"]), true)) delimiters.push("\\t");
|
||||||
|
if (truthy(value(args, ["semicolon"]))) delimiters.push(";");
|
||||||
|
if (truthy(value(args, ["comma"]))) delimiters.push(",");
|
||||||
|
if (truthy(value(args, ["space"]))) delimiters.push("\\s+");
|
||||||
|
if (truthy(value(args, ["other"]))) delimiters.push(text(args, ["otherChar"]).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||||
|
const separator = new RegExp(delimiters.filter(Boolean).join("|") || "\\t");
|
||||||
|
for (let row = startRow; row <= endRow; row += 1) {
|
||||||
|
String(cellValue(sheet.getCell(row, sourceColumn).value)).split(separator).forEach((item, offset) => {
|
||||||
|
sheet.getCell(row, destinationColumn + offset).value = item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return `${columnLetter(destinationColumn)}${startRow}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeWorkbook: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
if (truthy(value(args, ["save", "isSave"]), true)) await source.workbook.xlsx.writeFile(source.filePath);
|
||||||
|
releaseWorkbook(source.filePath);
|
||||||
|
return source.filePath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const range = explicitRange(args, sheet);
|
||||||
|
for (const cell of cellsInRange(sheet, range)) cell.value = null;
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
return range;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXCEL_COMMON_IDS = [
|
||||||
|
"Excel_WriteColumn", "Excel_WriteColumnNew", "Excel_Cs_WriteColumn",
|
||||||
|
"Excel_WriteRow", "Excel_WriteRowNew", "Excel_Cs_WriteRow", "Excel_Cs_WriteRowV2",
|
||||||
|
"Excel_Cs_WriteRangeV2", "Excel_GetRowData", "Excel_GetRowDataNew", "Excel_Cs_ReadRow",
|
||||||
|
"Excel_GetColData", "Excel_Cs_ReadColumnCount", "Excel_Cs_ReadRange",
|
||||||
|
"Excel_Cs_Find", "Excel_Cs_FindV2", "Excel_Cs_Search",
|
||||||
|
"Excel_Cs_Sort", "Excel_Cs_SortV1", "Excel_Cs_Filter", "Excel_Cs_ExcelFilter",
|
||||||
|
"Excel_Cs_ClearExcelFilter", "Excel_Cs_EnableExcelFilter", "Excel_Cs_DeleteDuplicates",
|
||||||
|
"Excel_CopyRange", "Excel_Cs_CopyRange", "Excel_Cs_CopyRangeV2", "Excel_Cs_CopyRangeV3",
|
||||||
|
"Excel_PasteRange", "Excel_Cs_PastRange", "Excel_Cs_PastRangeV2",
|
||||||
|
"Excel_CreateSheet", "Excel_DeleteSheet", "Excel_Cs_Delete", "Excel_ActiveSheet", "Excel_Cs_Active", "Excel_Cs_GetName",
|
||||||
|
"Excel_SetCellFontColor", "Excel_Cs_SetCellFont", "Excel_SetRangeFontColor", "Excel_Cs_SetRangeFont", "Excel_Cs_SetRangeFontV2",
|
||||||
|
"Excel_SetCellColor", "Excel_Cs_SetCellBackground", "Excel_SetRangeColor", "Excel_Cs_SetRangeBackground", "Excel_Cs_SetRangeBackgroundV2",
|
||||||
|
"Excel_Cs_ReadCellBackground", "Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2",
|
||||||
|
"Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2", "Excel_Close", "Excel_Cs_Close",
|
||||||
|
"Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
registerMany(["Excel_WriteRow", "Excel_WriteRowNew", "Excel_Cs_WriteRow", "Excel_Cs_WriteRowV2"], writeRow);
|
||||||
|
registerMany(["Excel_WriteColumn", "Excel_WriteColumnNew", "Excel_Cs_WriteColumn"], writeColumn);
|
||||||
|
register("Excel_Cs_WriteRangeV2", writeRange);
|
||||||
|
registerMany(["Excel_GetRowData", "Excel_GetRowDataNew", "Excel_Cs_ReadRow"], readRow);
|
||||||
|
register("Excel_GetColData", readColumn);
|
||||||
|
register("Excel_Cs_ReadColumnCount", readColumnCount);
|
||||||
|
register("Excel_Cs_ReadRange", readRange);
|
||||||
|
registerMany(["Excel_Cs_Find", "Excel_Cs_FindV2", "Excel_Cs_Search"], findOrReplace);
|
||||||
|
registerMany(["Excel_Cs_Sort", "Excel_Cs_SortV1"], sortRange);
|
||||||
|
registerMany(["Excel_Cs_Filter", "Excel_Cs_ExcelFilter"], filterRows);
|
||||||
|
registerMany(["Excel_Cs_ClearExcelFilter", "Excel_Cs_EnableExcelFilter"], clearFilter);
|
||||||
|
register("Excel_Cs_DeleteDuplicates", deleteDuplicates);
|
||||||
|
registerMany(["Excel_CopyRange", "Excel_Cs_CopyRange", "Excel_Cs_CopyRangeV2", "Excel_Cs_CopyRangeV3"], copyRange);
|
||||||
|
registerMany(["Excel_PasteRange", "Excel_Cs_PastRange", "Excel_Cs_PastRangeV2"], pasteRange);
|
||||||
|
register("Excel_CreateSheet", createSheet);
|
||||||
|
registerMany(["Excel_DeleteSheet", "Excel_Cs_Delete"], deleteSheet);
|
||||||
|
registerMany(["Excel_ActiveSheet", "Excel_Cs_Active"], activateSheet);
|
||||||
|
register("Excel_Cs_GetName", getSheetNames);
|
||||||
|
registerMany(["Excel_SetCellFontColor", "Excel_Cs_SetCellFont"], setColor("font", false));
|
||||||
|
registerMany(["Excel_SetRangeFontColor", "Excel_Cs_SetRangeFont", "Excel_Cs_SetRangeFontV2"], setColor("font", true));
|
||||||
|
registerMany(["Excel_SetCellColor", "Excel_Cs_SetCellBackground"], setColor("background", false));
|
||||||
|
registerMany(["Excel_SetRangeColor", "Excel_Cs_SetRangeBackground", "Excel_Cs_SetRangeBackgroundV2"], setColor("background", true));
|
||||||
|
register("Excel_Cs_ReadCellBackground", readBackground);
|
||||||
|
registerMany(["Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2"], autoFill);
|
||||||
|
registerMany(["Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2"], splitColumn);
|
||||||
|
registerMany(["Excel_Close", "Excel_Cs_Close"], closeWorkbook);
|
||||||
|
registerMany(["Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"], deleteRange);
|
||||||
|
|
||||||
|
|
||||||
|
// ===== Excel 高阶(批四) =====
|
||||||
|
function cellAddress(addr: string): { row: number; column: number } {
|
||||||
|
const m = addr.toUpperCase().match(/^([A-Z]+)(\d+)$/);
|
||||||
|
if (!m) throw new Error("Invalid cell address: " + addr);
|
||||||
|
return { row: Number(m[2]), column: columnNumber(m[1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArgb(color: string): string {
|
||||||
|
const hex = color.replace(/^#/, "").toUpperCase();
|
||||||
|
return hex.length === 6 ? "FF" + hex : hex.padStart(8, "F");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFormatSpec(raw: unknown): { bold?: boolean; italic?: boolean; underline?: boolean; fontSize?: number; color?: string; bgColor?: string; numFmt?: string; fontName?: string } {
|
||||||
|
if (typeof raw === "object" && raw !== null) return normalizeFormatSpec(raw as Record<string, unknown>);
|
||||||
|
const text = String(raw ?? "").trim();
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
if (typeof parsed === "object" && parsed) return normalizeFormatSpec(parsed);
|
||||||
|
} catch { /* fall through to keywords */ }
|
||||||
|
const spec: ReturnType<typeof parseFormatSpec> = {};
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (/加粗|bold/.test(lower)) spec.bold = true;
|
||||||
|
if (/斜体|italic/.test(lower)) spec.italic = true;
|
||||||
|
if (/下划线|underline/.test(lower)) spec.underline = true;
|
||||||
|
const sizeMatch = text.match(/(\d+)号|font-size[::]\s*(\d+)/i);
|
||||||
|
if (sizeMatch) spec.fontSize = Number(sizeMatch[1] || sizeMatch[2]);
|
||||||
|
// 无关键词命中时视为数字格式代码(如 "0.00"、"G/通用格式"、"yyyy-mm-dd")
|
||||||
|
if (!Object.keys(spec).length) spec.numFmt = text;
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容厂商格式 JSON(FontColor/BoldAndItalic/FontSize/BackgroundColor/DigitalFormat)与内部 camelCase 键 */
|
||||||
|
function normalizeFormatSpec(parsed: Record<string, unknown>): ReturnType<typeof parseFormatSpec> {
|
||||||
|
const spec: ReturnType<typeof parseFormatSpec> = {};
|
||||||
|
const flags = Number(parsed.BoldAndItalic ?? 0);
|
||||||
|
if (flags & 1) spec.bold = true;
|
||||||
|
if (flags & 2) spec.italic = true;
|
||||||
|
if (parsed.FontFormat) spec.fontName = String(parsed.FontFormat);
|
||||||
|
if (parsed.FontSize !== undefined && parsed.FontSize !== null && String(parsed.FontSize).trim() !== "") spec.fontSize = Number(parsed.FontSize);
|
||||||
|
if (parsed.FontColor) spec.color = String(parsed.FontColor);
|
||||||
|
if (parsed.BackgroundColor) spec.bgColor = String(parsed.BackgroundColor).split(",")[0].trim();
|
||||||
|
const digital = parsed.DigitalFormat as { formatstring?: unknown } | undefined;
|
||||||
|
if (digital?.formatstring && String(digital.formatstring).trim() !== "") spec.numFmt = String(digital.formatstring);
|
||||||
|
for (const key of ["bold", "italic", "underline", "fontSize", "color", "bgColor", "numFmt"] as const) {
|
||||||
|
if (parsed[key] !== undefined) (spec as Record<string, unknown>)[key] = parsed[key];
|
||||||
|
}
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMany(["Excel_Cs_SetCellFormat", "Excel_Cs_SetCellFormat_V2", "Excel_Cs_SetCellFormat_V3"], async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const range = text(args, ["excelRng", "range", "cellPos", "rng"], "A1");
|
||||||
|
const spec = parseFormatSpec(args.format);
|
||||||
|
const addresses: string[] = [];
|
||||||
|
const parts = range.toUpperCase().split(":");
|
||||||
|
if (parts.length === 2 && parts[0] !== parts[1]) {
|
||||||
|
const from = cellAddress(parts[0]);
|
||||||
|
const to = cellAddress(parts[1]);
|
||||||
|
for (let r = from.row; r <= to.row; r += 1) {
|
||||||
|
for (let cIdx = from.column; cIdx <= to.column; cIdx += 1) {
|
||||||
|
addresses.push(columnLetter(cIdx) + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addresses.push(parts[0]);
|
||||||
|
}
|
||||||
|
for (const addr of addresses) {
|
||||||
|
const cell = sheet.getCell(addr);
|
||||||
|
if (spec.bold !== undefined || spec.italic !== undefined || spec.underline !== undefined || spec.fontSize !== undefined || spec.color) {
|
||||||
|
cell.font = {
|
||||||
|
...cell.font,
|
||||||
|
bold: spec.bold,
|
||||||
|
italic: spec.italic,
|
||||||
|
underline: spec.underline,
|
||||||
|
size: spec.fontSize,
|
||||||
|
name: spec.fontName ?? cell.font?.name,
|
||||||
|
color: spec.color ? { argb: toArgb(spec.color) } : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (spec.numFmt) cell.numFmt = spec.numFmt;
|
||||||
|
if (spec.bgColor) cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: toArgb(spec.bgColor) } };
|
||||||
|
}
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
ctx.log("已设置 " + addresses.length + " 个单元格格式", "info", node.id);
|
||||||
|
return { formatted: addresses.length, range };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_Cs_ExcelFilter", async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const rule = text(args, ["rule"]);
|
||||||
|
const startRow = numberValue(args.startRow, 1);
|
||||||
|
const endRow = numberValue(args.endRow, sheet.rowCount || 1);
|
||||||
|
const range = rule && rule.includes(":") ? rule.toUpperCase() : "A" + startRow + ":" + columnLetter(sheet.columnCount || 1) + endRow;
|
||||||
|
sheet.autoFilter = range;
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
ctx.log("已设置自动筛选:" + range, "info", node.id);
|
||||||
|
return { filter: range };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_Cs_ShowTableStyle", async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
// 必须逐单元格设置:exceljs 的行级样式(row.font/row.fill + commit)会覆盖
|
||||||
|
// 该行已设置的单元格样式(如先执行过的设置单元格格式)
|
||||||
|
const lastCol = Math.max(1, sheet.columnCount);
|
||||||
|
for (let c = 1; c <= lastCol; c += 1) {
|
||||||
|
const cell = sheet.getRow(1).getCell(c);
|
||||||
|
cell.font = { ...cell.font, bold: true };
|
||||||
|
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD9E8F5" } };
|
||||||
|
}
|
||||||
|
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: Math.max(1, sheet.rowCount), column: lastCol } };
|
||||||
|
await saveWhenRequested(source, args, true);
|
||||||
|
ctx.log("已应用表格样式(表头加粗 + 筛选)", "info", node.id);
|
||||||
|
return { styled: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_Cs_Hide", async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const mode = String(value(args, ["mode"], "row")).toLowerCase();
|
||||||
|
const range = text(args, ["range"]);
|
||||||
|
const hide = !/取消|取消隐藏|unhide|show/i.test(mode + range);
|
||||||
|
if (mode.includes("col") || mode.includes("列")) {
|
||||||
|
const targets = range ? range.split(",") : [text(args, ["column"])];
|
||||||
|
for (const t of targets) {
|
||||||
|
if (!t.trim()) continue;
|
||||||
|
const letter = t.trim().replace(/^\d+:?/, "").toUpperCase() || t.trim().toUpperCase();
|
||||||
|
sheet.getColumn(columnNumber(letter)).hidden = hide;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targets = range ? range.split(":") : [text(args, ["row"])];
|
||||||
|
if (targets.length === 2) {
|
||||||
|
const from = Number(targets[0]); const to = Number(targets[1]);
|
||||||
|
for (let r = Math.min(from, to); r <= Math.max(from, to); r += 1) sheet.getRow(r).hidden = hide;
|
||||||
|
} else {
|
||||||
|
const r = Number(targets[0]);
|
||||||
|
if (Number.isFinite(r) && r > 0) sheet.getRow(r).hidden = hide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveWhenRequested(source, args);
|
||||||
|
ctx.log((hide ? "已隐藏" : "已取消隐藏") + "(" + mode + " " + range + ")", "info", node.id);
|
||||||
|
return { hidden: hide };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_Cs_GetLastEmptyCell", async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const column = columnLetter(numberValue(args.column, 1));
|
||||||
|
let lastFilled = 0;
|
||||||
|
for (let r = 1; r <= sheet.rowCount; r += 1) {
|
||||||
|
const v = sheet.getRow(r).getCell(numberValue(args.column, 1)).value;
|
||||||
|
if (v !== null && v !== undefined && v !== "") lastFilled = r;
|
||||||
|
}
|
||||||
|
const emptyRow = lastFilled + 1;
|
||||||
|
return { address: column + emptyRow, row: emptyRow, column: numberValue(args.column, 1) };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_GetColumnId", async (_ctx, _node, args) => columnLetter(numberValue(args.num, 1)));
|
||||||
|
|
||||||
|
register(["Excel_Cs_SelectRange", "Excel_Cs_SelectRangeV2", "Excel_Cs_SelectRangeV3", "Excel_SelectRange"], async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const cellPos = text(args, ["cellPos", "range", "Start"], "A1");
|
||||||
|
const parts = cellPos.toUpperCase().split(":");
|
||||||
|
const from = cellAddress(parts[0]);
|
||||||
|
const to = parts[1] ? cellAddress(parts[1]) : from;
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
for (let r = from.row; r <= to.row; r += 1) {
|
||||||
|
const row: unknown[] = [];
|
||||||
|
for (let cIdx = from.column; cIdx <= to.column; cIdx += 1) {
|
||||||
|
row.push(sheet.getRow(r).getCell(cIdx).value ?? "");
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
ctx.log("已读取区域 " + cellPos + "(" + rows.length + " 行)", "info", node.id);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_Cs_CloseV2", async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const save = !/不保存|discard|false/i.test(text(args, ["closeAction"]));
|
||||||
|
if (save) await source.workbook.xlsx.writeFile(source.filePath);
|
||||||
|
releaseWorkbook(source.filePath);
|
||||||
|
ctx.log("已关闭工作簿" + (save ? "(已保存)" : "(未保存)"), "info", node.id);
|
||||||
|
return { closed: true, saved: save };
|
||||||
|
});
|
||||||
|
|
||||||
|
register(["Excel_Cs_Load", "Excel_Cs_LoadV2"], async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheets = source.workbook.worksheets.map(w => w.name);
|
||||||
|
ctx.log("已加载工作簿:" + source.filePath + "(" + sheets.length + " 个 Sheet)", "info", node.id);
|
||||||
|
return { filePath: source.filePath, sheets };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Excel_WriteColumn", async (ctx, node, args) => {
|
||||||
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
|
const start = cellAddress(text(args, ["cellPos"], "A1").toUpperCase());
|
||||||
|
const valuesRaw = value(args, ["value"]);
|
||||||
|
const values = Array.isArray(valuesRaw) ? valuesRaw : [valuesRaw];
|
||||||
|
const flat = Array.isArray(values) ? values.flat() : [values];
|
||||||
|
flat.forEach((item, offset) => {
|
||||||
|
sheet.getRow(start.row + offset).getCell(start.column).value = item as ExcelJS.CellValue;
|
||||||
|
});
|
||||||
|
await saveWhenRequested(source, args, true);
|
||||||
|
ctx.log("已写入 " + flat.length + " 行(" + columnLetter(start.column) + start.row + " 起)", "info", node.id);
|
||||||
|
return { written: flat.length };
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
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 { RunContext } from "../engine/types";
|
||||||
|
import { getHandler } from "../engine/registry";
|
||||||
|
import "./office";
|
||||||
|
import "./extended";
|
||||||
|
import "./office-file-extended";
|
||||||
|
import "./office-aliases";
|
||||||
|
import "./excel-common";
|
||||||
|
import "./common-final";
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
const context = {} as RunContext;
|
||||||
|
const component = {} as CatalogComponent;
|
||||||
|
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||||
|
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||||
|
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
|
||||||
|
|
||||||
|
describe("excel shared workbook store", () => {
|
||||||
|
it("keeps widths/heights set by office-file-extended when excel-common saves afterwards", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||||
|
const handle = await run("Excel_NewExcel", { filePath: directory, name: "a.xlsx", isMakedirs: "True" }) as { path: string };
|
||||||
|
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称", "数量", "备注"], ["鼠标", 20, "A;B"]], isSave: "True" });
|
||||||
|
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A:C", width: "20", save: "True" });
|
||||||
|
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "26", save: "True" });
|
||||||
|
await run("Excel_Cs_MergeCells", { workbook: handle, sheet: "Sheet1", range: "A1:C1", merge: "0", save: "True" });
|
||||||
|
await run("Excel_Cs_SetRangeBackground", { workbook: handle, sheet: "Sheet1", range: "A1:C1", color: "FFF2CC", save: "True" });
|
||||||
|
await run("Excel_SetCellFontColor", { objExcel: handle, sheet: "Sheet1", cellPos: "A1", cellColor: "FF0000", isSave: "True" });
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
await wb.xlsx.readFile(handle.path);
|
||||||
|
const ws = wb.worksheets[0];
|
||||||
|
expect(ws.getColumn("A").width).toBe(20);
|
||||||
|
expect(ws.getRow(1).height).toBe(26);
|
||||||
|
expect(ws.model.merges).toContain("A1:C1");
|
||||||
|
expect((ws.getCell("A1").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FFFFF2CC");
|
||||||
|
expect((ws.getCell("A1").font?.color as { argb?: string } | undefined)?.argb).toBe("FFFF0000");
|
||||||
|
expect(ws.getCell("A1").value).toBe("名称");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps copied sheets created by office-file-extended when sheets are created and deleted later", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||||
|
const handle = await run("Excel_NewExcel", { filePath: directory, name: "b.xlsx", isMakedirs: "True" }) as { path: string };
|
||||||
|
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称"]], isSave: "True" });
|
||||||
|
await run("Excel_Cs_CopyV2", { workbook: handle, sheet: "Sheet1", newSheet: "Sheet1副本", override: "True", save: "True" });
|
||||||
|
await run("Excel_CreateSheet", { workbook: handle, sheet: "临时表", save: "True" });
|
||||||
|
await run("Excel_DeleteSheet", { workbook: handle, sheet: "临时表", save: "True" });
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
await wb.xlsx.readFile(handle.path);
|
||||||
|
expect(wb.worksheets.map((sheet) => sheet.name)).toEqual(["Sheet1", "Sheet1副本"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the shared instance usable after save-as switches the handle path", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||||
|
const handle = await run("Excel_NewExcel", { filePath: directory, name: "c.xlsx", isMakedirs: "True" }) as { path: string };
|
||||||
|
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A", width: "18", save: "True" });
|
||||||
|
const saved = await run("Excel_Cs_SaveAs", { workbook: handle, path: path.join(directory, "d.xlsx") }) as { path: string };
|
||||||
|
expect(saved.path).toBe(path.resolve(directory, "d.xlsx"));
|
||||||
|
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "30", save: "True" });
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
await wb.xlsx.readFile(path.resolve(directory, "d.xlsx"));
|
||||||
|
expect(wb.worksheets[0].getColumn("A").width).toBe(18);
|
||||||
|
expect(wb.worksheets[0].getRow(1).height).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// Excel 工作簿共享缓存:同一文件路径在整条流程中复用同一个 ExcelJS 实例。
|
||||||
|
// 此前 office.ts / office-file-extended.ts / excel-common.ts / extended.ts 各自持有
|
||||||
|
// 工作簿实例并整簿写回磁盘,后保存者会把先保存者的修改整体覆盖掉。
|
||||||
|
const cache = new Map<string, ExcelJS.Workbook>();
|
||||||
|
|
||||||
|
function keyOf(filePath: string): string {
|
||||||
|
return path.resolve(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按路径取工作簿:命中缓存直接复用,未命中从磁盘读取后登记。 */
|
||||||
|
export async function acquireWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
|
||||||
|
const key = keyOf(filePath);
|
||||||
|
let workbook = cache.get(key);
|
||||||
|
if (!workbook) {
|
||||||
|
workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(key);
|
||||||
|
cache.set(key, workbook);
|
||||||
|
}
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一个新建/另建的工作簿实例登记为该路径的共享实例。 */
|
||||||
|
export function publishWorkbook(filePath: string, workbook: ExcelJS.Workbook): ExcelJS.Workbook {
|
||||||
|
cache.set(keyOf(filePath), workbook);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放路径对应的共享实例(关闭工作簿时调用)。 */
|
||||||
|
export function releaseWorkbook(filePath: string): void {
|
||||||
|
cache.delete(keyOf(filePath));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import ExcelJS from "exceljs";
|
import ExcelJS from "exceljs";
|
||||||
import { register } from "../engine/registry";
|
import { register } from "../engine/registry";
|
||||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
|
import { acquireWorkbook } from "./excel-store";
|
||||||
|
|
||||||
type Table = unknown[][];
|
type Table = unknown[][];
|
||||||
|
|
||||||
@@ -243,8 +244,7 @@ function workbookPath(args: Record<string, unknown>, nodeId: string, componentId
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
|
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = await acquireWorkbook(workbookPath(args, nodeId, componentId));
|
||||||
await workbook.xlsx.readFile(workbookPath(args, nodeId, componentId));
|
|
||||||
const name = text(args, ["sheet", "sheetName"]);
|
const name = text(args, ["sheet", "sheetName"]);
|
||||||
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||||||
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
|
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ register("File_Write", async (_ctx, node, args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
register(["File_CreateFile"], async (_ctx, node, args) => {
|
register(["File_CreateFile"], async (_ctx, node, args) => {
|
||||||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
// catalog 字段为 folder_path + name(组合成完整路径),兼容旧键 path
|
||||||
|
const base = pick(args, ["folder_path", "path", "file", "file_path"]);
|
||||||
|
const name = pick(args, ["name"]);
|
||||||
|
const filePath = requirePath(name ? path.join(base, name) : base, "文件路径", node.id, node.componentId);
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.writeFile(filePath, "", { flag: "a" });
|
await fs.writeFile(filePath, "", { flag: "a" });
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -80,8 +83,15 @@ register("File_Copy", async (_ctx, node, args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
register(["File_File_Move", "File_Move"], async (_ctx, node, args) => {
|
register(["File_File_Move", "File_Move"], async (_ctx, node, args) => {
|
||||||
const source = requirePath(pick(args, ["pathSrc", "src", "source"]), "源路径", node.id, node.componentId);
|
// File_Move 的 catalog 字段为 file_type + file_path/folder_path + target_path(+name),与 File_File_Move 的 pathSrc/pathDest 兼容
|
||||||
const target = requirePath(pick(args, ["pathDest", "dest", "target"]), "目标路径", node.id, node.componentId);
|
const isFolder = String(args.file_type ?? "file") === "folder";
|
||||||
|
const source = requirePath(pick(args, ["pathSrc", isFolder ? "folder_path" : "file_path", "src", "source"]), "源路径", node.id, node.componentId);
|
||||||
|
let target = pick(args, ["pathDest", "dest"]);
|
||||||
|
if (!target) {
|
||||||
|
const destDir = pick(args, ["target_path"]);
|
||||||
|
const newName = truthy(args.rename) ? pick(args, ["name"]) : "";
|
||||||
|
target = requirePath(newName ? path.join(destDir, newName) : destDir, "目的地路径", node.id, node.componentId);
|
||||||
|
}
|
||||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||||
try {
|
try {
|
||||||
await fs.rename(source, target);
|
await fs.rename(source, target);
|
||||||
@@ -152,7 +162,8 @@ register(["File_PathSize", "File_PathSizeNew"], async (_ctx, node, args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
register("File_GetFileInfo", async (_ctx, node, args) => {
|
register("File_GetFileInfo", async (_ctx, node, args) => {
|
||||||
const target = requirePath(pick(args, ["path", "file"]), "路径", node.id, node.componentId);
|
// catalog 字段为 file_path,兼容 path/file
|
||||||
|
const target = requirePath(pick(args, ["file_path", "path", "file"]), "路径", node.id, node.componentId);
|
||||||
const stat = await fs.stat(target);
|
const stat = await fs.stat(target);
|
||||||
return {
|
return {
|
||||||
path: target,
|
path: target,
|
||||||
@@ -165,7 +176,8 @@ register("File_GetFileInfo", async (_ctx, node, args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
register("File_WaitFile", async (ctx, node, args) => {
|
register("File_WaitFile", async (ctx, node, args) => {
|
||||||
const target = requirePath(pick(args, ["path", "file"]), "文件路径", node.id, node.componentId);
|
// catalog 字段为 file_path,兼容 path/file
|
||||||
|
const target = requirePath(pick(args, ["file_path", "path", "file"]), "文件路径", node.id, node.componentId);
|
||||||
const timeout = Math.max(1, Number(args.time_out ?? args.timeout ?? 30)) * 1000;
|
const timeout = Math.max(1, Number(args.time_out ?? args.timeout ?? 30)) * 1000;
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
while (Date.now() - started < timeout) {
|
while (Date.now() - started < timeout) {
|
||||||
@@ -185,7 +197,8 @@ register("File_WaitFile", async (ctx, node, args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
register("File_CompressFile", async (_ctx, node, args) => {
|
register("File_CompressFile", async (_ctx, node, args) => {
|
||||||
const source = requirePath(pick(args, ["pathSrc", "path", "folder_path"]), "源路径", node.id, node.componentId);
|
// 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 target = requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包路径", node.id, node.componentId);
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
const stat = await fs.stat(source);
|
const stat = await fs.stat(source);
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Client } from "basic-ftp";
|
||||||
|
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 };
|
||||||
|
|
||||||
|
async function requireClient(nodeId: string): Promise<Client> {
|
||||||
|
if (flags.__rpaFtpClient) return flags.__rpaFtpClient;
|
||||||
|
throw new RuntimeError("validation_error", "尚未连接 FTP,请先使用 FTP_Connect", { nodeId, componentId: nodeId, recommendation: "先运行 FTP_Connect 建立连接" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(args: Record<string, unknown>, keys: string[], label: string, nodeId: string): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = String(args[key] ?? "").trim();
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
throw new RuntimeError("validation_error", label + "为空", { nodeId, componentId: nodeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
register("FTP_Connect", async (ctx, node, args) => {
|
||||||
|
const host = requireText(args, ["address"], "FTP 地址", node.id);
|
||||||
|
const port = Number(args.port ?? 21) || 21;
|
||||||
|
const user = String(args.user ?? "anonymous");
|
||||||
|
const password = String(args.pass_word ?? args.pass_word_file ?? "guest");
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
ctx.log("已断开 FTP 连接", "info", node.id);
|
||||||
|
return { disconnected: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_GetFileList", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const list = await client.list("");
|
||||||
|
const entries = list.map(item => ({ name: item.name, size: item.size, isDirectory: item.isDirectory, modifiedAt: item.modifiedAt instanceof Date ? item.modifiedAt.toISOString() : String(item.modifiedAt ?? "") }));
|
||||||
|
ctx.log("已获取文件列表:" + entries.length + " 项", "info", node.id);
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_Cwd", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const dir = requireText(args, ["dir"], "目标目录", node.id);
|
||||||
|
await client.cd(dir);
|
||||||
|
ctx.log("已切换工作目录:" + dir, "info", node.id);
|
||||||
|
return { cwd: dir };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_Download", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const remote = requireText(args, ["file_path"], "远程文件路径", node.id);
|
||||||
|
const localDir = String(args.folder_path ?? "").trim();
|
||||||
|
const target = requireText(args, ["target_path"], "本地保存路径", node.id) || (localDir ? path.join(localDir, path.basename(remote)) : path.basename(remote));
|
||||||
|
fs.mkdirSync(path.dirname(path.resolve(target)), { recursive: true });
|
||||||
|
if (String(args.file_type ?? "0") === "1") {
|
||||||
|
fs.mkdirSync(path.resolve(target), { recursive: true });
|
||||||
|
await client.ensureDir(remote);
|
||||||
|
await client.downloadToDir(target, remote);
|
||||||
|
} else {
|
||||||
|
await client.downloadTo(target, remote);
|
||||||
|
}
|
||||||
|
ctx.log("已下载到:" + target, "info", node.id);
|
||||||
|
return { downloaded: true, local: target };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_Upload", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const local = requireText(args, ["file_path"], "本地文件路径", node.id);
|
||||||
|
const remote = requireText(args, ["remote_path"], "远程目标路径", node.id);
|
||||||
|
if (String(args.file_type ?? "0") === "1" || fs.statSync(local).isDirectory()) {
|
||||||
|
await client.uploadFromDir(local, remote);
|
||||||
|
} else {
|
||||||
|
await client.uploadFrom(local, remote);
|
||||||
|
}
|
||||||
|
ctx.log("已上传:" + local + " → " + remote, "info", node.id);
|
||||||
|
return { uploaded: true, remote };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_Rename", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const from = requireText(args, ["file_path"], "原路径", node.id);
|
||||||
|
const to = requireText(args, ["toname"], "新名称", node.id);
|
||||||
|
const parent = path.posix.dirname(from);
|
||||||
|
await client.rename(from, parent === "." ? to : path.posix.join(parent, to));
|
||||||
|
ctx.log("已重命名:" + from + " → " + to, "info", node.id);
|
||||||
|
return { renamed: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_Delete", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const remote = requireText(args, ["file_path"], "远程路径", node.id);
|
||||||
|
if (String(args.file_type ?? "0") === "1") {
|
||||||
|
await client.removeDir(remote);
|
||||||
|
} else {
|
||||||
|
await client.remove(remote);
|
||||||
|
}
|
||||||
|
ctx.log("已删除:" + remote, "info", node.id);
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_CreateDir", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const dir = requireText(args, ["dirname"], "目录名", node.id);
|
||||||
|
await client.ensureDir(dir);
|
||||||
|
// 创建后进入该目录;必须 await,否则下个节点的命令与本次 cd 并发,
|
||||||
|
// basic-ftp 会以 "task while another one is still running" 关闭客户端
|
||||||
|
await client.cd(dir).catch(() => {});
|
||||||
|
ctx.log("已创建目录:" + dir, "info", node.id);
|
||||||
|
return { created: true, dir };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("FTP_Exist", async (ctx, node, args) => {
|
||||||
|
const client = await requireClient(node.id);
|
||||||
|
const target = requireText(args, ["path"], "远程路径", node.id);
|
||||||
|
const parent = path.posix.dirname(target);
|
||||||
|
const name = path.posix.basename(target);
|
||||||
|
const list = await client.list(parent === "." ? "" : parent);
|
||||||
|
const exists = list.some(item => item.name === name);
|
||||||
|
return { exists, path: target };
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { BrowserWindow } from "electron";
|
||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError } from "../engine/types";
|
||||||
|
|
||||||
|
interface FormFieldSpec { key?: string; name?: string; label?: string; type?: string; value?: string; options?: unknown[] }
|
||||||
|
|
||||||
|
function parseFields(raw: unknown, nodeId: string): FormFieldSpec[] {
|
||||||
|
if (raw === undefined || raw === null || raw === "") {
|
||||||
|
throw new RuntimeError("validation_error", "表单配置 jsonlist 为空", { nodeId, componentId: nodeId, recommendation: "填写 JSON 数组,例如 [{\"key\":\"name\",\"label\":\"名称\",\"type\":\"text\"}]" });
|
||||||
|
}
|
||||||
|
if (Array.isArray(raw)) return raw as FormFieldSpec[];
|
||||||
|
const text = String(raw).trim();
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (Array.isArray(parsed)) return parsed as FormFieldSpec[];
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
throw new RuntimeError("validation_error", "表单配置 jsonlist 无效", { nodeId, componentId: nodeId, recommendation: "填写 JSON 数组,例如 [{\"key\":\"name\",\"label\":\"名称\",\"type\":\"text\"}]" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldKey(f: FormFieldSpec, index: number): string {
|
||||||
|
return String(f.key ?? f.name ?? f.label ?? "field" + (index + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showForm(options: { title: string; fields: FormFieldSpec[]; timeoutSec: number }): Promise<Record<string, string>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rows = options.fields.map((f, i) => {
|
||||||
|
const key = escapeHtml(fieldKey(f, i));
|
||||||
|
const label = escapeHtml(f.label ?? fieldKey(f, i));
|
||||||
|
const value = escapeHtml(String(f.value ?? ""));
|
||||||
|
const type = String(f.type ?? "text").toLowerCase();
|
||||||
|
if (type === "select" || (Array.isArray(f.options) && f.options.length)) {
|
||||||
|
const opts = (f.options ?? []).map(o => {
|
||||||
|
const ov = typeof o === "string" ? o : String((o as { value?: string }).value ?? (o as { label?: string }).label ?? "");
|
||||||
|
const ol = typeof o === "string" ? o : String((o as { label?: string }).label ?? ov);
|
||||||
|
return '<option value="' + escapeHtml(ov) + '"' + (ov === String(f.value ?? "") ? " selected" : "") + ">" + escapeHtml(ol) + "</option>";
|
||||||
|
}).join("");
|
||||||
|
return "<label>" + label + '<br/><select data-key="' + key + '">' + opts + "</select></label><br/>";
|
||||||
|
}
|
||||||
|
if (type === "textarea") {
|
||||||
|
return "<label>" + label + '<br/><textarea data-key="' + key + '" rows="3">' + value + "</textarea></label><br/>";
|
||||||
|
}
|
||||||
|
if (type === "checkbox") {
|
||||||
|
const checked = String(f.value ?? "") === "true" || String(f.value ?? "") === "1" ? " checked" : "";
|
||||||
|
return '<label><input type="checkbox" data-key="' + key + '"' + checked + " /> " + label + "</label><br/>";
|
||||||
|
}
|
||||||
|
const inputType = type === "password" ? "password" : "text";
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
let settled = false;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
const finish = (value: Record<string, string>) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
if (!win.isDestroyed()) win.destroy();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const values = await win.webContents.executeJavaScript("window.__formValues || null", true);
|
||||||
|
if (values) finish(values as Record<string, string>);
|
||||||
|
} catch {
|
||||||
|
finish({});
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
try { void win.webContents.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {
|
||||||
|
finish({});
|
||||||
|
}
|
||||||
|
}, Math.max(5, options.timeoutSec) * 1000);
|
||||||
|
win.on("closed", () => finish({}));
|
||||||
|
void win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIALOG_IDS = [
|
||||||
|
"Dialog_GetConfig", "Dialog_GetConfigV1", "Dialog_GetConfigV2", "Dialog_GetConfigV3",
|
||||||
|
"Dialog_GetConfigV4", "Dialog_GetConfigV5", "Dialog_GetConfigV6", "Dialog_GetConfigV7", "Dialog_GetConfigV8"
|
||||||
|
];
|
||||||
|
|
||||||
|
register(DIALOG_IDS, async (ctx, node, args) => {
|
||||||
|
const fields = parseFields(args.jsonlist, node.id);
|
||||||
|
const title = String(args.title ?? args.configName ?? "请输入");
|
||||||
|
const timeoutSec = Number(args.wait_time ?? 30) || 30;
|
||||||
|
const values = await showForm({ title, fields, timeoutSec });
|
||||||
|
ctx.log("已获取人机交互表单输入:" + JSON.stringify(values), "info", node.id);
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
|
||||||
@@ -6,8 +6,19 @@ import "./browser";
|
|||||||
import "./browser-extended";
|
import "./browser-extended";
|
||||||
import "./browser-dom";
|
import "./browser-dom";
|
||||||
import "./browser-common";
|
import "./browser-common";
|
||||||
|
import "./browser-control";
|
||||||
import "./office";
|
import "./office";
|
||||||
import "./desktop";
|
import "./desktop";
|
||||||
|
import "./desktop-uia";
|
||||||
|
import "./web-batch2";
|
||||||
|
import "./web-crawl";
|
||||||
|
import "./human-dialog";
|
||||||
|
import "./time-caps";
|
||||||
|
import "./windows-system";
|
||||||
|
import "./ftp";
|
||||||
|
import "./ai-service";
|
||||||
|
import "./ai-caps";
|
||||||
|
import "./ocr-caps";
|
||||||
import "./pdf";
|
import "./pdf";
|
||||||
import "./word";
|
import "./word";
|
||||||
import "./word-extended";
|
import "./word-extended";
|
||||||
@@ -17,9 +28,12 @@ import "./extended";
|
|||||||
import "./system-extended";
|
import "./system-extended";
|
||||||
import "./office-file-extended";
|
import "./office-file-extended";
|
||||||
import "./office-aliases";
|
import "./office-aliases";
|
||||||
|
import "./excel-common";
|
||||||
import "./window-extended";
|
import "./window-extended";
|
||||||
import "./data-extended";
|
import "./data-extended";
|
||||||
import "./utility-extended";
|
import "./utility-extended";
|
||||||
import "./data-table-common";
|
import "./data-table-common";
|
||||||
|
import "./common-final";
|
||||||
|
|
||||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||||
|
|
||||||
@@ -27,14 +27,18 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
|
|||||||
const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25"));
|
const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25"));
|
||||||
const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False"));
|
const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False"));
|
||||||
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
|
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
|
||||||
const result = await transporter.sendMail({
|
try {
|
||||||
from: account,
|
const result = await transporter.sendMail({
|
||||||
to: list(text(args, ["to", "receiver"])),
|
from: account,
|
||||||
cc: list(text(args, ["Cc", "cc"])),
|
to: list(text(args, ["to", "receiver"])),
|
||||||
bcc: list(text(args, ["Bcc", "bcc"])),
|
cc: list(text(args, ["Cc", "cc"])),
|
||||||
subject: text(args, ["subject"]),
|
bcc: list(text(args, ["Bcc", "bcc"])),
|
||||||
text: text(args, ["content", "body"]),
|
subject: text(args, ["subject"]),
|
||||||
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
|
text: text(args, ["content", "body"]),
|
||||||
});
|
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
|
||||||
return { messageId: result.messageId, accepted: result.accepted };
|
});
|
||||||
|
return { messageId: result.messageId, accepted: result.accepted };
|
||||||
|
} finally {
|
||||||
|
transporter.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError } from "../engine/types";
|
||||||
|
|
||||||
|
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[] }> };
|
||||||
|
const mod = await import("@gutenye/ocr-node");
|
||||||
|
const instance = await mod.default.create();
|
||||||
|
ocrInstance = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireImage(args: Record<string, unknown>, nodeId: string): string {
|
||||||
|
for (const key of ["image_path", "imagePath", "file_path", "filePath", "path", "image", "pictureSource"]) {
|
||||||
|
const v = String(args[key] ?? "").trim();
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
throw new RuntimeError("validation_error", "图片路径为空", { nodeId, componentId: nodeId, recommendation: "填写本地图片路径(png/jpg/bmp)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxToRect(box: number[][] | undefined): { x: number; y: number; width: number; height: number } {
|
||||||
|
const points = box ?? [];
|
||||||
|
const xs = points.map(p => p[0]);
|
||||||
|
const ys = points.map(p => p[1]);
|
||||||
|
const minX = Math.min(...(xs.length ? xs : [0]));
|
||||||
|
const minY = Math.min(...(ys.length ? ys : [0]));
|
||||||
|
const maxX = Math.max(...(xs.length ? xs : [0]));
|
||||||
|
const maxY = Math.max(...(ys.length ? ys : [0]));
|
||||||
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||||
|
}
|
||||||
|
|
||||||
|
register("OcrOffline_GetOcrTextNew", async (ctx, node, args) => {
|
||||||
|
const imagePath = requireImage(args, node.id);
|
||||||
|
const ocr = await getOcr();
|
||||||
|
const result = await ocr.detect(imagePath);
|
||||||
|
const text = result.texts.map(t => t.text).join("\n");
|
||||||
|
ctx.log("OCR 识别完成:" + text.length + " 字符 / " + result.texts.length + " 行", "info", node.id);
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("OcrOffline_GetOcrTextPosition", async (ctx, node, args) => {
|
||||||
|
const imagePath = requireImage(args, node.id);
|
||||||
|
const ocr = await getOcr();
|
||||||
|
const result = await ocr.detect(imagePath);
|
||||||
|
const words = result.texts.map(t => {
|
||||||
|
const rect = boxToRect(t.box);
|
||||||
|
return { text: t.text, x: rect.x, y: rect.y, width: rect.width, height: rect.height, confidence: t.mean };
|
||||||
|
});
|
||||||
|
ctx.log("OCR 定位完成:" + words.length + " 行", "info", node.id);
|
||||||
|
return words;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("OcrOffline_GetOcrTable", async (ctx, node, args) => {
|
||||||
|
const imagePath = requireImage(args, node.id);
|
||||||
|
const ocr = await getOcr();
|
||||||
|
const result = await ocr.detect(imagePath);
|
||||||
|
const sorted = [...result.texts].sort((a, b) => {
|
||||||
|
const ay = a.box ? Math.min(...a.box.map(p => p[1])) : 0;
|
||||||
|
const by = b.box ? Math.min(...b.box.map(p => p[1])) : 0;
|
||||||
|
return ay - by;
|
||||||
|
});
|
||||||
|
const rows = sorted.map(t => t.text);
|
||||||
|
ctx.log("OCR 表格识别完成:" + rows.length + " 行", "info", node.id);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { readFile, mkdtemp, rm } from "node:fs/promises";
|
import { readFile, mkdtemp, rm } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
|
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
|
||||||
import type { RunContext } from "../engine/types";
|
import type { RunContext } from "../engine/types";
|
||||||
@@ -19,7 +21,7 @@ afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursiv
|
|||||||
|
|
||||||
describe("common Excel aliases", () => {
|
describe("common Excel aliases", () => {
|
||||||
it("registers only real catalog aliases", async () => {
|
it("registers only real catalog aliases", async () => {
|
||||||
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
const catalog = JSON.parse(await readFile(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../resources/catalog.json"), "utf8")) as Catalog;
|
||||||
const ids = new Set(catalog.components.map((item) => item.id));
|
const ids = new Set(catalog.components.map((item) => item.id));
|
||||||
expect(EXCEL_COMMON_ALIASES.length).toBeGreaterThanOrEqual(25);
|
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(); }
|
for (const id of EXCEL_COMMON_ALIASES) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); }
|
||||||
@@ -31,4 +33,85 @@ describe("common Excel aliases", () => {
|
|||||||
await run("Excel_WriteCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A", content: "alias", save: "1" });
|
await run("Excel_WriteCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A", content: "alias", save: "1" });
|
||||||
await expect(run("Excel_ReadCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A" })).resolves.toBe("alias");
|
await expect(run("Excel_ReadCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A" })).resolves.toBe("alias");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts the Excel_NewExcel catalog keys (filePath / isMakedirs)", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-new-")); dirs.push(directory);
|
||||||
|
const handle = await run("Excel_NewExcel", { filePath: directory, name: "catalog-keys.xlsx", isMakedirs: "True" }) as { path: string };
|
||||||
|
expect(handle.path).toBe(path.resolve(directory, "catalog-keys.xlsx"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing directory when isMakedirs is False", async () => {
|
||||||
|
const directory = path.join(await mkdtemp(path.join(os.tmpdir(), "rpa-excel-new-")), "missing");
|
||||||
|
dirs.push(directory);
|
||||||
|
await expect(run("Excel_NewExcel", { filePath: directory, name: "none.xlsx", isMakedirs: "False" })).rejects.toThrow("目录不存在");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a workbook through the Excel_OpenExcel filePath key", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-open-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "open-me.xlsx" }) as { path: string };
|
||||||
|
await expect(run("Excel_OpenExcel", { filePath: created.path })).resolves.toMatchObject({ path: created.path });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames a sheet through the Excel_RenameSheet newSheet key", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-rename-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "rename-me.xlsx" }) as { path: string };
|
||||||
|
await expect(run("Excel_RenameSheet", { objExcel: created, sheet: "Sheet1", newSheet: "库存表", isSave: "True" })).resolves.toBeTruthy();
|
||||||
|
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||||
|
await expect(run("Excel_GetSheetsName", { objExcel: reopened })).resolves.toEqual(["库存表"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a range through the Excel_WriteRange catalog keys (objExcel/cellPos/data/isSave)", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-range-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "range.xlsx" }) as { path: string };
|
||||||
|
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "B2", data: [[1, "壹"], [2, "贰"]], isSave: "True" });
|
||||||
|
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||||
|
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "B2:C3" })).resolves.toEqual([[1, "壹"], [2, "贰"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves as through the Excel_SaveAs catalog keys (filename/isAbsolutePath)", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "origin.xlsx" }) as { path: string };
|
||||||
|
await run("Excel_WriteCell", { workbook: created, sheet: "Sheet1", row: 1, column: "A", content: "saveas" });
|
||||||
|
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: path.join(directory, "legacy-saveas.xlsx"), isAbsolutePath: "True" }) as { path: string };
|
||||||
|
expect(savedAs.path).toBe(path.resolve(directory, "legacy-saveas.xlsx"));
|
||||||
|
const reopened = await run("Excel_OpenExcel", { filePath: savedAs.path }) as { id: string };
|
||||||
|
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1" })).resolves.toEqual([["saveas"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a relative Excel_SaveAs filename against the original workbook directory", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-rel-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "rel-origin.xlsx" }) as { path: string };
|
||||||
|
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: "rel-target.xlsx", isAbsolutePath: "False" }) as { path: string };
|
||||||
|
expect(savedAs.path).toBe(path.resolve(directory, "rel-target.xlsx"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines the Excel_Cs_SaveAs directory path with the name field", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-cs-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "cs-origin.xlsx" }) as { path: string };
|
||||||
|
const targetDir = path.join(directory, "nested");
|
||||||
|
const savedAs = await run("Excel_Cs_SaveAs", { workbook: created, path: targetDir, name: "cs-target.xlsx" }) as { path: string };
|
||||||
|
expect(savedAs.path).toBe(path.resolve(targetDir, "cs-target.xlsx"));
|
||||||
|
const reopened = new ExcelJS.Workbook();
|
||||||
|
await reopened.xlsx.readFile(savedAs.path);
|
||||||
|
expect(reopened.worksheets.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a range through the Excel_ClearRange cellPos key", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear.xlsx" }) as { path: string };
|
||||||
|
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["甲", "乙"], ["丙", "丁"]] });
|
||||||
|
await run("Excel_ClearRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1:B2", isFormat: "False", isSave: "True" });
|
||||||
|
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||||
|
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B2" })).resolves.toEqual([["", ""], ["", ""]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears through the Excel_Cs_ClearRange startRow/startColumn keys", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-cs-")); dirs.push(directory);
|
||||||
|
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear-cs.xlsx" }) as { path: string };
|
||||||
|
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["1", "2"], ["3", "4"]] });
|
||||||
|
await run("Excel_Cs_ClearRange", { objExcel: created, sheet: "Sheet1", startRow: "2", startColumn: "A", lastRow: "2", lastColumn: "B", mode: "内容", save: "True" });
|
||||||
|
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||||
|
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A2:B2" })).resolves.toEqual([["", ""]]);
|
||||||
|
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B1" })).resolves.toEqual([["1", "2"]]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { PDFDocument } from "pdf-lib";
|
import { PDFDocument } from "pdf-lib";
|
||||||
import { register } from "../engine/registry";
|
import { register } from "../engine/registry";
|
||||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
|
import { acquireWorkbook } from "./excel-store";
|
||||||
|
|
||||||
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||||||
|
|
||||||
@@ -29,8 +30,7 @@ function requiredPath(candidate: unknown, label: string, nodeId: string, compone
|
|||||||
|
|
||||||
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
|
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
|
||||||
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId);
|
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId);
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = await acquireWorkbook(filePath);
|
||||||
await workbook.xlsx.readFile(filePath);
|
|
||||||
return { workbook, filePath };
|
return { workbook, filePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +134,12 @@ const readColumn: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
const sheet = worksheetOf(source.workbook, args);
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A"));
|
const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A"));
|
||||||
const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1)));
|
const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1)));
|
||||||
return Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value));
|
const values = Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value));
|
||||||
|
// 触碰过尾部空行(如定位空单元格)会撑大 rowCount,裁掉尾部空值避免误报
|
||||||
|
while (values.length > 0 && (values[values.length - 1] === "" || values[values.length - 1] === null || values[values.length - 1] === undefined)) values.pop();
|
||||||
|
return values;
|
||||||
};
|
};
|
||||||
register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);
|
register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
|
||||||
|
|
||||||
register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
|
|
||||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
const sheet = worksheetOf(source.workbook, args);
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
const mode = text(args, ["mode"]);
|
const mode = text(args, ["mode"]);
|
||||||
@@ -163,12 +164,30 @@ register("Excel_Cs_DeleteColumn", async (_ctx, node, args) => {
|
|||||||
register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => {
|
register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => {
|
||||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||||
const sheet = worksheetOf(source.workbook, args);
|
const sheet = worksheetOf(source.workbook, args);
|
||||||
const range = text(args, ["excelRng", "range"]);
|
// 兼容各别名版本的键:V4=excelRng、V2/V3=range、Legacy Excel_ClearRange=cellPos、V1=startRow/startColumn/lastRow/lastColumn
|
||||||
|
let range = text(args, ["excelRng", "range", "cellPos"]);
|
||||||
|
if (!range) {
|
||||||
|
const startRow = text(args, ["startRow"]);
|
||||||
|
const startColumn = text(args, ["startColumn"]);
|
||||||
|
if (startRow && startColumn) {
|
||||||
|
const lastRow = text(args, ["lastRow"], startRow);
|
||||||
|
const lastColumn = text(args, ["lastColumn"], startColumn);
|
||||||
|
range = `${startColumn}${startRow}:${lastColumn}${lastRow}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||||||
const [start, end = start] = range.split(":");
|
const [start, end = start] = range.split(":");
|
||||||
const first = sheet.getCell(start);
|
const first = sheet.getCell(start);
|
||||||
const last = sheet.getCell(end);
|
const last = sheet.getCell(end);
|
||||||
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) sheet.getCell(row, column).value = null;
|
// 清除模式:全部=内容+格式,格式=仅格式,其余(内容/默认)=仅内容;Legacy isFormat=True 同样清格式
|
||||||
|
const mode = text(args, ["clearType", "mode"], "内容");
|
||||||
|
const clearFormat = mode.includes("全部") || mode.includes("格式") || truthy(value(args, ["isFormat"]));
|
||||||
|
const clearValue = !mode.includes("格式");
|
||||||
|
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) {
|
||||||
|
const cell = sheet.getCell(row, column);
|
||||||
|
if (clearValue) cell.value = null;
|
||||||
|
if (clearFormat) cell.style = {};
|
||||||
|
}
|
||||||
await saveWhenRequested(source, args);
|
await saveWhenRequested(source, args);
|
||||||
return range;
|
return range;
|
||||||
});
|
});
|
||||||
@@ -223,7 +242,8 @@ register("Excel_Cs_CopyV2", async (_ctx, node, args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function parsePdfPages(raw: string, pageCount: number): number[] {
|
function parsePdfPages(raw: string, pageCount: number): number[] {
|
||||||
if (!raw.trim()) return Array.from({ length: pageCount }, (_, index) => index);
|
// 页码留空时返回空集:由调用方抛出「No valid PDF pages」,避免静默复制全本
|
||||||
|
if (!raw.trim()) return [];
|
||||||
const pages = new Set<number>();
|
const pages = new Set<number>();
|
||||||
for (const value of parseList(raw, Number)) if (value >= 1 && value <= pageCount) pages.add(value - 1);
|
for (const value of parseList(raw, Number)) if (value >= 1 && value <= pageCount) pages.add(value - 1);
|
||||||
return [...pages];
|
return [...pages];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { register } from "../engine/registry";
|
import { register } from "../engine/registry";
|
||||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||||
|
import { acquireWorkbook, publishWorkbook, releaseWorkbook } from "./excel-store";
|
||||||
|
|
||||||
interface ExcelHandle {
|
interface ExcelHandle {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,6 +25,7 @@ function text(args: Record<string, unknown>, keys: string[], fallback = ""): str
|
|||||||
|
|
||||||
function bool(valueToRead: unknown, fallback = false): boolean {
|
function bool(valueToRead: unknown, fallback = false): boolean {
|
||||||
if (valueToRead === undefined || valueToRead === null || valueToRead === "") return fallback;
|
if (valueToRead === undefined || valueToRead === null || valueToRead === "") return fallback;
|
||||||
|
if (typeof valueToRead === "string") return ["true", "1", "yes", "是", "开启"].includes(valueToRead.trim().toLowerCase());
|
||||||
return [true, "true", "1", 1, "是", "开启"].includes(valueToRead as never);
|
return [true, "true", "1", 1, "是", "开启"].includes(valueToRead as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ function required(valueToRead: unknown, label: string, nodeId: string, component
|
|||||||
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle {
|
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle {
|
||||||
const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
|
const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
|
||||||
workbooks.set(handle.id, workbook);
|
workbooks.set(handle.id, workbook);
|
||||||
|
publishWorkbook(filePath, workbook);
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +46,12 @@ async function resolveWorkbook(args: Record<string, unknown>, nodeId: string, co
|
|||||||
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]);
|
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]);
|
||||||
const id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
|
const id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
|
||||||
const existing = id ? workbooks.get(id) : undefined;
|
const existing = id ? workbooks.get(id) : undefined;
|
||||||
if (existing) return { handle: { id, path: String((raw as Partial<ExcelHandle>).path || "") }, workbook: existing };
|
// 直接返回流程变量持有的原始句柄对象:saveAs 等处理器对其 path 的改写需要
|
||||||
|
// 同步反映到后续节点引用的同一个对象上
|
||||||
|
if (existing) return { handle: raw as ExcelHandle, workbook: existing };
|
||||||
const filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
|
const filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = await acquireWorkbook(filePath);
|
||||||
await workbook.xlsx.readFile(filePath);
|
return { handle: { id, path: filePath }, workbook };
|
||||||
return { handle: newHandle(filePath, workbook), workbook };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
|
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
|
||||||
@@ -101,9 +105,17 @@ function rowsFrom(valueToRead: unknown): unknown[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newExcel: CapabilityHandler = async (_ctx, node, args) => {
|
const newExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
const folder = required(value(args, ["path", "folder"]), "Excel目录", node.id, node.componentId);
|
const folder = required(value(args, ["filePath", "path", "folder"]), "文件路径", node.id, node.componentId);
|
||||||
const name = text(args, ["name"], "新建工作簿.xlsx");
|
const name = text(args, ["name"], "新建工作簿.xlsx");
|
||||||
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.xlsx`);
|
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.xlsx`);
|
||||||
|
const makedirs = bool(value(args, ["isMakedirs"]), true);
|
||||||
|
if (!makedirs) {
|
||||||
|
try {
|
||||||
|
await fs.access(path.dirname(filePath));
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeError("validation_error", `目录不存在:${path.dirname(filePath)}`, { nodeId: node.id, componentId: node.componentId, recommendation: "将「是否创建该路径」设为 True,或先创建目录" });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!bool(value(args, ["override"]), true)) {
|
if (!bool(value(args, ["override"]), true)) {
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath);
|
await fs.access(filePath);
|
||||||
@@ -120,10 +132,9 @@ const newExcel: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openExcel: CapabilityHandler = async (_ctx, node, args) => {
|
const openExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
const filePath = required(value(args, ["file", "path"]), "Excel文件", node.id, node.componentId);
|
const filePath = path.resolve(required(value(args, ["file", "filePath", "path"]), "Excel文件", node.id, node.componentId));
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = await acquireWorkbook(filePath);
|
||||||
await workbook.xlsx.readFile(path.resolve(filePath));
|
return newHandle(filePath, workbook);
|
||||||
return newHandle(path.resolve(filePath), workbook);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const readExcel: CapabilityHandler = async (_ctx, node, args) => {
|
const readExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
@@ -144,14 +155,18 @@ const writeCell: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
|
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||||
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
||||||
const start = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
|
const cellPos = text(args, ["cellPos"]);
|
||||||
|
const cellMatch = cellPos.match(/^([A-Za-z]+)(\d+)$/);
|
||||||
|
const column = cellMatch ? cellMatch[1] : text(args, ["column"], "A");
|
||||||
|
const row = cellMatch ? cellMatch[2] : text(args, ["row"], "1");
|
||||||
|
const start = `${column}${row}`;
|
||||||
const startCell = sheet.getCell(start);
|
const startCell = sheet.getCell(start);
|
||||||
const startRow = startCell.row;
|
const startRow = startCell.row;
|
||||||
const startColumn = startCell.col;
|
const startColumn = startCell.col;
|
||||||
for (const [rowIndex, row] of rowsFrom(value(args, ["contents", "table"])).entries()) {
|
for (const [rowIndex, row] of rowsFrom(value(args, ["contents", "data", "table"])).entries()) {
|
||||||
for (const [columnIndex, cell] of row.entries()) sheet.getCell(startRow + rowIndex, startColumn + columnIndex).value = cell as ExcelJS.CellValue;
|
for (const [columnIndex, cell] of row.entries()) sheet.getCell(startRow + rowIndex, startColumn + columnIndex).value = cell as ExcelJS.CellValue;
|
||||||
}
|
}
|
||||||
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
|
if (bool(value(args, ["save", "isSave"]), true)) await workbook.xlsx.writeFile(handle.path);
|
||||||
return handle;
|
return handle;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,10 +178,20 @@ const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
|
|||||||
|
|
||||||
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
|
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||||
const target = required(value(args, ["path", "file", "savePath"]), "另存为路径", node.id, node.componentId);
|
// CS 版(Excel_Cs_SaveAs)用 path=目录 + name=文件名;Legacy(Excel_SaveAs)用 filename=完整路径
|
||||||
handle.path = path.resolve(target);
|
const rawName = text(args, ["name"]);
|
||||||
|
const raw = rawName
|
||||||
|
? path.resolve(text(args, ["path", "file", "savePath"], ""), rawName)
|
||||||
|
: required(value(args, ["path", "file", "savePath", "filename", "saveFilePath"]), "另存为路径", node.id, node.componentId);
|
||||||
|
// Legacy Excel_SaveAs 用 isAbsolutePath 标记 filename 是否为绝对路径,相对路径基于原文件目录
|
||||||
|
const absolute = rawName ? true : bool(value(args, ["isAbsolutePath"]), true);
|
||||||
|
const previousPath = handle.path;
|
||||||
|
handle.path = absolute ? path.resolve(raw) : path.resolve(path.dirname(previousPath), raw);
|
||||||
await fs.mkdir(path.dirname(handle.path), { recursive: true });
|
await fs.mkdir(path.dirname(handle.path), { recursive: true });
|
||||||
await workbook.xlsx.writeFile(handle.path);
|
await workbook.xlsx.writeFile(handle.path);
|
||||||
|
// 另存后同一实例以新路径登记,旧路径的缓存不再代表最新内容,释放之
|
||||||
|
publishWorkbook(handle.path, workbook);
|
||||||
|
if (path.resolve(previousPath) !== handle.path) releaseWorkbook(previousPath);
|
||||||
return handle;
|
return handle;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,7 +209,7 @@ register(["Excel_GetSheetsName", "Excel_GetSheetNames"], async (_ctx, node, args
|
|||||||
register(["Excel_Cs_Rename", "Excel_RenameSheet"], async (_ctx, node, args) => {
|
register(["Excel_Cs_Rename", "Excel_RenameSheet"], async (_ctx, node, args) => {
|
||||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||||
const oldName = required(value(args, ["sheet", "oldName"]), "原 Sheet 名称", node.id, node.componentId);
|
const oldName = required(value(args, ["sheet", "oldName"]), "原 Sheet 名称", node.id, node.componentId);
|
||||||
const newName = required(value(args, ["newName", "name", "sheetName"]), "新 Sheet 名称", node.id, node.componentId);
|
const newName = required(value(args, ["newSheet", "newName", "name", "sheetName"]), "新 Sheet 名称", node.id, node.componentId);
|
||||||
const sheet = sheetOf(workbook, oldName);
|
const sheet = sheetOf(workbook, oldName);
|
||||||
sheet.name = newName;
|
sheet.name = newName;
|
||||||
await workbook.xlsx.writeFile(handle.path);
|
await workbook.xlsx.writeFile(handle.path);
|
||||||
|
|||||||
@@ -75,21 +75,30 @@ register("System_GetSystemPath", (_ctx, _node, args) => {
|
|||||||
const getPids: CapabilityHandler = async (_ctx, node, args) => {
|
const getPids: CapabilityHandler = async (_ctx, node, args) => {
|
||||||
const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId));
|
const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId));
|
||||||
const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`);
|
const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`);
|
||||||
return output.split(/\r?\n/).map(Number).filter(Number.isFinite);
|
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||||
|
.map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0);
|
||||||
};
|
};
|
||||||
register("App_GetPids", getPids);
|
register("App_GetPids", getPids);
|
||||||
register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null);
|
register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null);
|
||||||
|
|
||||||
register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => {
|
register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => {
|
||||||
const pid = Number(value(args, ["pid"]));
|
const mode = node.componentId === "App_Kill" ? "id" : value(args, ["close_type"], "id");
|
||||||
const name = value(args, ["p_name", "pattern"]);
|
if (mode === "id") {
|
||||||
if (Number.isFinite(pid) && pid > 0) {
|
const rawPid = args.pid;
|
||||||
|
const pid = typeof rawPid === "number" || typeof rawPid === "string" ? Number(rawPid) : NaN;
|
||||||
|
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||||
|
throw new RuntimeError("validation_error", "进程 PID 必须是正整数", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
}
|
||||||
if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId });
|
if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId });
|
||||||
await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`);
|
await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`);
|
||||||
return pid;
|
return pid;
|
||||||
}
|
}
|
||||||
const pattern = processPattern(required({ name }, ["name"], "进程名称", node.id, node.componentId));
|
if (mode !== "name") throw new RuntimeError("validation_error", "请选择按 PID 或按进程名称关闭", { nodeId: node.id, componentId: node.componentId });
|
||||||
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Stop-Process -Force -ErrorAction Stop`);
|
const pattern = processPattern(required(args, ["p_name", "pattern"], "进程名称", node.id, node.componentId));
|
||||||
|
if (pattern === "*") throw new RuntimeError("validation_error", "关闭应用程序必须指定具体的进程名称", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
const allSessions = [true, 1, "1", "true", "True"].includes(args.kill_all as never);
|
||||||
|
const session = allSessions ? "" : " -and $_.SessionId -eq [System.Diagnostics.Process]::GetCurrentProcess().SessionId";
|
||||||
|
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} -and $_.Id -ne ${process.pid}${session} } | Stop-Process -Force -ErrorAction Stop`);
|
||||||
return pattern;
|
return pattern;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { register } from "../engine/registry";
|
||||||
|
|
||||||
|
function part(args: Record<string, unknown>, get: (d: Date) => number): number {
|
||||||
|
const ts = Number(args.timestamp);
|
||||||
|
const d = Number.isFinite(ts) && ts > 0 ? new Date(ts) : new Date();
|
||||||
|
return get(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
register("Time_Year", async (_ctx, _node, args) => part(args, d => d.getFullYear()));
|
||||||
|
register("Time_Month", async (_ctx, _node, args) => part(args, d => d.getMonth() + 1));
|
||||||
|
register("Time_Day", async (_ctx, _node, args) => part(args, d => d.getDate()));
|
||||||
|
register("Time_Hour", async (_ctx, _node, args) => part(args, d => d.getHours()));
|
||||||
|
register("Time_Minute", async (_ctx, _node, args) => part(args, d => d.getMinutes()));
|
||||||
|
register("Time_Second", async (_ctx, _node, args) => part(args, d => d.getSeconds()));
|
||||||
|
register("Time_WeekDay", async (_ctx, _node, args) => part(args, d => { const w = d.getDay(); return w === 0 ? 7 : w; }));
|
||||||
@@ -79,6 +79,20 @@ describe("utility extended capabilities", () => {
|
|||||||
await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok");
|
await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("executes BAT files whose path contains spaces and Chinese characters", async () => {
|
||||||
|
if (process.platform !== "win32") return;
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-中文 空格-"));
|
||||||
|
directories.push(directory);
|
||||||
|
const file = path.join(directory, "测试 脚本.bat");
|
||||||
|
await writeFile(file, "@echo RPA-BAT-PATH-OK\r\n", "ascii");
|
||||||
|
await expect(run("System_ExecuteCommandV1", { command_type: "2", bat_file: file, get_ret: "1" })).resolves.toBe("RPA-BAT-PATH-OK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps PowerShell command output in UTF-8", async () => {
|
||||||
|
if (process.platform !== "win32") return;
|
||||||
|
await expect(run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output '中文输出测试'", get_ret: "1" })).resolves.toBe("中文输出测试");
|
||||||
|
});
|
||||||
|
|
||||||
it("compares local text files with structured line changes", async () => {
|
it("compares local text files with structured line changes", async () => {
|
||||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-"));
|
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-"));
|
||||||
directories.push(directory);
|
directories.push(directory);
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ register("Basic_GetProcessInfo", (ctx) => ({
|
|||||||
|
|
||||||
register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
||||||
const mode = String(args.command_type || "1");
|
const mode = String(args.command_type || "1");
|
||||||
|
if (!["1", "2", "3"].includes(mode)) throw new RuntimeError("validation_error", "请选择 DOS、BAT 或 PowerShell 命令类型", { nodeId: node.id, componentId: node.componentId });
|
||||||
const cwd = String(args.cwd || "").trim() || undefined;
|
const cwd = String(args.cwd || "").trim() || undefined;
|
||||||
const timeout = Math.max(1_000, Number(args.timeoutMs || 120_000));
|
const timeout = Math.max(1_000, Number(args.timeoutMs || 120_000));
|
||||||
let executable: string;
|
let executable: string;
|
||||||
@@ -264,7 +265,9 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
|||||||
commandArgs = ["/d", "/s", "/c", `call "${file}"`];
|
commandArgs = ["/d", "/s", "/c", `call "${file}"`];
|
||||||
} else if (mode === "3") {
|
} else if (mode === "3") {
|
||||||
executable = "powershell.exe";
|
executable = "powershell.exe";
|
||||||
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId)];
|
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
|
||||||
|
const script = utf8 + requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId);
|
||||||
|
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
|
||||||
} else {
|
} else {
|
||||||
executable = process.env.ComSpec || "cmd.exe";
|
executable = process.env.ComSpec || "cmd.exe";
|
||||||
commandArgs = ["/d", "/s", "/c", requiredText(args.dos_command, "DOS命令", node.id, node.componentId)];
|
commandArgs = ["/d", "/s", "/c", requiredText(args.dos_command, "DOS命令", node.id, node.componentId)];
|
||||||
@@ -276,7 +279,8 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
|||||||
executable = "/bin/sh";
|
executable = "/bin/sh";
|
||||||
commandArgs = ["-lc", command];
|
commandArgs = ["-lc", command];
|
||||||
}
|
}
|
||||||
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, maxBuffer: 10 * 1024 * 1024 });
|
// cmd.exe 自己解析 /c 后的命令;不能让 Node 再把 BAT 路径引号转成反斜杠引号。
|
||||||
|
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, windowsVerbatimArguments: process.platform === "win32" && mode !== "3", maxBuffer: 10 * 1024 * 1024 });
|
||||||
return bool(args.get_ret, true) ? String(result.stdout).trimEnd() : true;
|
return bool(args.get_ret, true) ? String(result.stdout).trimEnd() : true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { browserSession } from "../browser/session";
|
||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError, type BrowserLike, type BrowserTabInfo, type RunContext } from "../engine/types";
|
||||||
|
|
||||||
|
/** 优先使用流程中「打开网页」已建立的浏览器会话(含外部 Edge/Chrome),否则退回内置 */
|
||||||
|
async function currentSession(ctx: RunContext): Promise<BrowserLike> {
|
||||||
|
return ctx.browser ?? (await browserSession());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标题匹配:优先按正则解释,非法正则退化为字面包含 */
|
||||||
|
function titleMatcher(title: string): (tab: BrowserTabInfo) => boolean {
|
||||||
|
let pattern: RegExp;
|
||||||
|
try {
|
||||||
|
pattern = new RegExp(title);
|
||||||
|
} catch {
|
||||||
|
pattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||||
|
}
|
||||||
|
return (tab) => pattern.test(tab.title) || tab.title.includes(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssValue(selector: unknown): string {
|
||||||
|
const raw = typeof selector === "object" && selector !== null
|
||||||
|
? String((selector as { value?: string }).value ?? "")
|
||||||
|
: String(selector ?? "");
|
||||||
|
const text = raw.trim();
|
||||||
|
return text.startsWith("css:") ? text.slice(4).trim() : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUploadPath(args: Record<string, unknown>): string {
|
||||||
|
const filePath = String(args.file_path ?? args.filePath ?? args.path ?? "").trim();
|
||||||
|
const folder = String(args.folder_path ?? args.folder ?? "").trim();
|
||||||
|
const target = filePath || folder;
|
||||||
|
if (!target) {
|
||||||
|
throw new RuntimeError("validation_error", "上传文件路径为空", { recommendation: "填写要上传的本地文件完整路径" });
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(["Element_FileUploader", "WebBrowser_Upload", "WebBrowser_UploadDialog"], async (ctx, node, args) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const selector = cssValue(args.uiSelector ?? "css:input[type=file]") || "input[type=file]";
|
||||||
|
const filePath = requireUploadPath(args);
|
||||||
|
const nodeId = await browser.uploadFileToInput?.(selector, filePath);
|
||||||
|
ctx.log("已设置上传文件:" + filePath, "info", node.id);
|
||||||
|
return { uploaded: true, selector, filePath, nodeId };
|
||||||
|
});
|
||||||
|
|
||||||
|
register(["WebBrowser_GetTAB", "WebBrowser_GetTAB_V1", "WebBrowser_GetTAB_V2"], async (ctx, node, args) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const tabs = (await browser.listTabs?.()) ?? [{ index: 0, targetId: "", title: "", url: await browser.currentUrl(), active: true }];
|
||||||
|
const title = String(args.title ?? "").trim();
|
||||||
|
const url = String(args.url ?? "").trim();
|
||||||
|
const matchType = String(args.match_type ?? "0"); // 0=按标题 1=按网址
|
||||||
|
let matched = tabs;
|
||||||
|
if (title && (matchType !== "1" || !url)) matched = matched.filter(titleMatcher(title));
|
||||||
|
if (url && (matchType === "1" || !title)) matched = matched.filter((tab) => tab.url.includes(url));
|
||||||
|
if (matched.length === 0) {
|
||||||
|
throw new RuntimeError("tab_not_found", "没有匹配的标签页", {
|
||||||
|
detail: JSON.stringify(tabs.map((tab) => ({ title: tab.title, url: tab.url }))),
|
||||||
|
recommendation: "用「获取标签页」不加条件查看当前打开的全部页面"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 激活第一个匹配的标签页
|
||||||
|
const isActive = String(args.isActive ?? args.is_active ?? "0") === "1";
|
||||||
|
if (isActive && browser.switchTab) {
|
||||||
|
const first = matched[0];
|
||||||
|
await browser.switchTab(first.targetId ? { targetId: first.targetId } : { index: first.index });
|
||||||
|
}
|
||||||
|
ctx.log(`标签页匹配 ${matched.length}/${tabs.length} 个`, "info", node.id);
|
||||||
|
return matched;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_Switch_V1", async (ctx, node, args) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const tabs = (await browser.listTabs?.()) ?? [{ index: 0, targetId: "", title: "", url: await browser.currentUrl(), active: true }];
|
||||||
|
const active = tabs.find((tab) => tab.active) ?? tabs[0];
|
||||||
|
ctx.log(
|
||||||
|
tabs.length <= 1 ? "运行浏览器为单窗口模式,当前即唯一标签页" : `已定位当前标签页(共 ${tabs.length} 个,可用「获取标签页」+匹配条件切换)`,
|
||||||
|
"info",
|
||||||
|
node.id
|
||||||
|
);
|
||||||
|
return { switched: true, index: active?.index ?? 0, url: active?.url ?? "", active: String(args.is_active ?? "1") === "1" };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_Maximize", async (ctx, node) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
browser.maximize?.();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_Minimize", async (ctx, node) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
browser.minimize?.();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_Stop", async (ctx, node) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
browser.stopLoading?.();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
register(["Element_ElementAarray", "Element_ElementCaptureSimilar"], async (ctx, node, args) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const selector = args.uiSelector ?? args.selector ?? args.target;
|
||||||
|
if (!selector) {
|
||||||
|
throw new RuntimeError("validation_error", "元素选择器为空", { nodeId: node.id, componentId: node.componentId, recommendation: "请拾取或填写相似元素" });
|
||||||
|
}
|
||||||
|
const references = (await browser.getSimilarElements?.(selector, {
|
||||||
|
start: Number.isFinite(Number(args.start_index)) ? Math.trunc(Number(args.start_index)) : 1,
|
||||||
|
end: Number.isFinite(Number(args.end_index)) ? Math.trunc(Number(args.end_index)) : -1,
|
||||||
|
reverse: String(args.reverse ?? "") === "1" || String(args.reverse ?? "") === "true"
|
||||||
|
})) ?? [];
|
||||||
|
ctx.log("已获取 " + references.length + " 个相似元素(数组)", "info", node.id);
|
||||||
|
return references;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Element_StartAutoClosePopup", async (ctx, node) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const started = browser.startAutoCloseDialogs?.();
|
||||||
|
ctx.log(started ? "弹窗自动关闭已开启" : "弹窗自动关闭已在运行中", "info", node.id);
|
||||||
|
return { active: true, started };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("Element_StopAutoClosePopup", async (ctx, node) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const stopped = browser.stopAutoCloseDialogs?.();
|
||||||
|
ctx.log(stopped ? "弹窗自动关闭已停止" : "弹窗自动关闭未在运行", "info", node.id);
|
||||||
|
return { active: false, stopped };
|
||||||
|
});
|
||||||
|
|
||||||
|
register(["Element_GetLineBarChartData", "Element_GetPieChartData"], async (ctx, node, args) => {
|
||||||
|
const browser = await currentSession(ctx);
|
||||||
|
const selector = String((typeof args.uiSelector === "object" && args.uiSelector !== null ? (args.uiSelector as { value?: string }).value : args.uiSelector) ?? "body").replace(/^css:/, "").trim() || "body";
|
||||||
|
const script = "(function(){" +
|
||||||
|
"if (!window.echarts) return [];" +
|
||||||
|
"var sel = " + JSON.stringify(selector) + ";" +
|
||||||
|
"var dom = document.querySelector(sel);" +
|
||||||
|
"var inst = dom ? window.echarts.getInstanceByDom(dom) : null;" +
|
||||||
|
"if (!inst) { var all = document.querySelectorAll('[_echarts_instance_]'); for (var i = 0; i < all.length; i++) { inst = window.echarts.getInstanceByDom(all[i]); if (inst) break; } }" +
|
||||||
|
"if (!inst) return [];" +
|
||||||
|
"var opt = inst.getOption(); var out = [];" +
|
||||||
|
"(opt.series || []).forEach(function(s) { out.push({ name: s.name || '', type: s.type || '', data: s.data || [] }); });" +
|
||||||
|
"return out;" +
|
||||||
|
"})()";
|
||||||
|
const data = await browser.runJs(script);
|
||||||
|
ctx.log("已获取图表数据:" + JSON.stringify(data).slice(0, 120), "info", node.id);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { browserSession, externalBrowserSession } from "../browser/session";
|
||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集/监听组件的浏览器会话解析:
|
||||||
|
* 1. 流程中「打开网页」已建立的会话优先(内置或外部);
|
||||||
|
* 2. 否则若节点 browserType 填 chrome/edge → 拉起对应外部浏览器;
|
||||||
|
* 3. 其余情况退回内置运行浏览器。
|
||||||
|
*/
|
||||||
|
async function crawlSession(ctx: RunContext, args: Record<string, unknown> = {}): Promise<BrowserLike> {
|
||||||
|
if (ctx.browser) return ctx.browser;
|
||||||
|
const browserType = String(args.browserType ?? "").trim().toLowerCase();
|
||||||
|
if (browserType === "chrome" || browserType === "edge") {
|
||||||
|
return externalBrowserSession({ mode: "launch", browserType, port: 9222 });
|
||||||
|
}
|
||||||
|
return browserSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRAWL_IDS = [
|
||||||
|
"WebBrowser_DataCollection", "WebBrowser_DataCollectionV1", "WebBrowser_DataCollectionV2",
|
||||||
|
"WebBrowser_DataCollectionV3", "WebBrowser_DataCollectionV4", "WebBrowser_DataCollectionV5",
|
||||||
|
"WebBrowser_DataCrawl", "WebBrowser_DataCrawl_web", "WebBrowser_DataCrawl_software",
|
||||||
|
"WebBrowser_DataCrawler", "WebBrowser_BatchDataCrawl", "WebBrowser_BatchDataCrawlNew",
|
||||||
|
"WebBrowser_BatchDataCrawlNewV1"
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseList(value: unknown): Array<{ selector: string; label: string }> {
|
||||||
|
if (value === undefined || value === null || value === "") return [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => typeof item === "string" ? { selector: item, label: item } : { selector: String(item.selector ?? ""), label: String(item.label ?? item.selector ?? "") }).filter(item => item.selector);
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (Array.isArray(parsed)) return parseList(parsed);
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return [{ selector: text, label: text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstOf(args: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(CRAWL_IDS, async (ctx, node, args) => {
|
||||||
|
const browser = await crawlSession(ctx, args);
|
||||||
|
const rowSelector = firstOf(args, ["selector_page", "page_element", "uiSelector_page", "row_selector", "selectorCode", "rowSelector"]);
|
||||||
|
if (!rowSelector) {
|
||||||
|
throw new RuntimeError("validation_error", "行选择器为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写每一行数据的选择器,例如 css:table tbody tr" });
|
||||||
|
}
|
||||||
|
const rowSel = String(rowSelector).replace(/^css:/, "").trim();
|
||||||
|
const itemsRaw = firstOf(args, ["collect_items", "config", "columns", "column_selectors"]);
|
||||||
|
const columns = parseList(itemsRaw);
|
||||||
|
const pages = Math.max(1, Number(firstOf(args, ["collect_pages", "page_num", "pages", "repeat_count", "delay_pages"]) ?? 1) || 1);
|
||||||
|
const nextRaw = firstOf(args, ["selector_more", "next_selector", "uiSelector_num"]);
|
||||||
|
const nextSel = nextRaw ? String(nextRaw).replace(/^css:/, "").trim() : "";
|
||||||
|
const waitMs = Math.max(200, Number(firstOf(args, ["wait_time_page", "waitMs"]) ?? 1200) || 1200);
|
||||||
|
const dedup = String(firstOf(args, ["deduplication"]) ?? "") === "1" || String(firstOf(args, ["deduplication"]) ?? "") === "true";
|
||||||
|
|
||||||
|
const script = "(async () => {" +
|
||||||
|
"const rowSel = " + JSON.stringify(rowSel) + ";" +
|
||||||
|
"const cols = " + JSON.stringify(columns.map(c => c.selector)) + ";" +
|
||||||
|
"const pages = " + pages + ";" +
|
||||||
|
"const nextSel = " + JSON.stringify(nextSel) + ";" +
|
||||||
|
"const waitMs = " + waitMs + ";" +
|
||||||
|
"const dedup = " + dedup + ";" +
|
||||||
|
"const rows = []; const seen = new Set();" +
|
||||||
|
"for (let p = 0; p < pages; p++) {" +
|
||||||
|
" document.querySelectorAll(rowSel).forEach(r => {" +
|
||||||
|
" const row = cols.length ? cols.map(cs => { const c = r.querySelector(cs); return ((c && (c.innerText || c.textContent)) || '').trim(); }) : [((r.innerText || r.textContent) || '').trim()];" +
|
||||||
|
" const key = JSON.stringify(row);" +
|
||||||
|
" if (!dedup || !seen.has(key)) { seen.add(key); rows.push(row); }" +
|
||||||
|
" });" +
|
||||||
|
" if (p < pages - 1 && nextSel) {" +
|
||||||
|
" const btn = [...document.querySelectorAll(nextSel)].find(b => !b.disabled);" +
|
||||||
|
" if (!btn) break;" +
|
||||||
|
" btn.click();" +
|
||||||
|
" await new Promise(r => setTimeout(r, waitMs));" +
|
||||||
|
" }" +
|
||||||
|
"}" +
|
||||||
|
"return rows;" +
|
||||||
|
"})()";
|
||||||
|
const rows = await browser.runJs(script) as unknown[][];
|
||||||
|
const labels = columns.map(c => c.label).filter(Boolean);
|
||||||
|
ctx.log("已采集 " + (rows?.length ?? 0) + " 行" + (labels.length ? "(列:" + labels.join("、") + ")" : ""), "info", node.id);
|
||||||
|
return rows ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_StartWeblistening", async (ctx, node, args) => {
|
||||||
|
const browser = await crawlSession(ctx, args);
|
||||||
|
const started = browser.startNetworkListening?.();
|
||||||
|
ctx.log(started ? "网页监听已开启" : "网页监听已在运行中", "info", node.id);
|
||||||
|
return { listening: true, started };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_GetWeblistening", async (ctx, node, args) => {
|
||||||
|
const browser = await crawlSession(ctx, args);
|
||||||
|
const url = String(args.url ?? "").trim();
|
||||||
|
const wildcard = String(args.iswildcard ?? "") === "1" || String(args.iswildcard ?? "") === "true";
|
||||||
|
const resourceType = String(args.web_resource ?? "").trim();
|
||||||
|
const captures = (await browser.getNetworkCaptures?.({ url, wildcard, resourceType })) ?? [];
|
||||||
|
ctx.log("已获取 " + captures.length + " 条监听记录", "info", node.id);
|
||||||
|
return captures;
|
||||||
|
});
|
||||||
|
|
||||||
|
register("WebBrowser_StopWeblistening", async (ctx, node, args) => {
|
||||||
|
const browser = await crawlSession(ctx, args);
|
||||||
|
const stopped = browser.stopNetworkListening?.();
|
||||||
|
ctx.log(stopped ? "网页监听已停止" : "网页监听未在运行", "info", node.id);
|
||||||
|
return { listening: false, stopped };
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { register } from "../engine/registry";
|
||||||
|
import { RuntimeError } from "../engine/types";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
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 });
|
||||||
|
|
||||||
|
function reqText(args: Record<string, unknown>, keys: string[], label: string, nodeId: string): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = String(args[key] ?? "").trim();
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
throw new RuntimeError("validation_error", label + "为空", { nodeId, componentId: nodeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
register("ScreenControl_Screenlock", async () => {
|
||||||
|
await run("rundll32.exe", ["user32.dll,LockWorkStation"], { windowsHide: true });
|
||||||
|
return { locked: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("ScreenControl_ScreenUnlock", async () => {
|
||||||
|
// 仅唤醒屏幕/解除屏保;系统锁屏需人工输入密码,无法通用自动解锁
|
||||||
|
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);' -Name W32U -Namespace WinRpa | Out-Null; [WinRpa.W32U]::mouse_event(0x0001, 1, 1, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 200; [WinRpa.W32U]::mouse_event(0x0001, 0xFFFFFFFF, 0, 0, [UIntPtr]::Zero)");
|
||||||
|
return { awakened: true, loginUnlockSupported: false, note: "仅尝试唤醒屏幕;Windows 登录锁屏仍需本人手动解锁" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const SC_SCREENSAVE = 0xF140;
|
||||||
|
const WM_SYSCOMMAND = 0x0112;
|
||||||
|
register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async (_ctx, node, args) => {
|
||||||
|
if ([true, 1, "1", "true", "True"].includes(args.set_password as never)) {
|
||||||
|
throw new RuntimeError("validation_error", "系统屏保不支持设置自定义解锁密码,请关闭该选项并使用 Windows 原有登录保护", { nodeId: node.id, componentId: node.componentId });
|
||||||
|
}
|
||||||
|
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [uint32]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null");
|
||||||
|
return { screensaver: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_ScreenSaverStop", async () => {
|
||||||
|
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr FindWindow(string cls, string win); [DllImport(\"user32.dll\")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32P -Namespace WinRpa | Out-Null; $h = [WinRpa.W32P]::FindWindow('WindowsScreenSaverClass', $null); if ($h -ne [IntPtr]::Zero) { [WinRpa.W32P]::PostMessage($h, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null }");
|
||||||
|
return { stopped: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_ReadCredential", async (ctx, node, args) => {
|
||||||
|
const app = reqText(args, ["application"], "凭据目标", node.id);
|
||||||
|
const { stdout } = await run("cmdkey", ["/list"], { windowsHide: true });
|
||||||
|
const exists = stdout.includes(app);
|
||||||
|
const userLine = stdout.split(/\r?\n/).find(l => l.includes(app));
|
||||||
|
return { target: app, exists, detail: exists ? (userLine ?? "").trim() : "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_AddCredential", async (ctx, node, args) => {
|
||||||
|
const app = reqText(args, ["application"], "凭据目标", node.id);
|
||||||
|
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 });
|
||||||
|
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 });
|
||||||
|
return { deleted: true, target: app };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_ConnectRemoteDesktop", async (ctx, node, args) => {
|
||||||
|
const ip = reqText(args, ["ip"], "远程地址", node.id);
|
||||||
|
const port = String(args.port ?? "").trim();
|
||||||
|
const target = port ? ip + ":" + port : ip;
|
||||||
|
await run("mstsc.exe", ["/v:" + target], { windowsHide: true });
|
||||||
|
return { connected: true, target };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_CloseRemoteDesktop", async () => {
|
||||||
|
await run("tsdiscon.exe", [], { windowsHide: true });
|
||||||
|
return { disconnected: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_GetInputMethodState", async () => {
|
||||||
|
const { stdout } = await ps("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $cur = [System.Windows.Forms.InputLanguage]::CurrentInputLanguage; Write-Output ($cur.Culture.Name + '|' + $cur.LayoutName)");
|
||||||
|
const [culture, layout] = stdout.split("|");
|
||||||
|
return { culture, layout };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("System_SetInputMethodState", async (ctx, node, args) => {
|
||||||
|
const mode = reqText(args, ["mode"], "输入法文化名", node.id);
|
||||||
|
await ps("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $lang = [System.Windows.Forms.InputLanguage]::FromCulture('" + mode.replace(/'/g, "''") + "'); if ($lang) { [System.Windows.Forms.InputLanguage]::CurrentInputLanguage = $lang }");
|
||||||
|
return { set: true, mode };
|
||||||
|
});
|
||||||
|
|
||||||
|
register("File_OpenFile", async (ctx, node, args) => {
|
||||||
|
const p = reqText(args, ["path"], "文件夹路径", node.id);
|
||||||
|
try {
|
||||||
|
await run("explorer.exe", [p], { windowsHide: true });
|
||||||
|
} catch (error) {
|
||||||
|
// explorer.exe 打开成功也会返回退出码 1(Windows 已知行为),仅路径不存在时才是真失败
|
||||||
|
const code = (error as { code?: unknown }).code;
|
||||||
|
if (code !== 1) throw error;
|
||||||
|
}
|
||||||
|
return { opened: true, path: p };
|
||||||
|
});
|
||||||
@@ -32,5 +32,5 @@ describe("Word extended capabilities", () => {
|
|||||||
await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" });
|
await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" });
|
||||||
await run("Word_Enter", { objWord: handle });
|
await run("Word_Enter", { objWord: handle });
|
||||||
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
|
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
|
||||||
}, 60000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { app, BrowserWindow, screen } from "electron";
|
||||||
|
import { RuntimeError } from "./engine/types";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
function pickerScriptPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath || "", "desktop-picker.ps1"),
|
||||||
|
path.join(app.getAppPath(), "resources", "desktop-picker.ps1")
|
||||||
|
].filter(Boolean);
|
||||||
|
const found = candidates.find(p => fs.existsSync(p));
|
||||||
|
if (!found) throw new RuntimeError("picker_unavailable", "桌面拾取脚本缺失", { recommendation: "重新安装应用" });
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
export interface DesktopPickResult {
|
||||||
|
cancelled?: boolean;
|
||||||
|
window?: string;
|
||||||
|
name?: string;
|
||||||
|
automationId?: string;
|
||||||
|
controlType?: string;
|
||||||
|
className?: string;
|
||||||
|
selector: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickDesktopElement(): Promise<DesktopPickResult> {
|
||||||
|
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
|
||||||
|
for (const w of appWindows) w.minimize();
|
||||||
|
try {
|
||||||
|
const { stdout } = await run("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath()], { timeout: 180000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find(l => l.trim().startsWith("{"));
|
||||||
|
if (!jsonLine) throw new RuntimeError("picker_failed", "拾取未返回结果");
|
||||||
|
const parsed = JSON.parse(jsonLine) as Omit<DesktopPickResult, "selector">;
|
||||||
|
if (parsed.cancelled) return { ...parsed, selector: "" };
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (parsed.window) parts.push("window=" + parsed.window);
|
||||||
|
if (parsed.name) parts.push("name=" + parsed.name);
|
||||||
|
if (parsed.controlType) parts.push("type=" + parsed.controlType);
|
||||||
|
if (parsed.automationId) parts.push("id=" + parsed.automationId);
|
||||||
|
return { ...parsed, selector: parts.join(";") };
|
||||||
|
} finally {
|
||||||
|
for (const w of appWindows) {
|
||||||
|
if (!w.isDestroyed()) w.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function listVisibleWindows(): Promise<Array<{ title: string; process: string }>> {
|
||||||
|
const { stdout } = await run("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath(), "-Mode", "windows"], { timeout: 30000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find(l => l.trim().startsWith("["));
|
||||||
|
if (!jsonLine) return [];
|
||||||
|
return JSON.parse(jsonLine) as Array<{ title: string; process: string }>;
|
||||||
|
}
|
||||||
|
export async function pickScreenRegion(): Promise<{ x: number; y: number; width: number; height: number; imagePath: string }> {
|
||||||
|
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
|
||||||
|
for (const w of appWindows) w.minimize();
|
||||||
|
const display = screen.getPrimaryDisplay();
|
||||||
|
const sx = display.bounds.x, sy = display.bounds.y, sw = display.bounds.width, sh = display.bounds.height;
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
x: sx, y: sy, width: sw, height: sh,
|
||||||
|
frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, resizable: false, movable: false,
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
|
||||||
|
});
|
||||||
|
const htmlParts = [
|
||||||
|
"<!DOCTYPE html><html><head><meta charset='utf-8'><style>",
|
||||||
|
"html,body{margin:0;height:100%;background:rgba(0,30,80,0.25);cursor:crosshair;overflow:hidden}",
|
||||||
|
"#sel{position:absolute;border:2px solid #2563eb;background:rgba(37,99,235,0.15);display:none}",
|
||||||
|
"#tip{position:fixed;top:12px;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.65);padding:8px 18px;border-radius:6px;font-family:Microsoft YaHei;font-size:14px}",
|
||||||
|
"</style></head><body>",
|
||||||
|
"<div id=sel></div><div id=tip>拖拽框选区域,松开鼠标完成;Esc 取消</div>",
|
||||||
|
"<script>",
|
||||||
|
"var sel = document.getElementById('sel'); var start = null;",
|
||||||
|
"document.addEventListener('mousedown', function(e) { start = [e.clientX, e.clientY]; sel.style.display = 'block'; sel.style.left = e.clientX + 'px'; sel.style.top = e.clientY + 'px'; sel.style.width = '0px'; sel.style.height = '0px'; });",
|
||||||
|
"document.addEventListener('mousemove', function(e) { if (!start) return; sel.style.left = Math.min(start[0], e.clientX) + 'px'; sel.style.top = Math.min(start[1], e.clientY) + 'px'; sel.style.width = Math.abs(e.clientX - start[0]) + 'px'; sel.style.height = Math.abs(e.clientY - start[1]) + 'px'; });",
|
||||||
|
"document.addEventListener('mouseup', function(e) { if (!start) return; window.__region = { x: Math.min(start[0], e.clientX), y: Math.min(start[1], e.clientY), width: Math.abs(e.clientX - start[0]), height: Math.abs(e.clientY - start[1]) }; window.__done = true; });",
|
||||||
|
"document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { window.__cancelled = true; window.__done = true; } });",
|
||||||
|
"</script></body></html>"
|
||||||
|
];
|
||||||
|
void win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(htmlParts.join("")));
|
||||||
|
const deadline = Date.now() + 60000;
|
||||||
|
let region: { x: number; y: number; width: number; height: number } | null = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
try {
|
||||||
|
const state = await win.webContents.executeJavaScript("JSON.stringify({ done: window.__done === true, cancelled: window.__cancelled === true, region: window.__region || null })", true);
|
||||||
|
const parsed = JSON.parse(state) as { done: boolean; cancelled: boolean; region: { x: number; y: number; width: number; height: number } | null };
|
||||||
|
if (parsed.cancelled) { win.destroy(); throw new RuntimeError("pick_cancelled", "已取消框选"); }
|
||||||
|
if (parsed.done && parsed.region && parsed.region.width > 2 && parsed.region.height > 2) { region = parsed.region; break; }
|
||||||
|
if (parsed.done) break;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).message.includes("已取消框选")) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } }
|
||||||
|
win.destroy();
|
||||||
|
if (!region) throw new RuntimeError("pick_cancelled", "未完成框选");
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
const outDir = path.join(app.getPath("userData"), "region-picks");
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const imagePath = path.join(outDir, `pick-region-${Date.now()}.png`);
|
||||||
|
const psScript = [
|
||||||
|
"Add-Type -AssemblyName System.Drawing | Out-Null;",
|
||||||
|
"$bitmap = New-Object System.Drawing.Bitmap " + region.width + ", " + region.height + ";",
|
||||||
|
"$graphics = [System.Drawing.Graphics]::FromImage();",
|
||||||
|
"$graphics.CopyFromScreen(" + region.x + ", " + region.y + ", 0, 0, $bitmap.Size);",
|
||||||
|
"$graphics.Dispose();",
|
||||||
|
"$bitmap.Save(" + JSON.stringify(imagePath) + ");",
|
||||||
|
"$bitmap.Dispose();",
|
||||||
|
"Write-Output ok"
|
||||||
|
].join("");
|
||||||
|
await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript], { timeout: 30000, windowsHide: true });
|
||||||
|
return { ...region, imagePath };
|
||||||
|
}
|
||||||
@@ -197,6 +197,28 @@ describe("runFlow integration", () => {
|
|||||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "accumulate")).toHaveLength(3);
|
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "accumulate")).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs an array loop through the last item when end_index is -1", async () => {
|
||||||
|
const loop = node("loop", "logic_control_array_for_v3", {
|
||||||
|
array_name: "items",
|
||||||
|
reverse: false,
|
||||||
|
start_index: 0,
|
||||||
|
end_index: -1
|
||||||
|
});
|
||||||
|
const definition = flow("array-loop-flow", [
|
||||||
|
loop,
|
||||||
|
node("collect", "Basic_DebugOutput", { msg: "${item}" }, {}, {
|
||||||
|
parentId: loop.id,
|
||||||
|
slot: "array_for_fn"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result, events } = await execute(definition, { items: ["glm", "mini", "other", "kimi"] });
|
||||||
|
|
||||||
|
expect(result.status).toBe("succeeded");
|
||||||
|
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(4);
|
||||||
|
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "collect").map((event) => event.nodeId)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
it("retries a transient failure and records the successful attempt", async () => {
|
it("retries a transient failure and records the successful attempt", async () => {
|
||||||
const component = testComponent("Test.TransientFailure");
|
const component = testComponent("Test.TransientFailure");
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|||||||
@@ -409,7 +409,8 @@ async function executeContainer(
|
|||||||
const list = asArray(value);
|
const list = asArray(value);
|
||||||
const reverse = String(node.values?.reverse ?? "") === "true" || String(node.values?.reverse ?? "") === "1";
|
const reverse = String(node.values?.reverse ?? "") === "true" || String(node.values?.reverse ?? "") === "1";
|
||||||
const start = Math.max(0, toNumber(node.values?.start_index, 0));
|
const start = Math.max(0, toNumber(node.values?.start_index, 0));
|
||||||
const end = node.values?.end_index === "" || node.values?.end_index == null ? list.length : toNumber(node.values?.end_index, list.length);
|
const requestedEnd = node.values?.end_index === "" || node.values?.end_index == null ? list.length : toNumber(node.values?.end_index, list.length);
|
||||||
|
const end = requestedEnd < 0 ? Math.max(start, list.length + requestedEnd + 1) : requestedEnd;
|
||||||
const slice = (reverse ? [...list].reverse() : list).slice(start, end);
|
const slice = (reverse ? [...list].reverse() : list).slice(start, end);
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
for (let index = 0; index < slice.length; index += 1) {
|
for (let index = 0; index < slice.length; index += 1) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } f
|
|||||||
export interface BrowserLike {
|
export interface BrowserLike {
|
||||||
navigate(url: string): Promise<{ url: string; title: string }>;
|
navigate(url: string): Promise<{ url: string; title: string }>;
|
||||||
click(selector: unknown, timeoutMs?: number): Promise<unknown>;
|
click(selector: unknown, timeoutMs?: number): Promise<unknown>;
|
||||||
type(selector: unknown, text: string, clearFirst?: boolean): Promise<unknown>;
|
type(selector: unknown, text: string, clearFirst?: boolean, timeoutMs?: number): Promise<unknown>;
|
||||||
getText(selector: unknown, attribute?: string): Promise<string>;
|
getText(selector: unknown, attribute?: string): Promise<string>;
|
||||||
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
|
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
|
||||||
select(selector: unknown, value: string): Promise<unknown>;
|
select(selector: unknown, value: string): Promise<unknown>;
|
||||||
@@ -30,9 +30,37 @@ export interface BrowserLike {
|
|||||||
getTableData?(selector: unknown): Promise<string[][]>;
|
getTableData?(selector: unknown): Promise<string[][]>;
|
||||||
getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise<BrowserElementReference[]>;
|
getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise<BrowserElementReference[]>;
|
||||||
currentUrl(): Promise<string>;
|
currentUrl(): Promise<string>;
|
||||||
|
/** 设置文件上传 input 的本地文件路径 */
|
||||||
|
uploadFileToInput?(selector: string, filePath: string): Promise<number>;
|
||||||
|
maximize?(): void;
|
||||||
|
minimize?(): void;
|
||||||
|
stopLoading?(): void;
|
||||||
|
/** 自动关闭页面弹窗(alert/confirm),返回是否新开启 */
|
||||||
|
startAutoCloseDialogs?(): boolean;
|
||||||
|
stopAutoCloseDialogs?(): boolean;
|
||||||
|
/** 开启网络响应监听,返回是否新开启 */
|
||||||
|
startNetworkListening?(): boolean;
|
||||||
|
getNetworkCaptures?(options?: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean }): Promise<Array<Record<string, unknown>>>;
|
||||||
|
stopNetworkListening?(): boolean;
|
||||||
|
/** 列出当前浏览器的标签页(内置浏览器返回单页;外部浏览器走 Target 枚举) */
|
||||||
|
listTabs?(): Promise<BrowserTabInfo[]>;
|
||||||
|
/** 切换当前操作的标签页(按 targetId / 索引 / 标题 / 网址匹配,标题支持正则) */
|
||||||
|
switchTab?(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise<BrowserTabInfo>;
|
||||||
|
/** 在浏览器中新建标签页并切换过去(内置浏览器退化为当前页导航) */
|
||||||
|
openNewTab?(url: string): Promise<BrowserTabInfo>;
|
||||||
|
/** 全量 Cookie(含 HttpOnly;走 Network.getCookies) */
|
||||||
|
getCookies?(): Promise<Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowserTabInfo {
|
||||||
|
index: number;
|
||||||
|
targetId: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrowserElementReference {
|
export interface BrowserElementReference {
|
||||||
kind: "css" | "xpath";
|
kind: "css" | "xpath";
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export async function startFixtureServer(preferredPort = 17863): Promise<string>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (url.pathname !== "/" && url.pathname !== "/index.html") {
|
if (url.pathname !== "/" && url.pathname !== "/index.html") {
|
||||||
|
const safe = path.basename(url.pathname);
|
||||||
|
const file = path.join(root, safe);
|
||||||
|
if (/\.(html|css|js|png|jpg|svg)$/i.test(safe) && fs.existsSync(file)) {
|
||||||
|
response.writeHead(200, { "Content-Type": safe.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream", "Cache-Control": "no-store" });
|
||||||
|
fs.createReadStream(file).pipe(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
response.writeHead(404).end("Not found");
|
response.writeHead(404).end("Not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ vi.mock("electron", () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createDemoFlow, createFlow, listFlows, saveFlow } from "./flows";
|
import { createDemoFlow, createExampleFlows, createFlow, listFlows, saveFlow } from "./flows";
|
||||||
|
|
||||||
describe("flow summaries", () => {
|
describe("flow summaries", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -55,4 +55,12 @@ describe("flow summaries", () => {
|
|||||||
expect(flow.edges).toHaveLength(5);
|
expect(flow.edges).toHaveLength(5);
|
||||||
expect(flow.nodes.find((node) => node.id === "node_result")?.options.outputVar).toBe("订单结果");
|
expect(flow.nodes.find((node) => node.id === "node_result")?.options.outputVar).toBe("订单结果");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates six stable example flows without duplicate ids", () => {
|
||||||
|
const examples = createExampleFlows("http://127.0.0.1:17863/");
|
||||||
|
expect(examples).toHaveLength(6);
|
||||||
|
expect(new Set(examples.map((flow) => flow.id)).size).toBe(6);
|
||||||
|
expect(examples.every((flow) => flow.nodes.length > 2 && flow.edges.length > 1)).toBe(true);
|
||||||
|
expect(examples.find((flow) => flow.id === "example_error_route")?.edges.some((edge) => edge.sourceHandle === "error")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+80
-1
@@ -1,7 +1,7 @@
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { FlowDefinition, FlowSummary } from "../shared/types";
|
import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types";
|
||||||
|
|
||||||
const ID_PATTERN = /^[A-Za-z0-9_-]{3,80}$/;
|
const ID_PATTERN = /^[A-Za-z0-9_-]{3,80}$/;
|
||||||
|
|
||||||
@@ -92,12 +92,90 @@ export function createDemoFlow(url: string): FlowDefinition {
|
|||||||
return flow;
|
return flow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exampleFlow(id: string, name: string, description: string): FlowDefinition {
|
||||||
|
const flow = createFlow(name);
|
||||||
|
flow.id = id;
|
||||||
|
flow.description = description;
|
||||||
|
flow.nodes = [];
|
||||||
|
flow.edges = [];
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleNode(id: string, componentId: string, y: number, values: Record<string, unknown> = {}, outputVar?: string): FlowNode {
|
||||||
|
return { id, componentId, position: { x: 220, y }, values, options: { timeoutMs: 30000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, outputVar }, enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chain(nodes: FlowNode[]): FlowEdge[] {
|
||||||
|
return nodes.slice(0, -1).map((node, index) => ({ id: `edge_${node.id}_${nodes[index + 1].id}`, source: node.id, target: nodes[index + 1].id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExampleFlows(url: string): FlowDefinition[] {
|
||||||
|
const browser = createDemoFlow(url);
|
||||||
|
browser.id = "example_browser_order";
|
||||||
|
browser.name = "示例 1 · 本地网页订单";
|
||||||
|
|
||||||
|
const file = exampleFlow("example_file_text", "示例 2 · 文件与文本处理", "在临时目录写入文本、读取、转换为大写并写入结果文件。");
|
||||||
|
file.nodes = [
|
||||||
|
exampleNode("file_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||||
|
exampleNode("file_temp", "System_GetTempPath", 210, {}, "tempPath"),
|
||||||
|
exampleNode("file_write", "File_Write", 340, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", content: "hello rpa studio", encode: "utf-8", append: "False" }),
|
||||||
|
exampleNode("file_read", "File_Read", 470, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", encode: "utf-8" }, "sourceText"),
|
||||||
|
exampleNode("file_upper", "StrUtil_UpperCase", 600, { string: "${sourceText}" }, "upperText"),
|
||||||
|
exampleNode("file_result", "File_Write", 730, { path: "${tempPath}\\RPA-Studio-Demo\\result.txt", content: "${upperText}", encode: "utf-8", append: "False" }),
|
||||||
|
exampleNode("file_log", "Basic_DebugOutput", 860, { msg: "处理完成:${upperText}" })
|
||||||
|
]; file.edges = chain(file.nodes);
|
||||||
|
|
||||||
|
const excel = exampleFlow("example_excel_orders", "示例 3 · Excel 订单读写", "在临时目录创建 Excel,写入订单数据并读取验证。");
|
||||||
|
excel.nodes = [
|
||||||
|
exampleNode("excel_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||||
|
exampleNode("excel_temp", "System_GetTempPath", 210, {}, "tempPath"),
|
||||||
|
exampleNode("excel_new", "Excel_Cs_NewV5", 340, { path: "${tempPath}\\RPA-Studio-Demo", name: "orders.xlsx", override: "1", show: "0" }, "workbook"),
|
||||||
|
exampleNode("excel_write", "Excel_Cs_WriteRange", 470, { workbook: "${workbook}", sheet: "Sheet1", row: "1", column: "A", contents: '[["客户","金额","状态"],["张三",128,"已创建"],["李四",256,"已创建"]]', save: "1" }),
|
||||||
|
exampleNode("excel_read", "Excel_Cs_ReadRangeV2", 600, { workbook: "${workbook}", sheet: "Sheet1", range: "A1:C3", decimal: "0" }, "excelData"),
|
||||||
|
exampleNode("excel_log", "Basic_DebugOutput", 730, { msg: "Excel内容:${excelData}" })
|
||||||
|
]; excel.edges = chain(excel.nodes);
|
||||||
|
|
||||||
|
const clipboard = exampleFlow("example_clipboard", "示例 4 · 剪贴板往返", "写入并读取 Windows 剪贴板文本。");
|
||||||
|
clipboard.nodes = [
|
||||||
|
exampleNode("clip_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||||
|
exampleNode("clip_set", "Clipboard_SetText", 230, { text: "RPA Studio 剪贴板测试" }),
|
||||||
|
exampleNode("clip_get", "Clipboard_GetText", 380, {}, "clipboardText"),
|
||||||
|
exampleNode("clip_log", "Basic_DebugOutput", 530, { msg: "剪贴板内容:${clipboardText}" })
|
||||||
|
]; clipboard.edges = chain(clipboard.nodes);
|
||||||
|
|
||||||
|
const word = exampleFlow("example_word_document", "示例 5 · Word 文档生成", "在临时目录创建 Word 文档、写入文本并读取验证,需要本机安装 Word。");
|
||||||
|
word.nodes = [
|
||||||
|
exampleNode("word_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||||
|
exampleNode("word_temp", "System_GetTempPath", 200, {}, "tempPath"),
|
||||||
|
exampleNode("word_folder", "File_MakeDir", 320, { folder_path: "${tempPath}\\RPA-Studio-Demo" }),
|
||||||
|
exampleNode("word_new", "Word_New_V1", 440, { filePath: "${tempPath}\\RPA-Studio-Demo", name: "demo.docx", visible: "False" }, "wordDoc"),
|
||||||
|
exampleNode("word_write", "Word_Write", 560, { objWord: "${wordDoc}", text: "RPA Studio Word 自动化测试" }),
|
||||||
|
exampleNode("word_read", "Word_ReadAll", 680, { objWord: "${wordDoc}" }, "wordText"),
|
||||||
|
exampleNode("word_log", "Basic_DebugOutput", 800, { msg: "Word内容:${wordText}" })
|
||||||
|
]; word.edges = chain(word.nodes);
|
||||||
|
|
||||||
|
const error = exampleFlow("example_error_route", "示例 6 · 异常分支", "故意读取不存在文件,验证右侧异常出口和错误变量。");
|
||||||
|
error.nodes = [
|
||||||
|
exampleNode("error_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||||
|
{ ...exampleNode("error_fail", "File_Read", 230, { path: "Z:\\RPA-DEMO-NOT-FOUND.txt", encode: "utf-8" }), options: { timeoutMs: 5000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, errorRouteEnabled: true } },
|
||||||
|
exampleNode("error_normal", "Basic_DebugOutput", 390, { msg: "不应执行到正常出口" }),
|
||||||
|
{ ...exampleNode("error_recover", "Basic_DebugOutput", 230, { msg: "异常已捕获:${error.reason}" }), position: { x: 560, y: 230 } }
|
||||||
|
]; error.edges = [
|
||||||
|
{ id: "edge_error_start", source: "error_start", target: "error_fail" },
|
||||||
|
{ id: "edge_error_normal", source: "error_fail", target: "error_normal" },
|
||||||
|
{ id: "edge_error_recover", source: "error_fail", sourceHandle: "error", target: "error_recover" }
|
||||||
|
];
|
||||||
|
|
||||||
|
return [browser, file, excel, clipboard, word, error];
|
||||||
|
}
|
||||||
|
|
||||||
export function listFlows(): FlowSummary[] {
|
export function listFlows(): FlowSummary[] {
|
||||||
const base = dir();
|
const base = dir();
|
||||||
const rows: FlowSummary[] = [];
|
const rows: FlowSummary[] = [];
|
||||||
const runs = latestRuns();
|
const runs = latestRuns();
|
||||||
for (const entry of fs.readdirSync(base)) {
|
for (const entry of fs.readdirSync(base)) {
|
||||||
if (!entry.endsWith(".json")) continue;
|
if (!entry.endsWith(".json")) continue;
|
||||||
|
if (/-pre-|\\.bak/i.test(entry)) continue;
|
||||||
try {
|
try {
|
||||||
const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition;
|
const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition;
|
||||||
if (!flow?.id || !Array.isArray(flow.nodes)) continue;
|
if (!flow?.id || !Array.isArray(flow.nodes)) continue;
|
||||||
@@ -154,3 +232,4 @@ export function deleteFlow(id: string): void {
|
|||||||
const target = fileOf(id);
|
const target = fileOf(id);
|
||||||
if (fs.existsSync(target)) fs.unlinkSync(target);
|
if (fs.existsSync(target)) fs.unlinkSync(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
+86
-6
@@ -1,17 +1,26 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AppInfo, FlowDefinition } from "../shared/types";
|
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult } from "../shared/types";
|
||||||
|
import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session";
|
||||||
import "./capabilities";
|
import "./capabilities";
|
||||||
import { catalogPath, loadCatalog } from "./catalog";
|
import { catalogPath, loadCatalog } from "./catalog";
|
||||||
import { createDemoFlow, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||||
import { capabilityCount, capabilityIds } from "./engine/registry";
|
import { capabilityCount, capabilityIds } from "./engine/registry";
|
||||||
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
|
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
|
||||||
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
|
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
|
||||||
|
import { loadSettings, updateSettings } from "./settings";
|
||||||
|
import { pickDesktopElement, listVisibleWindows, pickScreenRegion } from "./desktop-pick";
|
||||||
|
import { logMain } from "./logger";
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
if (process.env.RPA_ENABLE_GPU !== "1") app.disableHardwareAcceleration();
|
||||||
|
process.on("uncaughtException", (error) => logMain("uncaughtException", error.stack || error.message));
|
||||||
|
process.on("unhandledRejection", (reason) => logMain("unhandledRejection", reason instanceof Error ? reason.stack || reason.message : String(reason)));
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const e2e = process.env.RPA_E2E === "1";
|
const e2e = process.env.RPA_E2E === "1";
|
||||||
|
logMain("createWindow", { packaged: app.isPackaged, appPath: app.getAppPath(), userData: app.getPath("userData") });
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -34,7 +43,18 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.on("ready-to-show", () => { if (!e2e) mainWindow?.show(); });
|
let shown = false;
|
||||||
|
const showWindow = (source: string) => {
|
||||||
|
if (e2e || shown || !mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
shown = true;
|
||||||
|
logMain("showWindow", source);
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
};
|
||||||
|
const showFallback = setTimeout(() => showWindow("fallback-timeout"), 5000);
|
||||||
|
mainWindow.on("ready-to-show", () => { clearTimeout(showFallback); showWindow("ready-to-show"); });
|
||||||
|
mainWindow.on("unresponsive", () => logMain("window-unresponsive"));
|
||||||
|
mainWindow.on("responsive", () => logMain("window-responsive"));
|
||||||
mainWindow.on("closed", () => {
|
mainWindow.on("closed", () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
@@ -42,6 +62,14 @@ function createWindow(): void {
|
|||||||
if (/^https?:/i.test(url)) void shell.openExternal(url);
|
if (/^https?:/i.test(url)) void shell.openExternal(url);
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
mainWindow.webContents.on("did-fail-load", (_event, code, description, url) => {
|
||||||
|
logMain("did-fail-load", { code, description, url });
|
||||||
|
showWindow("did-fail-load");
|
||||||
|
});
|
||||||
|
mainWindow.webContents.on("render-process-gone", (_event, details) => logMain("render-process-gone", details));
|
||||||
|
mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||||
|
if (level >= 2) logMain("renderer-console", { level, message, line, sourceId });
|
||||||
|
});
|
||||||
|
|
||||||
const devUrl = process.env.ELECTRON_RENDERER_URL;
|
const devUrl = process.env.ELECTRON_RENDERER_URL;
|
||||||
if (devUrl) {
|
if (devUrl) {
|
||||||
@@ -67,6 +95,27 @@ function registerIpc(): void {
|
|||||||
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
|
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
|
||||||
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
|
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
|
||||||
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
|
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
|
||||||
|
ipcMain.handle("settings:get", () => loadSettings());
|
||||||
|
ipcMain.handle("settings:update", (_event, patch: Partial<AppSettings> = {}) => {
|
||||||
|
const settings = updateSettings(patch);
|
||||||
|
setBrowserElementWaitMs(settings.browserElementWaitMs);
|
||||||
|
return settings;
|
||||||
|
});
|
||||||
|
ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => {
|
||||||
|
const url = options.url?.trim();
|
||||||
|
if (!hasBrowserSession() && !url) {
|
||||||
|
throw new Error("请先配置或运行「打开网页」,再拾取元素");
|
||||||
|
}
|
||||||
|
const session = await BrowserSession.create();
|
||||||
|
const currentUrl = await session.currentUrl();
|
||||||
|
if (url && (currentUrl === "about:blank" || currentUrl !== url)) {
|
||||||
|
await session.navigate(url);
|
||||||
|
}
|
||||||
|
return session.pickElement();
|
||||||
|
});
|
||||||
|
ipcMain.handle("desktop:pick-element", () => pickDesktopElement());
|
||||||
|
ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows());
|
||||||
|
ipcMain.handle("desktop:pick-region", () => pickScreenRegion());
|
||||||
ipcMain.handle("flows:list", () => listFlows());
|
ipcMain.handle("flows:list", () => listFlows());
|
||||||
ipcMain.handle("flows:create", (_event, name?: string) => {
|
ipcMain.handle("flows:create", (_event, name?: string) => {
|
||||||
const flow = createFlow(name);
|
const flow = createFlow(name);
|
||||||
@@ -76,8 +125,33 @@ function registerIpc(): void {
|
|||||||
const url = fixtureUrl();
|
const url = fixtureUrl();
|
||||||
return saveFlow(url ? createDemoFlow(url) : createFlow("未命名任务"));
|
return saveFlow(url ? createDemoFlow(url) : createFlow("未命名任务"));
|
||||||
});
|
});
|
||||||
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id));
|
ipcMain.handle("flows:create-examples", () => {
|
||||||
ipcMain.handle("flows:save", (_event, flow: FlowDefinition) => saveFlow(flow));
|
const url = fixtureUrl();
|
||||||
|
if (!url) throw new Error("本地演示站未启动");
|
||||||
|
for (const flow of createExampleFlows(url)) saveFlow(flow);
|
||||||
|
return listFlows();
|
||||||
|
});
|
||||||
|
ipcMain.handle("flows:get", (_event, id: string) => {
|
||||||
|
return getFlow(id);
|
||||||
|
});
|
||||||
|
ipcMain.handle("flows:save", (_event, flow: FlowDefinition, opts?: FlowSaveOptions): FlowSaveResult => {
|
||||||
|
if (!opts?.force && opts?.expectedUpdatedAt) {
|
||||||
|
let disk: FlowDefinition | null = null;
|
||||||
|
try {
|
||||||
|
disk = getFlow(flow.id);
|
||||||
|
} catch {
|
||||||
|
disk = null; // 磁盘文件不存在或损坏时视为全新保存
|
||||||
|
}
|
||||||
|
if (disk && disk.meta?.updatedAt && disk.meta.updatedAt !== opts.expectedUpdatedAt) {
|
||||||
|
// 内容一致(仅 updatedAt 差异,如并发/重复保存)不视为冲突
|
||||||
|
const normalize = (item: FlowDefinition) => JSON.stringify({ ...item, meta: undefined });
|
||||||
|
if (normalize(disk) !== normalize(flow)) {
|
||||||
|
return { conflict: true, diskFlow: disk };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { conflict: false, flow: saveFlow(flow) };
|
||||||
|
});
|
||||||
ipcMain.handle("flows:delete", (_event, id: string) => {
|
ipcMain.handle("flows:delete", (_event, id: string) => {
|
||||||
deleteFlow(id);
|
deleteFlow(id);
|
||||||
return true;
|
return true;
|
||||||
@@ -104,7 +178,8 @@ function registerIpc(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const gotLock = app.requestSingleInstanceLock();
|
// 打包冒烟/自动化测试需要与用户日常实例隔离;正常启动仍然保持单实例。
|
||||||
|
const gotLock = process.env.RPA_SKIP_SINGLE_INSTANCE === "1" ? true : app.requestSingleInstanceLock();
|
||||||
if (!gotLock) {
|
if (!gotLock) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
@@ -115,10 +190,13 @@ if (!gotLock) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
logMain("app-ready", { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
|
||||||
|
setBrowserElementWaitMs(loadSettings().browserElementWaitMs);
|
||||||
try {
|
try {
|
||||||
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
|
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("local fixture server unavailable", error);
|
console.warn("local fixture server unavailable", error);
|
||||||
|
logMain("fixture-server-failed", error instanceof Error ? error.stack || error.message : String(error));
|
||||||
}
|
}
|
||||||
registerIpc();
|
registerIpc();
|
||||||
createWindow();
|
createWindow();
|
||||||
@@ -136,3 +214,5 @@ if (!gotLock) {
|
|||||||
void stopFixtureServer();
|
void stopFixtureServer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { app } from "electron";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function logMain(event: string, detail: unknown = ""): void {
|
||||||
|
try {
|
||||||
|
const directory = path.join(app.getPath("userData"), "logs");
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
const content = typeof detail === "string" ? detail : JSON.stringify(detail);
|
||||||
|
fs.appendFileSync(path.join(directory, "startup.log"), `${new Date().toISOString()} [${event}] ${content}\r\n`, "utf8");
|
||||||
|
} catch {
|
||||||
|
// Logging must never prevent the app from starting.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const electronState = vi.hoisted(() => ({ userData: "" }));
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
app: {
|
||||||
|
getPath: () => electronState.userData
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { loadSettings, updateSettings } from "./settings";
|
||||||
|
|
||||||
|
describe("app settings", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-settings-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(electronState.userData, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the default element wait when no settings file exists", () => {
|
||||||
|
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists and clamps a user-adjustable element wait", async () => {
|
||||||
|
const saved = updateSettings({ browserElementWaitMs: 12500 });
|
||||||
|
expect(saved).toEqual({ browserElementWaitMs: 12500, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
||||||
|
expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
|
||||||
|
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
||||||
|
expect(loadSettings()).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { app } from "electron";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
|
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||||
|
browserElementWaitMs: 5000,
|
||||||
|
aiConfig: { baseUrl: "", apiKey: "", model: "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
let cache: AppSettings | null = null;
|
||||||
|
|
||||||
|
function settingsPath(): string {
|
||||||
|
return path.join(app.getPath("userData"), "settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettings(value: unknown): AppSettings {
|
||||||
|
const source = (value && typeof value === "object" ? value : {}) as Partial<AppSettings>;
|
||||||
|
const wait = Number(source.browserElementWaitMs);
|
||||||
|
const ai = (source.aiConfig && typeof source.aiConfig === "object" ? source.aiConfig : {}) as Partial<AppSettings["aiConfig"]>;
|
||||||
|
return {
|
||||||
|
browserElementWaitMs: Number.isFinite(wait)
|
||||||
|
? Math.min(600000, Math.max(0, Math.round(wait)))
|
||||||
|
: DEFAULT_APP_SETTINGS.browserElementWaitMs,
|
||||||
|
aiConfig: {
|
||||||
|
baseUrl: String(ai.baseUrl ?? ""),
|
||||||
|
apiKey: String(ai.apiKey ?? ""),
|
||||||
|
model: String(ai.model ?? "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSettings(): AppSettings {
|
||||||
|
try {
|
||||||
|
cache = normalizeSettings(JSON.parse(fs.readFileSync(settingsPath(), "utf8")));
|
||||||
|
} catch {
|
||||||
|
cache = { ...DEFAULT_APP_SETTINGS };
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(settings: AppSettings): AppSettings {
|
||||||
|
const normalized = normalizeSettings(settings);
|
||||||
|
fs.mkdirSync(app.getPath("userData"), { recursive: true });
|
||||||
|
fs.writeFileSync(settingsPath(), JSON.stringify(normalized, null, 2), "utf8");
|
||||||
|
cache = normalized;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(patch: Partial<AppSettings> = {}): AppSettings {
|
||||||
|
return saveSettings({ ...(cache || loadSettings()), ...patch });
|
||||||
|
}
|
||||||
+17
-2
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import type { AppInfo, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types";
|
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent } from "../shared/types";
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
app: {
|
app: {
|
||||||
@@ -8,12 +8,18 @@ const api = {
|
|||||||
catalog: {
|
catalog: {
|
||||||
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
|
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
|
||||||
},
|
},
|
||||||
|
desktop: {
|
||||||
|
pickElement: () => ipcRenderer.invoke('desktop:pick-element'),
|
||||||
|
pickWindowList: () => ipcRenderer.invoke('desktop:pick-window-list'),
|
||||||
|
pickRegion: () => ipcRenderer.invoke('desktop:pick-region')
|
||||||
|
},
|
||||||
flows: {
|
flows: {
|
||||||
list: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:list"),
|
list: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:list"),
|
||||||
create: (name?: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create", name),
|
create: (name?: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create", name),
|
||||||
createDemo: (): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create-demo"),
|
createDemo: (): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create-demo"),
|
||||||
|
createExamples: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:create-examples"),
|
||||||
get: (id: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:get", id),
|
get: (id: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:get", id),
|
||||||
save: (flow: FlowDefinition): Promise<FlowDefinition> => ipcRenderer.invoke("flows:save", flow),
|
save: (flow: FlowDefinition, opts?: FlowSaveOptions): Promise<FlowSaveResult> => ipcRenderer.invoke("flows:save", flow, opts),
|
||||||
delete: (id: string): Promise<boolean> => ipcRenderer.invoke("flows:delete", id)
|
delete: (id: string): Promise<boolean> => ipcRenderer.invoke("flows:delete", id)
|
||||||
},
|
},
|
||||||
dialog: {
|
dialog: {
|
||||||
@@ -22,6 +28,14 @@ const api = {
|
|||||||
save: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise<string | null> =>
|
save: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise<string | null> =>
|
||||||
ipcRenderer.invoke("dialog:save", options || {})
|
ipcRenderer.invoke("dialog:save", options || {})
|
||||||
},
|
},
|
||||||
|
browser: {
|
||||||
|
pickElement: (options?: { url?: string }): Promise<import("../shared/types").ElementPickResult> =>
|
||||||
|
ipcRenderer.invoke("browser:pick-element", options || {})
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
get: (): Promise<AppSettings> => ipcRenderer.invoke("settings:get"),
|
||||||
|
update: (patch: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke("settings:update", patch || {})
|
||||||
|
},
|
||||||
run: {
|
run: {
|
||||||
start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow),
|
start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow),
|
||||||
stop: (runId?: string): Promise<boolean> => ipcRenderer.invoke("run:stop", runId),
|
stop: (runId?: string): Promise<boolean> => ipcRenderer.invoke("run:stop", runId),
|
||||||
@@ -37,3 +51,4 @@ const api = {
|
|||||||
contextBridge.exposeInMainWorld("rpa", api);
|
contextBridge.exposeInMainWorld("rpa", api);
|
||||||
|
|
||||||
export type RpaApi = typeof api;
|
export type RpaApi = typeof api;
|
||||||
|
|
||||||
+23
-16
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Bot, CircleAlert, Loader2, ListChecks, Sparkles } from "lucide-react";
|
import { Bot, CircleAlert, Loader2, ListChecks } from "lucide-react";
|
||||||
import type { Catalog, FlowDefinition, FlowSummary } from "@shared/types";
|
import type { Catalog, FlowDefinition, FlowSummary } from "@shared/types";
|
||||||
import { useEditorStore } from "./store/editorStore";
|
import { useEditorStore } from "./store/editorStore";
|
||||||
import { useRunStore } from "./store/runStore";
|
import { useRunStore } from "./store/runStore";
|
||||||
@@ -18,13 +18,6 @@ interface ToastState {
|
|||||||
kind: "ok" | "error";
|
kind: "ok" | "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_LABEL: Record<Page, string> = {
|
|
||||||
tasks: "编排",
|
|
||||||
editor: "流程编排",
|
|
||||||
runs: "运行中心",
|
|
||||||
settings: "设置"
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeLegacyLinearLayout(flow: FlowDefinition): FlowDefinition {
|
function normalizeLegacyLinearLayout(flow: FlowDefinition): FlowDefinition {
|
||||||
const topLevel = flow.nodes.filter((node) => !node.parentId);
|
const topLevel = flow.nodes.filter((node) => !node.parentId);
|
||||||
if (topLevel.length < 2) return flow;
|
if (topLevel.length < 2) return flow;
|
||||||
@@ -128,8 +121,14 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const loaded = await window.rpa.flows.get(id);
|
const loaded = await window.rpa.flows.get(id);
|
||||||
const flow = normalizeLegacyLinearLayout(loaded);
|
const flow = normalizeLegacyLinearLayout(loaded);
|
||||||
if (flow !== loaded) await window.rpa.flows.save(flow);
|
if (flow !== loaded) {
|
||||||
loadFlow(flow);
|
// 归一化改写了布局:强制写盘后必须用保存返回的新 meta 加载,
|
||||||
|
// 否则编辑器持有旧 updatedAt,首次保存会误判磁盘版本冲突
|
||||||
|
const result = await window.rpa.flows.save(flow, { force: true });
|
||||||
|
loadFlow(result.conflict ? flow : result.flow);
|
||||||
|
} else {
|
||||||
|
loadFlow(flow);
|
||||||
|
}
|
||||||
setActiveFlowId(id);
|
setActiveFlowId(id);
|
||||||
setPage("editor");
|
setPage("editor");
|
||||||
} catch (openError) {
|
} catch (openError) {
|
||||||
@@ -149,6 +148,15 @@ export function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createExamples = async () => {
|
||||||
|
try {
|
||||||
|
setFlows(await window.rpa.flows.createExamples());
|
||||||
|
showToast("已安装 6 条示例任务");
|
||||||
|
} catch (exampleError) {
|
||||||
|
showToast(exampleError instanceof Error ? exampleError.message : String(exampleError), "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTask = async (id: string) => {
|
const deleteTask = async (id: string) => {
|
||||||
if (flows.length <= 1) {
|
if (flows.length <= 1) {
|
||||||
showToast("至少保留一个任务", "error");
|
showToast("至少保留一个任务", "error");
|
||||||
@@ -199,11 +207,6 @@ export function App() {
|
|||||||
</span>
|
</span>
|
||||||
<strong>RPA Studio</strong>
|
<strong>RPA Studio</strong>
|
||||||
</div>
|
</div>
|
||||||
<span className="titlebar-divider" />
|
|
||||||
<span className="workspace-chip">
|
|
||||||
{page === "tasks" ? <ListChecks className="icon" /> : page === "editor" ? <Sparkles className="icon" /> : page === "runs" ? <railIcons.runs className="icon" /> : <railIcons.settings className="icon" />}
|
|
||||||
{PAGE_LABEL[page]}
|
|
||||||
</span>
|
|
||||||
<div className="titlebar-right">
|
<div className="titlebar-right">
|
||||||
<span className="engine-chip" title="运行期完全离线">
|
<span className="engine-chip" title="运行期完全离线">
|
||||||
<span className="engine-dot" />
|
<span className="engine-dot" />
|
||||||
@@ -245,10 +248,12 @@ export function App() {
|
|||||||
<TasksPage
|
<TasksPage
|
||||||
flows={flows}
|
flows={flows}
|
||||||
onCreate={() => void createTask()}
|
onCreate={() => void createTask()}
|
||||||
|
onCreateExamples={() => void createExamples()}
|
||||||
onOpenEditor={(id) => void openEditor(id)}
|
onOpenEditor={(id) => void openEditor(id)}
|
||||||
onRun={(id) => void runTask(id)}
|
onRun={(id) => void runTask(id)}
|
||||||
onDelete={(id) => void deleteTask(id)}
|
onDelete={(id) => void deleteTask(id)}
|
||||||
onRefresh={() => void refreshFlows()}
|
onRefresh={() => refreshFlows()}
|
||||||
|
onToast={showToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
|
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
|
||||||
@@ -265,3 +270,5 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ import { useEditorStore } from "../store/editorStore";
|
|||||||
import { useRunStore } from "../store/runStore";
|
import { useRunStore } from "../store/runStore";
|
||||||
import { RpaNode, type RpaNodeData } from "./RpaNode";
|
import { RpaNode, type RpaNodeData } from "./RpaNode";
|
||||||
import { ContainerNode, containerSlots, type ContainerNodeData } from "./ContainerNode";
|
import { ContainerNode, containerSlots, type ContainerNodeData } from "./ContainerNode";
|
||||||
import { MousePointerClick } from "lucide-react";
|
|
||||||
|
|
||||||
const nodeTypes = { rpa: RpaNode, container: ContainerNode };
|
const nodeTypes = { rpa: RpaNode, container: ContainerNode };
|
||||||
|
|
||||||
@@ -256,16 +255,6 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
|
|||||||
maskColor="rgba(242,245,248,.72)"
|
maskColor="rgba(242,245,248,.72)"
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
{flow.nodes.length <= 1 && (
|
|
||||||
<div className="canvas-hint">
|
|
||||||
<MousePointerClick className="icon" />
|
|
||||||
<div>
|
|
||||||
<strong>从左侧拖入能力,开始搭建流程</strong>
|
|
||||||
<span>支持搜索 1016 项目录;单击组件也可追加。</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ArrowDown, Check, Copy, Download } from "lucide-react";
|
||||||
|
import type { RunLog } from "../store/runStore";
|
||||||
|
|
||||||
|
type LogFilter = "all" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
const LEVEL_LABEL: Record<LogFilter, string> = {
|
||||||
|
all: "全部",
|
||||||
|
info: "信息",
|
||||||
|
warn: "警告",
|
||||||
|
error: "错误"
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RunLogViewProps {
|
||||||
|
logs: RunLog[];
|
||||||
|
nameOf: (nodeId?: string) => string;
|
||||||
|
fileName?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyText = "运行日志会显示在这里…", className }: RunLogViewProps) {
|
||||||
|
const [filter, setFilter] = useState<LogFilter>("all");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [pinned, setPinned] = useState(true);
|
||||||
|
const consoleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pinnedRef = useRef(true);
|
||||||
|
const copiedTimerRef = useRef<number>();
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (copiedTimerRef.current !== undefined) window.clearTimeout(copiedTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const counts = useMemo(() => ({
|
||||||
|
all: logs.length,
|
||||||
|
info: logs.filter((log) => log.level === "info").length,
|
||||||
|
warn: logs.filter((log) => log.level === "warn").length,
|
||||||
|
error: logs.filter((log) => log.level === "error").length
|
||||||
|
}), [logs]);
|
||||||
|
|
||||||
|
const visibleLogs = useMemo(() => (
|
||||||
|
filter === "all" ? logs : logs.filter((log) => log.level === filter)
|
||||||
|
), [filter, logs]);
|
||||||
|
|
||||||
|
const logText = useMemo(() => (
|
||||||
|
logs.map((log) => `[${log.time}] [${log.level.toUpperCase()}] ${nameOf(log.nodeId)} - ${log.message}`).join("\n")
|
||||||
|
), [logs, nameOf]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = consoleRef.current;
|
||||||
|
if (element && pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||||
|
}, [visibleLogs]);
|
||||||
|
|
||||||
|
const trackPin = () => {
|
||||||
|
const element = consoleRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const nextPinned = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
||||||
|
if (nextPinned !== pinnedRef.current) {
|
||||||
|
pinnedRef.current = nextPinned;
|
||||||
|
setPinned(nextPinned);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToLatest = () => {
|
||||||
|
pinnedRef.current = true;
|
||||||
|
const element = consoleRef.current;
|
||||||
|
if (element) element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLogs = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(logText);
|
||||||
|
setCopied(true);
|
||||||
|
if (copiedTimerRef.current !== undefined) window.clearTimeout(copiedTimerRef.current);
|
||||||
|
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1600);
|
||||||
|
} catch {
|
||||||
|
// Clipboard access can be blocked by system policy; download remains available.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadLogs = () => {
|
||||||
|
const blob = new Blob([logText], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`run-log-view ${className || ""}`}>
|
||||||
|
<div className="run-log-toolbar">
|
||||||
|
<div className="run-log-filters" role="group" aria-label="日志级别过滤">
|
||||||
|
{(Object.keys(LEVEL_LABEL) as LogFilter[]).map((level) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={level}
|
||||||
|
className={`run-log-filter ${level}${filter === level ? " active" : ""}`}
|
||||||
|
onClick={() => setFilter(level)}
|
||||||
|
aria-pressed={filter === level}
|
||||||
|
>
|
||||||
|
{LEVEL_LABEL[level]}
|
||||||
|
<small>{counts[level]}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="run-log-action" onClick={() => void copyLogs()} disabled={!logs.length}>
|
||||||
|
{copied ? <Check className="icon" /> : <Copy className="icon" />}
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="run-log-action" onClick={downloadLogs} disabled={!logs.length}>
|
||||||
|
<Download className="icon" />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="run-log-console-wrap">
|
||||||
|
<div className="run-log-console" ref={consoleRef} onScroll={trackPin} role="log" aria-live="polite">
|
||||||
|
{!visibleLogs.length && <div className="run-log-empty">{logs.length ? "当前级别暂无日志。" : emptyText}</div>}
|
||||||
|
{visibleLogs.map((log) => (
|
||||||
|
<div className={`run-log-row ${log.level}`} key={log.id}>
|
||||||
|
<span className="run-log-time">{log.time}</span>
|
||||||
|
<span className="run-log-level">{LEVEL_LABEL[log.level]}</span>
|
||||||
|
<span className="run-log-node" title={log.nodeId ? nameOf(log.nodeId) : "流程"}>{log.nodeId ? nameOf(log.nodeId) : "流程"}</span>
|
||||||
|
<span className="run-log-message">{log.message || "—"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!pinned && visibleLogs.length > 0 && (
|
||||||
|
<button type="button" className="run-log-latest" onClick={scrollToLatest}>
|
||||||
|
<ArrowDown className="icon" />
|
||||||
|
回到最新
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Check, CircleAlert, CirclePlay, Loader2, Redo2, Save, Square, Undo2, X } from "lucide-react";
|
import { ArrowLeft, Check, CircleAlert, CirclePlay, Lightbulb, Loader2, Redo2, Save, Square, Undo2, X } from "lucide-react";
|
||||||
import type { Catalog } from "@shared/types";
|
import type { Catalog } from "@shared/types";
|
||||||
import { useEditorStore } from "../store/editorStore";
|
import { useEditorStore } from "../store/editorStore";
|
||||||
import { useRunStore } from "../store/runStore";
|
import { useRunStore } from "../store/runStore";
|
||||||
@@ -25,6 +25,8 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
const redo = useEditorStore((state) => state.redo);
|
const redo = useEditorStore((state) => state.redo);
|
||||||
const renameFlow = useEditorStore((state) => state.renameFlow);
|
const renameFlow = useEditorStore((state) => state.renameFlow);
|
||||||
const markSaved = useEditorStore((state) => state.markSaved);
|
const markSaved = useEditorStore((state) => state.markSaved);
|
||||||
|
const loadFlow = useEditorStore((state) => state.loadFlow);
|
||||||
|
const syncSavedFlow = useEditorStore((state) => state.syncSavedFlow);
|
||||||
const selectNode = useEditorStore((state) => state.selectNode);
|
const selectNode = useEditorStore((state) => state.selectNode);
|
||||||
const canUndo = useEditorStore((state) => state.past.length > 0);
|
const canUndo = useEditorStore((state) => state.past.length > 0);
|
||||||
const canRedo = useEditorStore((state) => state.future.length > 0);
|
const canRedo = useEditorStore((state) => state.future.length > 0);
|
||||||
@@ -32,29 +34,87 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
const startRun = useRunStore((state) => state.start);
|
const startRun = useRunStore((state) => state.start);
|
||||||
const stopRun = useRunStore((state) => state.stop);
|
const stopRun = useRunStore((state) => state.stop);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
// 同步锁:防止自动保存定时器与按钮点击(或双击)在同一帧内触发两次并发保存,
|
||||||
|
// 并发保存会让后到者误判磁盘版本冲突(saving state 异步更新拦不住同一帧的第二次调用)
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
const [saveConflict, setSaveConflict] = useState(false);
|
||||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||||
const [validationIssues, setValidationIssues] = useState<FlowValidationIssue[] | null>(null);
|
const [validationIssues, setValidationIssues] = useState<FlowValidationIssue[] | null>(null);
|
||||||
|
// 新建流程首访引导:仅第一次进入空流程页面时展示,5 秒后自动关闭
|
||||||
const save = useCallback(async () => {
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
const current = useEditorStore.getState().flow;
|
|
||||||
if (!current || saving) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await window.rpa.flows.save(current);
|
|
||||||
markSaved();
|
|
||||||
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
|
|
||||||
} catch (error) {
|
|
||||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, [markSaved, onToast, saving]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dirty) return;
|
if (!flow || flow.nodes.length > 1) return;
|
||||||
|
const seen = window.localStorage.getItem("rpa.editor.guide.seen");
|
||||||
|
if (seen) return;
|
||||||
|
setShowGuide(true);
|
||||||
|
window.localStorage.setItem("rpa.editor.guide.seen", "1");
|
||||||
|
const timer = window.setTimeout(() => setShowGuide(false), 5000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [flow?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 引导展示期间用户已拖入节点(不再是空流程)则立即收起
|
||||||
|
if (showGuide && flow && flow.nodes.length > 1) setShowGuide(false);
|
||||||
|
}, [flow?.nodes.length, showGuide]);
|
||||||
|
|
||||||
|
const save = useCallback(async (opts?: { interactive?: boolean }) => {
|
||||||
|
const current = useEditorStore.getState().flow;
|
||||||
|
if (!current || savingRef.current) return null;
|
||||||
|
savingRef.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await window.rpa.flows.save(current, {
|
||||||
|
// 用「磁盘基准版本」做乐观锁,不能用 current.meta.updatedAt——
|
||||||
|
// 每次本地编辑都会拨快它,会导致每次保存都误判磁盘冲突
|
||||||
|
expectedUpdatedAt: useEditorStore.getState().baseUpdatedAt || undefined
|
||||||
|
});
|
||||||
|
if (result.conflict) {
|
||||||
|
if (opts?.interactive) {
|
||||||
|
const loadDisk = window.confirm(
|
||||||
|
"任务已在磁盘上被外部修改(可能被其他工具保存过)。\n\n" +
|
||||||
|
"「确定」加载磁盘上的最新版本(放弃当前编辑)\n" +
|
||||||
|
"「取消」以当前编辑内容覆盖磁盘"
|
||||||
|
);
|
||||||
|
if (loadDisk) {
|
||||||
|
loadFlow(result.diskFlow);
|
||||||
|
markSaved();
|
||||||
|
setSaveConflict(false);
|
||||||
|
onToast("已加载磁盘上的最新版本");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const forced = await window.rpa.flows.save(current, { force: true });
|
||||||
|
if (forced.conflict) return null;
|
||||||
|
syncSavedFlow(forced.flow);
|
||||||
|
markSaved();
|
||||||
|
setSaveConflict(false);
|
||||||
|
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
|
||||||
|
onToast("已覆盖磁盘上的旧版本");
|
||||||
|
return forced.flow;
|
||||||
|
}
|
||||||
|
onToast("检测到磁盘上的任务已被外部修改,自动保存已暂停;点击保存按钮或按 Ctrl+S 处理", "error");
|
||||||
|
setSaveConflict(true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
syncSavedFlow(result.flow);
|
||||||
|
markSaved();
|
||||||
|
setSaveConflict(false);
|
||||||
|
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
|
||||||
|
return result.flow;
|
||||||
|
} catch (error) {
|
||||||
|
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false;
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [loadFlow, markSaved, onToast, syncSavedFlow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || saveConflict) return;
|
||||||
const timer = window.setTimeout(() => void save(), 900);
|
const timer = window.setTimeout(() => void save(), 900);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [dirty, flow, save]);
|
}, [dirty, flow, save, saveConflict]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
@@ -62,7 +122,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
if (!mod) return;
|
if (!mod) return;
|
||||||
if (event.key.toLowerCase() === "s") {
|
if (event.key.toLowerCase() === "s") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void save();
|
void save({ interactive: true });
|
||||||
} else if (event.key.toLowerCase() === "z" && !event.shiftKey) {
|
} else if (event.key.toLowerCase() === "z" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
undo();
|
undo();
|
||||||
@@ -76,16 +136,10 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
}, [redo, save, undo]);
|
}, [redo, save, undo]);
|
||||||
|
|
||||||
const executeCurrentFlow = useCallback(async () => {
|
const executeCurrentFlow = useCallback(async () => {
|
||||||
const current = useEditorStore.getState().flow;
|
const saved = await save({ interactive: true });
|
||||||
if (!current) return;
|
if (!saved) return;
|
||||||
try {
|
await startRun(saved);
|
||||||
const saved = await window.rpa.flows.save(current);
|
}, [save, startRun]);
|
||||||
markSaved();
|
|
||||||
await startRun(saved);
|
|
||||||
} catch (error) {
|
|
||||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
|
||||||
}
|
|
||||||
}, [markSaved, onToast, startRun]);
|
|
||||||
|
|
||||||
const runCurrentFlow = useCallback(async () => {
|
const runCurrentFlow = useCallback(async () => {
|
||||||
const current = useEditorStore.getState().flow;
|
const current = useEditorStore.getState().flow;
|
||||||
@@ -130,14 +184,16 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
|
|
||||||
<section className="panel canvas-panel" aria-label="流程画布">
|
<section className="panel canvas-panel" aria-label="流程画布">
|
||||||
<div className="canvas-toolbar">
|
<div className="canvas-toolbar">
|
||||||
|
<div className="editor-breadcrumb">
|
||||||
|
<button type="button" className="back-btn" onClick={onBack} title="返回任务中心" aria-label="返回任务中心">
|
||||||
|
<ArrowLeft className="icon" />
|
||||||
|
</button>
|
||||||
|
<span className="breadcrumb-current">编辑流程</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="spacer" />
|
||||||
|
|
||||||
<div className="flow-meta">
|
<div className="flow-meta">
|
||||||
<div className="editor-breadcrumb">
|
|
||||||
<button type="button" className="back-link" onClick={onBack}>
|
|
||||||
<ArrowLeft className="icon" /> 任务中心
|
|
||||||
</button>
|
|
||||||
<span>/</span>
|
|
||||||
<span>编辑流程</span>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
className="flow-name"
|
className="flow-name"
|
||||||
value={flow.name}
|
value={flow.name}
|
||||||
@@ -166,7 +222,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
<button type="button" className="icon-btn" title="重做 (Ctrl+Y)" onClick={redo} disabled={!canRedo}>
|
<button type="button" className="icon-btn" title="重做 (Ctrl+Y)" onClick={redo} disabled={!canRedo}>
|
||||||
<Redo2 className="icon" />
|
<Redo2 className="icon" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="icon-btn" title="保存 (Ctrl+S)" onClick={() => void save()} disabled={saving}>
|
<button type="button" className="icon-btn save-btn" title="保存 (Ctrl+S)" onClick={() => void save({ interactive: true })} disabled={saving}>
|
||||||
{saving ? <Loader2 className="icon spin" /> : <Save className="icon" />}
|
{saving ? <Loader2 className="icon spin" /> : <Save className="icon" />}
|
||||||
</button>
|
</button>
|
||||||
{running ? (
|
{running ? (
|
||||||
@@ -183,6 +239,16 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
|
|
||||||
<FlowCanvas catalog={catalog} onAddAt={(componentId, position) => addNode(componentId, position)} />
|
<FlowCanvas catalog={catalog} onAddAt={(componentId, position) => addNode(componentId, position)} />
|
||||||
|
|
||||||
|
{showGuide && (
|
||||||
|
<div className="editor-guide" role="status">
|
||||||
|
<span className="editor-guide-icon"><Lightbulb className="icon" /></span>
|
||||||
|
<div className="editor-guide-text">
|
||||||
|
<strong>从左侧「组件库」拖入能力节点,开始搭建流程</strong>
|
||||||
|
<span>拖拽到画布任意位置,或单击组件快速追加;支持按名称 / ID / 标签搜索。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="canvas-badge">
|
<div className="canvas-badge">
|
||||||
<Check className="icon" />
|
<Check className="icon" />
|
||||||
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项
|
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项
|
||||||
@@ -218,3 +284,4 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal, Timer } from "lucide-react";
|
import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal } from "lucide-react";
|
||||||
import { useEditorStore } from "../store/editorStore";
|
import { useEditorStore } from "../store/editorStore";
|
||||||
import { useRunStore } from "../store/runStore";
|
import { useRunStore } from "../store/runStore";
|
||||||
|
import { RunLogView } from "../components/RunLogView";
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
running: "运行中", succeeded: "成功", failed: "失败", skipped: "已跳过", cancelled: "已取消", idle: "待运行"
|
running: "运行中", succeeded: "成功", failed: "失败", skipped: "已跳过", cancelled: "已取消", idle: "待运行"
|
||||||
@@ -26,7 +27,7 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
|
|||||||
const component = catalog?.components.find((item) => item.id === node.componentId);
|
const component = catalog?.components.find((item) => item.id === node.componentId);
|
||||||
map.set(node.id, node.label || component?.name || node.componentId);
|
map.set(node.id, node.label || component?.name || node.componentId);
|
||||||
}
|
}
|
||||||
return (id: string) => map.get(id) || id;
|
return (id?: string) => (id ? map.get(id) || id : "流程");
|
||||||
}, [catalog, flow]);
|
}, [catalog, flow]);
|
||||||
|
|
||||||
const entries = Object.entries(nodes);
|
const entries = Object.entries(nodes);
|
||||||
@@ -52,6 +53,7 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="run-main">
|
||||||
<section className="panel run-detail">
|
<section className="panel run-detail">
|
||||||
<div className="panel-head"><Play className="icon" /><strong>节点进度</strong><span className="spacer" /><span className={`status-pill ${statusClass}`}>{STATUS_LABEL[effectiveStatus]}</span></div>
|
<div className="panel-head"><Play className="icon" /><strong>节点进度</strong><span className="spacer" /><span className={`status-pill ${statusClass}`}>{STATUS_LABEL[effectiveStatus]}</span></div>
|
||||||
<div className="run-detail-body">
|
<div className="run-detail-body">
|
||||||
@@ -76,11 +78,9 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
|
|||||||
|
|
||||||
<section className="panel run-log-panel">
|
<section className="panel run-log-panel">
|
||||||
<div className="panel-head"><Terminal className="icon" /><strong>运行日志</strong><span className="spacer" /><span className="count-pill">{logs.length}</span></div>
|
<div className="panel-head"><Terminal className="icon" /><strong>运行日志</strong><span className="spacer" /><span className="count-pill">{logs.length}</span></div>
|
||||||
<div className="run-page-logs">
|
<RunLogView logs={logs} nameOf={nameOf} fileName={`${runId || "run"}.log`} emptyText="节点执行、重试和异常信息会实时显示。" />
|
||||||
{!logs.length && <div className="empty"><Timer className="icon" /><strong>暂无日志</strong><span>节点执行、重试和异常信息会实时显示。</span></div>}
|
|
||||||
{logs.map((log) => <div className={`run-page-log ${log.level}`} key={log.id}><span>{log.time}</span><strong>{log.nodeId ? nameOf(log.nodeId) : "流程"}</strong><p>{log.message}</p></div>)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,67 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Cpu, Database, FolderCog, Globe2, HardDrive, Info, ShieldCheck } from "lucide-react";
|
import { Clock, Cpu, Database, FolderCog, Globe2, HardDrive, Info, Save, ShieldCheck, Sparkles } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { AppInfo, Catalog } from "@shared/types";
|
import type { AppInfo, AppSettings, Catalog } from "@shared/types";
|
||||||
|
|
||||||
type SettingsTab = "about" | "catalog" | "execution" | "browser";
|
type SettingsTab = "about" | "catalog" | "execution" | "browser" | "ai";
|
||||||
|
|
||||||
export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||||
const [tab, setTab] = useState<SettingsTab>("about");
|
const [tab, setTab] = useState<SettingsTab>("about");
|
||||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||||
const [handlers, setHandlers] = useState<{ count: number; ids: string[] } | null>(null);
|
const [handlers, setHandlers] = useState<{ count: number; ids: string[] } | null>(null);
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
|
const [waitDraft, setWaitDraft] = useState("5000");
|
||||||
|
const [waitSaving, setWaitSaving] = useState(false);
|
||||||
|
const [waitMessage, setWaitMessage] = useState<string | null>(null);
|
||||||
|
const [aiDraft, setAiDraft] = useState({ baseUrl: "", apiKey: "", model: "" });
|
||||||
|
const [aiSaving, setAiSaving] = useState(false);
|
||||||
|
const [aiMessage, setAiMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
|
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
|
||||||
void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null));
|
void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null));
|
||||||
|
void window.rpa.settings.get().then((value) => {
|
||||||
|
setSettings(value);
|
||||||
|
setWaitDraft(String(value.browserElementWaitMs));
|
||||||
|
setAiDraft(value.aiConfig ?? { baseUrl: "", apiKey: "", model: "" });
|
||||||
|
}).catch(() => setSettings(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const saveElementWait = async () => {
|
||||||
|
const value = Math.round(Number(waitDraft));
|
||||||
|
if (!Number.isFinite(value) || value < 0 || value > 600000) {
|
||||||
|
setWaitMessage("请输入 0 - 600000 之间的毫秒数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWaitSaving(true);
|
||||||
|
setWaitMessage(null);
|
||||||
|
try {
|
||||||
|
const saved = await window.rpa.settings.update({ browserElementWaitMs: value });
|
||||||
|
setSettings(saved);
|
||||||
|
setWaitDraft(String(saved.browserElementWaitMs));
|
||||||
|
setWaitMessage(`已保存:${saved.browserElementWaitMs} ms`);
|
||||||
|
} catch (error) {
|
||||||
|
setWaitMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setWaitSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const saveAiConfig = async () => {
|
||||||
|
setAiSaving(true);
|
||||||
|
setAiMessage(null);
|
||||||
|
try {
|
||||||
|
const saved = await window.rpa.settings.update({ aiConfig: aiDraft });
|
||||||
|
setSettings(saved);
|
||||||
|
setAiMessage("AI 服务配置已保存");
|
||||||
|
} catch (error) {
|
||||||
|
setAiMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setAiSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const executable = catalog.components.filter((item) => item.implemented).length;
|
const executable = catalog.components.filter((item) => item.implemented).length;
|
||||||
const groups = useMemo(() => catalog.groups.map((group) => {
|
const groups = useMemo(() => catalog.groups.map((group) => {
|
||||||
const components = catalog.components.filter((component) => component.group === group.id);
|
const components = catalog.components.filter((component) => component.group === group.id);
|
||||||
@@ -29,7 +75,8 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
|||||||
<button type="button" className={tab === "about" ? "active" : ""} onClick={() => setTab("about")}>关于与运行环境</button>
|
<button type="button" className={tab === "about" ? "active" : ""} onClick={() => setTab("about")}>关于与运行环境</button>
|
||||||
<button type="button" className={tab === "catalog" ? "active" : ""} onClick={() => setTab("catalog")}>组件目录</button>
|
<button type="button" className={tab === "catalog" ? "active" : ""} onClick={() => setTab("catalog")}>组件目录</button>
|
||||||
<button type="button" className={tab === "execution" ? "active" : ""} onClick={() => setTab("execution")}>执行与证据</button>
|
<button type="button" className={tab === "execution" ? "active" : ""} onClick={() => setTab("execution")}>执行与证据</button>
|
||||||
<button type="button" className={tab === "browser" ? "active" : ""} onClick={() => setTab("browser")}>浏览器白名单</button>
|
<button type="button" className={tab === "browser" ? "active" : ""} onClick={() => setTab("browser")}>浏览器访问</button>
|
||||||
|
<button type="button" className={tab === "ai" ? "active" : ""} onClick={() => setTab("ai")}>AI 服务</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="panel setting-body">
|
<section className="panel setting-body">
|
||||||
@@ -61,8 +108,59 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
|||||||
|
|
||||||
{tab === "browser" && (
|
{tab === "browser" && (
|
||||||
<>
|
<>
|
||||||
<SettingCard icon={Globe2} title="浏览器域名白名单"><dl className="kv"><dt>默认允许</dt><dd>127.0.0.1、localhost、example.com</dd><dt>本地演示站</dt><dd>{info?.fixtureUrl || "未启动"}</dd><dt>扩展方式</dt><dd>环境变量 RPA_ALLOWED_DOMAINS</dd><dt>格式</dt><dd>逗号分隔域名,不含协议和路径</dd></dl></SettingCard>
|
<SettingCard icon={Globe2} title="浏览器访问范围"><dl className="kv"><dt>默认允许</dt><dd>全部 HTTP/HTTPS 站点</dd><dt>本地演示站</dt><dd>{info?.fixtureUrl || "未启动"}</dd><dt>协议限制</dt><dd>HTTP / HTTPS</dd><dt>用途</dt><dd>支持内网、公网和测试站点</dd></dl></SettingCard>
|
||||||
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">导航只允许 HTTP/HTTPS。未列入白名单的域名会返回 blocked_url;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></SettingCard>
|
<SettingCard icon={Clock} title="元素等待时间">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="browser-element-wait">全局默认等待(毫秒)</label>
|
||||||
|
<input
|
||||||
|
id="browser-element-wait"
|
||||||
|
className="text-input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={600000}
|
||||||
|
step={100}
|
||||||
|
value={waitDraft}
|
||||||
|
onChange={(event) => setWaitDraft(event.target.value)}
|
||||||
|
disabled={waitSaving}
|
||||||
|
/>
|
||||||
|
<span className="field-hint">所有网页元素能力共用。找不到元素时会持续等待到设定时间;0 表示不等待。</span>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void saveElementWait()} disabled={waitSaving}>
|
||||||
|
<Save className="icon" />
|
||||||
|
{waitSaving ? "保存中" : "保存设置"}
|
||||||
|
</button>
|
||||||
|
{waitMessage && <span className="field-hint">{waitMessage}</span>}
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">浏览器导航不限制域名。只拒绝 HTTP/HTTPS 以外的协议;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></SettingCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "ai" && (
|
||||||
|
<>
|
||||||
|
<SettingCard icon={Sparkles} title="AI 服务配置(兼容 OpenAI 协议)">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="ai-base-url">API Base URL</label>
|
||||||
|
<input id="ai-base-url" className="text-input" value={aiDraft.baseUrl} onChange={(event) => setAiDraft({ ...aiDraft, baseUrl: event.target.value })} placeholder="https://api.openai.com/v1" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="ai-api-key">API Key</label>
|
||||||
|
<input id="ai-api-key" className="text-input" type="password" value={aiDraft.apiKey} onChange={(event) => setAiDraft({ ...aiDraft, apiKey: event.target.value })} placeholder="sk-..." />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="ai-model">模型名称</label>
|
||||||
|
<input id="ai-model" className="text-input" value={aiDraft.model} onChange={(event) => setAiDraft({ ...aiDraft, model: event.target.value })} placeholder="gpt-4o-mini / deepseek-chat / qwen-plus" />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void saveAiConfig()} disabled={aiSaving}>
|
||||||
|
<Save className="icon" />
|
||||||
|
{aiSaving ? "保存中" : "保存设置"}
|
||||||
|
</button>
|
||||||
|
{aiMessage && <span className="field-hint">{aiMessage}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="field-hint">兼容 OpenAI Chat Completions 协议(含本地部署服务)。配置后 AI 文本生成、关键词、纠错、审核、情感分析等能力即可用。</span>
|
||||||
|
</SettingCard>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
|
PackagePlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -18,10 +19,12 @@ import type { FlowSummary } from "@shared/types";
|
|||||||
interface TasksPageProps {
|
interface TasksPageProps {
|
||||||
flows: FlowSummary[];
|
flows: FlowSummary[];
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
|
onCreateExamples: () => void;
|
||||||
onOpenEditor: (id: string) => void;
|
onOpenEditor: (id: string) => void;
|
||||||
onRun: (id: string) => void;
|
onRun: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
function formatTime(value: string): string {
|
||||||
@@ -43,9 +46,21 @@ function formatDuration(value?: number): string {
|
|||||||
|
|
||||||
const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const;
|
const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const;
|
||||||
|
|
||||||
export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRefresh }: TasksPageProps) {
|
export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onRefresh, onToast }: TasksPageProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
|
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
onToast("任务列表已刷新", "ok");
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!flows.length) {
|
if (!flows.length) {
|
||||||
@@ -74,8 +89,8 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe
|
|||||||
<p>选择一个任务开始运行或编辑流程;组件库只在进入编辑时参与搭建。</p>
|
<p>选择一个任务开始运行或编辑流程;组件库只在进入编辑时参与搭建。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-heading-actions">
|
<div className="page-heading-actions">
|
||||||
<button type="button" className="icon-btn" title="刷新任务列表" onClick={onRefresh}>
|
<button type="button" className="btn" onClick={onCreateExamples}>
|
||||||
<RefreshCw className="icon" />
|
<PackagePlus className="icon" /> 安装示例任务
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={onCreate}>
|
<button type="button" className="btn btn-primary" onClick={onCreate}>
|
||||||
<Plus className="icon" /> 新建任务
|
<Plus className="icon" /> 新建任务
|
||||||
@@ -101,6 +116,15 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe
|
|||||||
<Search className="icon" />
|
<Search className="icon" />
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务" aria-label="搜索任务" />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务" aria-label="搜索任务" />
|
||||||
</label>
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`icon-btn refresh-btn${refreshing ? " refreshing" : ""}`}
|
||||||
|
title="刷新任务列表"
|
||||||
|
aria-label="刷新任务列表"
|
||||||
|
onClick={() => void handleRefresh()}
|
||||||
|
>
|
||||||
|
<RefreshCw className="icon" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="task-table-head" aria-hidden="true">
|
<div className="task-table-head" aria-hidden="true">
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ import type { Catalog, CatalogField, FlowNode, FlowVariable } from "@shared/type
|
|||||||
import { useEditorStore } from "../store/editorStore";
|
import { useEditorStore } from "../store/editorStore";
|
||||||
import { groupIcon } from "../components/icons";
|
import { groupIcon } from "../components/icons";
|
||||||
import { containerSlots } from "../canvas/ContainerNode";
|
import { containerSlots } from "../canvas/ContainerNode";
|
||||||
|
import { fieldIsActive } from "../validation/flowValidation";
|
||||||
|
|
||||||
type Tab = "props" | "vars" | "run";
|
type Tab = "props" | "vars" | "run";
|
||||||
|
|
||||||
|
interface VariableRef {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PropertiesProps {
|
interface PropertiesProps {
|
||||||
catalog: Catalog;
|
catalog: Catalog;
|
||||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||||
@@ -41,6 +47,23 @@ function numberOr(value: unknown, fallback: number): number {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化下拉/单选选项:字符串 → {label,value};当前值不在选项中时追加进去(兼容历史手填值) */
|
||||||
|
function selectOptionsWith(field: CatalogField, rawValue: unknown): SelectOption[] {
|
||||||
|
const normalized = (field.options ?? []).map((option): SelectOption =>
|
||||||
|
typeof option === "string" ? { label: option, value: option } : { label: option.label, value: option.value }
|
||||||
|
);
|
||||||
|
const current = textOf(rawValue);
|
||||||
|
if (current && !normalized.some((option) => option.value === current)) {
|
||||||
|
normalized.unshift({ label: `${current}(当前值)`, value: current });
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function Properties({ catalog, onToast }: PropertiesProps) {
|
export function Properties({ catalog, onToast }: PropertiesProps) {
|
||||||
const [tab, setTab] = useState<Tab>("props");
|
const [tab, setTab] = useState<Tab>("props");
|
||||||
const flow = useEditorStore((state) => state.flow);
|
const flow = useEditorStore((state) => state.flow);
|
||||||
@@ -69,6 +92,35 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
|
|||||||
() => (node ? flow?.nodes.filter((item) => item.parentId === node.id) || [] : []),
|
() => (node ? flow?.nodes.filter((item) => item.parentId === node.id) || [] : []),
|
||||||
[flow, node]
|
[flow, node]
|
||||||
);
|
);
|
||||||
|
const variableRefs = useMemo<VariableRef[]>(() => {
|
||||||
|
if (!flow || !node) return [];
|
||||||
|
const incoming = new Map<string, string[]>();
|
||||||
|
for (const edge of flow.edges) {
|
||||||
|
incoming.set(edge.target, [...(incoming.get(edge.target) || []), edge.source]);
|
||||||
|
}
|
||||||
|
const ancestors = new Set<string>();
|
||||||
|
const queue = [node.id];
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
for (const source of incoming.get(current) || []) {
|
||||||
|
if (ancestors.has(source)) continue;
|
||||||
|
ancestors.add(source);
|
||||||
|
queue.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const refs: VariableRef[] = [];
|
||||||
|
for (const item of flow.nodes) {
|
||||||
|
if (!ancestors.has(item.id)) continue;
|
||||||
|
const name = item.options.outputVar;
|
||||||
|
if (!name) continue;
|
||||||
|
const owner = catalog.components.find((entry) => entry.id === item.componentId);
|
||||||
|
refs.push({ name, source: item.label || owner?.name || item.componentId });
|
||||||
|
}
|
||||||
|
for (const variable of flow.variables) {
|
||||||
|
refs.push({ name: variable.name, source: "流程变量" });
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}, [flow, node, catalog]);
|
||||||
|
|
||||||
if (!flow) return null;
|
if (!flow) return null;
|
||||||
|
|
||||||
@@ -96,6 +148,14 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
|
|||||||
<strong className="props-title">{node.label || component.name}</strong>
|
<strong className="props-title">{node.label || component.name}</strong>
|
||||||
<span className="props-id">{component.id}</span>
|
<span className="props-id">{component.id}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="props-head-actions">
|
||||||
|
<button type="button" className="icon-btn head-action" title="复制节点" onClick={() => duplicateNode(node.id)}>
|
||||||
|
<Copy className="icon" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-btn head-action head-action-danger" title="删除节点" onClick={() => removeNode(node.id)}>
|
||||||
|
<Trash2 className="icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
@@ -117,6 +177,7 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
|
|||||||
component={component}
|
component={component}
|
||||||
parent={parent}
|
parent={parent}
|
||||||
childNodes={childNodes}
|
childNodes={childNodes}
|
||||||
|
variableRefs={variableRefs}
|
||||||
onLabel={(label) => updateNode(node.id, { label })}
|
onLabel={(label) => updateNode(node.id, { label })}
|
||||||
onChange={(key, value) => updateValue(node.id, key, value)}
|
onChange={(key, value) => updateValue(node.id, key, value)}
|
||||||
onChangeOption={(id, key, value) =>
|
onChangeOption={(id, key, value) =>
|
||||||
@@ -139,14 +200,6 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
|
|||||||
|
|
||||||
<div className="props-foot">
|
<div className="props-foot">
|
||||||
<span>{component.implemented ? "已接入执行器" : "待接入执行器"}</span>
|
<span>{component.implemented ? "已接入执行器" : "待接入执行器"}</span>
|
||||||
<div style={{ display: "flex", gap: 4 }}>
|
|
||||||
<button type="button" className="icon-btn" title="复制节点" onClick={() => duplicateNode(node.id)}>
|
|
||||||
<Copy className="icon" />
|
|
||||||
</button>
|
|
||||||
<button type="button" className="icon-btn" title="删除节点" onClick={() => removeNode(node.id)}>
|
|
||||||
<Trash2 className="icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -177,6 +230,7 @@ function PropertyTab({
|
|||||||
component,
|
component,
|
||||||
parent,
|
parent,
|
||||||
childNodes,
|
childNodes,
|
||||||
|
variableRefs,
|
||||||
onLabel,
|
onLabel,
|
||||||
onChange,
|
onChange,
|
||||||
onChangeOption,
|
onChangeOption,
|
||||||
@@ -187,6 +241,7 @@ function PropertyTab({
|
|||||||
component: Catalog["components"][number];
|
component: Catalog["components"][number];
|
||||||
parent: FlowNode | null;
|
parent: FlowNode | null;
|
||||||
childNodes: FlowNode[];
|
childNodes: FlowNode[];
|
||||||
|
variableRefs: VariableRef[];
|
||||||
onLabel: (label: string) => void;
|
onLabel: (label: string) => void;
|
||||||
onChange: (key: string, value: unknown) => void;
|
onChange: (key: string, value: unknown) => void;
|
||||||
onChangeOption: (id: string, key: keyof FlowNode["options"], value: unknown) => void;
|
onChangeOption: (id: string, key: keyof FlowNode["options"], value: unknown) => void;
|
||||||
@@ -194,8 +249,9 @@ function PropertyTab({
|
|||||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||||
}) {
|
}) {
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const basic = component.fields.filter((field) => !field.advanced && field.widget !== "hidden");
|
const active = component.fields.filter((field) => field.widget !== "hidden" && fieldIsActive(component.id, field.key, node.values));
|
||||||
const advanced = component.fields.filter((field) => field.advanced && field.widget !== "hidden");
|
const basic = active.filter((field) => !field.advanced);
|
||||||
|
const advanced = active.filter((field) => field.advanced);
|
||||||
const slots = component.slots.length > 0 ? containerSlots(component) : [];
|
const slots = component.slots.length > 0 ? containerSlots(component) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -274,6 +330,7 @@ function PropertyTab({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
field={field}
|
field={field}
|
||||||
node={node}
|
node={node}
|
||||||
|
variables={variableRefs}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onToast={onToast}
|
onToast={onToast}
|
||||||
/>
|
/>
|
||||||
@@ -294,6 +351,7 @@ function PropertyTab({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
field={field}
|
field={field}
|
||||||
node={node}
|
node={node}
|
||||||
|
variables={variableRefs}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onToast={onToast}
|
onToast={onToast}
|
||||||
/>
|
/>
|
||||||
@@ -392,15 +450,45 @@ function PropertyTab({
|
|||||||
function FieldControl({
|
function FieldControl({
|
||||||
field,
|
field,
|
||||||
node,
|
node,
|
||||||
|
variables,
|
||||||
onChange,
|
onChange,
|
||||||
onToast
|
onToast
|
||||||
}: {
|
}: {
|
||||||
field: CatalogField;
|
field: CatalogField;
|
||||||
node: FlowNode;
|
node: FlowNode;
|
||||||
|
variables: VariableRef[];
|
||||||
onChange: (key: string, value: unknown) => void;
|
onChange: (key: string, value: unknown) => void;
|
||||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||||
}) {
|
}) {
|
||||||
const raw = node.values[field.key] ?? field.defaultValue;
|
const raw = node.values[field.key] ?? field.defaultValue;
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
const isGroundTarget = node.componentId.endsWith("Ground");
|
||||||
|
const isVisionSource = /^(OcrOffline_|CV_|Image_)/.test(node.componentId);
|
||||||
|
const isWindowTarget = /^(Window_|System_ConnectRemoteDesktop)/.test(node.componentId);
|
||||||
|
const isVisionTarget = /^(OcrOffline_|CV_)/.test(node.componentId);
|
||||||
|
|
||||||
|
const insertVariable = (expression: string) => {
|
||||||
|
const current = textOf(raw).trim();
|
||||||
|
const known = variables.some((item) => item.name === current);
|
||||||
|
onChange(field.key, current === "" || known ? expression : `${textOf(raw)}${expression}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDesktop = async () => {
|
||||||
|
setPicking(true);
|
||||||
|
try {
|
||||||
|
const picked = await window.rpa.desktop.pickElement();
|
||||||
|
if (picked.cancelled) {
|
||||||
|
onToast("已取消拾取");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(field.key, picked.selector);
|
||||||
|
onToast("已拾取:" + (picked.name || picked.controlType || "元素"));
|
||||||
|
} catch (error) {
|
||||||
|
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
setPicking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pickPath = async (directories: boolean) => {
|
const pickPath = async (directories: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -420,6 +508,63 @@ function FieldControl({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const pickElement = async () => {
|
||||||
|
setPicking(true);
|
||||||
|
try {
|
||||||
|
const currentFlow = useEditorStore.getState().flow;
|
||||||
|
const nodeIndex = currentFlow?.nodes.findIndex((item) => item.id === node.id) ?? -1;
|
||||||
|
const openNode = currentFlow && nodeIndex > 0
|
||||||
|
? [...currentFlow.nodes.slice(0, nodeIndex)].reverse().find((item) => {
|
||||||
|
const url = textOf(item.values.url).trim();
|
||||||
|
return Boolean(url && !url.startsWith("${"));
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const picked = await window.rpa.browser.pickElement({ url: textOf(openNode?.values.url) || undefined });
|
||||||
|
onChange(field.key, picked.selector);
|
||||||
|
onToast(`已拾取 ${picked.tag}${picked.label ? ` · ${picked.label}` : ""}`);
|
||||||
|
} catch (error) {
|
||||||
|
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
setPicking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pickRegionToField = async () => {
|
||||||
|
setPicking(true);
|
||||||
|
try {
|
||||||
|
const region = await window.rpa.desktop.pickRegion();
|
||||||
|
onChange(field.key, region.imagePath);
|
||||||
|
onToast("已框选并截图:" + region.width + "×" + region.height);
|
||||||
|
} catch (error) {
|
||||||
|
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
setPicking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickWindowToField = async () => {
|
||||||
|
setPicking(true);
|
||||||
|
try {
|
||||||
|
const list = await window.rpa.desktop.pickWindowList();
|
||||||
|
if (!list.length) {
|
||||||
|
onToast("未找到可见窗口", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numbered = list.map((w: { title: string }, i: number) => (i + 1) + ". " + w.title).join("\n");
|
||||||
|
const choice = prompt("选择窗口(输入编号):\n" + numbered);
|
||||||
|
const idx = Number(choice) - 1;
|
||||||
|
if (Number.isInteger(idx) && list[idx]) {
|
||||||
|
onChange(field.key, list[idx].title);
|
||||||
|
onToast("已选择窗口:" + list[idx].title);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
setPicking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
if (field.widget === "boolean") {
|
if (field.widget === "boolean") {
|
||||||
return (
|
return (
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
@@ -449,22 +594,22 @@ function FieldControl({
|
|||||||
) : field.widget === "select" && field.options?.length ? (
|
) : field.widget === "select" && field.options?.length ? (
|
||||||
<select className="select-input" value={textOf(raw)} onChange={(event) => onChange(field.key, event.target.value)}>
|
<select className="select-input" value={textOf(raw)} onChange={(event) => onChange(field.key, event.target.value)}>
|
||||||
<option value="">请选择</option>
|
<option value="">请选择</option>
|
||||||
{field.options.map((option) => (
|
{selectOptionsWith(field, raw).map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option.value} value={option.value}>
|
||||||
{option}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : field.widget === "radio" && field.options?.length ? (
|
) : field.widget === "radio" && field.options?.length ? (
|
||||||
<div className="chip-row">
|
<div className="chip-row">
|
||||||
{field.options.map((option) => (
|
{selectOptionsWith(field, raw).map((option) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={option}
|
key={option.value}
|
||||||
className={`option-chip${textOf(raw) === option ? " active" : ""}`}
|
className={`option-chip${textOf(raw) === option.value ? " active" : ""}`}
|
||||||
onClick={() => onChange(field.key, option)}
|
onClick={() => onChange(field.key, option.value)}
|
||||||
>
|
>
|
||||||
{option}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -476,8 +621,29 @@ function FieldControl({
|
|||||||
placeholder={field.placeholder || (field.widget === "folder" ? "选择目录" : "选择文件")}
|
placeholder={field.placeholder || (field.widget === "folder" ? "选择目录" : "选择文件")}
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<VarMenu variables={variables} onPick={insertVariable} />
|
||||||
<button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览">
|
<button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览">
|
||||||
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
|
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
|
||||||
|
{isVisionTarget && /image/i.test(field.key) && (
|
||||||
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕框选区域并截图">
|
||||||
|
{picking ? "框选中" : "屏幕框选"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isWindowTarget && /title/i.test(field.key) && (
|
||||||
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="选择窗口">
|
||||||
|
{picking ? "选择中" : "选窗口"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isVisionTarget && /image/i.test(field.key) && (
|
||||||
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕框选区域并截图">
|
||||||
|
{picking ? "框选中" : "屏幕框选"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isWindowTarget && /title/i.test(field.key) && (
|
||||||
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="选择窗口">
|
||||||
|
{picking ? "选择中" : "选窗口"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : field.widget === "element" ? (
|
) : field.widget === "element" ? (
|
||||||
@@ -488,18 +654,27 @@ function FieldControl({
|
|||||||
placeholder="css:#submit / aria:button"
|
placeholder="css:#submit / aria:button"
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="mini-btn" disabled title="Day 2 接入浏览器拾取器">
|
{isGroundTarget ? (
|
||||||
拾取
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop()} title="从桌面拾取元素(移到目标按空格)">
|
||||||
</button>
|
{picking ? "桌面拾取中" : "桌面拾取"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素">
|
||||||
|
{picking ? "拾取中" : "拾取"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : field.widget === "secret" ? (
|
) : field.widget === "secret" ? (
|
||||||
<input
|
<div className="field-row">
|
||||||
className="text-input"
|
<input
|
||||||
type="password"
|
className="text-input"
|
||||||
value={textOf(raw)}
|
type="password"
|
||||||
placeholder={field.placeholder || "密钥引用"}
|
value={textOf(raw)}
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
placeholder={field.placeholder || "密钥引用"}
|
||||||
/>
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
<VarMenu variables={variables} onPick={insertVariable} />
|
||||||
|
</div>
|
||||||
) : field.widget === "number" ? (
|
) : field.widget === "number" ? (
|
||||||
<input
|
<input
|
||||||
className="text-input"
|
className="text-input"
|
||||||
@@ -509,12 +684,15 @@ function FieldControl({
|
|||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<div className="field-row">
|
||||||
className="text-input"
|
<input
|
||||||
value={textOf(raw)}
|
className="text-input"
|
||||||
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
|
value={textOf(raw)}
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
|
||||||
/>
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
<VarMenu variables={variables} onPick={insertVariable} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.description && <span className="field-hint">{field.description}</span>}
|
{field.description && <span className="field-hint">{field.description}</span>}
|
||||||
@@ -525,6 +703,48 @@ function FieldControl({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VarMenu({
|
||||||
|
variables,
|
||||||
|
onPick
|
||||||
|
}: {
|
||||||
|
variables: VariableRef[];
|
||||||
|
onPick: (expression: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!variables.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="var-menu-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mini-btn"
|
||||||
|
title="插入上游变量引用"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<Variable className="icon" style={{ width: 13, height: 13 }} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="var-menu" role="menu">
|
||||||
|
{variables.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.name}
|
||||||
|
className="var-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(`\${${item.name}}`);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code>{item.name}</code>
|
||||||
|
<span>{item.source}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NumberField({
|
function NumberField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user