2026-11-01 20:06:40 -05:00
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 ( ) ) ;
2026-11-01 22:07:27 -05:00
const conversion = new TestFlow ( 2 , "数据转换" , [ "Basic_StringToNum" , "Basic_ConvertNumber" , "Basic_ConvertInt" , "Basic_ConvertBool" , "Basic_ConvertString" , "Basic_DataTypeConversion" , "Basic_Dodecryption" ] ) ;
2026-11-01 20:06:40 -05:00
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"' ) ;
2026-11-01 22:07:27 -05:00
conversion . case ( "Basic_Dodecryption" , { words : "UlBBLURvZGVjcnlwdGlvbg==" } , 'Base64 解码 "RPA-Dodecryption"' , 'actual_11 === "RPA-Dodecryption"' ) ;
2026-11-01 20:06:40 -05:00
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 ;
}