比较提交

..
作者 SHA1 备注 提交日期
liuwenxin d489288c8e test(应用与系统): 添加组件验收流程与复测报告
提供 13 组、74 个用例,覆盖 49 项可执行组件。
新增隔离运行、真实界面检查、结果汇总和安全安装脚本。
升级仅覆盖未修改的测试模板,并保留原文件备份。
为手动操作设置保护开关,保留修复前后验证证据。

验证:43 项相关检查、类型检查与构建通过。
2026-11-02 09:06:40 +08:00
liuwenxin 5f9e28715c fix(应用与系统)!: 修复组件执行与参数校验
修正 PID 空结果、目标类型判断与转换、毫秒等待。
让关闭程序和命令行按所选模式显示并校验参数。
统一 PowerShell 输出编码,修正 BAT 路径引号处理。
明确屏保和屏幕唤醒的实际能力,补充缺陷回归测试。

验证:类型检查、构建与组件回归检查通过。

BREAKING CHANGE: 判断数据类型改为返回布尔值。
需要类型名称的旧流程请改用 Basic_Type。
屏保 V1 移除未实现参数,旧自定义密码节点需重新添加。
2026-11-02 09:05:48 +08:00
yu f74d1865da chore: 修复 desktop-picker.ps1 中 DllImport 字符串的引号不一致问题 2026-10-30 14:44:36 +08:00
yu 483b01c8b6 fix: 拾取/框选的窗口收集不再过滤最小化窗口,恢复时 show+restore+focus 2026-10-30 14:42:01 +08:00
yu 11fc88eec9 style(desktop-picker): 修改桌面选择器的透明样式并添加边框
将背景色改为 fuchsia 并设置透明键,添加绘制边框的 Paint 事件处理,提升视觉效果
2026-10-30 14:22:52 +08:00
yu 4ad238ff37 fix: 桌面拾取脚本补 DPI 感知,修复混合缩放下坐标错位 2026-10-30 14:16:34 +08:00
yu 8de4791e57 fix: 修复按键状态检测的位掩码值 2026-10-30 14:14:45 +08:00
yu b9bd6d671b fix: 修复桌面元素拾取时窗口未恢复的问题并优化脚本功能 2026-10-30 13:59:34 +08:00
yu a12e6cb0bd fix: 拾取循环补回 DoEvents 消息泵(v3 重写时丢失) 2026-10-30 12:57:52 +08:00
yu e6c34bafe5 fix: UIA Point 类型名修正(System.Windows.Point),脚本循环验证无错误 2026-10-30 12:52:43 +08:00
yu ed9b9daa50 fix: 拾取脚本完整重写——v3 逻辑+全链路日志(此前骨架覆盖事故修复) 2026-10-30 12:01:33 +08:00
yu a1490303d4 fix: 高亮框初始隐藏,仅有效矩形时显示 2026-10-30 12:01:30 +08:00
yu a968075100 feat: 外接浏览器支持阶段三+四(多标签管理 + 拾取与高级组件适配)
- external.ts: listPageTargets 标签枚举(过滤 chrome://edge:// 内部页)、openNewPageTarget(PUT /json/new)、ExternalConnection 增加 httpBase/targetId
- session.ts: listTabs/switchTab/openNewTab/getCookies 实现;外部按 targetId 缓存多 ws 会话,切换时迁移弹窗/网络监听;close 关闭全部会话;pickElement 放开外部浏览器(拾取脚本经 Runtime.evaluate 注入,行为与内置一致)
- engine/types.ts: BrowserLike 增加 listTabs/switchTab/openNewTab/getCookies 及窗口/监听可选方法
- browser.ts: Create 支持 openNewTab;WebBrowser_Switch 按标题(正则)/网址真实切换
- web-batch2.ts: GetTAB/V1/V2 真实枚举+匹配+激活;全部组件优先使用流程已选浏览器会话
- web-crawl.ts: 采集/监听组件优先流程会话;BatchDataCrawlNew browserType=chrome/edge 拉起外部浏览器
- browser-control.ts: GetCookieNew 升级 Network.getCookies(含 HttpOnly)
- 冒烟: Edge 双 Tab(ws 会话独立)、/json/new、页面枚举、Network.getCookies 全通过
2026-10-30 11:56:08 +08:00
yu fd507e4f62 fix: 修复用户级安装的 Chrome 探测失败
- LOCALAPPDATA 环境变量缺失(受限启动环境)时退回 os.homedir() 拼接 AppData/Local 候选路径
- 注册表 App Paths 兜底从仅 HKLM 扩展为 HKLM + WOW6432Node + HKCU(用户级安装的 Chrome 注册在 HKCU)
- 本机复现: Chrome 装于 C:/Users/6/AppData/Local, 注册于 HKCU App Paths, 修复后可探测到
2026-10-30 11:36:30 +08:00
yu f28b5ffc5e feat: 浏览器类型字段改为下拉选择
- catalog.json: WebBrowser_Create/V2/V3/CreateBackground 的 type 字段补 options(内置运行浏览器/Chrome/Edge,label 与存储值分离)
- shared/types.ts: CatalogField.options 支持字符串或 {label,value} 对象
- Properties.tsx: select/radio 选项归一化渲染 label;当前值不在选项中时追加显示(兼容历史手填值如 zexplorer)
- typecheck 通过;全量 vitest 除 4 项依赖真实 COM 的历史失败外全部通过
2026-10-30 11:26:33 +08:00
yu 3d64392eb0 fix: 拾取循环补 DoEvents 消息泵,修复高亮框/信息条冻结不刷新 2026-10-30 11:26:01 +08:00
yu ccfd82e894 feat: 外接浏览器支持阶段一+二(Edge/Chrome 打开与基础自动化)
- 新增 browser/transport.ts: CdpTransport 抽象(Embedded=webContents.debugger / External=ws 直连调试端口)
- 新增 browser/external.ts: Chrome/Edge 路径探测(常见目录+注册表App Paths)、带独立 user-data-dir 与调试端口拉起、端口占用自增、/json/version 就绪轮询、远程连接模式
- session.ts: BrowserSession 改为传输层注入, navigate/currentUrl/download/弹窗/网络监听双模式适配; 外部会话按 (mode,type,port) 缓存复用; pickElement 外部模式明确报不支持(阶段四)
- capabilities/browser.ts: WebBrowser_Create/V2/V3/CreateBackground 读取 type/isRemote/ipAddress/port/arguments; type=chrome/edge 或 isRemote=1 走外部浏览器, 缺省仍走内置兜底
- 依赖: +ws ^8.22.0
- 冒烟已验证: Edge 156 拉起→CDP 连接→Page.navigate+loadEventFired→取标题→截图→setDownloadBehavior→关闭 全通过
- 内置浏览器行为不变(browser.test.ts 21 项回归通过)
2026-10-30 11:09:19 +08:00
yu a25f8c76b6 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-10-30 10:52:41 +08:00
yu cc28d49ab5 fix: 修复质量检查发现的选择器与资源问题
修复浏览器通配符过滤和 ARIA 选择器转义,收敛表单窗口生命周期,释放邮件连接,清理日志复制定时器,并校准 Word COM 测试超时。
2026-10-30 10:51:27 +08:00
yu bc0ed3fcd8 fix: 文件/FTP实测修复三处真Bug
- windows-system.ts: File_OpenFile 打开文件夹时 explorer.exe 成功也返回退出码1, 放行该退出码(实测验证)
- ftp.ts: FTP_CreateDir 的 cd() 改为 await, 消除 basic-ftp 并发冲突(launched a task while another one is still running, 实测验证)
- flowValidation.ts: flow.variables 空值防御, 避免旧流程JSON缺字段时渲染崩溃

实测: FileTest 32/32, FtpTest 10/10 全部通过
2026-10-30 10:40:05 +08:00
yu f048be792c feat: 桌面拾取 v3——顶部居中信息条、高亮框跟随、两阶段交互(锁定/微调),显式 UTF8 BOM 2026-10-30 10:34:03 +08:00
yu 517d08d7ef docs: 桌面拾取与定位——原厂实现解读与增强项登记 2026-10-30 10:34:03 +08:00
yu c592cd891f docs: 外接浏览器(Edge/Chrome)支持改动方案
- 传输层抽象:CdpTransport 接口隔离 webContents.debugger 与 WebSocket 两种通道,BrowserLike 接口与 169 个网页组件零改动
- 唯一新依赖 ws(~50KB);客户机器零安装(Edge/Chrome 原生 CDP)
- 分四阶段:传输层+外部连接(2-3天) → 打开网页组件参数生效(1天) → 多标签 Target 域(2-3天) → 拾取器与高级组件适配(2-3天),合计约 1.5-2 周
- 关键坑已注明:独立 user-data-dir 必带、端口占用自增、断连报错
2026-10-30 10:24:01 +08:00
yu 19eb53b5d8 fix: 修复文件组 5 处目录字段与实现键名脱节
- File_CreateFile: 目录字段 folder_path/name,实现读 path → 必报文件路径为空
- File_GetFileInfo / File_WaitFile: 目录字段 file_path,实现读 path → 读不到
- File_CompressFile: 目录字段 file_path,实现读 pathSrc → 压缩源找不到
- File_Move: 目录字段 file_path/target_path/name,实现与 File_File_Move 共用
  pathSrc/pathDest → 读不到;补齐别名兼容
2026-10-30 10:14:22 +08:00
yu a3fbae9d83 docs: 图像与OCR 组 10 项能力问题检测报告
- 静态核对 + 真实运行双路验证,含 P0~P3 分级问题清单与修复优先级建议
- P0: GetOcrTextNew 目录字段无法传图(不可用);P1: Click 忽略 position/click_key、GetOcrTable 输出字段无效
- 算法层(飞桨 OCR/模板匹配 0.9993)实测正常,问题集中在目录与实现脱节
2026-10-30 10:11:07 +08:00
yu a4b7e24acd feat: 桌面拾取 v2——两阶段交互(悬停选目标/微调模式)、PageUp/PageDown 层级切换、高亮框跟随、UTF8 BOM 2026-10-30 09:58:42 +08:00
yu 745da55e4e docs: 按运行时注册校准能力接入状态 2026-10-30 09:26:28 +08:00
yu 7920a61e12 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-10-30 09:11:18 +08:00
yu bb3412fff7 fix: 桌面拾取脚本补 UTF8 BOM,修复 PowerShell 5.1 中文乱码 2026-10-30 08:58:44 +08:00
yu b19f9aa9ba fix: 修复拼接两列数据键名脱节导致的假成功
- SpliceColData/DoJoint 实现读取的键(col1/cola)与 catalog 必填字段
  (col_name_1/col_name_2/col_num_1/col_num_2)完全脱节,填了参数也按
  undefined 找列,拼接出空值仍报成功
- 改读 catalog 真实键名,按 change_type 支持列名/列号两种模式,兼容旧键
2026-10-29 17:58:57 +08:00
yu e59034d700 fix: 拾取/框选时最小化应用自身窗口,完成后恢复 2026-10-29 17:12:15 +08:00
yu f353254a86 fix: 补齐 desktop 拾取 IPC 注册与 import 2026-10-29 16:45:59 +08:00
yu c2b09f39e6 fix: 桌面拾取按钮判定与重复函数清理 2026-10-29 16:45:59 +08:00
yu 0694bd45d3 feat: 编辑器交互优化——圆形返回键、名称块居中、面板按钮悬停动画、删除移至右上角
- 返回键改圆形按钮:悬停变蓝+箭头左移动画,面包屑仅留「编辑流程」
- 流程名称块保持工具栏绝对居中
- 保存/运行按钮悬停动画(弹性缩放/上浮光晕);撤销重做保持静态
- 节点复制/删除移至属性面板右上角,删除悬停红色更醒目
2026-10-29 16:28:26 +08:00
yu 543d5d1564 fix: 清理重复声明,拾取矩阵与桌面拾取稳定 2026-10-29 16:24:35 +08:00
yu 8cedf45628 fix: 修复设置单元格格式不生效与表格样式覆盖单元格样式
- SetCellFormat V1/V2/V3:格式解析补厂商 JSON 键映射(FontColor/FontSize/
  BoldAndItalic 位标志/BackgroundColor 带透明度/DigitalFormat.formatstring/
  FontFormat 字体名),纯格式代码(如 0.00)回落为数字格式——此前全部被静默忽略
- ShowTableStyle:行级样式(row.font/fill+commit)在 exceljs 中会覆盖该行
  已设置的单元格样式,改为逐单元格设置表头样式
- 经 18 节点覆盖流程实测:V3 红字宋体加粗、V2 蓝字、V1 数字格式 0.00 均落盘
2026-10-29 15:57:57 +08:00
yu 4163b57d38 fix: 桌面 Ground 能力归类到独立的桌面元素分组 2026-10-29 15:54:07 +08:00
yu 8870050aaa feat: 拾取策略矩阵——桌面/视觉/窗口能力按类型显示拾取按钮(桌面拾取/屏幕框选/窗口列表) 2026-10-29 15:43:14 +08:00
yu b40fcbdd35 fix: 根治保存必弹磁盘冲突——编辑不再污染乐观锁基准
- editorStore 新增 baseUpdatedAt(磁盘基准版本),仅 loadFlow/syncSavedFlow 更新
- 保存 expectedUpdatedAt 改用 baseUpdatedAt;此前用每次编辑都会拨快的
  flow.meta.updatedAt 做比对,导致任何编辑后首次保存必然误判冲突
- 移除 flows:save/get 诊断日志(保留内容一致放行逻辑)
2026-10-29 15:33:09 +08:00
yu 778f69e14d fix: 桌面拾取下钻到包含鼠标点的最小控件,修复命中容器级大元素 2026-10-29 15:26:44 +08:00
yu 8b0004bf72 feat: 桌面拾取器——UIA 元素拾取(空格确认/Esc 取消)、窗口列表、屏幕区域框选截图,属性面板按能力类型切换网页/桌面拾取 2026-10-29 15:22:05 +08:00
yu 1e649d7121 chore: 忽略 Tesseract 语言包缓存 2026-10-29 14:37:40 +08:00
yu 0e65fd751d fix: 设置测试补 aiConfig 期望;打包配置 asarUnpack 原生模块与飞桨模型 2026-10-29 14:32:50 +08:00
yu 3b0883c80f feat: OCR 后端切换飞桨——RapidOCR ONNX(@gutenye/ocr-node,PaddleOCR v4 模型全离线) 2026-10-29 14:13:50 +08:00
yu 06886c5b31 fix: OCR 识别开启 blocks 输出并从块树提取词/行,修复坐标与表格为空 2026-10-29 13:51:51 +08:00
yu 6f008ed8c0 fix: AI 请求加 170 秒超时保护,防止服务端无响应时无限挂起 2026-10-29 11:39:50 +08:00
yu a4cccbc858 fix: 修正 AI/NLP 能力 id 与目录一致(情感/关键词/实体/批量评论),补充讯飞变体 2026-10-29 11:13:38 +08:00
yu d351556293 feat: 接入 AI 文本家族(OpenAI 兼容协议,17 id)与本地 OCR(Tesseract.js,4 id);设置页新增 AI 服务配置 2026-10-29 10:58:35 +08:00
yu d331d56f2e feat: 接入 FTP 全家桶(10 id)——连接/列表/下载/上传/重命名/删除/建目录/存在判断/断开 2026-10-29 10:28:08 +08:00
yu 26c682d2f9 feat: Excel 高阶接入——格式/筛选/表格样式/隐藏/尾空单元格/列标识/选中区域/关闭/加载/写列(16 id) 2026-10-29 10:10:34 +08:00
yu fc1207cddd feat: P2 接入——屏幕锁/屏保/凭据/远程桌面/输入法/文件对话框(19 id) 2026-10-29 10:01:32 +08:00
yu bb23bb192c fix: 移除 System_ExecuteCommandV1 重复注册,统一使用原生实现(type:1=DOS 2=BAT 3=PS) 2026-10-29 09:35:51 +08:00
yu de514500a7 feat: 接入 System_ExecuteCommandV1——按 command_type 分派 PS/DOS/BAT 2026-10-29 09:25:20 +08:00
yu 303560f0f8 feat: P1 接入——人机交互表单(9 id)、Time 家族(7 id)、图表数据读取(2 id) 2026-10-29 09:21:21 +08:00
yu b08f056aba fix: 修复批二 handler 缺失声明与 FlowSaveResult 类型收窄 2026-10-28 18:05:08 +08:00
yu f6bddb08d8 docs: 能力接入规划与排期——明确不接入清单、暂缓池与分批排期 2026-10-28 18:01:50 +08:00
yu 69e5a1c35c docs: README 能力计数更新至 655/1016 2026-10-28 17:54:33 +08:00
yu 5248165e83 fix: 打开流程归一化写盘后用最新 meta 加载编辑器,消除首次保存误报冲突 2026-10-28 17:54:07 +08:00
yu 4ac9755416 feat: 接入网页批三——数据采集家族(14 id)与网页监听三件套 2026-10-28 17:53:44 +08:00
yu 2ce0c13f7b docs: 更新 README——能力接入进度、桌面自动化、保存冲突检测与测试任务 2026-10-28 17:49:18 +08:00
yu afb2ed7c08 chore: 忽略本地测试产物目录 2026-10-28 17:46:32 +08:00
yu 38a561a7cd test: 打包冒烟支持跳过单实例锁与固定隔离环境 2026-10-28 17:46:32 +08:00
yu d6165c910c fix: 修复 web-batch2 文件内字面 \\n 导致的语法错误 2026-10-28 17:46:32 +08:00
yu 4c84674b29 fix: 补注册弹窗自动关闭两个能力 2026-10-28 17:46:32 +08:00
yu 560134171f feat: 接入网页批二——文件上传/多标签/窗口状态/停止加载/相似元素数组/弹窗自动关闭 2026-10-28 17:46:32 +08:00
yu 9b8bc0eb1a fix: SetScrollPos 兼容目录参数 scrollpos,修复滚动位置不生效 2026-10-28 17:46:32 +08:00
yu 67a418129b test: 新增网页元素演练场页面与三项网页自动化测试任务 2026-10-28 17:46:31 +08:00
yu a5d9f0bfee fix: 消除保存并发竞态——同步锁防重复保存,冲突判定先比对内容 2026-10-28 17:38:41 +08:00
yu 6cb10db375 fix: 保存按钮改用交互模式保存,可处理磁盘版本冲突(原先仅 Ctrl+S 可解) 2026-10-28 17:29:17 +08:00
yu ab8b0cde48 style: 移除左侧导航选中态的蓝色指示条 2026-10-28 17:21:41 +08:00
yu a7c74b85a0 style: 流程名块相对工具栏整体绝对居中 2026-10-28 17:19:37 +08:00
yu f2eb449b87 style: 工具栏流程名居中显示;移除顶栏页面徽标 2026-10-28 17:15:06 +08:00
yu e00a1e4743 style: 工具栏重排——面包屑放大居左,流程名与统计移至右侧操作区旁 2026-10-28 17:08:40 +08:00
yu 69e9554250 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-10-28 17:02:14 +08:00
yu 2b372e2d7f refactor: 移除画布中央常驻空流程提示,引导职责由首访横幅承担 2026-10-28 16:42:19 +08:00
yu 37f2c8977b feat: 新建流程首次进入展示引导提示,5 秒后自动关闭 2026-10-28 16:37:25 +08:00
yu 7c7a1b1a1f fix: 任务列表忽略备份文件,避免同名重复任务 2026-10-28 16:29:30 +08:00
yu 3a1c176f4d fix: 切换流程时清空运行结果面板,避免显示上一流程的运行状态 2026-10-28 16:04:44 +08:00
yu 7f3aaac2ff fix: Excel_Cs_SaveAs 支持 path(目录)+name(文件名) 组合另存 2026-10-28 15:59:41 +08:00
yu 9d996f137b fix: 补齐 setValue/getValue 脚本缺失的 try-catch 闭合,修复 PowerShell 语法错误 2026-10-28 15:49:33 +08:00
yu 06981dfff3 fix: 点击前检测最小化/屏幕外窗口并自动还原,修复 -32000 坐标点击落空 2026-10-28 15:43:00 +08:00
yu 093fc302fa fix: 桌面脚本强制 Per-Monitor DPI 感知,修复多屏缩放下点击坐标错位 2026-10-28 15:33:27 +08:00
yu 3b05d10ff4 fix: App_Run 参数为空时不传 -ArgumentList,避免部分应用启动即退出 2026-10-28 15:18:58 +08:00
yu 9c973d9bad fix: 窗口唤起改用 EnumWindows 跨虚拟桌面查找并移回主屏可视区 2026-10-28 15:11:01 +08:00
yu 0fe264a884 fix: 清空区域兼容 cellPos 与 startRow/startColumn 键并支持清除格式模式 2026-10-28 15:04:55 +08:00
yu 2c32a8f51d feat: Ground 能力支持托盘/隐藏窗口自动唤起与 AttachThreadInput 前台化 2026-10-28 15:01:37 +08:00
共修改 89 个文件,包含 138198 行新增和 252 行删除

没有与搜索条件相匹配的文件

+5
查看文件
@@ -7,3 +7,8 @@ etest2/
*.tmp *.tmp
.DS_Store .DS_Store
Thumbs.db Thumbs.db
test-output/
web-capability-audit.txt
*.traineddata
+52 -17
查看文件
@@ -3,19 +3,49 @@
画布式 RPA 桌面端(Electron + React Flow)。把 1016 项能力目录拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。 画布式 RPA 桌面端(Electron + React Flow)。把 1016 项能力目录拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。
> 当前状态:任务优先的编排工作台。默认进入“编排”任务列表,编辑任务后进入上下流向画布;组件库按分类树展示,设置为独立入口。 > 当前状态:任务优先的编排工作台。默认进入“编排”任务列表,编辑任务后进入上下流向画布;组件库按分类树展示,设置为独立入口。
> 可执行能力持续接入中:**732 / 1016**(网页自动化、桌面自动化、数据、文件、办公、HTTP 等),其余灰标「待接入」,运行前检查会提前定位。
## 已实现(Day 1) ## 已实现
### 编排与运行
- Electron 31 + React 18 + TypeScript + React Flow 画布 - Electron 31 + React 18 + TypeScript + React Flow 画布
- 1016 项组件目录:997 厂商 + 16 控制 + 3 触发器,搜索 / 11 组筛选 / 可执行与待接入状态 - 1016 项组件目录:搜索 / 11 组筛选 / 可执行与待接入状态;任务列表实时刷新
- 编排工作区:任务列表、任务概览、运行/编辑入口;左侧主导航仅保留“编排”和“设置”
- 组件库树:按能力分组展开/收起,搜索命中时自动展开分类,叶子组件支持拖拽
- 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做 - 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做
- 画布主流程上下连接;异常出口默认隐藏,可在节点失败策略中按需开启
- IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体 - IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体
- 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时) - 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时)
- 流程 JSON 本地原子保存、自动保存、重启恢复 - 流程 JSON 本地原子保存、自动保存、重启恢复;**保存冲突检测**——磁盘被外部修改时弹窗选择“加载磁盘版”或“覆盖磁盘”,不再静默互相覆盖
- 运行页与设置页骨架;运行期完全离线 - 运行中心:节点进度、运行日志(分级过滤/复制/导出)、运行变量查看
- 任务列表忽略备份文件;编排页与运行页骨架分离
### 网页自动化(Electron 内置浏览器 + CDP 可信输入)
- 浏览器:打开/关闭/切换标签、跳转/后退/前进/刷新、等待加载/停止加载、最大化/最小化
- 信息获取:URL、Title、网页源码、Cookies 读写、浏览器截图、滚动条读写
- 元素操作:点击、输入、密码框、下拉选择、勾选框、相似元素列表/数组、元素对象与信息、父/子/兄弟元素、文本/值/属性/源码读取、存在判断、按钮可用性、滚动到元素、鼠标悬停、元素截图、元素坐标
- 执行 JS(多版本)、文件上传(CDP 直塞文件输入框,绕开原生对话框)
- 网页数据表格写入(按行列号精确覆盖)
- 可信输入机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击
### 桌面自动化(Ground 系列,PowerShell + UIA 桥)
- 桌面元素点击(前台化 + AttachThreadInput 强制前台 + 真实鼠标坐标点击)、文本输入(ValuePattern + 剪贴板粘贴兜底)、元素存在判断、文本读取、等待元素
- 窗口自动唤起:EnumWindows 跨虚拟桌面查找 → ShowWindow 还原 → 移回主屏可视区
- Per-Monitor DPI 感知:多屏混合缩放下坐标不错位
- 隐式等待重试:元素未就绪时 4 秒内自动重试
- 底层统一走 PowerShell + UIAutomation(EncodedCommand,中文安全)
### AI 与离线 OCR(可选自配)
- AI 文本能力:文本生成 / 纠错 / 审核 / 关键词 / 对话 / 模型调用 / 文档比对等,通过设置页配置的 OpenAI 兼容服务接入
- 离线 OCR:图像识别 / 表格识别(含坐标)走本地模型,无需联网
### 质量与测试
- 全量测试 206 项(能力注册冒烟、流程运行集成、组件缺陷回归、测试流程包与安装保护、Word/Excel/PDF/桌面桥真实设备测试)
- 本地演示站(`127.0.0.1:17863`):网页订单页 + 网页元素演练场(表单/下拉/勾选/密码/禁用按钮/悬停/长页面)
- 内置 3 个网页自动化测试任务(浏览器导航、元素读写、滚动与 Cookie)+ 6 条固定示例任务
- 打包冒烟测试:跳过单实例锁隔离启动,验证产物可用性
## 开发 ## 开发
@@ -35,25 +65,30 @@ npm run test:e2e # 隔离启动应用并执行本地网页订单完整流
## 目录 ## 目录
``` ```
src/main 主进程:窗口、目录加载、流程存储、IPC src/main 主进程:窗口、目录加载、流程存储、IPC
src/preload 白名单桥 src/main/browser 运行浏览器会话(BrowserSession,CDP 桥)
src/renderer React UI:画布、组件库、属性面板、运行/设置页 src/main/capabilities 能力实现(网页/桌面/数据/文件/办公/HTTP…)
src/shared 主/渲染共享类型 src/main/fixture 本地演示站
scripts build-catalog / dev-safe / start / cdp-probe / 打包 src/preload 白名单桥
resources catalog.json(1016 项派生元数据)、夹具站、桌面桥 src/renderer React UI:画布、组件库、属性面板、运行/设置页
docs 现状分析、实施方案 src/shared 主/渲染共享类型
scripts build-catalog / dev-safe / start / cdp-probe / 打包冒烟
resources catalog.json(1016 项派生元数据)、夹具站(含元素演练场)、桌面桥
docs 现状分析、实施方案
``` ```
## 文档 ## 文档
- [应用与系统 49 项组件测试任务](resources/test-flows/app-system/README.md):13 组可安装任务、使用步骤、覆盖清单及实际验收报告。
- [现状分析与可行性](docs/01-现状分析与可行性.md) - [现状分析与可行性](docs/01-现状分析与可行性.md)
- [实施方案(三天)](docs/02-实施方案.md) - [实施方案(三天)](docs/02-实施方案.md)
## 边界 ## 边界
- 1016 项目录中当前有 619 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 - 1016 项目录中当前 732 项可执行;未接入的主要为:异常监控组件、桌面 Ground 剩余变体、SAP 专用、邮件收件(IMAP)、Word 选区 COM 等。图表数据读取(ECharts)已接入;下载对话框为明确不接入(下载能力已覆盖)。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。 - 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
- 编排首页提供“安装示例任务”,可安装网页订单、文件文本、Excel读写、剪贴板、Word文档和异常分支 6 条固定示例;重复安装会更新同名示例,不会无限复制。 - 编排首页提供“安装示例任务”,可安装网页订单、文件文本、Excel读写、剪贴板、Word文档和异常分支 6 条固定示例;重复安装会更新同名示例,不会无限复制。
- 浏览器可信输入与定位机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击。 - Tauri/WebView2 类应用的 UIA 支持天然受限(Invoke 假成功、ValuePattern 读回掩码):桌面元素点击采用“前台化 + 真实坐标点击”策略,输入采用“ValuePattern 写入 + 剪贴板粘贴兜底”。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。 - 运行期不访问厂商云服务;HTTP、邮件与 AI 能力属于用户在设置页显式配置的外部系统(OpenAI 兼容服务)。
+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 当前状态**:常用本地能力已进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **619 项可执行 / 1016 项总目录**。当前 23 个测试文件共 138 条测试,浏览器 E2E 真实结果为“订单创建成功:RPA 客户,金额 128”。数据处理仅剩 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 在外部浏览器下与内置结果一致
+99
查看文件
@@ -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 上隐藏无效字段避免误导
+5
查看文件
@@ -25,6 +25,11 @@ export default {
{ from: path.join(root, "resources", "fixture"), to: "fixture" } { from: path.join(root, "resources", "fixture"), to: "fixture" }
], ],
asar: true, asar: true,
asarUnpack: [
"node_modules/onnxruntime-node/**",
"node_modules/sharp/**",
"node_modules/@gutenye/**"
],
compression: "maximum", compression: "maximum",
npmRebuild: false, npmRebuild: false,
electronVersion: packageJson.devDependencies.electron, electronVersion: packageJson.devDependencies.electron,
自动生成
+831 -3
查看文件
@@ -8,8 +8,11 @@
"name": "rpa-studio", "name": "rpa-studio",
"version": "0.1.0", "version": "0.1.0",
"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",
@@ -18,6 +21,8 @@
"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": {
@@ -27,6 +32,7 @@
"@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-builder": "^26.15.7",
@@ -626,6 +632,23 @@
"node": ">=14.14" "node": ">=14.14"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1109,6 +1132,500 @@
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@gutenye/ocr-common": {
"version": "1.4.9",
"resolved": "https://registry.npmmirror.com/@gutenye/ocr-common/-/ocr-common-1.4.9.tgz",
"integrity": "sha512-1ry8Uw5fWZBZ/TsYB31x/YH04cOk7cO2PyLcauW9rmrK+0bxV0IeBRWngeje+fhdVJ99L1s+Y0BZwrjEVCxgOA==",
"license": "MIT",
"dependencies": {
"@techstark/opencv-js": "^4.9.0-release.3",
"js-clipper": "^1.0.1",
"tiny-invariant": "^1.3.3"
}
},
"node_modules/@gutenye/ocr-models": {
"version": "1.4.2",
"resolved": "https://registry.npmmirror.com/@gutenye/ocr-models/-/ocr-models-1.4.2.tgz",
"integrity": "sha512-y7cLEpGFOeroavopfCd/ejyZB/UeF6xdn0mBFqVHNOI5wQTv2My+/nKxvxKTtG4DVMBzt9TpOfaX7aZGbuciUw==",
"license": "MIT"
},
"node_modules/@gutenye/ocr-node": {
"version": "1.4.9",
"resolved": "https://registry.npmmirror.com/@gutenye/ocr-node/-/ocr-node-1.4.9.tgz",
"integrity": "sha512-D0cGYxld8l3ezhiZwE5WQNanPP3pqY99OBAA7dSNivxVNd7nLih7XqUIQ6vsf6jOeW0C4SO0PQWzzygt1g/Yng==",
"license": "MIT",
"dependencies": {
"@gutenye/ocr-common": "^1.2.0",
"@gutenye/ocr-models": "^1.2.2",
"onnxruntime-node": "^1.22.0-rev",
"sharp": "^0.34.3"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2256,6 +2773,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@techstark/opencv-js": {
"version": "4.9.0-release.3",
"resolved": "https://registry.npmmirror.com/@techstark/opencv-js/-/opencv-js-4.9.0-release.3.tgz",
"integrity": "sha512-y+KoLLlkXVUHWozYlN1vmMD9TQMPJObqml5nPA5vPWgHtOgm8q5O74UtzM23eayidvmeFX6tF5e2gDdyxcpy3Q==",
"license": "Apache-2.0"
},
"node_modules/@types/adm-zip": { "node_modules/@types/adm-zip": {
"version": "0.5.8", "version": "0.5.8",
"resolved": "https://registry.npmmirror.com/@types/adm-zip/-/adm-zip-0.5.8.tgz", "resolved": "https://registry.npmmirror.com/@types/adm-zip/-/adm-zip-0.5.8.tgz",
@@ -2492,6 +3015,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -3325,6 +3858,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/basic-ftp": {
"version": "6.2.3",
"resolved": "https://registry.npmmirror.com/basic-ftp/-/basic-ftp-6.2.3.tgz",
"integrity": "sha512-T7GYtQ2eKJUN5YORG7A8u2WfB9JMN0a4/wl3MI64qq2qTRfa3FXzoNq0DDojZDkDyStcL+TkfKsz4JHEZx5kGQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/big-integer": { "node_modules/big-integer": {
"version": "1.6.52", "version": "1.6.52",
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz", "resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
@@ -3365,6 +3907,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/boolean": { "node_modules/boolean": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
@@ -4186,6 +4734,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node": { "node_modules/detect-node": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
@@ -4872,9 +5429,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -5515,6 +6070,12 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/idb-keyval/-/idb-keyval-6.3.0.tgz",
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
"license": "Apache-2.0"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
@@ -5876,6 +6437,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -5973,6 +6540,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-clipper": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/js-clipper/-/js-clipper-1.0.1.tgz",
"integrity": "sha512-0XYAS0ZoCki5K0fWwj8j8ug4mgxHXReW3ayPbVqr4zXPJuIs2pyvemL1sALadsEiAywZwW5Ify1XfU4bNJvokg==",
"license": "boost"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6509,6 +7082,26 @@
"semver": "^7.3.5" "semver": "^7.3.5"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp": { "node_modules/node-gyp": {
"version": "12.4.0", "version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
@@ -6674,6 +7267,104 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/onnxruntime-common": {
"version": "1.31.1",
"resolved": "https://registry.npmmirror.com/onnxruntime-common/-/onnxruntime-common-1.31.1.tgz",
"integrity": "sha512-F+nnYNiQtmNJ95V6R/isBJqiVpdANCrsMEg1OaF+Bkbl3GAtzWLR7Ng17Ld0QtYtYJyotOHPJzOQn1Y6PLZjpg==",
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.31.1",
"resolved": "https://registry.npmmirror.com/onnxruntime-node/-/onnxruntime-node-1.31.1.tgz",
"integrity": "sha512-ijnSghbDHVWaQ2nDPmVccPlCHI8kH1Mx1aJ4a/qzRP16bBcVs4E3EqZQKZCkhWnbqN1tuQWxagimFtDpbPYYQw==",
"hasInstallScript": true,
"license": "MIT",
"os": [
"win32",
"darwin",
"linux"
],
"dependencies": {
"adm-zip": "^0.6.0",
"global-agent": "^4.1.3",
"onnxruntime-common": "1.31.1"
}
},
"node_modules/onnxruntime-node/node_modules/adm-zip": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.6.1.tgz",
"integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
"license": "MIT",
"engines": {
"node": ">=14.0"
}
},
"node_modules/onnxruntime-node/node_modules/global-agent": {
"version": "4.1.6",
"resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-4.1.6.tgz",
"integrity": "sha512-V9u791atI/bS7RryfRTK2oixDWo6/RCrIikjwuQ4kADPRZZml1C6LUJXc7y6qRhaAF8geo3bIvE/siAGJCw38A==",
"license": "BSD-3-Clause",
"dependencies": {
"globalthis": "^1.0.2",
"matcher": "^4.0.0",
"semver": "^7.3.5",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/onnxruntime-node/node_modules/matcher": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/matcher/-/matcher-4.0.0.tgz",
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onnxruntime-node/node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onnxruntime-node/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/own-keys": { "node_modules/own-keys": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.2.tgz",
@@ -7161,6 +7852,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7467,7 +8164,6 @@
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -7553,6 +8249,50 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7970,6 +8710,30 @@
"rimraf": "bin.js" "rimraf": "bin.js"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/tiny-async-pool": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -7990,6 +8754,12 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
@@ -8070,6 +8840,12 @@
"tmp": "^0.2.0" "tmp": "^0.2.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/traverse": { "node_modules/traverse": {
"version": "0.3.10", "version": "0.3.10",
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.10.tgz", "resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.10.tgz",
@@ -9586,6 +10362,12 @@
} }
} }
}, },
"node_modules/wasm-feature-detect": {
"version": "1.9.0",
"resolved": "https://registry.npmmirror.com/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz",
"integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==",
"license": "Apache-2.0"
},
"node_modules/webcrypto-core": { "node_modules/webcrypto-core": {
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -9607,6 +10389,22 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
@@ -9749,6 +10547,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -9870,6 +10689,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.16", "version": "5.0.16",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.16.tgz", "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.16.tgz",
+6
查看文件
@@ -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,6 +30,8 @@
"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": {
@@ -36,6 +41,7 @@
"@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-builder": "^26.15.7",
+68
查看文件
@@ -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
查看文件
文件差异过大,未显示。 加载差异
+169
查看文件
@@ -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) }
+44
查看文件
@@ -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>
+106
查看文件
@@ -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
+159
查看文件
@@ -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"
}
}
+278
查看文件
@@ -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;
}
+390
查看文件
@@ -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; });
}
+179
查看文件
@@ -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);
});
});
+2 -1
查看文件
@@ -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(",")}`);
+17
查看文件
@@ -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;
}
+7 -1
查看文件
@@ -49,7 +49,13 @@ async function verifyLaunch(executable, label) {
const child = spawn(executable, [`--user-data-dir=${userData}`], { const child = spawn(executable, [`--user-data-dir=${userData}`], {
cwd: path.dirname(executable), cwd: path.dirname(executable),
stdio: "ignore", stdio: "ignore",
windowsHide: false 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); await delay(8000);
const exited = child.exitCode !== null || child.signalCode !== null; const exited = child.exitCode !== null || child.signalCode !== null;
+40
查看文件
@@ -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);
});
+315
查看文件
@@ -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)}`;
}
+401 -29
查看文件
@@ -2,6 +2,15 @@ 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 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; const DEFAULT_ELEMENT_WAIT_MS = 5000;
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS; let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
@@ -19,7 +28,8 @@ 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";
function assertUrl(raw: string): URL { function assertUrl(raw: string): URL {
@@ -84,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,
@@ -114,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> {
@@ -162,6 +209,10 @@ export class BrowserSession implements BrowserLike {
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;
@@ -175,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 || '';
@@ -259,8 +310,40 @@ export class BrowserSession implements BrowserLike {
async navigate(url: string): Promise<{ url: string; title: string }> { async navigate(url: string): Promise<{ url: string; title: string }> {
const target = assertUrl(url); const target = assertUrl(url);
await this.cdp("Page.enable"); await this.cdp("Page.enable");
await this.win.loadURL(target.toString()); if (this.win) {
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() }; await this.win.loadURL(target.toString());
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
}
// 外部浏览器:Page.navigate + 等待 loadEventFired(与内置 loadURL 等加载完成的语义一致)
const loaded = new Promise<void>((resolve) => {
let done = false;
const onLoad = () => {
if (done) return;
done = true;
clearTimeout(timer);
this.transport.off("Page.loadEventFired", onLoad);
// 留 150ms 余量让 load 事件后的首帧渲染稳定
setTimeout(resolve, 150);
};
const timer = setTimeout(() => {
if (done) return;
done = true;
this.transport.off("Page.loadEventFired", onLoad);
resolve();
}, 30000);
this.transport.on("Page.loadEventFired", onLoad);
});
const result = await this.cdp<{ errorText?: string }>("Page.navigate", { url: target.toString() });
if (result?.errorText) {
throw new RuntimeError("browser_error", `页面导航失败:${result.errorText}`, {
detail: target.toString(),
recommendation: "确认网址可达"
});
}
await loaded;
const currentUrl = await this.evaluate<string>("location.href");
const title = await this.evaluate<string>("document.title");
return { url: currentUrl, title };
} }
private async wait(milliseconds: number): Promise<void> { private async wait(milliseconds: number): Promise<void> {
@@ -391,8 +474,10 @@ export class BrowserSession implements BrowserLike {
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> { async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
const info = await this.locate(selector, timeoutMs); const info = await this.locate(selector, timeoutMs);
this.win.show(); if (this.win) {
this.win.focus(); 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, ` const focused = await this.withElement<boolean>(selector, `
el.focus({ focusVisible: true }); el.focus({ focusVisible: true });
@@ -479,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);
@@ -505,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);
@@ -768,17 +898,231 @@ 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> { async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
const url = this.win.webContents.getURL(); // 拾取脚本经 Runtime.evaluate 注入并等待用户点击,内置/外部浏览器行为一致
const url = await this.currentUrl();
if (!url || url === "about:blank") { if (!url || url === "about:blank") {
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" }); throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
} }
this.win.show(); if (this.win) {
this.win.focus(); this.win.show();
this.win.focus();
}
const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => { const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => {
const timeout = ${JSON.stringify(timeoutMs)}; const timeout = ${JSON.stringify(timeoutMs)};
if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel(); if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel();
@@ -922,20 +1266,48 @@ export class BrowserSession implements BrowserLike {
} }
async close(): Promise<void> { async close(): Promise<void> {
if (!this.win.isDestroyed()) this.win.close(); if (this.win) {
globalThis.__rpaBrowserWindow = null; if (!this.win.isDestroyed()) this.win.close();
globalThis.__rpaBrowserWindow = null;
return;
}
// 外部浏览器:断开全部标签页 ws 会话;本模块拉起的进程按 killOnClose 决定是否关闭
if (this.externalKey) externalSessionCache().delete(this.externalKey);
const all = new Set<CdpTransport>([this.transport, ...this.tabTransports.values()]);
await Promise.all([...all].map((transport) => transport.close().catch(() => {})));
this.tabTransports.clear();
const child = this.external?.child;
if (this.external?.killOnClose && child && child.exitCode === null) {
try {
child.kill();
} catch {
// 进程可能已自行退出
}
}
this.external = null;
} }
} }
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var __rpaBrowserWindow: BrowserWindow | null | undefined; var __rpaBrowserWindow: BrowserWindow | null | undefined;
// eslint-disable-next-line no-var
var __rpaExternalSessions: Map<string, BrowserSession> | undefined;
}
function externalSessionCache(): Map<string, BrowserSession> {
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
return globalThis.__rpaExternalSessions;
} }
export async function browserSession(): Promise<BrowserSession> { export async function browserSession(): Promise<BrowserSession> {
return BrowserSession.create(); return BrowserSession.create();
} }
export async function externalBrowserSession(options: ExternalBrowserOptions): Promise<BrowserSession> {
return BrowserSession.createExternal(options);
}
export function hasBrowserSession(): boolean { export function hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()); return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
} }
+232
查看文件
@@ -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);
}
}
}
+55
查看文件
@@ -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;
});
}
+33
查看文件
@@ -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();
});
});
+25 -4
查看文件
@@ -15,16 +15,36 @@ register("WebBrowser_GetUrl", async (ctx) => session(ctx).then((browser) => brow
register("WebBrowser_GetDocument", async (ctx) => session(ctx).then((browser) => browser.runJs("document.documentElement.outerHTML"))); 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_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) => { register(["WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1"], async (ctx, _node, args) => {
const browser = await session(ctx); const x = Number(args.x ?? args.left ?? 0); const y = Number(args.y ?? args.top ?? args.position ?? 0); await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y }; 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 };
}); });
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx) => { /** 全量 Cookie:优先走 CDP Network.getCookies(含 HttpOnly),不支持时退回 document.cookie */
const raw = String(await (await session(ctx)).runJs("document.cookie")); const result: Record<string, string> = {}; for (const part of raw.split(";")) { const [name, ...rest] = part.trim().split("="); if (name) result[name] = decodeURIComponent(rest.join("=")); } return result; 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) => { register("WebBrowser_GetCookie", async (ctx, _node, args) => {
const name = text(args, ["name"]); const cookies = await getCookies(ctx); return cookies[name] ?? ""; const name = text(args, ["name"]); const cookies = await getCookies(ctx); return cookies[name] ?? "";
}); });
async function getCookies(ctx: RunContext): Promise<Record<string, string>> { const raw = String(await (await session(ctx)).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_SetCookies", async (ctx, node, args) => { 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 }); } } 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); if (!cookies || typeof cookies !== "object") throw new RuntimeError("validation_error", "Cookie对象为空", { nodeId: node.id, componentId: node.componentId }); const browser = await session(ctx);
@@ -53,3 +73,4 @@ register(["WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadF
}); });
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; 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 () => {
+10 -1
查看文件
@@ -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);
+13 -1
查看文件
@@ -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] ?? ""}`])];
}); });
+43 -6
查看文件
@@ -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;
});
+80 -7
查看文件
@@ -52,7 +52,10 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
return selector; return selector;
} }
const UIA_USING = "Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes;"; 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 = ` const FIND_HELPER = `
function Find-RpaWindow([string]$winName) { function Find-RpaWindow([string]$winName) {
@@ -60,8 +63,29 @@ function Find-RpaWindow([string]$winName) {
if (-not $winName) { return $root } if (-not $winName) { return $root }
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName) $cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond) $win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
if (-not $win) { throw "window_not_found:$winName" } if ($win) { return $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) { function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index) {
$conds = @() $conds = @()
@@ -80,7 +104,7 @@ function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [st
return $all[$pick - 1] return $all[$pick - 1]
} }
function Add-RpaUser32 { 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);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null 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
} }
`; `;
@@ -143,7 +167,37 @@ try {
} catch { $el = $null } } catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 } if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$rect = $el.Current.BoundingRectangle $rect = $el.Current.BoundingRectangle
$offscreen = $el.Current.IsOffscreen -or $rect.Width -le 0 -or $rect.Height -le 0 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" $method = "coordinate_click"
$ok = $false $ok = $false
if (-not $offscreen) { if (-not $offscreen) {
@@ -156,7 +210,18 @@ if (-not $offscreen) {
if ($h -ne 0) { $hwnd = [IntPtr]$h; break } if ($h -ne 0) { $hwnd = [IntPtr]$h; break }
$ancestor = $walker.GetParent($ancestor) $ancestor = $walker.GetParent($ancestor)
} }
if ($hwnd -ne [IntPtr]::Zero) { [WinRpa.RpaUser32Click]::SetForegroundWindow($hwnd) | Out-Null; Start-Sleep -Milliseconds 250 } 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) $x = [int]($rect.X + $rect.Width / 2)
$y = [int]($rect.Y + $rect.Height / 2) $y = [int]($rect.Y + $rect.Height / 2)
[WinRpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null [WinRpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null
@@ -189,13 +254,14 @@ if (-not $offscreen) {
$rect2 = $el.Current.BoundingRectangle $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)`; 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)`;
} }
function setValueScript(t: UiaTarget, value: string): string { export function setValueScript(t: UiaTarget, value: string): string {
const s = t.selector; const s = t.selector;
return `${UIA_USING} return `${UIA_USING}
${FIND_HELPER} ${FIND_HELPER}
try { try {
$parent = Find-RpaWindow ${psString(s.window ?? "")} $parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index} $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 } if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = ${psString(value)} $value = ${psString(value)}
$method = "value_pattern" $method = "value_pattern"
@@ -232,6 +298,7 @@ ${FIND_HELPER}
try { try {
$parent = Find-RpaWindow ${psString(s.window ?? "")} $parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index} $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 } if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = $el.Current.Name $value = $el.Current.Name
try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {} try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {}
@@ -320,3 +387,9 @@ register("Element_WaitElementGround", waitElementGround);
+57 -2
查看文件
@@ -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);
+213
查看文件
@@ -611,3 +611,216 @@ registerMany(["Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2"], autoFill);
registerMany(["Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2"], splitColumn); registerMany(["Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2"], splitColumn);
registerMany(["Excel_Close", "Excel_Cs_Close"], closeWorkbook); registerMany(["Excel_Close", "Excel_Cs_Close"], closeWorkbook);
registerMany(["Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"], deleteRange); 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 };
});
+19 -6
查看文件
@@ -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);
+129
查看文件
@@ -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 };
});
+103
查看文件
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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;
});
+9
查看文件
@@ -10,6 +10,15 @@ import "./browser-control";
import "./office"; import "./office";
import "./desktop"; import "./desktop";
import "./desktop-uia"; 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";
+14 -10
查看文件
@@ -27,14 +27,18 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25")); const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25"));
const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False")); const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined }); const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
const result = await transporter.sendMail({ try {
from: account, const result = await transporter.sendMail({
to: list(text(args, ["to", "receiver"])), from: account,
cc: list(text(args, ["Cc", "cc"])), to: list(text(args, ["to", "receiver"])),
bcc: list(text(args, ["Bcc", "bcc"])), cc: list(text(args, ["Cc", "cc"])),
subject: text(args, ["subject"]), bcc: list(text(args, ["Bcc", "bcc"])),
text: text(args, ["content", "body"]), subject: text(args, ["subject"]),
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined text: text(args, ["content", "body"]),
}); attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
return { messageId: result.messageId, accepted: result.accepted }; });
return { messageId: result.messageId, accepted: result.accepted };
} finally {
transporter.close();
}
}); });
+69
查看文件
@@ -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;
});
+33 -1
查看文件
@@ -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(); }
@@ -82,4 +84,34 @@ describe("common Excel aliases", () => {
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: "rel-target.xlsx", isAbsolutePath: "False" }) 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")); 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"]]);
});
}); });
+20 -2
查看文件
@@ -164,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;
}); });
+6 -2
查看文件
@@ -178,9 +178,13 @@ 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 raw = required(value(args, ["path", "file", "savePath", "filename", "saveFilePath"]), "另存为路径", node.id, node.componentId); // CS 版(Excel_Cs_SaveAs)用 path=目录 + name=文件名;Legacy(Excel_SaveAs)用 filename=完整路径
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 是否为绝对路径,相对路径基于原文件目录 // Legacy Excel_SaveAs 用 isAbsolutePath 标记 filename 是否为绝对路径,相对路径基于原文件目录
const absolute = bool(value(args, ["isAbsolutePath"]), true); const absolute = rawName ? true : bool(value(args, ["isAbsolutePath"]), true);
const previousPath = handle.path; const previousPath = handle.path;
handle.path = absolute ? path.resolve(raw) : path.resolve(path.dirname(previousPath), raw); 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 });
+15 -6
查看文件
@@ -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;
}); });
+15
查看文件
@@ -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);
+6 -2
查看文件
@@ -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;
}); });
+149
查看文件
@@ -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;
});
+116
查看文件
@@ -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 };
});
+101
查看文件
@@ -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 };
});
+1 -1
查看文件
@@ -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);
}); });
+116
查看文件
@@ -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 };
}
+28
查看文件
@@ -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;
+7
查看文件
@@ -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;
} }
+2
查看文件
@@ -175,6 +175,7 @@ export function listFlows(): 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;
@@ -231,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);
} }
+25 -9
查看文件
@@ -9,6 +9,7 @@ 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 { loadSettings, updateSettings } from "./settings";
import { pickDesktopElement, listVisibleWindows, pickScreenRegion } from "./desktop-pick";
import { logMain } from "./logger"; import { logMain } from "./logger";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@@ -112,6 +113,9 @@ function registerIpc(): void {
} }
return session.pickElement(); 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);
@@ -127,16 +131,27 @@ function registerIpc(): void {
for (const flow of createExampleFlows(url)) saveFlow(flow); for (const flow of createExampleFlows(url)) saveFlow(flow);
return listFlows(); return listFlows();
}); });
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id)); ipcMain.handle("flows:get", (_event, id: string) => {
return getFlow(id);
});
ipcMain.handle("flows:save", (_event, flow: FlowDefinition, opts?: FlowSaveOptions): FlowSaveResult => { ipcMain.handle("flows:save", (_event, flow: FlowDefinition, opts?: FlowSaveOptions): FlowSaveResult => {
if (!opts?.force && opts?.expectedUpdatedAt) { if (!opts?.force && opts?.expectedUpdatedAt) {
const disk = getFlow(flow.id); let disk: FlowDefinition | null = null;
if (disk && disk.meta?.updatedAt && disk.meta.updatedAt !== opts.expectedUpdatedAt) { try {
return { conflict: true, diskFlow: disk }; 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) };
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;
@@ -163,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 {
+4 -4
查看文件
@@ -22,14 +22,14 @@ describe("app settings", () => {
}); });
it("returns the default element wait when no settings file exists", () => { it("returns the default element wait when no settings file exists", () => {
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000 }); expect(loadSettings()).toEqual({ browserElementWaitMs: 5000, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
}); });
it("persists and clamps a user-adjustable element wait", async () => { it("persists and clamps a user-adjustable element wait", async () => {
const saved = updateSettings({ browserElementWaitMs: 12500 }); const saved = updateSettings({ browserElementWaitMs: 12500 });
expect(saved).toEqual({ 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(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0 }); expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
expect(loadSettings()).toEqual({ browserElementWaitMs: 0 }); expect(loadSettings()).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } });
}); });
}); });
+9 -2
查看文件
@@ -4,7 +4,8 @@ import path from "node:path";
import type { AppSettings } from "../shared/types"; import type { AppSettings } from "../shared/types";
export const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
browserElementWaitMs: 5000 browserElementWaitMs: 5000,
aiConfig: { baseUrl: "", apiKey: "", model: "" }
}; };
let cache: AppSettings | null = null; let cache: AppSettings | null = null;
@@ -16,10 +17,16 @@ function settingsPath(): string {
function normalizeSettings(value: unknown): AppSettings { function normalizeSettings(value: unknown): AppSettings {
const source = (value && typeof value === "object" ? value : {}) as Partial<AppSettings>; const source = (value && typeof value === "object" ? value : {}) as Partial<AppSettings>;
const wait = Number(source.browserElementWaitMs); const wait = Number(source.browserElementWaitMs);
const ai = (source.aiConfig && typeof source.aiConfig === "object" ? source.aiConfig : {}) as Partial<AppSettings["aiConfig"]>;
return { return {
browserElementWaitMs: Number.isFinite(wait) browserElementWaitMs: Number.isFinite(wait)
? Math.min(600000, Math.max(0, Math.round(wait))) ? Math.min(600000, Math.max(0, Math.round(wait)))
: DEFAULT_APP_SETTINGS.browserElementWaitMs : DEFAULT_APP_SETTINGS.browserElementWaitMs,
aiConfig: {
baseUrl: String(ai.baseUrl ?? ""),
apiKey: String(ai.apiKey ?? ""),
model: String(ai.model ?? "")
}
}; };
} }
+5
查看文件
@@ -8,6 +8,11 @@ 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),
+10 -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, { force: true }); if (flow !== loaded) {
loadFlow(flow); // 归一化改写了布局:强制写盘后必须用保存返回的新 meta 加载,
// 否则编辑器持有旧 updatedAt,首次保存会误判磁盘版本冲突
const result = await window.rpa.flows.save(flow, { force: true });
loadFlow(result.conflict ? flow : result.flow);
} else {
loadFlow(flow);
}
setActiveFlowId(id); setActiveFlowId(id);
setPage("editor"); setPage("editor");
} catch (openError) { } catch (openError) {
@@ -208,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" />
@@ -277,3 +271,4 @@ export function App() {
); );
} }
-11
查看文件
@@ -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>
); );
} }
+8 -2
查看文件
@@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, Check, Copy, Download } from "lucide-react"; import { ArrowDown, Check, Copy, Download } from "lucide-react";
import type { RunLog } from "../store/runStore"; import type { RunLog } from "../store/runStore";
@@ -25,6 +25,11 @@ export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyT
const [pinned, setPinned] = useState(true); const [pinned, setPinned] = useState(true);
const consoleRef = useRef<HTMLDivElement>(null); const consoleRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true); const pinnedRef = useRef(true);
const copiedTimerRef = useRef<number>();
useEffect(() => () => {
if (copiedTimerRef.current !== undefined) window.clearTimeout(copiedTimerRef.current);
}, []);
const counts = useMemo(() => ({ const counts = useMemo(() => ({
all: logs.length, all: logs.length,
@@ -66,7 +71,8 @@ export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyT
try { try {
await navigator.clipboard.writeText(logText); await navigator.clipboard.writeText(logText);
setCopied(true); setCopied(true);
window.setTimeout(() => setCopied(false), 1600); if (copiedTimerRef.current !== undefined) window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1600);
} catch { } catch {
// Clipboard access can be blocked by system policy; download remains available. // Clipboard access can be blocked by system policy; download remains available.
} }
+50 -14
查看文件
@@ -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";
@@ -34,17 +34,40 @@ 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 [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 [showGuide, setShowGuide] = useState(false);
useEffect(() => {
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 save = useCallback(async (opts?: { interactive?: boolean }) => {
const current = useEditorStore.getState().flow; const current = useEditorStore.getState().flow;
if (!current || saving) return null; if (!current || savingRef.current) return null;
savingRef.current = true;
setSaving(true); setSaving(true);
try { try {
const result = await window.rpa.flows.save(current, { const result = await window.rpa.flows.save(current, {
expectedUpdatedAt: current.meta?.updatedAt || undefined // 用「磁盘基准版本」做乐观锁,不能用 current.meta.updatedAt——
// 每次本地编辑都会拨快它,会导致每次保存都误判磁盘冲突
expectedUpdatedAt: useEditorStore.getState().baseUpdatedAt || undefined
}); });
if (result.conflict) { if (result.conflict) {
if (opts?.interactive) { if (opts?.interactive) {
@@ -69,7 +92,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
onToast("已覆盖磁盘上的旧版本"); onToast("已覆盖磁盘上的旧版本");
return forced.flow; return forced.flow;
} }
onToast("检测到磁盘上的任务已被外部修改,自动保存已暂停;请按 Ctrl+S 处理", "error"); onToast("检测到磁盘上的任务已被外部修改,自动保存已暂停;点击保存按钮或按 Ctrl+S 处理", "error");
setSaveConflict(true); setSaveConflict(true);
return null; return null;
} }
@@ -82,9 +105,10 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
onToast(error instanceof Error ? error.message : String(error), "error"); onToast(error instanceof Error ? error.message : String(error), "error");
return null; return null;
} finally { } finally {
savingRef.current = false;
setSaving(false); setSaving(false);
} }
}, [loadFlow, markSaved, onToast, saving, syncSavedFlow]); }, [loadFlow, markSaved, onToast, syncSavedFlow]);
useEffect(() => { useEffect(() => {
if (!dirty || saveConflict) return; if (!dirty || saveConflict) return;
@@ -160,14 +184,16 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
<section className="panel canvas-panel" aria-label="流程画布"> <section className="panel canvas-panel" aria-label="流程画布">
<div className="canvas-toolbar"> <div className="canvas-toolbar">
<div className="editor-breadcrumb">
<button type="button" className="back-btn" onClick={onBack} title="返回任务中心" aria-label="返回任务中心">
<ArrowLeft className="icon" />
</button>
<span className="breadcrumb-current">编辑流程</span>
</div>
<span className="spacer" />
<div className="flow-meta"> <div className="flow-meta">
<div className="editor-breadcrumb">
<button type="button" className="back-link" onClick={onBack}>
<ArrowLeft className="icon" /> 任务中心
</button>
<span>/</span>
<span>编辑流程</span>
</div>
<input <input
className="flow-name" className="flow-name"
value={flow.name} value={flow.name}
@@ -196,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 ? (
@@ -213,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} 项
+48 -2
查看文件
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Clock, Cpu, Database, FolderCog, Globe2, HardDrive, Info, Save, 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, AppSettings, 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");
@@ -14,6 +14,9 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
const [waitDraft, setWaitDraft] = useState("5000"); const [waitDraft, setWaitDraft] = useState("5000");
const [waitSaving, setWaitSaving] = useState(false); const [waitSaving, setWaitSaving] = useState(false);
const [waitMessage, setWaitMessage] = useState<string | null>(null); 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));
@@ -21,6 +24,7 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
void window.rpa.settings.get().then((value) => { void window.rpa.settings.get().then((value) => {
setSettings(value); setSettings(value);
setWaitDraft(String(value.browserElementWaitMs)); setWaitDraft(String(value.browserElementWaitMs));
setAiDraft(value.aiConfig ?? { baseUrl: "", apiKey: "", model: "" });
}).catch(() => setSettings(null)); }).catch(() => setSettings(null));
}, []); }, []);
@@ -43,6 +47,20 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
setWaitSaving(false); 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) => {
@@ -58,6 +76,7 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
<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">
@@ -117,6 +136,33 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">浏览器导航不限制域名。只拒绝 HTTP/HTTPS 以外的协议;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></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>
</div> </div>
); );
+122 -20
查看文件
@@ -17,6 +17,7 @@ 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";
@@ -46,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);
@@ -130,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">
@@ -174,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>
</> </>
) : ( ) : (
@@ -231,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 (
@@ -443,6 +462,10 @@ function FieldControl({
}) { }) {
const raw = node.values[field.key] ?? field.defaultValue; const raw = node.values[field.key] ?? field.defaultValue;
const [picking, setPicking] = useState(false); 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 insertVariable = (expression: string) => {
const current = textOf(raw).trim(); const current = textOf(raw).trim();
@@ -450,6 +473,23 @@ function FieldControl({
onChange(field.key, current === "" || known ? expression : `${textOf(raw)}${expression}`); 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 {
const selected = await window.rpa.dialog.open({ const selected = await window.rpa.dialog.open({
@@ -468,6 +508,7 @@ function FieldControl({
} }
}; };
const pickElement = async () => { const pickElement = async () => {
setPicking(true); setPicking(true);
try { try {
@@ -488,6 +529,41 @@ function FieldControl({
setPicking(false); 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 (
@@ -518,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>
@@ -548,6 +624,26 @@ function FieldControl({
<VarMenu variables={variables} onPick={insertVariable} /> <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" ? (
@@ -558,9 +654,15 @@ 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={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素"> {isGroundTarget ? (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop()} title="从桌面拾取元素(移到目标按空格)">
{picking ? "桌面拾取中" : "桌面拾取"}
</button>
) : (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素">
{picking ? "拾取中" : "拾取"} {picking ? "拾取中" : "拾取"}
</button> </button>
)}
</div> </div>
) : field.widget === "secret" ? ( ) : field.widget === "secret" ? (
<div className="field-row"> <div className="field-row">
+23 -3
查看文件
@@ -8,6 +8,7 @@ import type {
FlowNodeOptions, FlowNodeOptions,
FlowVariable FlowVariable
} from "@shared/types"; } from "@shared/types";
import { useRunStore } from "./runStore";
const clone = <T,>(value: T): T => structuredClone(value); const clone = <T,>(value: T): T => structuredClone(value);
@@ -56,6 +57,10 @@ function uniqueOutputVar(base: string, nodes: FlowNode[]): string {
interface EditorState { interface EditorState {
flow: FlowDefinition | null; flow: FlowDefinition | null;
/** 乐观锁基准:最近一次从磁盘加载/保存成功返回的 meta.updatedAt。
* 保存时以此为 expectedUpdatedAt;不能直接用 flow.meta.updatedAt,
* 因为 commit() 每次编辑都会拨快它,会导致每次保存都误判磁盘冲突。 */
baseUpdatedAt: string | null;
catalog: Catalog | null; catalog: Catalog | null;
selectedNodeId: string | null; selectedNodeId: string | null;
dirty: boolean; dirty: boolean;
@@ -103,6 +108,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
return { return {
flow: null, flow: null,
baseUpdatedAt: null,
catalog: null, catalog: null,
selectedNodeId: null, selectedNodeId: null,
dirty: false, dirty: false,
@@ -111,7 +117,19 @@ export const useEditorStore = create<EditorState>((set, get) => {
setCatalog: (catalog) => set({ catalog }), setCatalog: (catalog) => set({ catalog }),
loadFlow: (flow) => set({ flow, selectedNodeId: null, dirty: false, past: [], future: [] }), loadFlow: (flow) => {
// 切换流程时清掉上一次的运行面板状态,避免显示旧流程的运行结果
const runState = useRunStore.getState();
if (!runState.running) runState.clear();
set({
flow,
selectedNodeId: null,
dirty: false,
past: [],
future: [],
baseUpdatedAt: flow.meta?.updatedAt ?? null
});
},
selectNode: (id) => set({ selectedNodeId: id }), selectNode: (id) => set({ selectedNodeId: id }),
@@ -334,9 +352,11 @@ export const useEditorStore = create<EditorState>((set, get) => {
}, },
markSaved: () => set({ dirty: false }), markSaved: () => set({ dirty: false }),
syncSavedFlow: (flow) => set({ flow, dirty: false }), syncSavedFlow: (flow) =>
set({ flow, dirty: false, baseUpdatedAt: flow.meta?.updatedAt ?? null }),
reset: () => set({ flow: null, selectedNodeId: null, dirty: false, past: [], future: [] }) reset: () =>
set({ flow: null, baseUpdatedAt: null, selectedNodeId: null, dirty: false, past: [], future: [] })
}; };
}); });
+175 -49
查看文件
@@ -226,17 +226,6 @@ textarea:focus-visible {
font-weight: 650; font-weight: 650;
} }
.rail-item.active::before {
content: "";
position: absolute;
left: -8px;
top: 16px;
bottom: 16px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--blue);
}
.rail-spacer { .rail-spacer {
flex: 1; flex: 1;
} }
@@ -591,14 +580,36 @@ textarea:focus-visible {
gap: 10px; gap: 10px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
position: relative;
}
.canvas-toolbar .spacer {
flex: 1;
}
.canvas-toolbar .editor-breadcrumb {
flex: 1;
min-width: 0;
}
.canvas-toolbar .toolbar-actions {
flex: 1;
justify-content: flex-end;
} }
.flow-meta { .flow-meta {
min-width: 0; min-width: 0;
flex: 1; flex: none;
max-width: 300px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: 2px; gap: 2px;
/* 相对整个工具栏绝对居中,不受左右两区宽度差影响 */
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
} }
.flow-name { .flow-name {
@@ -610,7 +621,9 @@ textarea:focus-visible {
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--ink); color: var(--ink);
min-width: 0; min-width: 0;
width: 100%; width: auto;
max-width: 100%;
text-align: center;
} }
.flow-name:focus { .flow-name:focus {
@@ -621,6 +634,7 @@ textarea:focus-visible {
.flow-sub { .flow-sub {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 6px; gap: 6px;
color: var(--muted); color: var(--muted);
font-size: 10.5px; font-size: 10.5px;
@@ -668,6 +682,39 @@ textarea:focus-visible {
border-color: var(--line); border-color: var(--line);
} }
/* 保存按钮:悬停泛蓝 + 图标弹性放大,呼应"落盘"动作 */
.icon-btn.save-btn:not(:disabled):hover {
color: var(--blue);
border-color: var(--blue);
background: var(--blue-soft);
}
.icon-btn.save-btn:not(:disabled):hover .icon {
animation: save-pop 0.32s ease;
}
@keyframes save-pop {
0% { transform: scale(1); }
45% { transform: scale(1.22) translateY(1px); }
100% { transform: scale(1); }
}
/* 运行按钮:悬停上浮 + 光晕阴影 + 播放图标微放大 */
.toolbar-actions .btn-primary:not(:disabled):hover,
.toolbar-actions .btn-danger:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(34, 100, 229, 0.32);
}
.toolbar-actions .btn-primary:not(:disabled):hover .icon {
transform: scale(1.12);
}
.toolbar-actions .btn-primary .icon,
.toolbar-actions .btn-danger .icon {
transition: transform 0.16s ease;
}
.icon-btn:disabled { .icon-btn:disabled {
opacity: 0.4; opacity: 0.4;
cursor: default; cursor: default;
@@ -1004,6 +1051,37 @@ textarea:focus-visible {
flex: none; flex: none;
} }
/* 节点复制/删除:移到面板右上角,删除红色更醒目 */
.props-head-actions {
margin-left: auto;
display: flex;
gap: 4px;
flex: none;
}
.props-head-actions .head-action {
width: 28px;
height: 28px;
border-radius: 8px;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
}
.props-head-actions .head-action:hover {
transform: translateY(-1px);
}
.props-head-actions .head-action:not(.head-action-danger):hover {
color: var(--blue);
border-color: var(--blue);
background: var(--blue-soft);
}
.props-head-actions .head-action-danger:hover {
color: #d64545;
border-color: #f2b8b8;
background: #fdeeee;
}
.props-icon { .props-icon {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -2213,50 +2291,70 @@ textarea:focus-visible {
margin-top: 2px; margin-top: 2px;
} }
.canvas-hint {
/* 新建流程首访引导:顶部浮出,5 秒后自动淡出,不接收鼠标事件 */
.editor-guide {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 44%; top: 52px;
transform: translate(-50%, -50%); transform: translateX(-50%);
z-index: 4; z-index: 5;
min-width: 300px; max-width: 460px;
max-width: 420px; padding: 11px 16px;
padding: 13px 15px;
display: flex; display: flex;
gap: 10px; gap: 11px;
align-items: flex-start; align-items: center;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 13px; border-radius: 13px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-panel); box-shadow: var(--shadow-panel);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
pointer-events: none; pointer-events: none;
animation: guide-life 5s ease forwards;
} }
.canvas-hint .icon { .editor-guide-icon {
width: 18px; width: 30px;
height: 18px; height: 30px;
color: var(--blue);
flex: none; flex: none;
margin-top: 1px; display: grid;
place-items: center;
border-radius: 9px;
background: var(--blue-soft);
color: var(--blue-dark);
} }
.canvas-hint strong { .editor-guide-icon .icon {
width: 16px;
height: 16px;
}
.editor-guide-text strong {
display: block; display: block;
font-size: 11.5px; font-size: 12.5px;
font-weight: 700; font-weight: 700;
color: var(--ink); color: var(--ink);
line-height: 17px; line-height: 18px;
} }
.canvas-hint span { .editor-guide-text span {
display: block; display: block;
font-size: 10.5px; font-size: 11px;
color: var(--muted); color: var(--muted);
line-height: 15px; line-height: 16px;
margin-top: 2px; margin-top: 2px;
} }
@keyframes guide-life {
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
6% { opacity: 1; transform: translateX(-50%) translateY(0); }
90% { opacity: 1; }
100% { opacity: 0; transform: translateX(-50%) translateY(-6px); }
}
.mono { .mono {
font-family: var(--mono); font-family: var(--mono);
} }
@@ -3193,30 +3291,58 @@ textarea:focus-visible {
/* ---------- 编辑器返回路径与上下连线 ---------- */ /* ---------- 编辑器返回路径与上下连线 ---------- */
.editor-breadcrumb { .editor-breadcrumb {
min-height: 18px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 10px;
color: var(--faint); color: var(--ink-2);
font-size: 10px; font-size: 12.5px;
flex: none;
} }
.back-link { .breadcrumb-sep {
display: inline-flex; color: var(--faint);
align-items: center; font-size: 11.5px;
gap: 4px; }
color: var(--blue-dark);
font-size: 10px; .breadcrumb-current {
color: var(--ink-2);
font-weight: 650; font-weight: 650;
} }
.back-link:hover { /* 圆形返回按钮:边框 + 悬停反馈(背景加深 + 箭头左移动画),明确表达"返回" */
color: var(--blue); .back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--line);
border-radius: 50%;
background: #fff;
color: var(--ink-2);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
} }
.back-link .icon { .back-btn .icon {
width: 13px; width: 15px;
height: 13px; height: 15px;
transition: transform 0.15s ease;
}
.back-btn:hover {
border-color: var(--blue);
color: var(--blue);
background: var(--blue-soft);
}
.back-btn:hover .icon {
transform: translateX(-2px);
}
.back-btn:active {
transform: scale(0.94);
} }
.rpa-error-handle { .rpa-error-handle {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Catalog, FlowDefinition } from "@shared/types"; import type { Catalog, FlowDefinition } from "@shared/types";
import { validateFlow } from "./flowValidation"; import { fieldIsActive, validateFlow } from "./flowValidation";
const catalog = { const catalog = {
schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [], schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [],
@@ -20,6 +20,24 @@ function flow(): FlowDefinition {
} }
describe("flow validation", () => { describe("flow validation", () => {
it("关闭程序按模式仅显示并校验 PID 或进程名称", () => {
expect(fieldIsActive("App_KillV1", "pid", { close_type: "id" })).toBe(true);
expect(fieldIsActive("App_KillV1", "p_name", { close_type: "id" })).toBe(false);
expect(fieldIsActive("App_KillV1", "pid", { close_type: "name" })).toBe(false);
expect(fieldIsActive("App_KillV1", "p_name", { close_type: "name" })).toBe(true);
});
it.each(["1", "2", "3"])("命令模式 %s 仅显示并校验当前命令输入", (mode) => {
const fields = ["dos_command", "bat_file", "ps_command"];
const active = fields.filter(key => fieldIsActive("System_ExecuteCommandV1", key, { command_type: mode }));
expect(active).toEqual([fields[Number(mode) - 1]]);
});
it("旧屏保元数据不再要求未启用的密码", () => {
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "0" })).toBe(false);
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "1" })).toBe(true);
});
it("reports pending components, required fields, and disconnected nodes", () => { it("reports pending components, required fields, and disconnected nodes", () => {
const issues = validateFlow(flow(), catalog); const issues = validateFlow(flow(), catalog);
expect(issues).toEqual(expect.arrayContaining([ expect(issues).toEqual(expect.arrayContaining([
+19 -1
查看文件
@@ -15,7 +15,25 @@ function truthy(value: unknown): boolean {
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never); return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
} }
/** 属性显示与必填校验使用相同的模式条件,切换模式时保留未激活字段的旧值。 */
export function fieldIsActive(componentId: string, key: string, values: Record<string, unknown>): boolean {
if (componentId === "App_KillV1") {
const mode = String(values.close_type ?? "id");
if (key === "pid") return mode === "id";
if (key === "p_name" || key === "kill_all") return mode === "name";
}
if (componentId === "System_ExecuteCommandV1") {
const mode = String(values.command_type ?? "1");
if (["dos_command", "bat_file", "ps_command"].includes(key)) {
return key === ({ "1": "dos_command", "2": "bat_file", "3": "ps_command" } as Record<string, string>)[mode];
}
}
if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password);
return true;
}
function requiredInContext(componentId: string, key: string, values: Record<string, unknown>): boolean { function requiredInContext(componentId: string, key: string, values: Record<string, unknown>): boolean {
if (!fieldIsActive(componentId, key, values)) return false;
if (key === "designated_obj" && !truthy(values.designated_info)) return false; if (key === "designated_obj" && !truthy(values.designated_info)) return false;
if (key === "cvSelector" && String(values.choose ?? "0") !== "1") return false; if (key === "cvSelector" && String(values.choose ?? "0") !== "1") return false;
if (componentId === "Image_Screenshot" && ["image_path", "outImagePath", "outImageName"].includes(key)) return false; if (componentId === "Image_Screenshot" && ["image_path", "outImagePath", "outImageName"].includes(key)) return false;
@@ -56,7 +74,7 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida
} }
const definedVars = new Set<string>([ const definedVars = new Set<string>([
...flow.variables.map((item) => item.name), ...(flow.variables ?? []).map((item) => item.name),
...flow.nodes.map((node) => node.options?.outputVar).filter(Boolean) as string[] ...flow.nodes.map((node) => node.options?.outputVar).filter(Boolean) as string[]
]); ]);
const variableRef = /\$\{\s*([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s*(?:\.[A-Za-z_]\w*)?\s*\}/g; const variableRef = /\$\{\s*([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s*(?:\.[A-Za-z_]\w*)?\s*\}/g;
+9 -1
查看文件
@@ -25,7 +25,8 @@ export interface CatalogField {
description: string; description: string;
advanced: boolean; advanced: boolean;
valueTypes: string[]; valueTypes: string[];
options?: string[]; /** 下拉/单选选项:字符串(显示=存储值)或 { label, value }(显示与存储分离) */
options?: Array<string | { label: string; value: string }>;
unit?: string; unit?: string;
} }
@@ -179,8 +180,15 @@ export interface AppInfo {
fixtureUrl?: string; fixtureUrl?: string;
} }
export interface AiServiceConfig {
baseUrl: string;
apiKey: string;
model: string;
}
export interface AppSettings { export interface AppSettings {
browserElementWaitMs: number; browserElementWaitMs: number;
aiConfig: AiServiceConfig;
} }
export interface AppSettings { export interface AppSettings {