比较提交
117
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
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 |
没有与搜索条件相匹配的文件
+5
@@ -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 条固定示例任务
|
||||||
|
- 打包冒烟测试:跳过单实例锁隔离启动,验证产物可用性
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -36,23 +66,29 @@ npm run test:e2e # 隔离启动应用并执行本地网页订单完整流
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/main 主进程:窗口、目录加载、流程存储、IPC
|
src/main 主进程:窗口、目录加载、流程存储、IPC
|
||||||
|
src/main/browser 运行浏览器会话(BrowserSession,CDP 桥)
|
||||||
|
src/main/capabilities 能力实现(网页/桌面/数据/文件/办公/HTTP…)
|
||||||
|
src/main/fixture 本地演示站
|
||||||
src/preload 白名单桥
|
src/preload 白名单桥
|
||||||
src/renderer React UI:画布、组件库、属性面板、运行/设置页
|
src/renderer React UI:画布、组件库、属性面板、运行/设置页
|
||||||
src/shared 主/渲染共享类型
|
src/shared 主/渲染共享类型
|
||||||
scripts build-catalog / dev-safe / start / cdp-probe / 打包
|
scripts build-catalog / dev-safe / start / cdp-probe / 打包冒烟
|
||||||
resources catalog.json(1016 项派生元数据)、夹具站、桌面桥
|
resources catalog.json(1016 项派生元数据)、夹具站(含元素演练场)、桌面桥
|
||||||
docs 现状分析、实施方案
|
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 兼容服务)。
|
||||||
|
|
||||||
二进制
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 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` 校准。
|
||||||
|
|
||||||
|
|||||||
+420
@@ -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
|
||||||
|
};
|
||||||
自动生成
+2617
-540
文件差异过大,未显示。
加载差异
+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
文件差异过大,未显示。
加载差异
@@ -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
|
||||||
二进制文件未显示。
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
文件差异过大,未显示。
加载差异
文件差异过大,未显示。
加载差异
文件差异过大,未显示。
加载差异
文件差异过大,未显示。
加载差异
文件差异过大,未显示。
加载差异
文件差异过大,未显示。
加载差异
文件差异过大,未显示。
加载差异
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
文件差异过大,未显示。
加载差异
@@ -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)}`;
|
||||||
|
}
|
||||||
+631
-45
@@ -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");
|
||||||
|
if (this.win) {
|
||||||
await this.win.loadURL(target.toString());
|
await this.win.loadURL(target.toString());
|
||||||
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
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) {
|
||||||
if (!this.win.isDestroyed()) this.win.close();
|
if (!this.win.isDestroyed()) this.win.close();
|
||||||
globalThis.__rpaBrowserWindow = null;
|
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;
|
||||||
|
|
||||||
+65
-14
@@ -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,6 +27,7 @@ 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 });
|
||||||
|
try {
|
||||||
const result = await transporter.sendMail({
|
const result = await transporter.sendMail({
|
||||||
from: account,
|
from: account,
|
||||||
to: list(text(args, ["to", "receiver"])),
|
to: list(text(args, ["to", "receiver"])),
|
||||||
@@ -37,4 +38,7 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
|
|||||||
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
|
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];
|
||||||
|
|||||||
+40
-15
@@ -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) {
|
||||||
|
|||||||
+29
-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;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -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;
|
||||||
|
|
||||||
+22
-15
@@ -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) {
|
||||||
|
// 归一化改写了布局:强制写盘后必须用保存返回的新 meta 加载,
|
||||||
|
// 否则编辑器持有旧 updatedAt,首次保存会误判磁盘版本冲突
|
||||||
|
const result = await window.rpa.flows.save(flow, { force: true });
|
||||||
|
loadFlow(result.conflict ? flow : result.flow);
|
||||||
|
} else {
|
||||||
loadFlow(flow);
|
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 {
|
|
||||||
const saved = await window.rpa.flows.save(current);
|
|
||||||
markSaved();
|
|
||||||
await startRun(saved);
|
await startRun(saved);
|
||||||
} catch (error) {
|
}, [save, startRun]);
|
||||||
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="flow-meta">
|
|
||||||
<div className="editor-breadcrumb">
|
<div className="editor-breadcrumb">
|
||||||
<button type="button" className="back-link" onClick={onBack}>
|
<button type="button" className="back-btn" onClick={onBack} title="返回任务中心" aria-label="返回任务中心">
|
||||||
<ArrowLeft className="icon" /> 任务中心
|
<ArrowLeft className="icon" />
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span className="breadcrumb-current">编辑流程</span>
|
||||||
<span>编辑流程</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className="spacer" />
|
||||||
|
|
||||||
|
<div className="flow-meta">
|
||||||
<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,11 +654,18 @@ 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="从桌面拾取元素(移到目标按空格)">
|
||||||
|
{picking ? "桌面拾取中" : "桌面拾取"}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素">
|
||||||
|
{picking ? "拾取中" : "拾取"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : field.widget === "secret" ? (
|
) : field.widget === "secret" ? (
|
||||||
|
<div className="field-row">
|
||||||
<input
|
<input
|
||||||
className="text-input"
|
className="text-input"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -500,6 +673,8 @@ function FieldControl({
|
|||||||
placeholder={field.placeholder || "密钥引用"}
|
placeholder={field.placeholder || "密钥引用"}
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
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)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="field-row">
|
||||||
<input
|
<input
|
||||||
className="text-input"
|
className="text-input"
|
||||||
value={textOf(raw)}
|
value={textOf(raw)}
|
||||||
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
|
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
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,
|
||||||
|
|||||||
已加载 100/106 个文件。由于本次变更涉及的文件过多,其余文件未显示。
显示更多
在新工单中引用
屏蔽一个用户