Catch-all commit for pending work on the task-galaxy-e2e branch that
wasn't part of the FOCAS migration. Grouping by topic so future per-topic
commits can be cherry-picked if needed.
TwinCAT
- src/.../Driver.TwinCAT/AdsTwinCATClient.cs + TwinCATDriverFactoryExtensions.cs:
factory-registration extensions + ADS client refinements.
- src/.../Driver.TwinCAT.Cli/Commands/BrowseCommand.cs: new browse command
for the TwinCAT test-client CLI.
- tests/.../Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs + TwinCatProject/:
fixture scaffold with a minimal POU + README pointing at the TCBSD/ESXi
VM for e2e.
- docs/Driver.TwinCAT.Cli.md + docs/drivers/TwinCAT-Test-Fixture.md:
documentation for the above.
- docs/v3/twincat-backlog.md: forward-looking backlog seed.
Admin UI + fleet status
- src/.../Admin/Components/Pages/Clusters/DriversTab.razor + Hosts.razor:
UI refresh for fleet-status rendering.
- src/.../Admin/Hubs/FleetStatusHub.cs + FleetStatusPoller.cs +
Admin/Program.cs: SignalR hub + poller plumbing for live fleet data.
- tests/.../Admin.Tests/FleetStatusPollerTests.cs: poller coverage.
Server + redundancy runtime (Phase 6.3 follow-ups)
- src/.../Server/Hosting/RedundancyPublisherHostedService.cs: HostedService
that owns the RedundancyStatePublisher lifecycle + wires peer reachability.
- src/.../Server/Redundancy/ServerRedundancyNodeWriter.cs: OPC UA
variable-node writer binding ServiceLevel + ServerUriArray to the
publisher's events.
- src/.../Server/Program.cs + Server.csproj: hosted-service registration.
- tests/.../Server.Tests/ServerRedundancyNodeWriterTests.cs +
Server.Tests.csproj: coverage for the above.
Configuration
- src/.../Configuration/Validation/DraftValidator.cs +
tests/.../Configuration.Tests/DraftValidatorTests.cs: draft-validation
refinements.
E2E scripts (shared infrastructure)
- scripts/e2e/README.md + _common.ps1 + test-all.ps1: shared helpers + the
all-drivers test-all runner.
- scripts/e2e/test-opcuaclient.ps1: OPC UA Client e2e runner.
Docs
- docs/v2/implementation/phase-6-{1,2,3,4}*.md + exit-gate-phase-{3,7}.md:
phase-gate + implementation doc updates.
- docs/v2/plan.md: top-level plan refresh.
- docs/v2/redundancy-interop-playbook.md: client interop playbook for the
Phase 6.3 redundancy-runtime work.
Two orphan FOCAS docs remain on disk but deliberately unstaged —
docs/v2/focas-deployment.md and docs/v2/implementation/focas-simulator-plan.md
describe the now-retired Tier-C topology and should either be rewritten
or deleted in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
434 lines
17 KiB
PowerShell
434 lines
17 KiB
PowerShell
# Shared PowerShell helpers for the OtOpcUa end-to-end CLI test scripts.
|
|
#
|
|
# Every per-protocol script dot-sources this file and calls the Test-* functions
|
|
# below. Keeps the per-script code down to ~50 lines of parameterisation +
|
|
# bridging-tag identifiers.
|
|
#
|
|
# Conventions:
|
|
# - All test helpers return a hashtable: @{ Passed=<bool>; Reason=<string> }
|
|
# - Helpers never throw unless the test setup is itself broken (a crashed
|
|
# CLI is a test failure, not an exception).
|
|
# - Output is plain text with [PASS] / [FAIL] / [SKIP] / [INFO] prefixes so
|
|
# grep/log-scraping works.
|
|
|
|
Set-StrictMode -Version 3.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Colouring + prefixes.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Write-Header {
|
|
param([string]$Title)
|
|
Write-Host ""
|
|
Write-Host "=== $Title ===" -ForegroundColor Cyan
|
|
}
|
|
|
|
function Write-Pass {
|
|
param([string]$Message)
|
|
Write-Host "[PASS] $Message" -ForegroundColor Green
|
|
}
|
|
|
|
function Write-Fail {
|
|
param([string]$Message)
|
|
Write-Host "[FAIL] $Message" -ForegroundColor Red
|
|
}
|
|
|
|
function Write-Skip {
|
|
param([string]$Message)
|
|
Write-Host "[SKIP] $Message" -ForegroundColor Yellow
|
|
}
|
|
|
|
function Write-Info {
|
|
param([string]$Message)
|
|
Write-Host "[INFO] $Message" -ForegroundColor Gray
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI invocation helpers.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Resolve a CLI path from either a published binary OR a `dotnet run` fallback.
|
|
# Preferred order:
|
|
# 1. $env:OTOPCUA_CLI_BIN points at a publish/ folder → use <exe> there
|
|
# 2. Fall back to `dotnet run --project src/<ProjectFolder> --`
|
|
#
|
|
# $ProjectFolder = relative path from repo root
|
|
# $ExeName = expected AssemblyName (no .exe)
|
|
function Get-CliInvocation {
|
|
param(
|
|
[Parameter(Mandatory)] [string]$ProjectFolder,
|
|
[Parameter(Mandatory)] [string]$ExeName
|
|
)
|
|
|
|
if ($env:OTOPCUA_CLI_BIN) {
|
|
$binPath = Join-Path $env:OTOPCUA_CLI_BIN "$ExeName.exe"
|
|
if (Test-Path $binPath) {
|
|
return @{ File = $binPath; PrefixArgs = @() }
|
|
}
|
|
}
|
|
|
|
# Dotnet-run fallback. --no-build would be faster but not every CI step
|
|
# has rebuilt; default to a full run so the script is forgiving.
|
|
return @{
|
|
File = "dotnet"
|
|
PrefixArgs = @("run", "--project", $ProjectFolder, "--")
|
|
}
|
|
}
|
|
|
|
# Run a CLI and capture stdout+stderr+exitcode. Never throws.
|
|
function Invoke-Cli {
|
|
param(
|
|
[Parameter(Mandatory)] $Cli, # output of Get-CliInvocation
|
|
[Parameter(Mandatory)] [string[]]$Args, # CLI arguments (after `-- `)
|
|
[int]$TimeoutSec = 30
|
|
)
|
|
|
|
$allArgs = @($Cli.PrefixArgs) + $Args
|
|
$output = $null
|
|
$exitCode = -1
|
|
|
|
try {
|
|
$output = & $Cli.File @allArgs 2>&1 | Out-String
|
|
$exitCode = $LASTEXITCODE
|
|
}
|
|
catch {
|
|
return @{
|
|
Output = $_.Exception.Message
|
|
ExitCode = -1
|
|
}
|
|
}
|
|
|
|
return @{
|
|
Output = $output
|
|
ExitCode = $exitCode
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test helpers — reusable building blocks every per-protocol script calls.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Test 1 — the driver CLI's probe command exits 0. Confirms the PLC / simulator
|
|
# is reachable and speaks the protocol. Prerequisite for everything else.
|
|
function Test-Probe {
|
|
param(
|
|
[Parameter(Mandatory)] $Cli,
|
|
[Parameter(Mandatory)] [string[]]$ProbeArgs
|
|
)
|
|
Write-Header "Probe"
|
|
$r = Invoke-Cli -Cli $Cli -Args $ProbeArgs
|
|
if ($r.ExitCode -eq 0) {
|
|
Write-Pass "driver CLI probe succeeded"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "driver CLI probe exit=$($r.ExitCode)"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "probe exit $($r.ExitCode)" }
|
|
}
|
|
|
|
# Test 2 — driver-loopback. Write a value via the driver CLI, read it back via
|
|
# the same CLI, assert round-trip equality. Confirms the driver itself is
|
|
# functional without pulling the OtOpcUa server into the loop.
|
|
function Test-DriverLoopback {
|
|
param(
|
|
[Parameter(Mandatory)] $Cli,
|
|
[Parameter(Mandatory)] [string[]]$WriteArgs,
|
|
[Parameter(Mandatory)] [string[]]$ReadArgs,
|
|
[Parameter(Mandatory)] [string]$ExpectedValue
|
|
)
|
|
Write-Header "Driver loopback"
|
|
|
|
$w = Invoke-Cli -Cli $Cli -Args $WriteArgs
|
|
if ($w.ExitCode -ne 0) {
|
|
Write-Fail "write failed (exit=$($w.ExitCode))"
|
|
Write-Host $w.Output
|
|
return @{ Passed = $false; Reason = "write failed" }
|
|
}
|
|
Write-Info "write ok"
|
|
|
|
$r = Invoke-Cli -Cli $Cli -Args $ReadArgs
|
|
if ($r.ExitCode -ne 0) {
|
|
Write-Fail "read failed (exit=$($r.ExitCode))"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "read failed" }
|
|
}
|
|
|
|
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
|
Write-Pass "round-trip equals $ExpectedValue"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "round-trip value mismatch — expected $ExpectedValue"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "value mismatch" }
|
|
}
|
|
|
|
# Test 3 — server bridge. Write via the driver CLI, read the corresponding
|
|
# OPC UA NodeId via the OPC UA client CLI. Confirms the full path:
|
|
# driver CLI → PLC → OtOpcUa server (polling/subscription) → OPC UA client.
|
|
function Test-ServerBridge {
|
|
param(
|
|
[Parameter(Mandatory)] $DriverCli,
|
|
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
|
|
[Parameter(Mandatory)] $OpcUaCli,
|
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
|
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
|
[Parameter(Mandatory)] [string]$ExpectedValue,
|
|
[int]$ServerPollDelaySec = 3
|
|
)
|
|
Write-Header "Server bridge"
|
|
|
|
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
|
|
if ($w.ExitCode -ne 0) {
|
|
Write-Fail "driver-side write failed (exit=$($w.ExitCode))"
|
|
Write-Host $w.Output
|
|
return @{ Passed = $false; Reason = "driver write failed" }
|
|
}
|
|
Write-Info "driver write ok, waiting ${ServerPollDelaySec}s for server-side poll"
|
|
Start-Sleep -Seconds $ServerPollDelaySec
|
|
|
|
$r = Invoke-Cli -Cli $OpcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $OpcUaNodeId)
|
|
if ($r.ExitCode -ne 0) {
|
|
Write-Fail "OPC UA client read failed (exit=$($r.ExitCode))"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "opc-ua read failed" }
|
|
}
|
|
|
|
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
|
Write-Pass "server-side read equals $ExpectedValue"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "server-side value mismatch — expected $ExpectedValue"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "bridge value mismatch" }
|
|
}
|
|
|
|
# Test 4 — reverse bridge. Write via the OPC UA client CLI, then read the PLC
|
|
# side via the driver CLI. Confirms the write path: OPC UA client → server →
|
|
# driver → PLC. This is the direction Test-ServerBridge does NOT cover — a
|
|
# clean Test-ServerBridge only proves reads flow server-ward.
|
|
function Test-OpcUaWriteBridge {
|
|
param(
|
|
[Parameter(Mandatory)] $OpcUaCli,
|
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
|
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
|
[Parameter(Mandatory)] $DriverCli,
|
|
[Parameter(Mandatory)] [string[]]$DriverReadArgs,
|
|
[Parameter(Mandatory)] [string]$ExpectedValue,
|
|
[int]$DriverPollDelaySec = 3
|
|
)
|
|
Write-Header "OPC UA write bridge"
|
|
|
|
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
|
|
"write", "-u", $OpcUaUrl, "-n", $OpcUaNodeId, "-v", $ExpectedValue)
|
|
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
|
|
Write-Fail "OPC UA client write failed (exit=$($w.ExitCode))"
|
|
Write-Host $w.Output
|
|
return @{ Passed = $false; Reason = "opc-ua write failed" }
|
|
}
|
|
Write-Info "opc-ua write ok, waiting ${DriverPollDelaySec}s for driver-side apply"
|
|
Start-Sleep -Seconds $DriverPollDelaySec
|
|
|
|
$r = Invoke-Cli -Cli $DriverCli -Args $DriverReadArgs
|
|
if ($r.ExitCode -ne 0) {
|
|
Write-Fail "driver-side read failed (exit=$($r.ExitCode))"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "driver read failed" }
|
|
}
|
|
|
|
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
|
Write-Pass "PLC-side value equals $ExpectedValue"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "PLC-side value mismatch — expected $ExpectedValue"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "reverse-bridge value mismatch" }
|
|
}
|
|
|
|
# Test 5 — subscribe-sees-change. Start `otopcua-cli subscribe --duration N`
|
|
# in the background, give it ~2s to attach, then write a known value via the
|
|
# driver CLI. After the subscription window closes, assert its captured
|
|
# output mentions the new value. Confirms the OPC UA server is actually
|
|
# pushing data-change notifications for driver-originated changes — not just
|
|
# that a fresh read returns the new value.
|
|
function Test-SubscribeSeesChange {
|
|
param(
|
|
[Parameter(Mandatory)] $OpcUaCli,
|
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
|
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
|
[Parameter(Mandatory)] $DriverCli,
|
|
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
|
|
[Parameter(Mandatory)] [string]$ExpectedValue,
|
|
[int]$DurationSec = 8,
|
|
[int]$SettleSec = 2
|
|
)
|
|
Write-Header "Subscribe sees change"
|
|
|
|
# `Start-Job` would spin up a fresh PowerShell runtime and cost 2s+. Use
|
|
# Start-Process + a temp file instead — it's the same shape Invoke-Cli
|
|
# uses but non-blocking.
|
|
$stdout = New-TemporaryFile
|
|
$stderr = New-TemporaryFile
|
|
$allArgs = @($OpcUaCli.PrefixArgs) + @(
|
|
"subscribe", "-u", $OpcUaUrl, "-n", $OpcUaNodeId,
|
|
"-i", "200", "--duration", "$DurationSec")
|
|
$proc = Start-Process -FilePath $OpcUaCli.File `
|
|
-ArgumentList $allArgs `
|
|
-NoNewWindow -PassThru `
|
|
-RedirectStandardOutput $stdout.FullName `
|
|
-RedirectStandardError $stderr.FullName
|
|
Write-Info "subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle"
|
|
Start-Sleep -Seconds $SettleSec
|
|
|
|
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
|
|
if ($w.ExitCode -ne 0) {
|
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
|
Write-Fail "driver write during subscribe failed (exit=$($w.ExitCode))"
|
|
Write-Host $w.Output
|
|
return @{ Passed = $false; Reason = "driver write failed" }
|
|
}
|
|
Write-Info "driver write ok, waiting for subscription window to close"
|
|
|
|
# Wait for the subscribe process to exit its --duration timer. Grace
|
|
# margin on top of the duration in case the first data-change races the
|
|
# final flush.
|
|
$proc.WaitForExit(($DurationSec + 5) * 1000) | Out-Null
|
|
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
|
|
|
|
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
|
|
|
# The subscribe command prints `[timestamp] displayName = value (status)`
|
|
# per data-change event. We only care that one of those events carried
|
|
# the new value.
|
|
if ($out -match "=\s*$([Regex]::Escape($ExpectedValue))\b") {
|
|
Write-Pass "subscribe saw $ExpectedValue"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "subscribe did not observe $ExpectedValue in ${DurationSec}s"
|
|
Write-Host $out
|
|
return @{ Passed = $false; Reason = "change not observed on subscription" }
|
|
}
|
|
|
|
# Test — alarm fires on threshold. Start `otopcua-cli alarms --refresh` on the
|
|
# alarm Condition NodeId in the background; drive the underlying data change via
|
|
# `otopcua-cli write` on the input NodeId; wait for the subscription window to
|
|
# close; assert the captured stdout contains a matching ALARM line (`SourceName`
|
|
# of the Condition + an Active state). Covers Part 9 alarm propagation through
|
|
# the server → driver → Condition node path.
|
|
function Test-AlarmFiresOnThreshold {
|
|
param(
|
|
[Parameter(Mandatory)] $OpcUaCli,
|
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
|
[Parameter(Mandatory)] [string]$AlarmNodeId,
|
|
[Parameter(Mandatory)] [string]$InputNodeId,
|
|
[Parameter(Mandatory)] [string]$TriggerValue,
|
|
[int]$DurationSec = 10,
|
|
[int]$SettleSec = 2
|
|
)
|
|
Write-Header "Alarm fires on threshold"
|
|
|
|
$stdout = New-TemporaryFile
|
|
$stderr = New-TemporaryFile
|
|
$allArgs = @($OpcUaCli.PrefixArgs) + @(
|
|
"alarms", "-u", $OpcUaUrl, "-n", $AlarmNodeId, "-i", "500", "--refresh")
|
|
$proc = Start-Process -FilePath $OpcUaCli.File `
|
|
-ArgumentList $allArgs `
|
|
-NoNewWindow -PassThru `
|
|
-RedirectStandardOutput $stdout.FullName `
|
|
-RedirectStandardError $stderr.FullName
|
|
Write-Info "alarm subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle"
|
|
Start-Sleep -Seconds $SettleSec
|
|
|
|
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
|
|
"write", "-u", $OpcUaUrl, "-n", $InputNodeId, "-v", $TriggerValue)
|
|
if ($w.ExitCode -ne 0) {
|
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
|
Write-Fail "input write failed (exit=$($w.ExitCode))"
|
|
Write-Host $w.Output
|
|
return @{ Passed = $false; Reason = "input write failed" }
|
|
}
|
|
Write-Info "input write ok, waiting up to ${DurationSec}s for the alarm to surface"
|
|
|
|
# otopcua-cli alarms runs until Ctrl+C; terminate it ourselves after the
|
|
# duration window (no built-in --duration flag on the alarms command).
|
|
Start-Sleep -Seconds $DurationSec
|
|
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
|
|
|
|
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
|
|
|
# AlarmsCommand emits `[ts] ALARM <SourceName>` per event + lines for
|
|
# State: Active,Unacknowledged | Severity | Message. Match on `ALARM` +
|
|
# `Active` — both need to appear for the alarm to count as fired.
|
|
if ($out -match "ALARM\b" -and $out -match "Active\b") {
|
|
Write-Pass "alarm condition fired with Active state"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "no Active alarm event observed in ${DurationSec}s"
|
|
Write-Host $out
|
|
return @{ Passed = $false; Reason = "no alarm event" }
|
|
}
|
|
|
|
# Test — history-read returns samples. Calls `otopcua-cli historyread` on the
|
|
# target NodeId for a time window (default 1h back) and asserts the CLI reports
|
|
# at least one value returned. Works against any historized tag — driver-sourced,
|
|
# virtual, or scripted-alarm historizing to the Aveva / SQLite sink.
|
|
function Test-HistoryHasSamples {
|
|
param(
|
|
[Parameter(Mandatory)] $OpcUaCli,
|
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
|
[Parameter(Mandatory)] [string]$NodeId,
|
|
[int]$LookbackSec = 3600,
|
|
[int]$MinSamples = 1
|
|
)
|
|
Write-Header "History read"
|
|
|
|
$end = (Get-Date).ToUniversalTime().ToString("o")
|
|
$start = (Get-Date).ToUniversalTime().AddSeconds(-$LookbackSec).ToString("o")
|
|
|
|
$r = Invoke-Cli -Cli $OpcUaCli -Args @(
|
|
"historyread", "-u", $OpcUaUrl, "-n", $NodeId,
|
|
"--start", $start, "--end", $end, "--max", "1000")
|
|
if ($r.ExitCode -ne 0) {
|
|
Write-Fail "historyread exit=$($r.ExitCode)"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "historyread failed" }
|
|
}
|
|
|
|
# HistoryReadCommand ends with `N values returned.` — parse and check >= MinSamples.
|
|
if ($r.Output -match '(\d+)\s+values?\s+returned') {
|
|
$count = [int]$Matches[1]
|
|
if ($count -ge $MinSamples) {
|
|
Write-Pass "$count samples returned (>= $MinSamples)"
|
|
return @{ Passed = $true }
|
|
}
|
|
Write-Fail "only $count samples returned, expected >= $MinSamples — tag may not be historized, or lookback window misses samples"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "insufficient samples" }
|
|
}
|
|
Write-Fail "could not parse 'N values returned.' marker from historyread output"
|
|
Write-Host $r.Output
|
|
return @{ Passed = $false; Reason = "parse failure" }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary helper — caller passes an array of test results.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Write-Summary {
|
|
param(
|
|
[Parameter(Mandatory)] [string]$Title,
|
|
[Parameter(Mandatory)] [array]$Results
|
|
)
|
|
# @(...) forces an array even when Where-Object matches 0 or 1 items,
|
|
# otherwise .Count trips Set-StrictMode -Version 3.0 ("property 'Count'
|
|
# cannot be found on this object") on $null or on a single hashtable.
|
|
$passed = @($Results | Where-Object { $_.Passed }).Count
|
|
$failed = @($Results | Where-Object { -not $_.Passed }).Count
|
|
Write-Host ""
|
|
Write-Host "=== $Title summary: $passed/$($Results.Count) passed ===" `
|
|
-ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Red" })
|
|
}
|