#requires -Version 7.0
<#
.SYNOPSIS
Minimal network test runner for lab use.
.DESCRIPTION
YAML 1 file = 1 test suite.
The runner changes target settings, executes requests, records facts, and cleans up.
It does not judge pass/fail.
Flow:
PreCleanup (optional, reverse order)
Setup
For each Settings item:
CleanupSetting (reverse order, pre-clean)
ApplySetting
Requests
CleanupSetting (reverse order)
Teardown (reverse order, always)
.NOTES
Required module: powershell-yaml
Install is attempted automatically for CurrentUser if missing.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$Scenario = ".\tests\fgt_webfilter_curl.yaml",
[Parameter(Mandatory = $false)]
[string]$ScenarioDir,
[Parameter(Mandatory = $false)]
[string]$EnvFile = ".\env\lab.json",
[Parameter(Mandatory = $false)]
[string]$OutDir = ".\results",
[Parameter(Mandatory = $false)]
[switch]$SkipSerialCheck,
[Parameter(Mandatory = $false)]
[switch]$NoModuleInstall
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Ensure-YamlModule {
if (Get-Module -ListAvailable -Name powershell-yaml) {
Import-Module powershell-yaml -ErrorAction Stop
return
}
if ($NoModuleInstall) {
throw "Required module 'powershell-yaml' is not installed. Install it or rerun without -NoModuleInstall."
}
Write-Host "Installing powershell-yaml for CurrentUser..."
Install-Module powershell-yaml -Scope CurrentUser -Force -AllowClobber
Import-Module powershell-yaml -ErrorAction Stop
}
function ConvertTo-HashtableRecursive {
param([Parameter(ValueFromPipeline = $true)]$InputObject)
process {
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [System.Collections.IDictionary]) {
$h = [ordered]@{}
foreach ($key in $InputObject.Keys) {
$h[[string]$key] = ConvertTo-HashtableRecursive $InputObject[$key]
}
return $h
}
if ($InputObject -is [pscustomobject]) {
$h = [ordered]@{}
foreach ($p in $InputObject.PSObject.Properties) {
$h[$p.Name] = ConvertTo-HashtableRecursive $p.Value
}
return $h
}
if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
$arr = @()
foreach ($item in $InputObject) {
$arr += ,(ConvertTo-HashtableRecursive $item)
}
return $arr
}
return $InputObject
}
}
function Load-JsonAsHashtable {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Env file not found: $Path"
}
$json = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
if ([string]::IsNullOrWhiteSpace($json)) { return [ordered]@{} }
$obj = $json | ConvertFrom-Json -Depth 100
return ConvertTo-HashtableRecursive $obj
}
function Get-VarValue {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][hashtable]$Vars,
[Parameter(Mandatory = $false)]$DefaultValue = ""
)
if ($Name.StartsWith("ENV_")) {
$envName = $Name.Substring(4)
$envValue = [Environment]::GetEnvironmentVariable($envName)
if ($null -ne $envValue) { return $envValue }
return $DefaultValue
}
if ($Vars.ContainsKey($Name) -and $null -ne $Vars[$Name]) {
return [string]$Vars[$Name]
}
return $DefaultValue
}
function Render-String {
param(
[AllowNull()][string]$Text,
[Parameter(Mandatory = $true)][hashtable]$Vars
)
if ($null -eq $Text) { return $null }
# %%?name%% means non-string JSON literal. If undefined, use literal null.
$result = [regex]::Replace($Text, '%%\?([A-Za-z0-9_]+)%%', {
param($m)
$name = $m.Groups[1].Value
return Get-VarValue -Name $name -Vars $Vars -DefaultValue "null"
})
# %%name%% means string replacement. If undefined, empty string.
$result = [regex]::Replace($result, '%%([A-Za-z0-9_]+)%%', {
param($m)
$name = $m.Groups[1].Value
return Get-VarValue -Name $name -Vars $Vars -DefaultValue ""
})
return $result
}
function Render-Object {
param(
[AllowNull()]$InputObject,
[Parameter(Mandatory = $true)][hashtable]$Vars
)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [string]) {
return Render-String -Text $InputObject -Vars $Vars
}
if ($InputObject -is [System.Collections.IDictionary]) {
$h = [ordered]@{}
foreach ($key in $InputObject.Keys) {
$h[$key] = Render-Object -InputObject $InputObject[$key] -Vars $Vars
}
return $h
}
if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
$arr = @()
foreach ($item in $InputObject) {
$arr += ,(Render-Object -InputObject $item -Vars $Vars)
}
return $arr
}
return $InputObject
}
function Merge-Vars {
param(
[Parameter(Mandatory = $true)][hashtable]$Base,
[Parameter(Mandatory = $false)]$Extra
)
$merged = [ordered]@{}
foreach ($key in $Base.Keys) { $merged[$key] = $Base[$key] }
if ($null -ne $Extra) {
$extraHash = ConvertTo-HashtableRecursive $Extra
if ($extraHash -is [System.Collections.IDictionary]) {
foreach ($key in $extraHash.Keys) { $merged[$key] = $extraHash[$key] }
}
}
return $merged
}
function Get-StepArray {
param(
[Parameter(Mandatory = $true)]$Suite,
[Parameter(Mandatory = $true)][string]$Name
)
if (-not $Suite.ContainsKey($Name) -or $null -eq $Suite[$Name]) { return @() }
if ($Suite[$Name] -is [System.Collections.IEnumerable] -and $Suite[$Name] -isnot [string] -and $Suite[$Name] -isnot [System.Collections.IDictionary]) {
return @($Suite[$Name])
}
return @($Suite[$Name])
}
function Get-ReversedArray {
param([array]$Items)
$copy = @($Items)
[array]::Reverse($copy)
return $copy
}
function Add-FortiGateVdomQuery {
param(
[Parameter(Mandatory = $true)][string]$Endpoint,
[Parameter(Mandatory = $false)][string]$Vdom
)
if ([string]::IsNullOrWhiteSpace($Vdom)) { return $Endpoint }
if ($Endpoint -match '(\?|&)vdom=') { return $Endpoint }
$sep = '?'
if ($Endpoint.Contains('?')) { $sep = '&' }
return "$Endpoint${sep}vdom=$Vdom"
}
function Get-TargetBaseUri {
param([Parameter(Mandatory = $true)]$Target)
if (-not $Target.ContainsKey('Host')) { throw "Target requires Host." }
$scheme = "https"
if ($Target.ContainsKey('Scheme') -and -not [string]::IsNullOrWhiteSpace($Target.Scheme)) {
$scheme = [string]$Target.Scheme
}
return "$scheme://$($Target.Host)"
}
function New-StepResultBase {
param(
[Parameter(Mandatory = $true)][string]$SuiteId,
[Parameter(Mandatory = $false)][string]$SettingId,
[Parameter(Mandatory = $true)]$Step,
[Parameter(Mandatory = $false)][string]$Phase,
[Parameter(Mandatory = $false)][string]$TargetName,
[Parameter(Mandatory = $false)][string]$TargetType
)
return [ordered]@{
suiteId = $SuiteId
settingId = $SettingId
phase = $Phase
stepId = if ($Step.ContainsKey('ID')) { $Step.ID } else { "" }
target = $TargetName
targetType = $TargetType
type = if ($Step.ContainsKey('Type')) { $Step.Type } else { "" }
startedAt = (Get-Date).ToString("o")
endedAt = $null
durationMs = $null
ok = $false
error = $null
}
}
function Write-StepResult {
param(
[Parameter(Mandatory = $true)][string]$RunDir,
[Parameter(Mandatory = $true)]$Result
)
$setting = if ([string]::IsNullOrWhiteSpace($Result.settingId)) { "_global" } else { $Result.settingId }
$phase = if ([string]::IsNullOrWhiteSpace($Result.phase)) { "phase" } else { $Result.phase }
$step = if ([string]::IsNullOrWhiteSpace($Result.stepId)) { "step" } else { $Result.stepId }
$safeName = ($phase + "__" + $setting + "__" + $step) -replace '[^A-Za-z0-9_.-]', '_'
$stepDir = Join-Path $RunDir "steps"
New-Item -ItemType Directory -Force -Path $stepDir | Out-Null
$path = Join-Path $stepDir ("{0:0000}__{1}.json" -f $script:StepCounter, $safeName)
$script:StepCounter++
$Result | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $path -Encoding UTF8
}
function Invoke-ApiStep {
param(
[Parameter(Mandatory = $true)]$Step,
[Parameter(Mandatory = $true)]$Target,
[Parameter(Mandatory = $true)][string]$TargetName,
[Parameter(Mandatory = $true)][string]$SuiteId,
[Parameter(Mandatory = $false)][string]$SettingId,
[Parameter(Mandatory = $true)][string]$Phase,
[Parameter(Mandatory = $true)][string]$RunDir,
[Parameter(Mandatory = $false)][switch]$Allow404
)
$targetType = if ($Target.ContainsKey('Type')) { [string]$Target.Type } else { "Generic" }
$result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $Step -Phase $Phase -TargetName $TargetName -TargetType $targetType
$sw = [System.Diagnostics.Stopwatch]::StartNew()
try {
if (-not $Step.ContainsKey('Method')) { throw "API step requires Method." }
if (-not $Step.ContainsKey('Endpoint')) { throw "API step requires Endpoint." }
$method = [string]$Step.Method
$endpoint = [string]$Step.Endpoint
if ($targetType -eq "FortiGate" -and $Target.ContainsKey('Vdom')) {
$endpoint = Add-FortiGateVdomQuery -Endpoint $endpoint -Vdom ([string]$Target.Vdom)
}
$uri = (Get-TargetBaseUri -Target $Target) + $endpoint
$headers = @{}
if ($Target.ContainsKey('TokenEnv') -and -not [string]::IsNullOrWhiteSpace($Target.TokenEnv)) {
$token = [Environment]::GetEnvironmentVariable([string]$Target.TokenEnv)
if ([string]::IsNullOrWhiteSpace($token)) { throw "Environment variable '$($Target.TokenEnv)' is empty or undefined." }
$headers["Authorization"] = "Bearer $token"
}
if ($Target.ContainsKey('Headers') -and $Target.Headers -is [System.Collections.IDictionary]) {
foreach ($key in $Target.Headers.Keys) { $headers[$key] = [string]$Target.Headers[$key] }
}
$body = $null
$contentType = "application/json"
if ($Step.ContainsKey('PayloadFormat') -and $null -ne $Step.PayloadFormat) {
switch ([string]$Step.PayloadFormat) {
"native" {
$body = ($Step.Payload | ConvertTo-Json -Depth 100 -Compress)
$contentType = "application/json"
}
"raw_json" {
$body = [string]$Step.Payload
$null = $body | ConvertFrom-Json -Depth 100
$contentType = "application/json"
}
"raw_xml" {
$body = [string]$Step.Payload
$null = [xml]$body
$contentType = "application/xml"
}
"text_list" {
$body = ($Step.Payload -join "`n")
$contentType = "text/plain"
}
default { throw "Unsupported PayloadFormat: $($Step.PayloadFormat)" }
}
}
if ($Step.ContainsKey('ContentType') -and -not [string]::IsNullOrWhiteSpace($Step.ContentType)) {
$contentType = [string]$Step.ContentType
}
$params = @{
Uri = $uri
Method = $method
Headers = $headers
TimeoutSec = if ($Step.ContainsKey('TimeoutSec')) { [int]$Step.TimeoutSec } else { 60 }
ErrorAction = 'Stop'
}
if ($null -ne $body) {
$params['Body'] = $body
$params['ContentType'] = $contentType
}
if ($Target.ContainsKey('SkipCertificateCheck') -and [bool]$Target.SkipCertificateCheck) {
$params['SkipCertificateCheck'] = $true
}
$resp = Invoke-WebRequest @params
$result.method = $method
$result.endpoint = $endpoint
$result.statusCode = [int]$resp.StatusCode
$result.responseBody = [string]$resp.Content
$result.ok = $true
}
catch {
$statusCode = $null
$responseBody = $null
if ($_.Exception.Response) {
try { $statusCode = [int]$_.Exception.Response.StatusCode } catch {}
try {
$stream = $_.Exception.Response.GetResponseStream()
if ($null -ne $stream) {
$reader = [System.IO.StreamReader]::new($stream)
$responseBody = $reader.ReadToEnd()
}
} catch {}
try {
if ($_.Exception.Response.Content) {
$responseBody = $_.Exception.Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
} catch {}
}
$result.statusCode = $statusCode
$result.responseBody = $responseBody
$result.error = $_.Exception.Message
if ($Allow404 -and $statusCode -eq 404) {
$result.ok = $true
$result.ignored = "404 allowed"
} else {
$result.ok = $false
}
}
finally {
$sw.Stop()
$result.endedAt = (Get-Date).ToString("o")
$result.durationMs = [int64]$sw.ElapsedMilliseconds
Write-StepResult -RunDir $RunDir -Result $result
}
if (-not $result.ok) {
throw "API step failed: phase=$Phase setting=$SettingId step=$($result.stepId) status=$($result.statusCode) error=$($result.error)"
}
}
function Invoke-CommandStep {
param(
[Parameter(Mandatory = $true)]$Step,
[Parameter(Mandatory = $true)][string]$SuiteId,
[Parameter(Mandatory = $false)][string]$SettingId,
[Parameter(Mandatory = $true)][string]$Phase,
[Parameter(Mandatory = $true)][string]$RunDir
)
$result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $Step -Phase $Phase -TargetName "LOCAL" -TargetType "Local"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
try {
if (-not $Step.ContainsKey('Command')) { throw "Command step requires Command." }
$command = [string]$Step.Command
$args = @()
if ($Step.ContainsKey('Args') -and $null -ne $Step.Args) { $args = @($Step.Args) }
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $command
foreach ($a in $args) { [void]$psi.ArgumentList.Add([string]$a) }
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::new()
$proc.StartInfo = $psi
[void]$proc.Start()
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
$timeoutMs = if ($Step.ContainsKey('TimeoutMs')) { [int]$Step.TimeoutMs } else { 60000 }
$finished = $proc.WaitForExit($timeoutMs)
if (-not $finished) {
try { $proc.Kill($true) } catch {}
$result.timedOut = $true
}
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
$result.command = $command
$result.args = $args
$result.exitCode = if ($finished) { [int]$proc.ExitCode } else { $null }
$result.stdout = $stdout
$result.stderr = $stderr
$result.ok = $finished
# This runner records facts only. Non-zero exit code is not pass/fail.
# Process timeout is treated as runner failure because stdout/stderr may be incomplete.
}
catch {
$result.ok = $false
$result.error = $_.Exception.Message
}
finally {
$sw.Stop()
$result.endedAt = (Get-Date).ToString("o")
$result.durationMs = [int64]$sw.ElapsedMilliseconds
Write-StepResult -RunDir $RunDir -Result $result
}
if (-not $result.ok) {
throw "Command step failed or timed out: phase=$Phase setting=$SettingId step=$($result.stepId) error=$($result.error)"
}
}
function Invoke-Step {
param(
[Parameter(Mandatory = $true)]$RawStep,
[Parameter(Mandatory = $true)][hashtable]$Vars,
[Parameter(Mandatory = $true)]$Targets,
[Parameter(Mandatory = $true)][string]$SuiteId,
[Parameter(Mandatory = $false)][string]$SettingId,
[Parameter(Mandatory = $true)][string]$Phase,
[Parameter(Mandatory = $true)][string]$RunDir,
[Parameter(Mandatory = $false)][switch]$Allow404
)
$step = Render-Object -InputObject $RawStep -Vars $Vars
if (-not $step.ContainsKey('Type')) { throw "Step requires Type. Phase=$Phase" }
switch ([string]$step.Type) {
"API" {
$targetName = if ($step.ContainsKey('Target') -and -not [string]::IsNullOrWhiteSpace($step.Target)) { [string]$step.Target } else { "FGT" }
if (-not $Targets.ContainsKey($targetName)) { throw "Target '$targetName' not found." }
Invoke-ApiStep -Step $step -Target $Targets[$targetName] -TargetName $targetName -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir -Allow404:$Allow404
}
"Command" {
Invoke-CommandStep -Step $step -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir
}
"Wait" {
$ms = if ($step.ContainsKey('Ms')) { [int]$step.Ms } elseif ($step.ContainsKey('WaitAfterMs')) { [int]$step.WaitAfterMs } else { 1000 }
$result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $step -Phase $Phase -TargetName "LOCAL" -TargetType "Local"
Start-Sleep -Milliseconds $ms
$result.ms = $ms
$result.ok = $true
$result.endedAt = (Get-Date).ToString("o")
$result.durationMs = $ms
Write-StepResult -RunDir $RunDir -Result $result
}
default { throw "Unsupported step Type: $($step.Type)" }
}
if ($step.ContainsKey('WaitAfterMs') -and [int]$step.WaitAfterMs -gt 0) {
Start-Sleep -Milliseconds ([int]$step.WaitAfterMs)
}
}
function Invoke-StepList {
param(
[Parameter(Mandatory = $true)][array]$Steps,
[Parameter(Mandatory = $true)][hashtable]$Vars,
[Parameter(Mandatory = $true)]$Targets,
[Parameter(Mandatory = $true)][string]$SuiteId,
[Parameter(Mandatory = $false)][string]$SettingId,
[Parameter(Mandatory = $true)][string]$Phase,
[Parameter(Mandatory = $true)][string]$RunDir,
[Parameter(Mandatory = $false)][switch]$Reverse,
[Parameter(Mandatory = $false)][switch]$Allow404
)
$items = @($Steps)
if ($Reverse) { $items = Get-ReversedArray -Items $items }
foreach ($s in $items) {
Invoke-Step -RawStep $s -Vars $Vars -Targets $Targets -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir -Allow404:$Allow404
}
}
function Test-TargetSafety {
param(
[Parameter(Mandatory = $true)]$Targets,
[Parameter(Mandatory = $true)][string]$SuiteId,
[Parameter(Mandatory = $true)][string]$RunDir
)
foreach ($name in $Targets.Keys) {
$t = $Targets[$name]
if (-not $t.ContainsKey('ExpectedSerial') -or [string]::IsNullOrWhiteSpace($t.ExpectedSerial)) { continue }
if ($SkipSerialCheck) { continue }
if ($t.ExpectedSerial -eq "CHANGE_ME") { throw "Target '$name' ExpectedSerial is CHANGE_ME. Set the real serial or use -SkipSerialCheck deliberately." }
if ($t.ContainsKey('Type') -and $t.Type -eq "FortiGate") {
$step = [ordered]@{
ID = "safety_check_$name"
Type = "API"
Target = $name
Method = "GET"
Endpoint = "/api/v2/monitor/system/status"
}
Invoke-ApiStep -Step $step -Target $t -TargetName $name -SuiteId $SuiteId -SettingId "" -Phase "SafetyCheck" -RunDir $RunDir
$latest = Get-ChildItem -Path (Join-Path $RunDir "steps") -Filter "*.json" | Sort-Object Name -Descending | Select-Object -First 1
$obj = Get-Content -LiteralPath $latest.FullName -Raw | ConvertFrom-Json -Depth 100
if ([string]$obj.responseBody -notlike "*$($t.ExpectedSerial)*") {
throw "Safety check failed. Target '$name' response did not contain ExpectedSerial '$($t.ExpectedSerial)'."
}
}
}
}
function Invoke-ScenarioFile {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { throw "Scenario file not found: $Path" }
$envVars = Load-JsonAsHashtable -Path $EnvFile
$rawYaml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$renderedYaml = Render-String -Text $rawYaml -Vars $envVars
$suite = ConvertTo-HashtableRecursive (ConvertFrom-Yaml -Yaml $renderedYaml)
if (-not $suite.ContainsKey('SuiteID')) { throw "Scenario requires SuiteID: $Path" }
if (-not $suite.ContainsKey('Targets')) { throw "Scenario requires Targets: $Path" }
$suiteId = [string]$suite.SuiteID
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDir = Join-Path (Join-Path $OutDir $suiteId) $timestamp
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
$script:StepCounter = 1
$targets = $suite.Targets
$settings = Get-StepArray -Suite $suite -Name "Settings"
if ($settings.Count -eq 0) { $settings = @([ordered]@{ ID = "default" }) }
$summary = [ordered]@{
suiteId = $suiteId
scenario = (Resolve-Path -LiteralPath $Path).Path
envFile = (Resolve-Path -LiteralPath $EnvFile).Path
startedAt = (Get-Date).ToString("o")
endedAt = $null
result = "Running"
settings = @($settings | ForEach-Object { if ($_ -is [System.Collections.IDictionary] -and $_.ContainsKey('ID')) { $_.ID } else { "" } })
runDir = (Resolve-Path -LiteralPath $runDir).Path
error = $null
}
try {
Test-TargetSafety -Targets $targets -SuiteId $suiteId -RunDir $runDir
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "PreCleanup") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "PreCleanup" -RunDir $runDir -Reverse -Allow404
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Setup") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "Setup" -RunDir $runDir
foreach ($setting in $settings) {
$settingId = if ($setting -is [System.Collections.IDictionary] -and $setting.ContainsKey('ID')) { [string]$setting.ID } else { "default" }
$vars = Merge-Vars -Base $envVars -Extra $setting
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "CleanupSetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "CleanupSettingBefore" -RunDir $runDir -Reverse -Allow404
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "ApplySetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "ApplySetting" -RunDir $runDir
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Requests") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "Requests" -RunDir $runDir
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "CleanupSetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "CleanupSettingAfter" -RunDir $runDir -Reverse -Allow404
}
$summary.result = "Completed"
}
catch {
$summary.result = "Failed"
$summary.error = $_.Exception.Message
throw
}
finally {
try {
Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Teardown") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "Teardown" -RunDir $runDir -Reverse -Allow404
}
catch {
$summary.result = "FailedDuringTeardown"
$summary.error = if ($summary.error) { $summary.error + " / Teardown: " + $_.Exception.Message } else { "Teardown: " + $_.Exception.Message }
}
$summary.endedAt = (Get-Date).ToString("o")
$summaryPath = Join-Path $runDir "summary.json"
$summary | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $summaryPath -Encoding UTF8
Write-Host "Result: $summaryPath"
}
}
Ensure-YamlModule
if ($ScenarioDir) {
$files = Get-ChildItem -LiteralPath $ScenarioDir -Filter "*.yaml" | Sort-Object Name
foreach ($f in $files) { Invoke-ScenarioFile -Path $f.FullName }
} else {
Invoke-ScenarioFile -Path $Scenario
}