LiveStackConfig resolves the pipe name + per-install shared secret from two sources in order: OTOPCUA_GALAXY_PIPE + OTOPCUA_GALAXY_SECRET env vars first (for CI / benchwork overrides), then the service's per-process Environment registry values under HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost (what Install-Services.ps1 writes at install time). Registry read requires the test host to run elevated on most boxes — the skip message says so explicitly so operators see the right remediation. Hard-coded secrets are deliberately avoided: the installer generates 32 fresh random bytes per install, a committed secret would diverge from production the moment the service is re-installed. LiveStackFixture is an IAsyncLifetime that (1) runs AvevaPrerequisites.CheckAllAsync with CheckGalaxyHostPipe=true + CheckHistorian=false — produces a structured PrerequisiteReport whose SkipReason is the exact operator-facing 'here's what you need to fix' text, (2) resolves LiveStackConfig and surfaces a clear skip when the secret isn't discoverable, (3) instantiates GalaxyProxyDriver + calls InitializeAsync (the IPC handshake), capturing a skip with the exception detail + common-cause hints (secret mismatch, SID not in pipe ACL, Host's backend couldn't connect to ZB) rather than letting a NullRef cascade through every subsequent test. SkipIfUnavailable() translates the captured SkipReason into Assert.Skip at the top of every fact so tests read as cleanly-skipped with a visible reason, not silently-passed or crashed. LiveStackSmokeTests (5 facts, Collection=LiveStack, Category=LiveGalaxy): Fixture_initialized_successfully (cheapest possible end-to-end assertion — if this passes, the IPC handshake worked); Driver_reports_Healthy_after_IPC_handshake (DriverHealth.State post-connect); DiscoverAsync_returns_at_least_one_variable_from_live_galaxy (captures every Variable() call from DiscoverAsync via CapturingAddressSpaceBuilder and asserts > 0 — zero here usually means the Host couldn't read ZB, the skip message names OTOPCUA_GALAXY_ZB_CONN to check); GetHostStatuses_reports_at_least_one_platform (IHostConnectivityProbe surface — zero means the probe loop hasn't fired or no Platform is deployed locally); Can_read_a_discovered_variable_from_live_galaxy (reads the first discovered attribute's full reference, asserts status != BadInternalError — Galaxy's Uncertain-quality-until-first-Engine-scan is intentionally NOT treated as failure since it depends on runtime state that varies across test runs). Read-only by design; writes need an agreed scratch tag to avoid mutating a process-critical attribute — deferred to a follow-up PR that reuses this fixture. CapturingAddressSpaceBuilder is a minimal IAddressSpaceBuilder that flattens every Variable() call into a list so tests can inspect what discovery produced without booting the full OPC UA node-manager stack; alarm annotation + property calls are no-ops. Scoped private to the test class. Galaxy.Proxy.Tests csproj gains a ProjectReference to Driver.Galaxy.TestSupport (PR 36) for AvevaPrerequisites. The NU1702 warning about the Host project being net48-referenced-by-net10 is pre-existing from the HostSubprocessParityTests — Proxy.Tests only needs the Host EXE path for that parity scenario, not type surface. Test run on THIS machine (OtOpcUaGalaxyHost not yet installed): Skipped! Failed 0, Passed 0, Skipped 5 — each skip message includes the full prerequisites report pointing at the missing service. Once the service is installed + started (scripts\install\Install-Services.ps1), the 5 facts will execute against live Galaxy. Proxy.Tests Unit: 17 pass / 0 fail (unchanged — new tests are Category=LiveGalaxy, separate suite). Full Proxy build clean. Memory already captures the 'live tests run via already-running service, don't spawn' convention (project_galaxy_host_service.md). lmx-followups.md #5 updated: status is 'IN PROGRESS' across PRs 36 + 37 with the explicit remaining work (install + start services, subscribe-and-receive, write round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
5.0 KiB
C#
121 lines
5.0 KiB
C#
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
|
|
|
/// <summary>
|
|
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
|
|
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
|
|
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
|
|
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
|
|
/// to translate that into <c>Assert.Skip</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
|
|
/// as a standalone Windows service — spawning a second instance from a test would
|
|
/// bypass the COM-apartment + service-account setup and fail differently than
|
|
/// production (see <c>project_galaxy_host_service.md</c> memory).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
|
|
/// first, then the service's registry-stored <c>Environment</c> values. Requires
|
|
/// the test process to have read access to
|
|
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
|
|
/// that typically means running the test host elevated, or exporting
|
|
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LiveStackFixture : IAsyncLifetime
|
|
{
|
|
public GalaxyProxyDriver? Driver { get; private set; }
|
|
|
|
public string? SkipReason { get; private set; }
|
|
|
|
public PrerequisiteReport? PrerequisiteReport { get; private set; }
|
|
|
|
public LiveStackConfig? Config { get; private set; }
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
// 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing.
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync(
|
|
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false },
|
|
cts.Token);
|
|
|
|
if (!PrerequisiteReport.IsLivetestReady)
|
|
{
|
|
SkipReason = PrerequisiteReport.SkipReason;
|
|
return;
|
|
}
|
|
|
|
// 2. Secret / pipe-name resolution. If the service is running but we can't discover its
|
|
// env vars from registry (non-elevated test host), a clear message beats a silent
|
|
// connect-rejected failure 10 seconds later.
|
|
Config = LiveStackConfig.Resolve();
|
|
if (Config is null)
|
|
{
|
|
SkipReason =
|
|
$"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " +
|
|
$"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " +
|
|
$"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment.";
|
|
return;
|
|
}
|
|
|
|
// 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second
|
|
// ConnectTimeout gives enough headroom for a service that just started.
|
|
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
|
{
|
|
DriverInstanceId = "live-stack-smoke",
|
|
PipeName = Config.PipeName,
|
|
SharedSecret = Config.SharedSecret,
|
|
ConnectTimeout = TimeSpan.FromSeconds(5),
|
|
});
|
|
|
|
try
|
|
{
|
|
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SkipReason =
|
|
$"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " +
|
|
$"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " +
|
|
$"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " +
|
|
$"test must run as that user), or Host's backend couldn't connect to ZB.";
|
|
Driver.Dispose();
|
|
Driver = null;
|
|
return;
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Driver is not null)
|
|
{
|
|
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ }
|
|
Driver.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. Tests call this at the
|
|
/// top of every fact so a fixture init failure shows up as a cleanly-skipped test with
|
|
/// the full prerequisites report, not a cascading NullReferenceException on
|
|
/// <see cref="Driver"/>.
|
|
/// </summary>
|
|
public void SkipIfUnavailable()
|
|
{
|
|
if (SkipReason is not null) Assert.Skip(SkipReason);
|
|
}
|
|
}
|
|
|
|
[CollectionDefinition(Name)]
|
|
public sealed class LiveStackCollection : ICollectionFixture<LiveStackFixture>
|
|
{
|
|
public const string Name = "LiveStack";
|
|
}
|