Files
lmxopcua/scripts/e2e/test-galaxy.ps1
Joseph Doherty 69e1d320ac Cold-start guard for script engines — skip evaluation with empty upstream
Both VirtualTagEngine and ScriptedAlarmEngine share a pattern: the
BuildReadCache helper iterates the script's declared input set, reading
from _valueCache with a fallback to _upstream.ReadTag. When an upstream
tag hasn't yet delivered its first subscription push, ReadTag returns a
DataValueSnapshot with a null Value and BadNotConnected quality. User
scripts then cast `(double)ctx.GetTag(path).Value` unconditionally and
throw NullReferenceException — once per evaluation tick until the cache
fills, spamming the log with identical stack traces. The existing catch
block recovered (kept the prior state) but didn't silence the churn.

Add AreInputsReady(cache) to both engines: return true only when every
entry has a non-null Value and a non-Bad StatusCode (Good + Uncertain
are both considered ready). Skip script evaluation when the check
returns false — the engine holds the prior state (alarm) or the prior
snapshot (virtual tag) until upstream delivers. Eliminates the cold-
start NRE spam at root without changing the script-engine contract.

Also: fix $changeLines.Count in test-galaxy.ps1 — PowerShell's
Set-StrictMode -Version 3.0 errors on .Count when Where-Object returns
0 or 1 items. Wrap in `@(...)` to force an array; same pattern the
sibling _common.ps1 already uses in Write-Summary for the same reason.

Task #112 — the Galaxy live E2E now passes 3/7 stages (probe + source
read + reverse-bridge-ACL). The remaining 4 stages (virtual-tag,
subscribe-sees-change, alarm-fires, history-read) are deployment-
specific: MoveInBatchID is idle in this Galaxy + its AccessLevel blocks
writes + it's not historized. Cold-start behaviour is now correct, so
once the seed points at a live attribute those stages should light up.

Tests: 36/36 VirtualTags.Tests + 47/47 ScriptedAlarms.Tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:43:48 -04:00

