Files
lmxopcua/scripts/e2e/test-focas.ps1
Joseph Doherty 4b0664bd55 FOCAS — retire Tier-C split, inline managed wire client, make read-only
Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.

Architecture

- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
  (owner-imported — see Wire/FocasWireClient.cs for the full surface).
  Opens two TCP sockets, runs the initiate handshake, serialises requests
  on socket 2 through a semaphore, closes cleanly with PDU + socket
  teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
  `IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
  alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
  against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
  and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
  rejected at startup with a diagnostic pointing at the migration doc.

Deletions

- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
  Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
  FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
  P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
  `IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
  Host-process supervisor (backoff, circuit breaker, heartbeat, post-
  mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
  FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
  SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
  exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
  DLL that masqueraded as Fwlib64.dll.

Solution changes

- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
  `Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
  hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
  entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
  `WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.

Integration tests

- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
  tree reads (identity, axes, dynamic, program, operation mode, timers,
  spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
  PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
  `IAlarmSource` raise/clear transitions, and `ProbeAsync` /
  `OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
  (Docker container with the vendored Python mock's native FOCAS/2
  Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
  compose down. Dropped the shim-build stage + DLL-copy step + the split
  testhost workaround (the latter only existed because of native-DLL
  lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
  service. Tests seed per-series state via `mock_load_profile` at test
  start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
  FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
  pre-refresh snapshot only spoke the JSON admin protocol.

Tests

- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
  63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
  surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
  to assert the new diagnostic message pointing at
  `docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.

Docs

- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
  deployment collapses to one `"Backend": "wire"` config block, no
  separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
  gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
  topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
  the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
  driver complement closed. FOCAS change-log entry added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:10:59 -04:00

221 lines
8.1 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
.DESCRIPTION
Runs the CLI against either the managed wire client (default — Driver.FOCAS.Cli
dials the CNC on TCP:8193 directly, no native dependencies) or the focas-mock
Docker fixture. Hardware-gated by default because the default CncHost is
127.0.0.1; set FOCAS_TRUST_WIRE=1 once -CncHost points at a real CNC, or pass
-ProfileName to run against the Docker sim.
The script also supports three nice-to-have modes shipped 2026-04-24:
-Series — per-series matrix mode. Accepts a comma-separated list; the
core stages are run once per series, swapping the -Address to
the supplied per-series probe. Fails fast if any series's
configured address is outside the documented range (the driver
itself enforces that at InitializeAsync).
-ProfileName — for use with the Python Docker simulator (see
docs/v2/implementation/focas-simulator-plan.md). Selects a
docker-compose profile + matching -Series. When set, the
FOCAS_TRUST_WIRE gate is considered satisfied because the sim
is a legitimate non-hardware target.
-HandleLeakCycles <int> — stress stage that opens + closes <N> sessions
via the CLI's `probe` command with a short sleep between
cycles. Exercises the Tier-C supervisor's handle-recycle path
without touching user data. Typical values: 1001000. A CNC's
FWLIB handle pool is finite (~510), so this shakes out
handle-leak bugs if either side forgets to free.
.PARAMETER CncHost
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
.PARAMETER CncPort
FOCAS TCP port. Default 8193.
.PARAMETER Address
FOCAS address to exercise. Default R100 (PMC R-file register). Ignored
when -Series is set and the series profile supplies its own probe.
.PARAMETER Series
Comma-separated list of CNC series to run the matrix against. Known:
ZeroI_D, ZeroI_F, ZeroI_MF, ZeroI_TF, Sixteen_i, Thirty_i, ThirtyOne_i,
ThirtyTwo_i, PowerMotion_i. When empty the script runs a single pass
without a series constraint.
.PARAMETER ProfileName
docker-compose profile name from tests/.../Docker/profiles/. When set,
the script assumes the Python simulator is the target + un-gates
FOCAS_TRUST_WIRE.
.PARAMETER HandleLeakCycles
Run a handle-leak stress stage with <N> open/close cycles. 0 = skip.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the Address.
#>
param(
[string]$CncHost = "127.0.0.1",
[int]$CncPort = 8193,
[string]$Address = "R100",
[string]$Series = "",
[string]$ProfileName = "",
[int]$HandleLeakCycles = 0,
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$simGated = -not [string]::IsNullOrWhiteSpace($ProfileName)
if (-not $simGated -and -not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
Write-Skip "FOCAS_TRUST_WIRE not set. Pass -ProfileName <profile> to run against the Docker mock in tests/.../Driver.FOCAS.IntegrationTests/Docker/, or set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC."
exit 0
}
if ($simGated) {
Write-Info "Sim mode — profile '$ProfileName'. FOCAS_TRUST_WIRE gate bypassed."
}
# Per-series probe addresses — each one is inside the authoritative range for
# that series (docs/v2/focas-version-matrix.md). Picking one representative per
# kind (PMC / parameter / macro) is enough to exercise the driver's validator.
$seriesProbes = @{
"ZeroI_D" = "R100"
"ZeroI_F" = "R100"
"ZeroI_MF" = "R100"
"ZeroI_TF" = "R100"
"Sixteen_i" = "R100"
"Thirty_i" = "R100"
"ThirtyOne_i" = "R100"
"ThirtyTwo_i" = "R100"
"PowerMotion_i"= "R100"
}
$seriesList = @()
if (-not [string]::IsNullOrWhiteSpace($Series)) {
$seriesList = @($Series.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$unknown = @($seriesList | Where-Object { -not $seriesProbes.ContainsKey($_) })
if ($unknown.Count -gt 0) {
Write-Fail "Unknown -Series entries: $($unknown -join ', '). Known: $($seriesProbes.Keys -join ', ')."
exit 2
}
}
$focasCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
-ExeName "otopcua-focas-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$allResults = @()
function Invoke-FocasCore {
param(
[string]$Label,
[string]$ProbeAddress
)
Write-Header "FOCAS stages — $Label"
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
$results = @()
$results += Test-Probe `
-Cli $focasCli `
-ProbeArgs (@("probe") + $commonFocas + @("-a", $ProbeAddress, "--type", "Int16"))
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$results += Test-DriverLoopback `
-Cli $focasCli `
-WriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $writeValue)) `
-ReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
-ExpectedValue "$writeValue"
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
$results += Test-ServerBridge `
-DriverCli $focasCli `
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $bridgeValue)) `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-ExpectedValue "$bridgeValue"
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
$results += Test-OpcUaWriteBridge `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $focasCli `
-DriverReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
-ExpectedValue "$reverseValue"
$subValue = Get-Random -Minimum 30000 -Maximum 32766
$results += Test-SubscribeSeesChange `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-OpcUaNodeId $BridgeNodeId `
-DriverCli $focasCli `
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $subValue)) `
-ExpectedValue "$subValue"
return $results
}
function Invoke-HandleLeakStage {
param([int]$Cycles)
Write-Header "FOCAS handle-leak stress — $Cycles cycles"
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
$failed = 0
for ($i = 1; $i -le $Cycles; $i++) {
$probe = Test-Probe `
-Cli $focasCli `
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
if (-not $probe.Passed) {
$failed++
# First 3 failures are informative; the rest just tally.
if ($failed -le 3) {
Write-Fail "cycle $i failed: $($probe.Reason)"
}
}
# Tiny delay so a broken loop can't DDoS the CNC; FWLIB handles take a
# few tens of ms to recycle in practice.
Start-Sleep -Milliseconds 50
}
$passed = $Cycles - $failed
if ($failed -eq 0) {
Write-Pass "handle-leak stress: $passed/$Cycles cycles succeeded"
return @{ Passed = $true; Reason = "$passed/$Cycles" }
} else {
Write-Fail "handle-leak stress: $failed/$Cycles cycles failed"
return @{ Passed = $false; Reason = "$failed/$Cycles failed" }
}
}
if ($seriesList.Count -eq 0) {
$allResults += Invoke-FocasCore -Label "single" -ProbeAddress $Address
} else {
foreach ($series in $seriesList) {
$probeAddr = $seriesProbes[$series]
Write-Info "Running matrix pass for series '$series' with address $probeAddr"
$allResults += Invoke-FocasCore -Label $series -ProbeAddress $probeAddr
}
}
if ($HandleLeakCycles -gt 0) {
$allResults += Invoke-HandleLeakStage -Cycles $HandleLeakCycles
}
Write-Summary -Title "FOCAS e2e" -Results $allResults
if ($allResults | Where-Object { -not $_.Passed }) { exit 1 }