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