283 lines
14 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe,
alarms, and history through a running OtOpcUa server.
.DESCRIPTION
Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy
driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost`
over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process).
Every stage therefore goes through `otopcua-cli` against the published OPC UA
address space.
Seven stages:
1. Probe — otopcua-cli connect + read the source NodeId; confirms
the whole Galaxy.Host → Proxy → server → client chain is
up
2. Source read — otopcua-cli read returns a Good value for the source
attribute; proves IReadable.ReadAsync is dispatching
through the IPC bridge
3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms
the Phase 7 CachedTagUpstreamSource is bridging the
driver-sourced input into the scripting engine
4. Subscribe-sees-change — subscribe to the source NodeId in the background;
Galaxy pushes a data-change event within N seconds
(Galaxy's underlying attribute must be actively
changing — production Galaxies typically have
scan-driven updates; for idle galaxies, widen
-ChangeWaitSec or drive the write stage below first)
5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute;
read it back. Gracefully becomes INFO-only if the
attribute's Galaxy-side AccessLevel forbids writes
(BadUserAccessDenied / BadNotWritable)
6. Alarm fires — subscribe to the scripted-alarm Condition NodeId,
drive the source tag above its threshold, confirm an
Active alarm event surfaces. Exercises the Part 9
alarm-condition propagation path
7. History read — historyread on the source tag over the last hour;
confirms Aveva Historian → IHistoryProvider dispatch
returns samples
The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the
right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag
(source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy
attribute FullName into `dbo.Tag.TagConfig` before running.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
.PARAMETER SourceNodeId
NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). NodeIds
are path-based per OPC UA Part 3 §5.2.2 — the default matches the Phase 7 seed
walking `p7-smoke-galaxy` (DriverInstanceId) → `lab-floor` → `galaxy-line` →
`reactor-1` → `Source` (Tag.Name).
.PARAMETER VirtualNodeId
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). Same
path-based scheme, ending in the VirtualTag.Name (`Doubled`).
.PARAMETER AlarmNodeId
NodeId of the scripted-alarm Condition (fires when Source > 50). Same
path-based scheme, ending in ScriptedAlarm.Name (`OverTemp`).
.PARAMETER AlarmTriggerValue
Value written to -SourceNodeId to push it over the alarm threshold.
Default 75 (well above the seeded 50-threshold).
.PARAMETER ChangeWaitSec
Seconds the subscribe-sees-change stage waits for a natural data change.
Default 10. Idle galaxies may need this extended or the stage will fail
with "subscribe did not observe...".
.PARAMETER AlarmWaitSec
Seconds the alarm-fires stage waits after triggering the write. Default 10.
.PARAMETER HistoryLookbackSec
Seconds back from now to query history. Default 3600 (1 h).
.EXAMPLE
# Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server
./scripts/e2e/test-galaxy.ps1
.EXAMPLE
# Custom NodeIds from a non-smoke cluster
./scripts/e2e/test-galaxy.ps1 `
-SourceNodeId "ns=2;s=Reactor1.Temperature" `
-VirtualNodeId "ns=2;s=Reactor1.TempDoubled" `
-AlarmNodeId "ns=2;s=Reactor1.OverTemp" `
-AlarmTriggerValue 120
#>
param(
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source",
[string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Doubled",
[string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp",
[string]$AlarmTriggerValue = "75",
[int]$ChangeWaitSec = 10,
[int]$AlarmWaitSec = 10,
[int]$HistoryLookbackSec = 3600
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$results = @()
# ---------------------------------------------------------------------------
# Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId;
# success implies Galaxy.Host is up + the pipe ACL lets the server connect +
# the Proxy is tracking the tag + the server published it.
# ---------------------------------------------------------------------------
Write-Header "Probe"
$probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
$results += @{ Passed = $true }
} else {
Write-Fail "probe read failed (exit=$($probe.ExitCode))"
Write-Host $probe.Output
$results += @{ Passed = $false; Reason = "probe failed" }
}
# ---------------------------------------------------------------------------
# Stage 2 — Source read. Captures the current value for the later virtual-tag
# comparison + confirms read dispatch works end-to-end. Failure here without a
# stage-1 failure would be unusual — probe already reads.
# ---------------------------------------------------------------------------
Write-Header "Source read"
$sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
$sourceValue = $null
if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
$sourceValue = $Matches[1].Trim()
Write-Pass "source value = $sourceValue"
$results += @{ Passed = $true }
} else {
Write-Fail "source read failed"
Write-Host $sourceRead.Output
$results += @{ Passed = $false; Reason = "source read failed" }
}
# ---------------------------------------------------------------------------
# Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not
# strictly driver-specific, but exercises the CachedTagUpstreamSource bridge
# (the seam most likely to silently stop working after a Galaxy-side change).
# Skip if the VirtualNodeId param is empty (non-Phase-7 clusters).
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($VirtualNodeId)) {
Write-Header "Virtual-tag bridge"
Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
} else {
Write-Header "Virtual-tag bridge"
$vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
$vtValue = $Matches[1].Trim()
Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
$results += @{ Passed = $true }
} else {
Write-Fail "virtual-tag read failed"
Write-Host $vtRead.Output
$results += @{ Passed = $false; Reason = "virtual-tag read failed" }
}
}
# ---------------------------------------------------------------------------
# Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background;
# wait N seconds for Galaxy to push any data-change event on the source node.
# This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec.
# ---------------------------------------------------------------------------
Write-Header "Subscribe sees change"
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$subArgs = @($opcUaCli.PrefixArgs) + @(
"subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
"-i", "500", "--duration", "$ChangeWaitSec")
$subProc = Start-Process -FilePath $opcUaCli.File `
-ArgumentList $subArgs -NoNewWindow -PassThru `
-RedirectStandardOutput $stdout.FullName `
-RedirectStandardError $stderr.FullName
Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s"
$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
# Any `=` followed by `(Good)` line after the initial subscribe-confirmation
# indicates at least one data-change tick arrived. The `@(...)` forces an array
# so `.Count` works on the 0-match + single-match cases that Set-StrictMode
# -Version 3.0 otherwise flags as `property 'Count' cannot be found`.
$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" })
if ($changeLines.Count -gt 0) {
Write-Pass "$($changeLines.Count) data-change events observed"
$results += @{ Passed = $true }
} else {
Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first"
Write-Host $subOut
$results += @{ Passed = $false; Reason = "no data-change" }
}
# ---------------------------------------------------------------------------
# Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with
# AccessLevel > FreeAccess often reject anonymous writes; record as INFO when
# that's the case rather than failing the whole script.
# ---------------------------------------------------------------------------
Write-Header "Reverse bridge (OPC UA write)"
$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
$w = Invoke-Cli -Cli $opcUaCli -Args @(
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue")
if ($w.ExitCode -ne 0) {
# Connection/protocol failure — still a test failure.
Write-Fail "write CLI exit=$($w.ExitCode)"
Write-Host $w.Output
$results += @{ Passed = $false; Reason = "write failed" }
} elseif ($w.Output -match "Write failed:\s*0x801F0000") {
Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute."
$results += @{ Passed = $true; Reason = "acl-expected" }
} elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") {
Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)."
$results += @{ Passed = $true; Reason = "readonly-expected" }
} elseif ($w.Output -match "Write successful") {
# Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
Start-Sleep -Seconds 2
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
Write-Pass "write propagated — source reads back $writeValue"
$results += @{ Passed = $true }
} else {
Write-Fail "write reported success but read-back did not reflect $writeValue"
Write-Host $r.Output
$results += @{ Passed = $false; Reason = "write-readback mismatch" }
}
} else {
Write-Fail "unexpected write response"
Write-Host $w.Output
$results += @{ Passed = $false; Reason = "unexpected write response" }
}
# ---------------------------------------------------------------------------
# Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already
# wrote the trigger value the alarm may already be active; that's fine — the
# Part 9 ConditionRefresh in the alarms CLI replays the current state so the
# subscribe window still captures the Active event.
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
Write-Header "Alarm fires on threshold"
Write-Skip "AlarmNodeId not supplied — skipping alarm check"
} else {
$results += Test-AlarmFiresOnThreshold `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-AlarmNodeId $AlarmNodeId `
-InputNodeId $SourceNodeId `
-TriggerValue $AlarmTriggerValue `
-DurationSec $AlarmWaitSec
}
# ---------------------------------------------------------------------------
# Stage 7 — History read. historyread against the source tag over the last N
# seconds. Failure modes the skip pattern catches: tag not historized in the
# Galaxy attribute's historization profile, or the lookback window misses the
# sample cadence.
# ---------------------------------------------------------------------------
$results += Test-HistoryHasSamples `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-NodeId $SourceNodeId `
-LookbackSec $HistoryLookbackSec
Write-Summary -Title "Galaxy e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }