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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+