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>
148 lines
7.2 KiB
C#
148 lines
7.2 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
|
|
|
/// <summary>
|
|
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
|
|
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
|
|
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
|
|
/// live MXAccess runtime → real Galaxy objects + attributes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
|
|
/// <c>Assert.Skip</c> when missing):
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item>AVEVA System Platform installed + Platform deployed.</item>
|
|
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
|
|
/// <item>MXAccess COM server registered.</item>
|
|
/// <item>ZB database exists with at least one deployed gobject.</item>
|
|
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
|
|
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
|
|
/// service's registry Environment values (test host typically needs to be elevated
|
|
/// to read the latter).</item>
|
|
/// <item>Test process runs as the account listed in the service's pipe ACL
|
|
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
|
|
/// </list>
|
|
/// <para>
|
|
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
|
|
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
|
|
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
|
|
/// PR that reuses this fixture.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Trait("Category", "LiveGalaxy")]
|
|
[Collection(LiveStackCollection.Name)]
|
|
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
|
|
{
|
|
[Fact]
|
|
public void Fixture_initialized_successfully()
|
|
{
|
|
fixture.SkipIfUnavailable();
|
|
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
|
|
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
|
|
// every other test in this class depends on it.
|
|
fixture.Driver.ShouldNotBeNull();
|
|
fixture.Config.ShouldNotBeNull();
|
|
fixture.PrerequisiteReport.ShouldNotBeNull();
|
|
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void Driver_reports_Healthy_after_IPC_handshake()
|
|
{
|
|
fixture.SkipIfUnavailable();
|
|
var health = fixture.Driver!.GetHealth();
|
|
health.State.ShouldBe(DriverState.Healthy,
|
|
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
|
|
{
|
|
fixture.SkipIfUnavailable();
|
|
var builder = new CapturingAddressSpaceBuilder();
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
|
|
|
builder.Variables.Count.ShouldBeGreaterThan(0,
|
|
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
|
|
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
|
|
|
|
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
|
|
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
|
|
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetHostStatuses_reports_at_least_one_platform()
|
|
{
|
|
fixture.SkipIfUnavailable();
|
|
var statuses = fixture.Driver!.GetHostStatuses();
|
|
statuses.Count.ShouldBeGreaterThan(0,
|
|
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
|
|
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
|
|
|
|
// Host names are driver-opaque to the Core but non-empty by contract.
|
|
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Can_read_a_discovered_variable_from_live_galaxy()
|
|
{
|
|
fixture.SkipIfUnavailable();
|
|
var builder = new CapturingAddressSpaceBuilder();
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
|
builder.Variables.Count.ShouldBeGreaterThan(0);
|
|
|
|
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
|
|
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
|
|
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
|
|
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
|
|
var full = builder.Variables[0].AttributeInfo.FullName;
|
|
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
|
|
|
|
snapshots.Count.ShouldBe(1);
|
|
var snap = snapshots[0];
|
|
snap.StatusCode.ShouldNotBe(0x80020000u,
|
|
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
|
|
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
|
|
/// Variable() call into a flat list so tests can inspect what discovery produced
|
|
/// without running the full OPC UA node-manager stack.
|
|
/// </summary>
|
|
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
|
{
|
|
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
|
|
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
{
|
|
Variables.Add((browseName, attributeInfo));
|
|
return new NoopHandle(attributeInfo.FullName);
|
|
}
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
|
|
private sealed class NoopHandle(string fullReference) : IVariableHandle
|
|
{
|
|
public string FullReference { get; } = fullReference;
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
|
|
private sealed class NoopSink : IAlarmConditionSink
|
|
{
|
|
public void OnTransition(AlarmEventArgs args) { }
|
|
}
|
|
}
|
|
}
|
|
}
|