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