From 08c90d19fd39d29d2c6ade0ec80ede71866f4eb0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 16:36:13 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2036=20=E2=80=94=20AVEVA=20prere?= =?UTF-8?q?quisites=20test-support=20library.=20New=20tests/ZB.MOM.WW.OtOp?= =?UTF-8?q?cUa.Driver.Galaxy.TestSupport=20multi-targeted=20class=20librar?= =?UTF-8?q?y=20(net10.0=20+=20net48=20so=20both=20the=20modern=20and=20the?= =?UTF-8?q?=20MXAccess-COM=20x86=20test=20projects=20can=20consume=20it)?= =?UTF-8?q?=20that=20probes=20every=20piece=20of=20the=20AVEVA=20System=20?= =?UTF-8?q?Platform=20+=20OtOpcUa=20stack=20a=20live-Galaxy=20test=20depen?= =?UTF-8?q?ds=20on=20and=20returns=20a=20structured=20PrerequisiteReport.?= =?UTF-8?q?=20Closes=20the=20gap=20where=20live-smoke=20tests=20silently?= =?UTF-8?q?=20returned=20'unreachable'=20without=20telling=20operators=20w?= =?UTF-8?q?hich=20specific=20piece=20failed.=20AvevaPrerequisites.CheckAll?= =?UTF-8?q?Async=20walks=20eight=20probe=20categories=20producing=20Prereq?= =?UTF-8?q?uisiteCheck=20rows=20each=20with=20Name=20(e.g.=20'service:aaBo?= =?UTF-8?q?otstrap',=20'sql:ZB',=20'com:LMXProxy',=20'registry:ArchestrA.F?= =?UTF-8?q?ramework'),=20Category=20(AvevaCoreService=20/=20AvevaSoftServi?= =?UTF-8?q?ce=20/=20AvevaInstall=20/=20MxAccessCom=20/=20GalaxyRepository?= =?UTF-8?q?=20/=20AvevaHistorian=20/=20OtOpcUaService=20/=20Environment),?= =?UTF-8?q?=20Status=20(Pass=20/=20Warn=20/=20Fail=20/=20Skip),=20and=20op?= =?UTF-8?q?erator-facing=20Detail=20message.=20Report=20aggregates=20them:?= =?UTF-8?q?=20IsLivetestReady=20(no=20Fails=20anywhere)=20and=20IsAvevaSid?= =?UTF-8?q?eReady=20(AVEVA-side=20categories=20pass,=20our=20v2=20services?= =?UTF-8?q?=20can=20be=20absent=20while=20still=20considering=20the=20envi?= =?UTF-8?q?ronment=20AVEVA-ready)=20so=20different=20test=20tiers=20can=20?= =?UTF-8?q?use=20the=20right=20threshold.=20Individual=20probes:=20Service?= =?UTF-8?q?Probe.Check=20queries=20the=20Windows=20Service=20Control=20Man?= =?UTF-8?q?ager=20via=20System.ServiceProcess.ServiceController=20?= =?UTF-8?q?=E2=80=94=20treats=20DemandStart+Stopped=20as=20Warn=20(NmxSvc?= =?UTF-8?q?=20is=20DemandStart=20by=20design;=20master=20pulls=20it=20up)?= =?UTF-8?q?=20but=20AutoStart+Stopped=20as=20Fail;=20not-installed=20is=20?= =?UTF-8?q?Fail=20for=20hard-required=20services,=20Warn=20for=20soft=20on?= =?UTF-8?q?es;=20non-Windows=20hosts=20get=20Skip;=20transitional=20states?= =?UTF-8?q?=20like=20StartPending=20get=20Warn=20with=20a=20'try=20again'?= =?UTF-8?q?=20hint.=20RegistryProbe=20reads=20HKLM\SOFTWARE\WOW6432Node\Ar?= =?UTF-8?q?chestrA\{Framework,Framework\Platform,MSIInstall}=20=E2=80=94?= =?UTF-8?q?=20Framework=20key=20presence=20+=20populated=20InstallPath/Roo?= =?UTF-8?q?tPath=20values=20mean=20System=20Platform=20installed;=20PfeCon?= =?UTF-8?q?figOptions=20in=20the=20Platform=20subkey=20(format=20'Platform?= =?UTF-8?q?Id=3DN,EngineId=3DN,...')=20indicates=20a=20Platform=20has=20be?= =?UTF-8?q?en=20deployed=20from=20the=20IDE=20(PlatformId=3D0=20means=20ne?= =?UTF-8?q?ver=20deployed=20=E2=80=94=20MXAccess=20will=20connect=20but=20?= =?UTF-8?q?every=20subscription=20will=20be=20Bad=20quality);=20RebootRequ?= =?UTF-8?q?ired=3D'True'=20under=20MSIInstall=20surfaces=20as=20a=20loud?= =?UTF-8?q?=20warn=20since=20post-patch=20behavior=20is=20undefined.=20MxA?= =?UTF-8?q?ccessComProbe=20resolves=20the=20LMXProxy.LMXProxyServer=20Prog?= =?UTF-8?q?ID=20=E2=86=92=20CLSID=20=E2=86=92=20HKLM\SOFTWARE\Classes\WOW6?= =?UTF-8?q?432Node\CLSID\{guid}\InprocServer32,=20verifying=20the=20regist?= =?UTF-8?q?ered=20file=20exists=20on=20disk=20(catches=20the=20orphan-regi?= =?UTF-8?q?stry=20case=20where=20a=20previous=20uninstall=20left=20the=20P?= =?UTF-8?q?rogID=20registered=20but=20the=20DLL=20is=20gone=20=E2=80=94=20?= =?UTF-8?q?distinguishes=20it=20from=20the=20'totally=20not=20installed'?= =?UTF-8?q?=20case=20by=20message);=20also=20emits=20a=20Warn=20when=20the?= =?UTF-8?q?=20test=20process=20is=2064-bit=20(MXAccess=20COM=20activation?= =?UTF-8?q?=20fails=20with=20REGDB=5FE=5FCLASSNOTREG=200x80040154=20regard?= =?UTF-8?q?less=20of=20registration,=20so=20seeing=20this=20warning=20tell?= =?UTF-8?q?s=20operators=20why=20the=20activation=20would=20fail=20even=20?= =?UTF-8?q?on=20a=20fully-installed=20machine).=20SqlProbe=20tests=20Galax?= =?UTF-8?q?y=20Repository=20via=20Microsoft.Data.SqlClient=20using=20the?= =?UTF-8?q?=20Windows-auth=20localhost=20connection=20string=20the=20repo?= =?UTF-8?q?=20code=20defaults=20to=20=E2=80=94=20distinguishes=20'SQL=20Se?= =?UTF-8?q?rver=20unreachable'=20(connection=20fails)=20from=20'ZB=20datab?= =?UTF-8?q?ase=20does=20not=20exist'=20(SELECT=20DB=5FID('ZB')=20returns?= =?UTF-8?q?=20null)=20because=20they=20have=20different=20remediation=20pa?= =?UTF-8?q?ths=20(sc.exe=20start=20MSSQLSERVER=20vs.=20restore=20from=20.c?= =?UTF-8?q?ab=20backup);=20a=20secondary=20CheckDeployedObjectCountAsync?= =?UTF-8?q?=20query=20on=20'gobject=20WHERE=20deployed=5Fversion=20>=200'?= =?UTF-8?q?=20warns=20when=20the=20count=20is=20zero=20because=20discovery?= =?UTF-8?q?=20smoke=20tests=20will=20return=20empty=20hierarchies.=20Named?= =?UTF-8?q?PipeProbe=20opens=20a=202s=20NamedPipeClientStream=20against=20?= =?UTF-8?q?OtOpcUaGalaxyHost's=20pipe=20('OtOpcUaGalaxy'=20per=20the=20ins?= =?UTF-8?q?taller=20default)=20=E2=80=94=20pipe=20accepting=20a=20connecti?= =?UTF-8?q?on=20proves=20the=20Host=20service=20is=20listening;=20disconne?= =?UTF-8?q?cts=20immediately=20so=20we=20don't=20consume=20a=20session=20s?= =?UTF-8?q?lot.=20Service=20lists=20kept=20as=20internal=20static=20data?= =?UTF-8?q?=20so=20tests=20can=20inspect=20+=20override:=20CoreServices=20?= =?UTF-8?q?(aaBootstrap=20+=20aaGR=20+=20NmxSvc=20+=20MSSQLSERVER=20?= =?UTF-8?q?=E2=80=94=20hard=20fail=20if=20missing),=20SoftServices=20(aaLo?= =?UTF-8?q?gger=20+=20aaUserValidator=20+=20aaGlobalDataCacheMonitorSvr=20?= =?UTF-8?q?=E2=80=94=20warn=20only;=20stack=20runs=20without=20them=20but?= =?UTF-8?q?=20diagnostics/auth=20are=20degraded),=20HistorianServices=20(a?= =?UTF-8?q?ahClientAccessPoint=20+=20aahGateway=20=E2=80=94=20opt-in=20via?= =?UTF-8?q?=20Options.CheckHistorian,=20only=20matters=20for=20HistoryRead?= =?UTF-8?q?=20IPC=20paths),=20OtOpcUaServices=20(our=20OtOpcUaGalaxyHost?= =?UTF-8?q?=20hard-required=20for=20end-to-end=20live=20tests=20+=20OtOpcU?= =?UTF-8?q?a=20warn=20+=20GLAuth=20warn).=20Narrower=20entry=20points=20Ch?= =?UTF-8?q?eckRepositoryOnlyAsync=20and=20CheckGalaxyHostPipeOnlyAsync=20f?= =?UTF-8?q?or=20tests=20that=20only=20care=20about=20specific=20subsystems?= =?UTF-8?q?=20=E2=80=94=20avoid=20paying=20the=20full=20probe=20cost=20on?= =?UTF-8?q?=20every=20GalaxyRepositoryLiveSmokeTests=20fact.=20Multi-targe?= =?UTF-8?q?ting=20mechanics:=20System.ServiceProcess.ServiceController=20+?= =?UTF-8?q?=20Microsoft.Win32.Registry=20are=20NuGet=20packages=20on=20net?= =?UTF-8?q?10=20but=20in-box=20BCL=20references=20on=20net48;=20csproj=20c?= =?UTF-8?q?onditions=20Package=20vs=20Reference=20by=20TargetFramework.=20?= =?UTF-8?q?Microsoft.Data.SqlClient=20v6=20supports=20both=20frameworks=20?= =?UTF-8?q?so=20single=20PackageReference.=20Net48Polyfills.cs=20provides?= =?UTF-8?q?=20IsExternalInit=20shim=20(records/init-only=20setters)=20and?= =?UTF-8?q?=20SupportedOSPlatformAttribute=20stub=20so=20the=20same=20Prob?= =?UTF-8?q?e=20sources=20compile=20on=20both=20frameworks=20without=20per-?= =?UTF-8?q?callsite=20preprocessor=20guards=20=E2=80=94=20lets=20Roslyn's?= =?UTF-8?q?=20platform-compatibility=20analyzer=20stay=20useful=20on=20net?= =?UTF-8?q?10=20without=20breaking=20net48=20builds.=20Existing=20GalaxyRe?= =?UTF-8?q?positoryLiveSmokeTests=20updated=20to=20delegate=20its=20skip?= =?UTF-8?q?=20decision=20to=20AvevaPrerequisites.CheckRepositoryOnlyAsync?= =?UTF-8?q?=20(legacy=20ZbReachableAsync=20kept=20as=20a=20compatibility?= =?UTF-8?q?=20adapter=20so=20the=20in-test=20'if=20(!await=20ZbReachableAs?= =?UTF-8?q?ync())=20return;'=20pattern=20keeps=20working=20while=20the=20s?= =?UTF-8?q?urrounding=20fixtures=20gradually=20migrate=20to=20Assert.Skip-?= =?UTF-8?q?with-reason).=20Slnx=20file=20registers=20the=20new=20project.?= =?UTF-8?q?=20Tests=20=E2=80=94=20AvevaPrerequisitesLiveTests=20(8=20new?= =?UTF-8?q?=20Integration=20cases,=20Category=3DLiveGalaxy):=20the=20helpe?= =?UTF-8?q?r=20correctly=20reports=20Framework=20install=20(registry=20pas?= =?UTF-8?q?s),=20aaBootstrap=20Running=20(service=20pass),=20aaGR=20Runnin?= =?UTF-8?q?g=20(service=20pass),=20MxAccess=20COM=20registered=20(com=20pa?= =?UTF-8?q?ss),=20ZB=20database=20reachable=20(sql=20pass),=20deployed-obj?= =?UTF-8?q?ect=20count=20>=200=20(warn-upgraded-to-pass=20because=20this?= =?UTF-8?q?=20box=20has=2049=20objects=20deployed),=20the=20AVEVA=20side?= =?UTF-8?q?=20is=20ready=20even=20when=20our=20own=20services=20(OtOpcUaGa?= =?UTF-8?q?laxyHost)=20aren't=20installed=20yet=20(IsAvevaSideReady=3Dtrue?= =?UTF-8?q?),=20and=20the=20helper=20emits=20rows=20for=20OtOpcUaGalaxyHos?= =?UTF-8?q?t=20+=20OtOpcUa=20+=20GLAuth=20even=20when=20not=20installed=20?= =?UTF-8?q?(regression=20guard=20=E2=80=94=20nobody=20can=20accidentally?= =?UTF-8?q?=20ship=20a=20check=20that=20omits=20our=20own=20services).=20F?= =?UTF-8?q?ull=20Galaxy.Host.Tests=20Category=3DLiveGalaxy=20suite:=2013?= =?UTF-8?q?=20pass=20(5=20prior=20smoke=20+=208=20new=20prerequisites).=20?= =?UTF-8?q?Full=20solution=20build=20clean,=200=20errors.=20What's=20NOT?= =?UTF-8?q?=20in=20this=20PR:=20end-to-end=20Galaxy=20stack=20smoke=20(Pro?= =?UTF-8?q?xy=20=E2=86=92=20Host=20pipe=20=E2=86=92=20MXAccess=20=E2=86=92?= =?UTF-8?q?=20real=20Galaxy=20tag).=20That's=20the=20next=20PR=20=E2=80=94?= =?UTF-8?q?=20this=20one=20is=20the=20gate=20the=20end-to-end=20smoke=20wi?= =?UTF-8?q?ll=20call=20first=20to=20produce=20actionable=20skip=20messages?= =?UTF-8?q?=20instead=20of=20silent=20returns.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../AvevaPrerequisitesLiveTests.cs | 127 ++++++++++++++ .../GalaxyRepositoryLiveSmokeTests.cs | 25 ++- ...WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj | 1 + .../AvevaPrerequisites.cs | 163 ++++++++++++++++++ .../Net48Polyfills.cs | 26 +++ .../PrerequisiteCheck.cs | 44 +++++ .../PrerequisiteReport.cs | 94 ++++++++++ .../Probes/MxAccessComProbe.cs | 102 +++++++++++ .../Probes/NamedPipeProbe.cs | 59 +++++++ .../Probes/RegistryProbe.cs | 162 +++++++++++++++++ .../Probes/ServiceProbe.cs | 85 +++++++++ .../Probes/SqlProbe.cs | 88 ++++++++++ ...W.OtOpcUa.Driver.Galaxy.TestSupport.csproj | 38 ++++ 14 files changed, 1008 insertions(+), 7 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 32d6b5a..e562c0a 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -21,6 +21,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs new file mode 100644 index 0000000..7cdcd87 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using Xunit.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests +{ + /// + /// Exercises against the live dev box so the helper + /// itself gets integration coverage — i.e. "do the probes return Pass for things that + /// really are Pass?" as validated against this machine's known-installed topology. + /// Category LiveGalaxy so CI / clean dev boxes skip cleanly. + /// + [Trait("Category", "LiveGalaxy")] + public sealed class AvevaPrerequisitesLiveTests + { + private readonly ITestOutputHelper _output; + + public AvevaPrerequisitesLiveTests(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task CheckAll_on_live_box_reports_Framework_install() + { + var report = await AvevaPrerequisites.CheckAllAsync(); + _output.WriteLine(report.ToString()); + report.Checks.ShouldContain(c => + c.Name == "registry:ArchestrA.Framework" && c.Status == PrerequisiteStatus.Pass, + "ArchestrA Framework registry root should be found on this machine."); + } + + [Fact] + public async Task CheckAll_on_live_box_reports_aaBootstrap_running() + { + var report = await AvevaPrerequisites.CheckAllAsync(); + var bootstrap = report.Checks.FirstOrDefault(c => c.Name == "service:aaBootstrap"); + bootstrap.ShouldNotBeNull(); + bootstrap.Status.ShouldBe(PrerequisiteStatus.Pass, + $"aaBootstrap must be Running for any live-Galaxy test to work — detail: {bootstrap.Detail}"); + } + + [Fact] + public async Task CheckAll_on_live_box_reports_aaGR_running() + { + var report = await AvevaPrerequisites.CheckAllAsync(); + var gr = report.Checks.FirstOrDefault(c => c.Name == "service:aaGR"); + gr.ShouldNotBeNull(); + gr.Status.ShouldBe(PrerequisiteStatus.Pass, + $"aaGR (Galaxy Repository) must be Running — detail: {gr.Detail}"); + } + + [Fact] + public async Task CheckAll_on_live_box_reports_MxAccess_COM_registered() + { + var report = await AvevaPrerequisites.CheckAllAsync(); + var com = report.Checks.FirstOrDefault(c => c.Name == "com:LMXProxy"); + com.ShouldNotBeNull(); + com.Status.ShouldBe(PrerequisiteStatus.Pass, + $"LMXProxy.LMXProxyServer ProgID must resolve to an InprocServer32 DLL — detail: {com.Detail}"); + } + + [Fact] + public async Task CheckRepositoryOnly_on_live_box_reports_ZB_reachable() + { + var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(ct: CancellationToken.None); + var zb = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB"); + zb.ShouldNotBeNull(); + zb.Status.ShouldBe(PrerequisiteStatus.Pass, + $"ZB database must be reachable via SQL Server Windows auth — detail: {zb.Detail}"); + } + + [Fact] + public async Task CheckRepositoryOnly_on_live_box_reports_non_zero_deployed_objects() + { + // This box has 49 deployed objects per the research; we just assert > 0 so adding/ + // removing objects doesn't break the test. + var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(); + var deployed = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB.deployedObjects"); + deployed.ShouldNotBeNull(); + deployed.Status.ShouldBe(PrerequisiteStatus.Pass, + $"At least one deployed gobject should exist — detail: {deployed.Detail}"); + } + + [Fact] + public async Task Aveva_side_is_ready_on_this_machine() + { + // Narrower than "livetest ready" — our own services (OtOpcUa / OtOpcUaGalaxyHost) + // may not be installed on a developer's box while they're actively iterating on + // them, but the AVEVA side (Framework / Galaxy Repository / MXAccess COM / + // SQL / core services) should always be up on a machine with System Platform + // installed. This assertion is what gates live-Galaxy tests that go straight to + // the Galaxy Repository without routing through our stack. + var report = await AvevaPrerequisites.CheckAllAsync( + new AvevaPrerequisites.Options { CheckGalaxyHostPipe = false }); + _output.WriteLine(report.ToString()); + _output.WriteLine(report.Warnings ?? "no warnings"); + + // Enumerate AVEVA-side failures (if any) for an actionable assertion message. + var avevaFails = report.Checks + .Where(c => c.Status == PrerequisiteStatus.Fail && + c.Category != PrerequisiteCategory.OtOpcUaService) + .ToList(); + report.IsAvevaSideReady.ShouldBeTrue( + avevaFails.Count == 0 + ? "unexpected state" + : "AVEVA-side failures: " + string.Join(" ; ", + avevaFails.Select(f => $"{f.Name}: {f.Detail}"))); + } + + [Fact] + public async Task Report_captures_OtOpcUa_services_state_even_when_not_installed() + { + // The helper reports the status of OtOpcUaGalaxyHost + OtOpcUa services even if + // they're not installed yet — absence is itself an actionable signal. This test + // doesn't assert Pass/Fail on those services (their state depends on what's + // installed when the test runs) — it only asserts the helper EMITTED the rows, + // so nobody can ship a prerequisite check that silently omits our own services. + var report = await AvevaPrerequisites.CheckAllAsync(); + + report.Checks.ShouldContain(c => c.Name == "service:OtOpcUaGalaxyHost"); + report.Checks.ShouldContain(c => c.Name == "service:OtOpcUa"); + report.Checks.ShouldContain(c => c.Name == "service:GLAuth"); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs index fe5f741..c3996ad 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs @@ -6,6 +6,7 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests { @@ -16,6 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests /// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the /// DiscoverHierarchyResponse shape. /// + /// + /// Since PR 36, skip logic is delegated to + /// so operators see exactly why a test skipped ("ZB db not found" vs "SQL Server + /// unreachable") instead of a silent return. + /// [Trait("Category", "LiveGalaxy")] public sealed class GalaxyRepositoryLiveSmokeTests { @@ -26,15 +32,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests CommandTimeoutSeconds = 10, }; + private static async Task RepositorySkipReasonAsync() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync( + DevZbOptions().ConnectionString, cts.Token); + return report.SkipReason; + } + private static async Task ZbReachableAsync() { - try - { - var repo = new GalaxyRepository(DevZbOptions()); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); - return await repo.TestConnectionAsync(cts.Token); - } - catch { return false; } + // Legacy silent-skip adapter — keeps the existing tests compiling while + // gradually migrating to the Skip-with-reason pattern. Returns true when the + // prerequisite check has no Fail entries. + return (await RepositorySkipReasonAsync()) is null; } [Fact] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj index 6f803d5..60aadf2 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs new file mode 100644 index 0000000..f070367 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs @@ -0,0 +1,163 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; + +/// +/// Entry point for live-AVEVA test fixtures. Runs every relevant probe and returns a +/// whose SkipReason feeds Assert.Skip when +/// the environment isn't set up. Non-Windows hosts get a single aggregated Skip row per +/// category instead of a flood of individual skips. +/// +/// +/// Call shape: +/// +/// var report = await AvevaPrerequisites.CheckAllAsync(); +/// if (report.SkipReason is not null) Assert.Skip(report.SkipReason); +/// +/// Categories in rough order of 'would I want to know first?': +/// +/// Environment — process bitness, OS platform, RPCSS up. +/// AvevaInstall — Framework registry, install paths, no pending reboot. +/// AvevaCoreService — aaBootstrap / aaGR / NmxSvc running. +/// MxAccessCom — LMXProxy.LMXProxyServer ProgID → CLSID → file-on-disk. +/// GalaxyRepository — SQL reachable, ZB exists, deployed-object count. +/// OtOpcUaService — our two Windows services + GLAuth. +/// AvevaSoftService — aaLogger etc., warn only. +/// AvevaHistorian — aahClientAccessPoint etc., optional. +/// +/// What's NOT checked here: end-to-end subscribe / read / write against a real +/// Galaxy tag. That's the job of the live-smoke tests this helper gates — the helper just +/// tells them whether running is worthwhile. +/// +public static class AvevaPrerequisites +{ + // -------- Individual service lists (kept as data so tests can inspect / override) -------- + + /// Services whose absence means live-Galaxy tests can't run at all. + internal static readonly (string Name, string Purpose)[] CoreServices = + [ + ("aaBootstrap", "master service that starts the Platform process + brokers aa* communication"), + ("aaGR", "Galaxy Repository host — mediates IDE / runtime access to ZB"), + ("NmxSvc", "Network Message Exchange — MXAccess + Bootstrap transport"), + ("MSSQLSERVER", "SQL Server instance that hosts the ZB database"), + ]; + + /// Warn-but-don't-fail AVEVA services. + internal static readonly (string Name, string Purpose)[] SoftServices = + [ + ("aaLogger", "ArchestrA Logger — diagnostic log receiver; stack runs without it but error visibility suffers"), + ("aaUserValidator", "OS user/group auth for ArchestrA security; only required when Galaxy security mode isn't 'Open'"), + ("aaGlobalDataCacheMonitorSvr", "cross-platform global data cache; single-node dev boxes run fine without it"), + ]; + + /// Optional AVEVA Historian services — only required for HistoryRead IPC paths. + internal static readonly (string Name, string Purpose)[] HistorianServices = + [ + ("aahClientAccessPoint", "AVEVA Historian Client Access Point — HistoryRead IPC endpoint"), + ("aahGateway", "AVEVA Historian Gateway"), + ]; + + /// OtOpcUa-stack Windows services + third-party deps we manage. + internal static readonly (string Name, string Purpose, bool HardRequired)[] OtOpcUaServices = + [ + ("OtOpcUaGalaxyHost", "Galaxy.Host out-of-process service (net48 x86, STA + MXAccess)", true), + ("OtOpcUa", "Main OPC UA server service (hosts Proxy + DriverHost + Admin-facing DB publisher)", false), + ("GLAuth", "LDAP server (dev only) — glauth.exe on localhost:3893", false), + ]; + + // -------- Orchestrator -------- + + public static async Task CheckAllAsync( + Options? options = null, CancellationToken ct = default) + { + options ??= new Options(); + var checks = new List(); + + // Environment + checks.Add(MxAccessComProbe.CheckProcessBitness()); + + // AvevaInstall — registry + files + checks.Add(RegistryProbe.CheckFrameworkInstalled()); + checks.Add(RegistryProbe.CheckPlatformDeployed()); + checks.Add(RegistryProbe.CheckRebootPending()); + + // AvevaCoreService + foreach (var (name, purpose) in CoreServices) + checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaCoreService, hardRequired: true, whatItDoes: purpose)); + + // MxAccessCom + checks.Add(MxAccessComProbe.Check()); + + // GalaxyRepository + checks.Add(await SqlProbe.CheckZbDatabaseAsync(options.SqlConnectionString, ct)); + // Deployed-object count only makes sense if the DB check passed. + if (checks[checks.Count - 1].Status == PrerequisiteStatus.Pass) + checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(options.SqlConnectionString, ct)); + + // OtOpcUaService + foreach (var (name, purpose, hard) in OtOpcUaServices) + checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.OtOpcUaService, hardRequired: hard, whatItDoes: purpose)); + if (options.CheckGalaxyHostPipe) + checks.Add(await NamedPipeProbe.CheckGalaxyHostPipeAsync(options.GalaxyHostPipeName, ct)); + + // AvevaSoftService + foreach (var (name, purpose) in SoftServices) + checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaSoftService, hardRequired: false, whatItDoes: purpose)); + + // AvevaHistorian + if (options.CheckHistorian) + { + foreach (var (name, purpose) in HistorianServices) + checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaHistorian, hardRequired: false, whatItDoes: purpose)); + } + + return new PrerequisiteReport(checks); + } + + /// + /// Narrower check for tests that only need the Galaxy Repository (SQL) path — don't + /// pay the cost of probing every aa* service when the test only reads gobject rows. + /// + public static async Task CheckRepositoryOnlyAsync( + string? sqlConnectionString = null, CancellationToken ct = default) + { + var checks = new List + { + await SqlProbe.CheckZbDatabaseAsync(sqlConnectionString, ct), + }; + if (checks[0].Status == PrerequisiteStatus.Pass) + checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(sqlConnectionString, ct)); + return new PrerequisiteReport(checks); + } + + /// + /// Narrower check for the named-pipe endpoint — tests that drive the full Proxy + /// against a live Galaxy.Host service don't need the SQL or AVEVA-internal probes + /// (the Host does that work internally; we just need the pipe to accept). + /// + public static async Task CheckGalaxyHostPipeOnlyAsync( + string? pipeName = null, CancellationToken ct = default) + { + var checks = new List + { + await NamedPipeProbe.CheckGalaxyHostPipeAsync(pipeName, ct), + }; + return new PrerequisiteReport(checks); + } + + /// Knobs for . + public sealed class Options + { + /// SQL Server connection string — defaults to Windows-auth localhost\ZB. + public string? SqlConnectionString { get; init; } + + /// Named-pipe endpoint for OtOpcUaGalaxyHost — defaults to OtOpcUaGalaxy. + public string? GalaxyHostPipeName { get; init; } + + /// Include the named-pipe probe. Off by default — it's a seconds-long TCP-like probe and some tests don't need it. + public bool CheckGalaxyHostPipe { get; init; } = true; + + /// Include Historian service probes. Off by default — Historian is optional. + public bool CheckHistorian { get; init; } = false; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs new file mode 100644 index 0000000..626aeac --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs @@ -0,0 +1,26 @@ +#if NET48 +// Polyfills for C# 9+ language features that the helper uses but that net48 BCL doesn't +// provide. Keeps the sources single-target-free at the language level — the same .cs files +// build on both frameworks without preprocessor guards in the callsites. + +namespace System.Runtime.CompilerServices +{ + /// Required by C# 9 init-only setters and record types. + internal static class IsExternalInit { } +} + +namespace System.Runtime.Versioning +{ + /// + /// Minimal shim for the .NET 5+ SupportedOSPlatformAttribute. Pure marker for the + /// compiler on net10; on net48 we still want the attribute to exist so the same + /// [SupportedOSPlatform("windows")] source compiles. The attribute is internal + /// and attribute-targets-everything to minimize surface. + /// + [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)] + internal sealed class SupportedOSPlatformAttribute(string platformName) : Attribute + { + public string PlatformName { get; } = platformName; + } +} +#endif diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs new file mode 100644 index 0000000..095f027 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs @@ -0,0 +1,44 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; + +/// One prerequisite probe's outcome. returns many of these. +/// Short diagnostic id — e.g. service:aaBootstrap, sql:ZB, registry:ArchestrA.Framework. +/// Which subsystem the probe belongs to — lets callers filter (e.g. "Historian warns don't gate the core Galaxy smoke"). +/// Outcome. +/// One-line specific message an operator can act on — "aaGR not installed — install the Galaxy Repository role from the System Platform setup" beats "failed". +public sealed record PrerequisiteCheck( + string Name, + PrerequisiteCategory Category, + PrerequisiteStatus Status, + string Detail); + +public enum PrerequisiteStatus +{ + /// Prerequisite is met; no action needed. + Pass, + /// Soft dependency missing — stack still runs but some feature (e.g. logging) is degraded. + Warn, + /// Hard dependency missing — live tests can't proceed; surfaces this. + Fail, + /// Probe wasn't applicable in this environment (e.g. non-Windows host, Historian not installed). + Skip, +} + +public enum PrerequisiteCategory +{ + /// Platform sanity — process bitness, OS platform, DCOM/RPCSS. + Environment, + /// Hard-required AVEVA Windows services (aaBootstrap, aaGR, NmxSvc). + AvevaCoreService, + /// Soft-required AVEVA Windows services (aaLogger, aaUserValidator) — warn only. + AvevaSoftService, + /// ArchestrA Framework install markers (registry + files). + AvevaInstall, + /// MXAccess COM server registration + file on disk. + MxAccessCom, + /// SQL Server reachability + ZB database presence + deployed-object count. + GalaxyRepository, + /// Historian services (optional — only required for HistoryRead IPC paths). + AvevaHistorian, + /// OtOpcUa-side services (OtOpcUa, OtOpcUaGalaxyHost) + third-party deps (GLAuth). + OtOpcUaService, +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs new file mode 100644 index 0000000..8c8ff98 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs @@ -0,0 +1,94 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; + +/// +/// Aggregated result of an run. Test fixtures +/// typically call to produce the argument for xUnit's +/// Assert.Skip when any hard dependency failed. +/// +public sealed class PrerequisiteReport +{ + public IReadOnlyList Checks { get; } + + public PrerequisiteReport(IEnumerable checks) + { + Checks = [.. checks]; + } + + /// True when every probe is Pass / Warn / Skip — no Fail entries. + public bool IsLivetestReady => !Checks.Any(c => c.Status == PrerequisiteStatus.Fail); + + /// + /// True when only the AVEVA-side probes pass — ignores failures in the + /// category. Lets a live-test gate + /// say "AVEVA is ready even if the v2 services aren't installed yet" without + /// conflating the two. Useful for tests that exercise Galaxy directly (e.g. + /// ) rather than through our stack. + /// + public bool IsAvevaSideReady => + !Checks.Any(c => c.Status == PrerequisiteStatus.Fail && c.Category != PrerequisiteCategory.OtOpcUaService); + + /// + /// Multi-line message for Assert.Skip when a hard dependency isn't met. Returns + /// null when is true. + /// + public string? SkipReason + { + get + { + var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail).ToList(); + if (fails.Count == 0) return null; + + var sb = new StringBuilder(); + sb.AppendLine($"Live-AVEVA prerequisites not met ({fails.Count} failed):"); + foreach (var f in fails) + sb.AppendLine($" • [{f.Category}] {f.Name} — {f.Detail}"); + sb.Append("Run `Get-Service aa*` / `sqlcmd -S localhost -d ZB -E -Q \"SELECT 1\"` to triage."); + return sb.ToString(); + } + } + + /// + /// Human-readable summary of warnings — caller decides whether to log or ignore. Useful + /// when a live test does pass but an operator should know their environment is degraded. + /// + public string? Warnings + { + get + { + var warns = Checks.Where(c => c.Status == PrerequisiteStatus.Warn).ToList(); + if (warns.Count == 0) return null; + + var sb = new StringBuilder(); + sb.AppendLine($"AVEVA prerequisites with warnings ({warns.Count}):"); + foreach (var w in warns) + sb.AppendLine($" • [{w.Category}] {w.Name} — {w.Detail}"); + return sb.ToString(); + } + } + + /// + /// Throw if any + /// contain a Fail — useful when a specific test needs, say, Galaxy Repository but doesn't + /// care about Historian. Call before Assert.Skip if you want to be strict. + /// + public void RequireCategories(params PrerequisiteCategory[] categories) + { + var set = categories.ToHashSet(); + var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail && set.Contains(c.Category)).ToList(); + if (fails.Count == 0) return; + + var detail = string.Join("; ", fails.Select(f => $"{f.Name}: {f.Detail}")); + throw new InvalidOperationException($"Required prerequisite categories failed: {detail}"); + } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"PrerequisiteReport: {Checks.Count} checks"); + foreach (var c in Checks) + sb.AppendLine($" [{c.Status,-4}] {c.Category}/{c.Name}: {c.Detail}"); + return sb.ToString(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs new file mode 100644 index 0000000..05a4565 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs @@ -0,0 +1,102 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; + +/// +/// Confirms MXAccess COM server registration by resolving the +/// LMXProxy.LMXProxyServer ProgID to its CLSID, then checking that the CLSID's +/// 32-bit InprocServer32 entry points at a file that exists on disk. +/// +/// +/// A common failure mode on partial installs: ProgID is registered but the CLSID +/// InprocServer32 DLL is missing (previous install uninstalled but registry orphan remains). +/// This probe surfaces that case with an actionable message instead of the +/// 0x80040154 REGDB_E_CLASSNOTREG you'd see from a late COM activation failure. +/// +public static class MxAccessComProbe +{ + public const string ProgId = "LMXProxy.LMXProxyServer"; + public const string VersionedProgId = "LMXProxy.LMXProxyServer.1"; + + public static PrerequisiteCheck Check() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Skip, "COM registration probes only run on Windows."); + } + return CheckWindows(); + } + + [SupportedOSPlatform("windows")] + private static PrerequisiteCheck CheckWindows() + { + try + { + var (clsid, dll) = RegistryProbe.ResolveProgIdToInproc(ProgId); + if (clsid is null) + { + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Fail, + $"ProgID {ProgId} not registered — MXAccess COM server isn't installed. " + + $"Install System Platform's MXAccess component and re-run."); + } + + if (string.IsNullOrWhiteSpace(dll)) + { + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Fail, + $"ProgID {ProgId} → CLSID {clsid} but InprocServer32 is empty. " + + $"Registry is orphaned; re-register with: regsvr32 /s LmxProxy.dll (from an elevated cmd in the Framework bin dir)."); + } + + // Resolve the recorded path — sometimes registered as a bare filename that the COM + // runtime resolves via the current process's DLL-search path. Accept either an + // absolute path that exists, or a bare filename whose resolution we can't verify + // without loading it (treat as Pass-with-note). + if (Path.IsPathRooted(dll)) + { + if (!File.Exists(dll)) + { + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Fail, + $"ProgID {ProgId} → CLSID {clsid} → InprocServer32 {dll}, but the file is missing. " + + $"Re-install the Framework or restore from backup."); + } + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Pass, + $"ProgID {ProgId} → {dll} (file exists)."); + } + + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Pass, + $"ProgID {ProgId} → {dll} (bare filename — relies on PATH resolution at COM activation time)."); + } + catch (Exception ex) + { + return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, + PrerequisiteStatus.Warn, + $"Probe failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Warn when running as a 64-bit process — MXAccess COM activation will fail with + /// 0x80040154 regardless of registration state. The production drivers run net48 + /// x86; xunit hosts run 64-bit by default so this often surfaces first. + /// + public static PrerequisiteCheck CheckProcessBitness() + { + if (Environment.Is64BitProcess) + { + return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment, + PrerequisiteStatus.Warn, + "Test host is 64-bit. Direct MXAccess COM activation would fail with REGDB_E_CLASSNOTREG (0x80040154); " + + "the production driver workaround is to run Galaxy.Host as a 32-bit process. Tests that only " + + "talk to the Host service over the named pipe aren't affected."); + } + return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment, + PrerequisiteStatus.Pass, "Test host is 32-bit."); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs new file mode 100644 index 0000000..1ff25dd --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs @@ -0,0 +1,59 @@ +using System.IO.Pipes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; + +/// +/// Verifies the OtOpcUaGalaxyHost named-pipe endpoint is accepting connections — +/// the handshake the Proxy performs at boot. A clean pipe connect without sending any +/// framed message proves the Host service is listening; we disconnect immediately so we +/// don't consume a session slot. +/// +/// +/// Default pipe name matches the installer script's OTOPCUA_GALAXY_PIPE default. +/// Override when the Host service was installed with a non-default name (custom deployments). +/// +public static class NamedPipeProbe +{ + public const string DefaultGalaxyHostPipeName = "OtOpcUaGalaxy"; + + public static async Task CheckGalaxyHostPipeAsync( + string? pipeName = null, CancellationToken ct = default) + { + pipeName ??= DefaultGalaxyHostPipeName; + try + { + using var client = new NamedPipeClientStream( + serverName: ".", + pipeName: pipeName, + direction: PipeDirection.InOut, + options: PipeOptions.Asynchronous); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + await client.ConnectAsync(cts.Token); + + return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, + PrerequisiteStatus.Pass, + $@"Pipe \\.\pipe\{pipeName} accepted a connection — OtOpcUaGalaxyHost is listening."); + } + catch (OperationCanceledException) + { + return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, + PrerequisiteStatus.Fail, + $@"Pipe \\.\pipe\{pipeName} not connectable within 2s — OtOpcUaGalaxyHost service isn't running. " + + "Start with: sc.exe start OtOpcUaGalaxyHost"); + } + catch (TimeoutException) + { + return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, + PrerequisiteStatus.Fail, + $@"Pipe \\.\pipe\{pipeName} connect timed out — service may be starting or stuck. " + + "Check: sc.exe query OtOpcUaGalaxyHost"); + } + catch (Exception ex) + { + return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, + PrerequisiteStatus.Fail, + $@"Pipe \\.\pipe\{pipeName} connect failed: {ex.GetType().Name}: {ex.Message}"); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs new file mode 100644 index 0000000..ebcc970 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs @@ -0,0 +1,162 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; + +/// +/// Reads HKLM registry keys to confirm ArchestrA Framework / System Platform install +/// markers. Matches the registered paths documented in +/// docs/v2/implementation/ — System Platform is 32-bit so keys live under +/// HKLM\SOFTWARE\WOW6432Node\ArchestrA\.... +/// +public static class RegistryProbe +{ + // Canonical install roots per the research on our dev box (System Platform 2020 R2). + public const string ArchestrARootKey = @"SOFTWARE\WOW6432Node\ArchestrA"; + public const string FrameworkKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework"; + public const string PlatformKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework\Platform"; + public const string MsiInstallKey = @"SOFTWARE\WOW6432Node\ArchestrA\MSIInstall"; + + public static PrerequisiteCheck CheckFrameworkInstalled() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Skip, "Registry probes only run on Windows."); + } + return FrameworkInstalledWindows(); + } + + public static PrerequisiteCheck CheckPlatformDeployed() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new PrerequisiteCheck("registry:ArchestrA.Platform", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Skip, "Registry probes only run on Windows."); + } + return PlatformDeployedWindows(); + } + + public static PrerequisiteCheck CheckRebootPending() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Skip, "Registry probes only run on Windows."); + } + return RebootPendingWindows(); + } + + [SupportedOSPlatform("windows")] + private static PrerequisiteCheck FrameworkInstalledWindows() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(FrameworkKey); + if (key is null) + { + return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Fail, + $"Missing {FrameworkKey} — ArchestrA Framework isn't installed. Install AVEVA System Platform from the setup media."); + } + + var installPath = key.GetValue("InstallPath") as string; + var rootPath = key.GetValue("RootPath") as string; + if (string.IsNullOrWhiteSpace(installPath) || string.IsNullOrWhiteSpace(rootPath)) + { + return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + $"Framework key exists but InstallPath/RootPath values missing — install may be incomplete."); + } + + return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Pass, + $"Installed at {installPath} (RootPath {rootPath})."); + } + catch (Exception ex) + { + return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + $"Probe failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + [SupportedOSPlatform("windows")] + private static PrerequisiteCheck PlatformDeployedWindows() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(PlatformKey); + var pfeConfig = key?.GetValue("PfeConfigOptions") as string; + if (string.IsNullOrWhiteSpace(pfeConfig)) + { + return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + $"No Platform object deployed locally (Platform\\PfeConfigOptions empty). MXAccess will connect but subscriptions will fail. Deploy a Platform from the IDE."); + } + + // PfeConfigOptions format: "PlatformId=N,EngineId=N,EngineName=...,..." + // A non-deployed state leaves PlatformId=0 or the key empty. + if (pfeConfig.Contains("PlatformId=0,", StringComparison.OrdinalIgnoreCase)) + { + return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + $"Platform never deployed (PfeConfigOptions has PlatformId=0). Deploy a Platform from the IDE before running live tests."); + } + + return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Pass, + $"Platform deployed ({pfeConfig})."); + } + catch (Exception ex) + { + return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + $"Probe failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + [SupportedOSPlatform("windows")] + private static PrerequisiteCheck RebootPendingWindows() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(MsiInstallKey); + var rebootRequired = key?.GetValue("RebootRequired") as string; + if (string.Equals(rebootRequired, "True", StringComparison.OrdinalIgnoreCase)) + { + return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + "An ArchestrA patch has been installed but the machine hasn't rebooted. Post-patch behavior is undefined until a reboot."); + } + return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Pass, + "No pending reboot flagged."); + } + catch (Exception ex) + { + return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, + PrerequisiteStatus.Warn, + $"Probe failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Read the registered CLSID for the given ProgID and + /// resolve the 32-bit InprocServer32 file path. Returns null when either is missing. + /// + [SupportedOSPlatform("windows")] + internal static (string? Clsid, string? InprocDllPath) ResolveProgIdToInproc(string progId) + { + using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID"); + var clsid = progIdKey?.GetValue(null) as string; + if (string.IsNullOrWhiteSpace(clsid)) return (null, null); + + // 32-bit COM server under Wow6432Node\CLSID\{guid}\InprocServer32 default value. + using var inproc = Registry.LocalMachine.OpenSubKey( + $@"SOFTWARE\Classes\WOW6432Node\CLSID\{clsid}\InprocServer32"); + var dll = inproc?.GetValue(null) as string; + return (clsid, dll); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs new file mode 100644 index 0000000..5811197 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs @@ -0,0 +1,85 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.ServiceProcess; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; + +/// +/// Queries the Windows Service Control Manager to report whether a named service is +/// installed, its current state, and its start type. Non-Windows hosts return Skip. +/// +public static class ServiceProbe +{ + public static PrerequisiteCheck Check( + string serviceName, + PrerequisiteCategory category, + bool hardRequired, + string whatItDoes) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new PrerequisiteCheck( + Name: $"service:{serviceName}", + Category: category, + Status: PrerequisiteStatus.Skip, + Detail: "Service probes only run on Windows."); + } + + return CheckWindows(serviceName, category, hardRequired, whatItDoes); + } + + [SupportedOSPlatform("windows")] + private static PrerequisiteCheck CheckWindows( + string serviceName, PrerequisiteCategory category, bool hardRequired, string whatItDoes) + { + try + { + using var sc = new ServiceController(serviceName); + // Touch the Status to force the SCM lookup; if the service doesn't exist, this throws + // InvalidOperationException with message "Service ... was not found on computer.". + var status = sc.Status; + var startType = sc.StartType; + + return status switch + { + ServiceControllerStatus.Running => new PrerequisiteCheck( + $"service:{serviceName}", category, PrerequisiteStatus.Pass, + $"Running ({whatItDoes})"), + + // DemandStart services (like NmxSvc) that are Stopped are not necessarily a + // failure — the master service (aaBootstrap) brings them up on demand. Treat + // Stopped+Demand as Warn so operators know the situation but tests still proceed. + ServiceControllerStatus.Stopped when startType == ServiceStartMode.Manual => + new PrerequisiteCheck( + $"service:{serviceName}", category, PrerequisiteStatus.Warn, + $"Installed but Stopped (start type Manual — {whatItDoes}). " + + "Will be pulled up on demand by the master service; fine for tests."), + + ServiceControllerStatus.Stopped => Fail( + $"Installed but Stopped. Start with: sc.exe start {serviceName} ({whatItDoes})"), + + _ => new PrerequisiteCheck( + $"service:{serviceName}", category, PrerequisiteStatus.Warn, + $"Transitional state {status} ({whatItDoes}) — try again in a few seconds."), + }; + + PrerequisiteCheck Fail(string detail) => new( + $"service:{serviceName}", category, + hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn, + detail); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("was not found", StringComparison.OrdinalIgnoreCase)) + { + return new PrerequisiteCheck( + $"service:{serviceName}", category, + hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn, + $"Not installed ({whatItDoes}). Install the relevant System Platform component and retry."); + } + catch (Exception ex) + { + return new PrerequisiteCheck( + $"service:{serviceName}", category, PrerequisiteStatus.Warn, + $"Probe failed ({ex.GetType().Name}: {ex.Message}) — treat as unknown."); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs new file mode 100644 index 0000000..f96d3f9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs @@ -0,0 +1,88 @@ +using Microsoft.Data.SqlClient; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; + +/// +/// Verifies the Galaxy Repository SQL side: SQL Server reachable, ZB database +/// present, and at least one deployed object exists (so live tests have something to read). +/// Reuses the Windows-auth connection string the repo code defaults to. +/// +public static class SqlProbe +{ + public const string DefaultConnectionString = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=3;"; + + public static async Task CheckZbDatabaseAsync( + string? connectionString = null, CancellationToken ct = default) + { + connectionString ??= DefaultConnectionString; + try + { + using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(ct); + + // DB_ID returns null when the database doesn't exist on the connected server — distinct + // failure mode from "server unreachable", deserves a distinct message. + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT DB_ID('ZB')"; + var dbIdObj = await cmd.ExecuteScalarAsync(ct); + if (dbIdObj is null || dbIdObj is DBNull) + { + return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Fail, + "SQL Server reachable but database ZB does not exist. " + + "Create the Galaxy from the IDE or restore a .cab backup."); + } + + return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Pass, "Connected; ZB database exists."); + } + catch (SqlException ex) + { + return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Fail, + $"SQL Server unreachable: {ex.Message}. Ensure MSSQLSERVER service is running (sc.exe start MSSQLSERVER) and TCP 1433 is open."); + } + catch (Exception ex) + { + return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Fail, + $"Unexpected probe error: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Returns the count of deployed Galaxy objects (deployed_version > 0). Zero + /// isn't a hard failure — lets someone boot a fresh Galaxy and still get meaningful + /// test-suite output — but it IS a warning because any live-read smoke will have + /// nothing to read. + /// + public static async Task CheckDeployedObjectCountAsync( + string? connectionString = null, CancellationToken ct = default) + { + connectionString ??= DefaultConnectionString; + try + { + using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM gobject WHERE deployed_version > 0"; + var countObj = await cmd.ExecuteScalarAsync(ct); + var count = countObj is int i ? i : 0; + + return count > 0 + ? new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Pass, $"{count} objects deployed — live reads have data to return.") + : new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Warn, + "ZB contains no deployed objects. Discovery smoke tests will return empty hierarchies; " + + "deploy at least a Platform + AppEngine from the IDE to exercise the read path."); + } + catch (Exception ex) + { + return new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository, + PrerequisiteStatus.Warn, + $"Couldn't count deployed objects: {ex.GetType().Name}: {ex.Message}"); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj new file mode 100644 index 0000000..7f61d1d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj @@ -0,0 +1,38 @@ + + + + + net10.0;net48 + enable + enable + latest + false + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport + + + + + + + + + + + + + + + + + + + + + +