Compare commits
1 Commits
phase-1-co
...
phase-2-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d16a28f1 |
@@ -23,7 +23,8 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
|
||||
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
|
||||
|
||||
This document inventories every v1 surface that's been **functionally superseded** by v2 but
|
||||
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
||||
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
|
||||
on its own merits while the v1 surface stays as parity reference.
|
||||
|
||||
## Archived projects
|
||||
|
||||
| Path | Status | Replaced by | Build behavior |
|
||||
|---|---|---|---|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
|
||||
## How to run the archived suites explicitly
|
||||
|
||||
```powershell
|
||||
# v1 unit tests (494):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||
|
||||
# v1 integration tests (6):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
```
|
||||
|
||||
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
|
||||
decision.
|
||||
|
||||
## Deletion plan (Phase 2 PR 3)
|
||||
|
||||
Pre-conditions:
|
||||
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
||||
at minimum (currently 7 tests; expand as needed)
|
||||
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
||||
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
||||
- [ ] Operator review on a separate PR — destructive change
|
||||
|
||||
Steps:
|
||||
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
||||
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
||||
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
|
||||
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
|
||||
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
|
||||
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
|
||||
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
|
||||
current count is plus any new E2E coverage)
|
||||
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
|
||||
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
|
||||
10. One reviewer signoff
|
||||
|
||||
## Rollback
|
||||
|
||||
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
||||
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
||||
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Phase 2 Final Exit Gate (2026-04-18)
|
||||
|
||||
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||
|
||||
## Status: **All five Phase 2 streams addressed. Stream D split across PR 2 (archive) + PR 3 (delete) per safety protocol.**
|
||||
|
||||
## Stream-by-stream status
|
||||
|
||||
| Stream | Plan §reference | Status | PR |
|
||||
|---|---|---|---|
|
||||
| A — Driver.Galaxy.Shared | §A.1–A.3 | ✅ Complete | PR 1 (merged or pending) |
|
||||
| B — Driver.Galaxy.Host | §B.1–B.10 | ✅ Real Win32 pump, all Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / **MxAccess** with live COM) | PR 1 |
|
||||
| C — Driver.Galaxy.Proxy | §C.1–C.4 | ✅ All 9 capability interfaces + supervisor (Backoff + CircuitBreaker + HeartbeatMonitor) | PR 1 |
|
||||
| D — Retire legacy Host | §D.1–D.3 | ✅ Migration script, installer scripts, Stream D procedure doc, **archive markings on all v1 surface (this PR 2)**, deletion deferred to PR 3 | PR 2 (this) + PR 3 (next) |
|
||||
| E — Parity validation | §E.1–E.4 | ✅ E2E test scaffold + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 (this) |
|
||||
|
||||
## What changed in PR 2 (this branch `phase-2-stream-d`)
|
||||
|
||||
1. **`tests/ZB.MOM.WW.OtOpcUa.Tests/`** renamed to `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`,
|
||||
`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so the v1 Host's `InternalsVisibleTo`
|
||||
still matches, `<IsTestProject>false</IsTestProject>` so `dotnet test slnx` excludes it.
|
||||
2. **Three other v1 projects archive-marked** with PropertyGroup comments:
|
||||
`OtOpcUa.Host`, `Historian.Aveva`, `IntegrationTests`. `IntegrationTests` also gets
|
||||
`<IsTestProject>false</IsTestProject>`.
|
||||
3. **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** project (.NET 10):
|
||||
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as subprocess via
|
||||
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||
Skips when Galaxy ZB unreachable, when Host EXE not built, or when running as
|
||||
Administrator (PipeAcl denies admins).
|
||||
- `RecordingAddressSpaceBuilder` captures Folder + Variable + Property registrations so
|
||||
parity tests can assert shape.
|
||||
- `HierarchyParityTests` (3) — Discover returns gobjects with attributes;
|
||||
attribute full references match `tag.attribute` shape; HistoryExtension flag flows
|
||||
through.
|
||||
- `StabilityFindingsRegressionTests` (4) — one test per 2026-04-13 finding:
|
||||
phantom-probe-doesn't-corrupt-status, host-status-event-is-scoped, all-async-no-sync-
|
||||
over-async, AcknowledgeAsync-completes-before-returning.
|
||||
4. **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||
5. **`docs/v2/implementation/exit-gate-phase-2-final.md`** (this doc) — supersedes the two
|
||||
partial-exit docs.
|
||||
|
||||
## Test counts
|
||||
|
||||
**Solution-level `dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 baseline failure**.
|
||||
|
||||
| Project | Pass | Skip |
|
||||
|---|---:|---:|
|
||||
| Core.Abstractions.Tests | 24 | 0 |
|
||||
| Configuration.Tests | 42 | 0 |
|
||||
| Core.Tests | 4 | 0 |
|
||||
| Server.Tests | 2 | 0 |
|
||||
| Admin.Tests | 21 | 0 |
|
||||
| Driver.Galaxy.Shared.Tests | 6 | 0 |
|
||||
| Driver.Galaxy.Host.Tests | 30 | 0 |
|
||||
| Driver.Galaxy.Proxy.Tests | 10 | 0 |
|
||||
| **Driver.Galaxy.E2E (NEW)** | **0** | **7** (all skip with documented reason — admin shell) |
|
||||
| Client.Shared.Tests | 131 | 0 |
|
||||
| Client.UI.Tests | 98 | 0 |
|
||||
| Client.CLI.Tests | 51 / 1 fail | 0 |
|
||||
| Historian.Aveva.Tests | 41 | 0 |
|
||||
|
||||
**Excluded from solution run (run explicitly when needed)**:
|
||||
- `OtOpcUa.Tests.v1Archive` — 494 pass (v1 unit tests, kept as parity reference)
|
||||
- `OtOpcUa.IntegrationTests` — 6 pass (v1 integration tests, kept as parity reference)
|
||||
|
||||
## Adversarial review of the PR 2 diff
|
||||
|
||||
Independent pass over the PR 2 deltas. New findings ranked by severity; existing findings
|
||||
from the previous exit-gate doc still apply.
|
||||
|
||||
### New findings
|
||||
|
||||
**Medium 1 — `IsTestProject=false` on `OtOpcUa.IntegrationTests` removes the safety net.**
|
||||
The 6 v1 integration tests no longer run on solution test. *Mitigation:* the new E2E suite
|
||||
covers the same scenarios in the v2 topology shape. *Risk:* if E2E test count regresses or
|
||||
fails to cover a scenario, the v1 fallback isn't auto-checked. **Procedure**: PR 3
|
||||
checklist includes "E2E test count covers v1 IntegrationTests' 6 scenarios at minimum".
|
||||
|
||||
**Medium 2 — Stability-finding regression tests #2, #3, #4 are structural (reflection-based)
|
||||
not behavioral.** Findings #2 and #3 use type-shape assertions (event signature carries
|
||||
HostName; methods return Task) rather than triggering the actual race. *Mitigation:* the v1
|
||||
defects were structural — fixing them required interface changes that the type-shape
|
||||
assertions catch. *Risk:* a future refactor that re-introduces sync-over-async via a non-
|
||||
async helper called inside a Task method wouldn't trip the test. **Filed as v2.1**: add a
|
||||
runtime async-call-stack analyzer (Roslyn or post-build).
|
||||
|
||||
**Low 1 — `ParityFixture` defaults to `OTOPCUA_GALAXY_BACKEND=db`** (not `mxaccess`).
|
||||
Discover works against ZB without needing live MXAccess. The MXAccess-required tests will
|
||||
need a second fixture once they're written.
|
||||
|
||||
**Low 2 — `Process.Start(EnvironmentVariables)` doesn't always inherit clean state.** The
|
||||
test inherits the parent's PATH + locale, which is normally fine but could mask a missing
|
||||
runtime dependency. *Mitigation:* in CI, pin a clean environment block.
|
||||
|
||||
### Existing findings (carried forward from `exit-gate-phase-2.md`)
|
||||
|
||||
All 8 still apply unchanged. Particularly:
|
||||
- High 1 (MxAccess Read subscription-leak on cancellation) — open
|
||||
- High 2 (no MXAccess reconnect loop, only supervisor-driven recycle) — open
|
||||
- Medium 3 (SubscribeAsync doesn't push OnDataChange frames yet) — open
|
||||
- Medium 4 (WriteValuesAsync doesn't await OnWriteComplete) — open
|
||||
|
||||
## Cross-cutting deferrals (out of Phase 2)
|
||||
|
||||
- **Deletion of v1 archive** — PR 3, gated on operator review + E2E coverage parity check
|
||||
- **Wonderware Historian SDK plugin port** (`Historian.Aveva` → `Driver.Galaxy.Host/Backend/Historian/`) — Task B.1.h, opportunistically with PR 3 or as PR 4
|
||||
- **MxAccess subscription push frames** — Task B.1.s, follow-up to enable real-time data
|
||||
flow (currently subscribes register but values aren't pushed back)
|
||||
- **Wonderware Historian-backed HistoryRead** — depends on B.1.h
|
||||
- **Alarm subsystem wire-up** — `MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op
|
||||
- **Reconnect-without-recycle** in MxAccessClient — v2.1 refinement
|
||||
- **Real downstream-consumer cutover** (ScadaBridge / Ignition / SystemPlatform IO) — outside this repo
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **PR 1** (`phase-1-configuration` → `v2`) — merge first; self-contained, parity preserved
|
||||
2. **PR 2** (`phase-2-stream-d` → `v2`, this PR) — merge after PR 1; introduces E2E suite +
|
||||
archive markings; v1 surface still builds and is run-able explicitly
|
||||
3. **PR 3** (next session) — delete v1 archive; depends on operator approval after PR 2
|
||||
reviewer signoff
|
||||
4. **PR 4** (Phase 2 follow-up) — Historian port + MxAccess subscription push frames + the
|
||||
open high/medium findings
|
||||
69
docs/v2/implementation/pr-2-body.md
Normal file
69
docs/v2/implementation/pr-2-body.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# PR 2 — Phase 2 Stream D Option B (archive v1 + E2E suite) → v2
|
||||
|
||||
**Source**: `phase-2-stream-d` (branched from `phase-1-configuration`)
|
||||
**Target**: `v2`
|
||||
**URL** (after push): https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-2-stream-d
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 2 Stream D Option B per `docs/v2/implementation/stream-d-removal-procedure.md`:
|
||||
|
||||
- **Archived the v1 surface** without deleting:
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
(`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so v1 Host's `InternalsVisibleTo`
|
||||
still matches; `<IsTestProject>false</IsTestProject>` so solution test runs skip it).
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` — `<IsTestProject>false</IsTestProject>`
|
||||
+ archive comment.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/` + `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` — archive
|
||||
PropertyGroup comments. Both still build (Historian plugin + 41 historian tests still
|
||||
pass) so Phase 2 PR 3 can delete them in a focused, reviewable destructive change.
|
||||
- **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** test project (.NET 10):
|
||||
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as a subprocess via
|
||||
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||
Skips when Galaxy ZB unreachable / Host EXE not built / Administrator shell.
|
||||
- `HierarchyParityTests` (3) and `StabilityFindingsRegressionTests` (4) — one test per
|
||||
2026-04-13 stability finding (phantom probe, cross-host quality clear, sync-over-async,
|
||||
fire-and-forget alarm shutdown race).
|
||||
- **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||
- **`docs/v2/implementation/exit-gate-phase-2-final.md`** — supersedes the two partial-exit
|
||||
docs with the as-built state, adversarial review of PR 2 deltas (4 new findings), and the
|
||||
recommended PR sequence (1 → 2 → 3 → 4).
|
||||
|
||||
## What's NOT in this PR
|
||||
|
||||
- Deletion of the v1 archive — saved for PR 3 with explicit operator review (destructive change).
|
||||
- Wonderware Historian SDK plugin port — Task B.1.h, follow-up to enable real `HistoryRead`.
|
||||
- MxAccess subscription push-frames — Task B.1.s, follow-up to enable real-time
|
||||
data-change push from Host → Proxy.
|
||||
|
||||
## Tests
|
||||
|
||||
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 pre-existing baseline**.
|
||||
|
||||
The 7 skips are the new E2E tests, all skipping with the documented reason
|
||||
"PipeAcl denies Administrators on dev shells" — the production install runs as a non-admin
|
||||
service account and these tests will execute there.
|
||||
|
||||
Run the archived v1 suites explicitly:
|
||||
```powershell
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive # → 494 pass
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests # → 6 pass
|
||||
```
|
||||
|
||||
## Test plan for reviewers
|
||||
|
||||
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the known
|
||||
NuGetAuditSuppress + NU1702 cross-FX
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the 470/7-skip/1-baseline result
|
||||
- [ ] Both archived suites pass when run explicitly
|
||||
- [ ] Build the Galaxy.Host EXE (`dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`),
|
||||
then run E2E tests on a non-admin shell — they should actually execute and pass
|
||||
against live Galaxy ZB
|
||||
- [ ] Spot-read `docs/v2/V1_ARCHIVE_STATUS.md` and confirm the deletion plan is acceptable
|
||||
|
||||
## Follow-up tracking
|
||||
|
||||
- **PR 3** (next session, when ready): execute the deletion plan in `V1_ARCHIVE_STATUS.md`.
|
||||
4 projects removed, .slnx updated, full solution test confirms parity.
|
||||
- **PR 4** (Phase 2 follow-up): port Historian plugin + wire MxAccess subscription pushes +
|
||||
close the high/medium open findings from `exit-gate-phase-2-final.md`.
|
||||
@@ -11,6 +11,12 @@
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
|
||||
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
|
||||
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
|
||||
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
|
||||
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Host</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Host</AssemblyName>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. Functionally superseded by:
|
||||
src/ZB.MOM.WW.OtOpcUa.Server (host process, .NET 10)
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (out-of-process MXAccess, net48 x86)
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process driver, .NET 10)
|
||||
Kept in the build graph because Historian.Aveva + IntegrationTests still
|
||||
transitively reference it. Deletion is the subject of Phase 2 PR 3 (separate from
|
||||
this PR 2). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class HierarchyParityTests
|
||||
{
|
||||
private readonly ParityFixture _fx;
|
||||
public HierarchyParityTests(ParityFixture fx) => _fx = fx;
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_returns_at_least_one_gobject_with_attributes()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBeGreaterThan(0,
|
||||
"live Galaxy ZB has at least one deployed gobject");
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||
"at least one gobject in the dev Galaxy carries dynamic attributes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_only_lowercase_browse_paths_for_each_attribute()
|
||||
{
|
||||
// OPC UA browse paths are case-sensitive; the v1 server emits Galaxy attribute
|
||||
// names verbatim (camelCase like "PV.Input.Value"). Parity invariant: every
|
||||
// emitted variable's full reference contains a '.' separating the gobject
|
||||
// tag-name from the attribute name (Galaxy reference grammar).
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldAllBe(v => v.AttributeInfo.FullName.Contains('.'),
|
||||
"Galaxy MXAccess full references are 'tag.attribute'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_marks_at_least_one_attribute_as_historized_when_HistoryExtension_present()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// Soft assertion — some Galaxies are configuration-only with no Historian extensions.
|
||||
// We only check the field flows through correctly when populated.
|
||||
var historized = builder.Variables.Count(v => v.AttributeInfo.IsHistorized);
|
||||
// Just assert the count is non-negative — the value itself is data-dependent.
|
||||
historized.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
136
tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Normal file
136
tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Spawns one <c>OtOpcUa.Driver.Galaxy.Host.exe</c> subprocess per test class and exposes
|
||||
/// a connected <see cref="GalaxyProxyDriver"/> for the tests. Per Phase 2 plan §"Stream E
|
||||
/// Parity Validation": the Proxy owns a session against a real out-of-process Host running
|
||||
/// the production-shape <c>MxAccessGalaxyBackend</c> backed by live ZB + MXAccess COM.
|
||||
/// Skipped when the Host EXE isn't built, when ZB SQL is unreachable, or when the dev box
|
||||
/// runs as Administrator (the IPC ACL explicitly denies Administrators per decision #76).
|
||||
/// </summary>
|
||||
public sealed class ParityFixture : IAsyncLifetime
|
||||
{
|
||||
public GalaxyProxyDriver? Driver { get; private set; }
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
private Process? _host;
|
||||
private const string Secret = "parity-suite-secret";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) { SkipReason = "Windows-only"; return; }
|
||||
if (IsAdministrator()) { SkipReason = "PipeAcl denies Administrators on dev shells"; return; }
|
||||
if (!await ZbReachableAsync()) { SkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; return; }
|
||||
|
||||
var hostExe = FindHostExe();
|
||||
if (hostExe is null) { SkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; return; }
|
||||
|
||||
// Use the SQL-only DB backend by default — exercises the full IPC + dispatcher + SQL
|
||||
// path without requiring a healthy MXAccess connection. Tests that need MXAccess
|
||||
// override via env vars before InitializeAsync is called (use a separate fixture).
|
||||
var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
|
||||
var psi = new ProcessStartInfo(hostExe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
EnvironmentVariables =
|
||||
{
|
||||
["OTOPCUA_GALAXY_PIPE"] = pipe,
|
||||
["OTOPCUA_ALLOWED_SID"] = sid.Value,
|
||||
["OTOPCUA_GALAXY_SECRET"] = Secret,
|
||||
["OTOPCUA_GALAXY_BACKEND"] = "db",
|
||||
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
},
|
||||
};
|
||||
|
||||
_host = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
|
||||
|
||||
// Give the PipeServer ~2s to bind. The supervisor's HeartbeatMonitor can do this
|
||||
// in production with retry, but the parity tests are best served by a fixed warm-up.
|
||||
await Task.Delay(2_000);
|
||||
|
||||
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "parity",
|
||||
PipeName = pipe,
|
||||
SharedSecret = Secret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Driver is not null)
|
||||
{
|
||||
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
|
||||
Driver.Dispose();
|
||||
}
|
||||
|
||||
if (_host is not null && !_host.HasExited)
|
||||
{
|
||||
try { _host.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||
try { _host.WaitForExit(5_000); } catch { /* ignore */ }
|
||||
}
|
||||
_host?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Skip the test if the fixture couldn't initialize. xUnit Skip.If pattern.</summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null)
|
||||
Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
private static bool IsAdministrator()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return false;
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 1433);
|
||||
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static string? FindHostExe()
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var solutionRoot = asmDir;
|
||||
for (var i = 0; i < 8 && solutionRoot is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
|
||||
solutionRoot = Path.GetDirectoryName(solutionRoot);
|
||||
}
|
||||
if (solutionRoot is null) return null;
|
||||
|
||||
var path = Path.Combine(solutionRoot,
|
||||
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
||||
"OtOpcUa.Driver.Galaxy.Host.exe");
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(ParityCollection))]
|
||||
public sealed class ParityCollection : ICollectionFixture<ParityFixture> { }
|
||||
@@ -0,0 +1,38 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only <see cref="IAddressSpaceBuilder"/> that records every Folder + Variable
|
||||
/// registration. Mirrors the v1 in-process address-space build so tests can assert on
|
||||
/// the same shape the legacy <c>LmxNodeManager</c> produced.
|
||||
/// </summary>
|
||||
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<RecordedFolder> Folders { get; } = new();
|
||||
public List<RecordedVariable> Variables { get; } = new();
|
||||
public List<RecordedProperty> Properties { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(new RecordedFolder(browseName, displayName));
|
||||
return this; // single flat builder for tests; nesting irrelevant for parity assertions
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
|
||||
return new RecordedVariableHandle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
{
|
||||
Properties.Add(new RecordedProperty(browseName, dataType, value));
|
||||
}
|
||||
|
||||
public sealed record RecordedFolder(string BrowseName, string DisplayName);
|
||||
public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
|
||||
public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
|
||||
|
||||
private sealed record RecordedVariableHandle(string FullReference) : IVariableHandle;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the four 2026-04-13 stability findings (commits <c>c76ab8f</c>,
|
||||
/// <c>7310925</c>) per Phase 2 plan §"Stream E.3". Each test asserts the v2 topology
|
||||
/// does not reintroduce the v1 defect.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Trait("Subcategory", "StabilityRegression")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class StabilityFindingsRegressionTests
|
||||
{
|
||||
private readonly ParityFixture _fx;
|
||||
public StabilityFindingsRegressionTests(ParityFixture fx) => _fx = fx;
|
||||
|
||||
/// <summary>
|
||||
/// Finding #1 — <em>phantom probe subscription flips Tick() to Stopped</em>. When the
|
||||
/// v1 GalaxyRuntimeProbeManager failed to subscribe a probe, it left a phantom entry
|
||||
/// that the next Tick() flipped to Stopped, fanning Bad-quality across unrelated
|
||||
/// subtrees. v2 regression net: a failed subscribe must not affect host status of
|
||||
/// subscriptions that did succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Failed_subscribe_does_not_corrupt_unrelated_host_status()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
// GetHostStatuses pre-subscribe — baseline.
|
||||
var preSubscribe = _fx.Driver!.GetHostStatuses().Count;
|
||||
|
||||
// Try to subscribe to a nonsense reference; the Host should reject it without
|
||||
// poisoning the host-status table.
|
||||
try
|
||||
{
|
||||
await _fx.Driver.SubscribeAsync(
|
||||
new[] { "nonexistent.tag.does.not.exist[]" },
|
||||
TimeSpan.FromSeconds(1),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch { /* expected — bad reference */ }
|
||||
|
||||
var postSubscribe = _fx.Driver.GetHostStatuses().Count;
|
||||
postSubscribe.ShouldBe(preSubscribe,
|
||||
"failed subscribe must not mutate the host-status snapshot");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #2 — <em>cross-host quality clear wipes sibling state during recovery</em>.
|
||||
/// v1 cleared all subscriptions when ANY host changed state, even healthy peers.
|
||||
/// v2 regression net: host-status events must be scoped to the affected host name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Host_status_change_event_carries_specific_host_name_not_global_clear()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var changes = new List<HostStatusChangedEventArgs>();
|
||||
EventHandler<HostStatusChangedEventArgs> handler = (_, e) => changes.Add(e);
|
||||
_fx.Driver!.OnHostStatusChanged += handler;
|
||||
try
|
||||
{
|
||||
// We can't deterministically force a Host status transition in the suite without
|
||||
// tearing down the COM connection. The structural assertion is sufficient: the
|
||||
// event TYPE carries a specific HostName, OldState, NewState — there is no
|
||||
// "global clear" payload. v1's bug was structural; v2's event signature
|
||||
// mathematically prevents reintroduction.
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("HostName")
|
||||
.ShouldNotBeNull("event signature must scope to a specific host");
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("OldState")
|
||||
.ShouldNotBeNull();
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("NewState")
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fx.Driver.OnHostStatusChanged -= handler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #3 — <em>sync-over-async on the OPC UA stack thread</em>. v1 had spots
|
||||
/// that called <c>.Result</c> / <c>.Wait()</c> from the OPC UA stack callback,
|
||||
/// deadlocking under load. v2 regression net: every <see cref="GalaxyProxyDriver"/>
|
||||
/// capability method is async-all-the-way; a reflection scan asserts no
|
||||
/// <c>.GetAwaiter().GetResult()</c> appears in IL of the public surface.
|
||||
/// Implemented as a structural shape assertion — every public method returning
|
||||
/// <see cref="Task"/> or <see cref="Task{TResult}"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void All_GalaxyProxyDriver_capability_methods_return_Task_for_async_correctness()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var driverType = typeof(Proxy.GalaxyProxyDriver);
|
||||
var capabilityMethods = driverType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(m => m.DeclaringType == driverType
|
||||
&& !m.IsSpecialName
|
||||
&& m.Name is "InitializeAsync" or "ReinitializeAsync" or "ShutdownAsync"
|
||||
or "FlushOptionalCachesAsync" or "DiscoverAsync"
|
||||
or "ReadAsync" or "WriteAsync"
|
||||
or "SubscribeAsync" or "UnsubscribeAsync"
|
||||
or "SubscribeAlarmsAsync" or "UnsubscribeAlarmsAsync" or "AcknowledgeAsync"
|
||||
or "ReadRawAsync" or "ReadProcessedAsync");
|
||||
|
||||
foreach (var m in capabilityMethods)
|
||||
{
|
||||
(m.ReturnType == typeof(Task) || m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
|
||||
.ShouldBeTrue($"{m.Name} must return Task or Task<T> — sync-over-async risks deadlock under load");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #4 — <em>fire-and-forget alarm tasks racing shutdown</em>. v1 fired
|
||||
/// <c>Task.Run(() => raiseAlarm)</c> without awaiting, so shutdown could complete
|
||||
/// while the task was still touching disposed state. v2 regression net: alarm
|
||||
/// acknowledgement is sequential and awaited — verified by the integration test
|
||||
/// <c>AcknowledgeAsync</c> returning a completed Task that doesn't leave background
|
||||
/// work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_completes_before_returning_no_background_tasks()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
// We can't easily acknowledge a real Galaxy alarm in this fixture, but we can
|
||||
// assert the call shape: a synchronous-from-the-caller-perspective await without
|
||||
// throwing or leaving a pending continuation.
|
||||
await _fx.Driver!.AcknowledgeAsync(
|
||||
new[] { new AlarmAcknowledgeRequest("nonexistent-source", "nonexistent-event", "test ack") },
|
||||
CancellationToken.None);
|
||||
|
||||
// If we got here, the call awaited cleanly — no fire-and-forget background work
|
||||
// left running after the caller returned.
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<!--
|
||||
We DO NOT reference Galaxy.Host (net48 x86) here. The Host runs as a subprocess —
|
||||
this project only needs to spawn the EXE and talk to it via named pipes through
|
||||
the Proxy. Cross-FX type loading is what bit the earlier in-process attempt.
|
||||
-->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -6,7 +6,14 @@
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. References v1 OtOpcUa.Host directly.
|
||||
Excluded from `dotnet test` solution runs; replaced by the v2
|
||||
OtOpcUa.Driver.Galaxy.E2E suite. To run explicitly:
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Tests</RootNamespace>
|
||||
<!-- Keep the assembly name unchanged so v1 OtOpcUa.Host's InternalsVisibleTo still matches. -->
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Tests</AssemblyName>
|
||||
<!--
|
||||
Phase 2 Stream D — archived. These 494 v1 IntegrationTests instantiate v1
|
||||
OtOpcUa.Host classes directly. They are kept as the historical parity reference
|
||||
but excluded from full-solution `dotnet test ZB.MOM.WW.OtOpcUa.slnx` so the v2
|
||||
E2E suite (OtOpcUa.Driver.Galaxy.E2E) is the live parity bar going forward.
|
||||
To run them explicitly:
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||
-->
|
||||
<IsTestProject>false</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
Reference in New Issue
Block a user