# 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=; Reason= } # - 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 there # 2. Fall back to `dotnet run --project src/ --` # # $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 ` 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 ) $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" }) }