Files
lmxopcua/scripts/e2e/test-galaxy.ps1
Joseph Doherty 5fc596a9a1 E2E test script — Galaxy (MXAccess) driver: read / write / subscribe / alarms / history
Seven-stage e2e script covering every Galaxy-specific capability surface:
IReadable + IWritable + ISubscribable + IAlarmSource + IHistoryProvider.
Unlike the other drivers there is no per-protocol CLI — Galaxy's proxy
lives in-process with the server + talks to OtOpcUaGalaxyHost over a
named pipe (MXAccess COM is 32-bit-only), so every stage runs through
`otopcua-cli` against the published OPC UA address space.

## Stages

1. Probe                   — otopcua-cli read on the source NodeId
2. Source read             — capture value for downstream comparison
3. Virtual-tag bridge      — Phase 7 VirtualTag (source × 2) through
                             CachedTagUpstreamSource
4. Subscribe-sees-change   — data-change events propagate
5. Reverse bridge          — opc-ua write → Galaxy; soft-passes if the
                             attribute's Galaxy-side ACL forbids writes
                             (`BadUserAccessDenied` / `BadNotWritable`)
6. Alarm fires             — scripted-alarm Condition fires with Active
                             state when source crosses threshold
7. History read            — historyread returns samples from the Aveva
                             Historian → IHistoryProvider path

## Two new helpers in _common.ps1

- `Test-AlarmFiresOnThreshold` — start `otopcua-cli alarms --refresh`
  in the background on a Condition NodeId, drive the source change,
  assert captured stdout contains `ALARM` + `Active`. Uses the same
  Start-Process + temp-file pattern as `Test-SubscribeSeesChange` since
  the alarms command runs until Ctrl+C (no built-in --duration).
- `Test-HistoryHasSamples` — call `otopcua-cli historyread` over a
  configurable lookback window, parse `N values returned.` marker, fail
  if below MinSamples. Works for driver-sourced, virtual, or scripted-
  alarm historized nodes.

## Wiring

- `test-all.ps1` picks up the optional `galaxy` sidecar section and
  runs the script with the configured NodeIds + wait windows.
- `e2e-config.sample.json` adds a `galaxy` section seeded with the
  Phase 7 defaults (`p7-smoke-tag-source` / `-vt-derived` /
  `-al-overtemp`) — matches `scripts/smoke/seed-phase-7-smoke.sql`.
- `scripts/e2e/README.md` expected-matrix gains a Galaxy row.

## Prereqs

- OtOpcUaGalaxyHost running (NSSM-wrapped) with the Galaxy + MXAccess
  runtime available
- `seed-phase-7-smoke.sql` applied with a live Galaxy attribute
  substituted into `dbo.Tag.TagConfig`
- OtOpcUa server running against the `p7-smoke` cluster
- Non-elevated shell (Galaxy.Host pipe ACL denies Admins)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:59:06 -04:00

279 lines
13 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).
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`.
.PARAMETER VirtualNodeId
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting).
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`.
.PARAMETER AlarmNodeId
NodeId of the scripted-alarm Condition (fires when Source > 50).
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-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-tag-source",
[string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived",
[string]$AlarmNodeId = "ns=2;s=p7-smoke-al-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.
$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 }