From 8adc8f5ab824229c1085ae83d0a327d9710b37dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 16:49:51 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2037=20=E2=80=94=20End-to-end=20?= =?UTF-8?q?live-stack=20Galaxy=20smoke=20test.=20Closes=20the=20code=20sid?= =?UTF-8?q?e=20of=20LMX=20follow-up=20#5;=20once=20OtOpcUaGalaxyHost=20is?= =?UTF-8?q?=20installed=20+=20started=20on=20the=20dev=20box,=20the=20suit?= =?UTF-8?q?e=20exercises=20the=20full=20topology=20GalaxyProxyDriver=20in-?= =?UTF-8?q?process=20=E2=86=92=20named-pipe=20IPC=20=E2=86=92=20running=20?= =?UTF-8?q?OtOpcUaGalaxyHost=20Windows=20service=20=E2=86=92=20MxAccessGal?= =?UTF-8?q?axyBackend=20=E2=86=92=20live=20MXAccess=20runtime=20=E2=86=92?= =?UTF-8?q?=20real=20deployed=20Galaxy=20objects.=20Never=20spawns=20the?= =?UTF-8?q?=20Host=20process=20itself=20=E2=80=94=20connects=20to=20the=20?= =?UTF-8?q?already-running=20service=20per=20project=5Fgalaxy=5Fhost=5Fser?= =?UTF-8?q?vice.md,=20which=20is=20the=20only=20way=20to=20exercise=20the?= =?UTF-8?q?=20production=20COM-apartment=20+=20service-account=20+=20pipe-?= =?UTF-8?q?ACL=20configuration.=20LiveStackConfig=20resolves=20the=20pipe?= =?UTF-8?q?=20name=20+=20per-install=20shared=20secret=20from=20two=20sour?= =?UTF-8?q?ces=20in=20order:=20OTOPCUA=5FGALAXY=5FPIPE=20+=20OTOPCUA=5FGAL?= =?UTF-8?q?AXY=5FSECRET=20env=20vars=20first=20(for=20CI=20/=20benchwork?= =?UTF-8?q?=20overrides),=20then=20the=20service's=20per-process=20Environ?= =?UTF-8?q?ment=20registry=20values=20under=20HKLM\SYSTEM\CurrentControlSe?= =?UTF-8?q?t\Services\OtOpcUaGalaxyHost=20(what=20Install-Services.ps1=20w?= =?UTF-8?q?rites=20at=20install=20time).=20Registry=20read=20requires=20th?= =?UTF-8?q?e=20test=20host=20to=20run=20elevated=20on=20most=20boxes=20?= =?UTF-8?q?=E2=80=94=20the=20skip=20message=20says=20so=20explicitly=20so?= =?UTF-8?q?=20operators=20see=20the=20right=20remediation.=20Hard-coded=20?= =?UTF-8?q?secrets=20are=20deliberately=20avoided:=20the=20installer=20gen?= =?UTF-8?q?erates=2032=20fresh=20random=20bytes=20per=20install,=20a=20com?= =?UTF-8?q?mitted=20secret=20would=20diverge=20from=20production=20the=20m?= =?UTF-8?q?oment=20the=20service=20is=20re-installed.=20LiveStackFixture?= =?UTF-8?q?=20is=20an=20IAsyncLifetime=20that=20(1)=20runs=20AvevaPrerequi?= =?UTF-8?q?sites.CheckAllAsync=20with=20CheckGalaxyHostPipe=3Dtrue=20+=20C?= =?UTF-8?q?heckHistorian=3Dfalse=20=E2=80=94=20produces=20a=20structured?= =?UTF-8?q?=20PrerequisiteReport=20whose=20SkipReason=20is=20the=20exact?= =?UTF-8?q?=20operator-facing=20'here's=20what=20you=20need=20to=20fix'=20?= =?UTF-8?q?text,=20(2)=20resolves=20LiveStackConfig=20and=20surfaces=20a?= =?UTF-8?q?=20clear=20skip=20when=20the=20secret=20isn't=20discoverable,?= =?UTF-8?q?=20(3)=20instantiates=20GalaxyProxyDriver=20+=20calls=20Initial?= =?UTF-8?q?izeAsync=20(the=20IPC=20handshake),=20capturing=20a=20skip=20wi?= =?UTF-8?q?th=20the=20exception=20detail=20+=20common-cause=20hints=20(sec?= =?UTF-8?q?ret=20mismatch,=20SID=20not=20in=20pipe=20ACL,=20Host's=20backe?= =?UTF-8?q?nd=20couldn't=20connect=20to=20ZB)=20rather=20than=20letting=20?= =?UTF-8?q?a=20NullRef=20cascade=20through=20every=20subsequent=20test.=20?= =?UTF-8?q?SkipIfUnavailable()=20translates=20the=20captured=20SkipReason?= =?UTF-8?q?=20into=20Assert.Skip=20at=20the=20top=20of=20every=20fact=20so?= =?UTF-8?q?=20tests=20read=20as=20cleanly-skipped=20with=20a=20visible=20r?= =?UTF-8?q?eason,=20not=20silently-passed=20or=20crashed.=20LiveStackSmoke?= =?UTF-8?q?Tests=20(5=20facts,=20Collection=3DLiveStack,=20Category=3DLive?= =?UTF-8?q?Galaxy):=20Fixture=5Finitialized=5Fsuccessfully=20(cheapest=20p?= =?UTF-8?q?ossible=20end-to-end=20assertion=20=E2=80=94=20if=20this=20pass?= =?UTF-8?q?es,=20the=20IPC=20handshake=20worked);=20Driver=5Freports=5FHea?= =?UTF-8?q?lthy=5Fafter=5FIPC=5Fhandshake=20(DriverHealth.State=20post-con?= =?UTF-8?q?nect);=20DiscoverAsync=5Freturns=5Fat=5Fleast=5Fone=5Fvariable?= =?UTF-8?q?=5Ffrom=5Flive=5Fgalaxy=20(captures=20every=20Variable()=20call?= =?UTF-8?q?=20from=20DiscoverAsync=20via=20CapturingAddressSpaceBuilder=20?= =?UTF-8?q?and=20asserts=20>=200=20=E2=80=94=20zero=20here=20usually=20mea?= =?UTF-8?q?ns=20the=20Host=20couldn't=20read=20ZB,=20the=20skip=20message?= =?UTF-8?q?=20names=20OTOPCUA=5FGALAXY=5FZB=5FCONN=20to=20check);=20GetHos?= =?UTF-8?q?tStatuses=5Freports=5Fat=5Fleast=5Fone=5Fplatform=20(IHostConne?= =?UTF-8?q?ctivityProbe=20surface=20=E2=80=94=20zero=20means=20the=20probe?= =?UTF-8?q?=20loop=20hasn't=20fired=20or=20no=20Platform=20is=20deployed?= =?UTF-8?q?=20locally);=20Can=5Fread=5Fa=5Fdiscovered=5Fvariable=5Ffrom=5F?= =?UTF-8?q?live=5Fgalaxy=20(reads=20the=20first=20discovered=20attribute's?= =?UTF-8?q?=20full=20reference,=20asserts=20status=20!=3D=20BadInternalErr?= =?UTF-8?q?or=20=E2=80=94=20Galaxy's=20Uncertain-quality-until-first-Engin?= =?UTF-8?q?e-scan=20is=20intentionally=20NOT=20treated=20as=20failure=20si?= =?UTF-8?q?nce=20it=20depends=20on=20runtime=20state=20that=20varies=20acr?= =?UTF-8?q?oss=20test=20runs).=20Read-only=20by=20design;=20writes=20need?= =?UTF-8?q?=20an=20agreed=20scratch=20tag=20to=20avoid=20mutating=20a=20pr?= =?UTF-8?q?ocess-critical=20attribute=20=E2=80=94=20deferred=20to=20a=20fo?= =?UTF-8?q?llow-up=20PR=20that=20reuses=20this=20fixture.=20CapturingAddre?= =?UTF-8?q?ssSpaceBuilder=20is=20a=20minimal=20IAddressSpaceBuilder=20that?= =?UTF-8?q?=20flattens=20every=20Variable()=20call=20into=20a=20list=20so?= =?UTF-8?q?=20tests=20can=20inspect=20what=20discovery=20produced=20withou?= =?UTF-8?q?t=20booting=20the=20full=20OPC=20UA=20node-manager=20stack;=20a?= =?UTF-8?q?larm=20annotation=20+=20property=20calls=20are=20no-ops.=20Scop?= =?UTF-8?q?ed=20private=20to=20the=20test=20class.=20Galaxy.Proxy.Tests=20?= =?UTF-8?q?csproj=20gains=20a=20ProjectReference=20to=20Driver.Galaxy.Test?= =?UTF-8?q?Support=20(PR=2036)=20for=20AvevaPrerequisites.=20The=20NU1702?= =?UTF-8?q?=20warning=20about=20the=20Host=20project=20being=20net48-refer?= =?UTF-8?q?enced-by-net10=20is=20pre-existing=20from=20the=20HostSubproces?= =?UTF-8?q?sParityTests=20=E2=80=94=20Proxy.Tests=20only=20needs=20the=20H?= =?UTF-8?q?ost=20EXE=20path=20for=20that=20parity=20scenario,=20not=20type?= =?UTF-8?q?=20surface.=20Test=20run=20on=20THIS=20machine=20(OtOpcUaGalaxy?= =?UTF-8?q?Host=20not=20yet=20installed):=20Skipped!=20Failed=200,=20Passe?= =?UTF-8?q?d=200,=20Skipped=205=20=E2=80=94=20each=20skip=20message=20incl?= =?UTF-8?q?udes=20the=20full=20prerequisites=20report=20pointing=20at=20th?= =?UTF-8?q?e=20missing=20service.=20Once=20the=20service=20is=20installed?= =?UTF-8?q?=20+=20started=20(scripts\install\Install-Services.ps1),=20the?= =?UTF-8?q?=205=20facts=20will=20execute=20against=20live=20Galaxy.=20Prox?= =?UTF-8?q?y.Tests=20Unit:=2017=20pass=20/=200=20fail=20(unchanged=20?= =?UTF-8?q?=E2=80=94=20new=20tests=20are=20Category=3DLiveGalaxy,=20separa?= =?UTF-8?q?te=20suite).=20Full=20Proxy=20build=20clean.=20Memory=20already?= =?UTF-8?q?=20captures=20the=20'live=20tests=20run=20via=20already-running?= =?UTF-8?q?=20service,=20don't=20spawn'=20convention=20(project=5Fgalaxy?= =?UTF-8?q?=5Fhost=5Fservice.md).=20lmx-followups.md=20#5=20updated:=20sta?= =?UTF-8?q?tus=20is=20'IN=20PROGRESS'=20across=20PRs=2036=20+=2037=20with?= =?UTF-8?q?=20the=20explicit=20remaining=20work=20(install=20+=20start=20s?= =?UTF-8?q?ervices,=20subscribe-and-receive,=20write=20round-trip).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/lmx-followups.md | 38 +++-- .../LiveStack/LiveStackConfig.cs | 75 +++++++++ .../LiveStack/LiveStackFixture.cs | 120 ++++++++++++++ .../LiveStack/LiveStackSmokeTests.cs | 147 ++++++++++++++++++ ...W.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj | 1 + 5 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index a6f5f90..19d9443 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -77,18 +77,36 @@ drive a full OPC UA session with username/password, then read an `IHostConnectivityProbe`-style "whoami" node to verify the role surfaced). That needs a test-only address-space node and is a separate PR. -## 5. Full Galaxy live-service smoke test against the merged v2 stack +## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)** -**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13 -probe manager, PR 14 alarm tracker), but the full loop — OPC UA client → -`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to -Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has -no single end-to-end smoke test. +PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes +every dependency a live smoke test needs and produces actionable skip +messages. -**To do**: -- Test that spawns the full topology, discovers a deployed Galaxy object, - subscribes to one of its attributes, writes a value back, and asserts the - write round-tripped through MXAccess. Skip when ArchestrA isn't running. +PR 37 shipped the live-stack smoke test project structure: +`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects +to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe; +never spawns the Host process) and `LiveStackSmokeTests` covering: + +- Fixture initializes successfully (IPC handshake succeeds end-to-end). +- Driver reports `DriverState.Healthy` post-handshake. +- `DiscoverAsync` returns at least one variable from the live Galaxy. +- `GetHostStatuses` reports at least one Platform/AppEngine host. +- `ReadAsync` on a discovered variable round-trips through + Proxy → Host pipe → MXAccess → back without a BadInternalError. + +Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` / +`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's +registry-stored Environment values (requires elevated test host). + +**Remaining**: +- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box + (`scripts/install/Install-Services.ps1`) so the skip-on-unready tests + actually execute and the smoke PR lands green. +- Subscribe-and-receive-data-change fact (needs a known tag that actually + ticks; deferred until operators confirm a scratch tag exists). +- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag + so we can't accidentally mutate a process-critical value). ## 6. Second driver instance on the same server — **DONE (PR 32)** diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs new file mode 100644 index 0000000..9941aaf --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs @@ -0,0 +1,75 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; + +/// +/// Resolves the pipe name + shared secret the live needs +/// to connect to a running OtOpcUaGalaxyHost Windows service. Two sources are +/// consulted, first match wins: +/// +/// Explicit env vars (OTOPCUA_GALAXY_PIPE, OTOPCUA_GALAXY_SECRET) — lets CI / benchwork override. +/// The service's per-process Environment registry values under +/// HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost — what +/// Install-Services.ps1 writes at install time. Requires the test to run as a +/// principal with read access to that registry key (typically Administrators). +/// +/// +/// +/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the +/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret +/// in tests would diverge from production the moment someone re-installed the service. +/// +public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source) +{ + public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE"; + public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET"; + public const string ServiceRegistryKey = + @"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost"; + public const string DefaultPipeName = "OtOpcUaGalaxy"; + + public static LiveStackConfig? Resolve() + { + var envPipe = Environment.GetEnvironmentVariable(EnvPipeName); + var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret); + if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret)) + return new LiveStackConfig(envPipe, envSecret, "env vars"); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return null; + + return FromServiceRegistry(); + } + + [SupportedOSPlatform("windows")] + private static LiveStackConfig? FromServiceRegistry() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey); + if (key is null) return null; + var env = key.GetValue("Environment") as string[]; + if (env is null || env.Length == 0) return null; + + string? pipe = null, secret = null; + foreach (var line in env) + { + var eq = line.IndexOf('='); + if (eq <= 0) continue; + var name = line[..eq]; + var value = line[(eq + 1)..]; + if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value; + else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value; + } + + if (string.IsNullOrWhiteSpace(secret)) return null; + return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry"); + } + catch + { + // Access denied / key missing / malformed — caller gets null and surfaces a Skip. + return null; + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs new file mode 100644 index 0000000..7199c22 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs @@ -0,0 +1,120 @@ +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; + +/// +/// Connects a single to the already-running +/// OtOpcUaGalaxyHost Windows service for the lifetime of a test class. Uses +/// to decide whether to proceed; on failure, +/// is populated and each test calls +/// to translate that into Assert.Skip. +/// +/// +/// +/// Does NOT spawn the Host process. Production deploys OtOpcUaGalaxyHost +/// as a standalone Windows service — spawning a second instance from a test would +/// bypass the COM-apartment + service-account setup and fail differently than +/// production (see project_galaxy_host_service.md memory). +/// +/// +/// Shared-secret handling: read from — env vars +/// first, then the service's registry-stored Environment values. Requires +/// the test process to have read access to +/// HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost; on a dev box +/// that typically means running the test host elevated, or exporting +/// OTOPCUA_GALAXY_SECRET out-of-band. +/// +/// +public sealed class LiveStackFixture : IAsyncLifetime +{ + public GalaxyProxyDriver? Driver { get; private set; } + + public string? SkipReason { get; private set; } + + public PrerequisiteReport? PrerequisiteReport { get; private set; } + + public LiveStackConfig? Config { get; private set; } + + public async ValueTask InitializeAsync() + { + // 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync( + new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false }, + cts.Token); + + if (!PrerequisiteReport.IsLivetestReady) + { + SkipReason = PrerequisiteReport.SkipReason; + return; + } + + // 2. Secret / pipe-name resolution. If the service is running but we can't discover its + // env vars from registry (non-elevated test host), a clear message beats a silent + // connect-rejected failure 10 seconds later. + Config = LiveStackConfig.Resolve(); + if (Config is null) + { + SkipReason = + $"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " + + $"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " + + $"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment."; + return; + } + + // 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second + // ConnectTimeout gives enough headroom for a service that just started. + Driver = new GalaxyProxyDriver(new GalaxyProxyOptions + { + DriverInstanceId = "live-stack-smoke", + PipeName = Config.PipeName, + SharedSecret = Config.SharedSecret, + ConnectTimeout = TimeSpan.FromSeconds(5), + }); + + try + { + await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None); + } + catch (Exception ex) + { + SkipReason = + $"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " + + $"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " + + $"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " + + $"test must run as that user), or Host's backend couldn't connect to ZB."; + Driver.Dispose(); + Driver = null; + return; + } + } + + public async ValueTask DisposeAsync() + { + if (Driver is not null) + { + try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ } + Driver.Dispose(); + } + } + + /// + /// Translate into Assert.Skip. Tests call this at the + /// top of every fact so a fixture init failure shows up as a cleanly-skipped test with + /// the full prerequisites report, not a cascading NullReferenceException on + /// . + /// + public void SkipIfUnavailable() + { + if (SkipReason is not null) Assert.Skip(SkipReason); + } +} + +[CollectionDefinition(Name)] +public sealed class LiveStackCollection : ICollectionFixture +{ + public const string Name = "LiveStack"; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs new file mode 100644 index 0000000..18fd429 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; + +/// +/// End-to-end smoke against the installed OtOpcUaGalaxyHost Windows service. +/// Closes LMX follow-up #5 — exercises the full topology: +/// in-process → named-pipe IPC → OtOpcUaGalaxyHost service → MxAccessGalaxyBackend → +/// live MXAccess runtime → real Galaxy objects + attributes. +/// +/// +/// +/// Preconditions (all checked by , surfaced via +/// Assert.Skip when missing): +/// +/// +/// AVEVA System Platform installed + Platform deployed. +/// aaBootstrap / aaGR / NmxSvc / MSSQLSERVER running. +/// MXAccess COM server registered. +/// ZB database exists with at least one deployed gobject. +/// OtOpcUaGalaxyHost service installed + running (named pipe accepting connections). +/// Shared secret discoverable via OTOPCUA_GALAXY_SECRET env var or the +/// service's registry Environment values (test host typically needs to be elevated +/// to read the latter). +/// Test process runs as the account listed in the service's pipe ACL +/// (OTOPCUA_ALLOWED_SID, typically the service account per decision #76). +/// +/// +/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a +/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't +/// accidentally mutate a process-critical value. Adding a write test is a follow-up +/// PR that reuses this fixture. +/// +/// +[Trait("Category", "LiveGalaxy")] +[Collection(LiveStackCollection.Name)] +public sealed class LiveStackSmokeTests(LiveStackFixture fixture) +{ + [Fact] + public void Fixture_initialized_successfully() + { + fixture.SkipIfUnavailable(); + // If the fixture init succeeded, Driver is non-null and InitializeAsync completed. + // This is the cheapest possible assertion that the IPC handshake worked end-to-end; + // every other test in this class depends on it. + fixture.Driver.ShouldNotBeNull(); + fixture.Config.ShouldNotBeNull(); + fixture.PrerequisiteReport.ShouldNotBeNull(); + fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason); + } + + [Fact] + public void Driver_reports_Healthy_after_IPC_handshake() + { + fixture.SkipIfUnavailable(); + var health = fixture.Driver!.GetHealth(); + health.State.ShouldBe(DriverState.Healthy, + $"Expected Healthy after successful IPC connect; Reason={health.LastError}"); + } + + [Fact] + public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy() + { + fixture.SkipIfUnavailable(); + var builder = new CapturingAddressSpaceBuilder(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await fixture.Driver!.DiscoverAsync(builder, cts.Token); + + builder.Variables.Count.ShouldBeGreaterThan(0, + "Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " + + "Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment)."); + + // Every discovered attribute must carry a non-empty FullName so the OPC UA server can + // route reads/writes back. Regression guard — PR 19 normalized this across drivers. + builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName)); + } + + [Fact] + public void GetHostStatuses_reports_at_least_one_platform() + { + fixture.SkipIfUnavailable(); + var statuses = fixture.Driver!.GetHostStatuses(); + statuses.Count.ShouldBeGreaterThan(0, + "Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " + + "Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally."); + + // Host names are driver-opaque to the Core but non-empty by contract. + statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName)); + } + + [Fact] + public async Task Can_read_a_discovered_variable_from_live_galaxy() + { + fixture.SkipIfUnavailable(); + var builder = new CapturingAddressSpaceBuilder(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await fixture.Driver!.DiscoverAsync(builder, cts.Token); + builder.Variables.Count.ShouldBeGreaterThan(0); + + // Pick the first discovered variable. Read-only smoke — we don't assert on Value, + // only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back + // returns a snapshot with a non-BadInternalError status. Galaxy attributes default to + // Uncertain quality until the Engine's first scan publishes them, which is fine here. + var full = builder.Variables[0].AttributeInfo.FullName; + var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token); + + snapshots.Count.ShouldBe(1); + var snap = snapshots[0]; + snap.StatusCode.ShouldNotBe(0x80020000u, + $"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " + + $"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs."); + } + + /// + /// Minimal implementation that captures every + /// Variable() call into a flat list so tests can inspect what discovery produced + /// without running the full OPC UA node-manager stack. + /// + private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new NoopHandle(attributeInfo.FullName); + } + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class NoopHandle(string fullReference) : IVariableHandle + { + public string FullReference { get; } = fullReference; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink(); + private sealed class NoopSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj index f90149b..25fac36 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj @@ -22,6 +22,7 @@ + -- 2.49.1