chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke tests that exercise the real libplctag stack against a running
|
||||
/// <c>ab_server</c>. Skipped when the binary isn't on PATH (<see cref="AbServerFactAttribute"/>).
|
||||
/// Parametrized over <see cref="KnownProfiles.All"/> so one test file covers every family
|
||||
/// (ControlLogix / CompactLogix / Micro800 / GuardLogix).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipReadSmokeTests
|
||||
{
|
||||
public static IEnumerable<object[]> Profiles =>
|
||||
KnownProfiles.All.Select(p => new object[] { p });
|
||||
|
||||
[AbServerTheory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile)
|
||||
{
|
||||
var fixture = new AbServerFixture(profile);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
|
||||
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, $"drv-smoke-{profile.Family}");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP
|
||||
/// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the
|
||||
/// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses
|
||||
/// <c>AB_SERVER_ENDPOINT</c> (default <c>localhost:44818</c>) + TCP-connects
|
||||
/// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/>
|
||||
/// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so
|
||||
/// <c>dotnet test</c> stays green on a fresh clone without Docker running.
|
||||
/// Matches the <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> /
|
||||
/// <c>OpcPlcFixture</c> shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Docker is the only supported launch path — no native-binary spawn + no
|
||||
/// PATH lookup. Bring the container up before <c>dotnet test</c>:
|
||||
/// <c>docker compose -f Docker/docker-compose.yml --profile controllogix up</c>.
|
||||
/// </remarks>
|
||||
public sealed class AbServerFixture : IAsyncLifetime
|
||||
{
|
||||
private const string EndpointEnvVar = "AB_SERVER_ENDPOINT";
|
||||
|
||||
/// <summary>The profile this fixture instance represents. Parallel family smoke tests
|
||||
/// instantiate the fixture with the profile matching their compose-file service.</summary>
|
||||
public AbServerProfile Profile { get; }
|
||||
|
||||
public string Host { get; } = "127.0.0.1";
|
||||
public int Port { get; } = AbServerProfile.DefaultPort;
|
||||
|
||||
public AbServerFixture() : this(KnownProfiles.ControlLogix) { }
|
||||
|
||||
public AbServerFixture(AbServerProfile profile)
|
||||
{
|
||||
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
|
||||
|
||||
// Endpoint override applies to both host + port — targeting a real PLC at
|
||||
// non-default host or port shouldn't need fixture changes.
|
||||
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
||||
{
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when ab_server is reachable at this fixture's Host/Port. Used by
|
||||
/// <see cref="AbServerFactAttribute"/> / <see cref="AbServerTheoryAttribute"/>
|
||||
/// to decide whether to skip tests on a fresh clone without a running container.
|
||||
/// </summary>
|
||||
public static bool IsServerAvailable() =>
|
||||
TcpProbe(ResolveHost(), ResolvePort());
|
||||
|
||||
private static string ResolveHost() =>
|
||||
Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "127.0.0.1";
|
||||
|
||||
private static int ResolvePort()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||
if (raw is null) return AbServerProfile.DefaultPort;
|
||||
var parts = raw.Split(':', 2);
|
||||
return parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : AbServerProfile.DefaultPort;
|
||||
}
|
||||
|
||||
/// <summary>One-shot TCP probe; 500 ms budget so a missing container fails the probe fast.</summary>
|
||||
private static bool TcpProbe(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a
|
||||
/// live Docker listener on <c>localhost:44818</c> or an <c>AB_SERVER_ENDPOINT</c>
|
||||
/// override pointing at a real PLC.
|
||||
/// </summary>
|
||||
public sealed class AbServerFactAttribute : FactAttribute
|
||||
{
|
||||
public AbServerFactAttribute()
|
||||
{
|
||||
if (!AbServerFixture.IsServerAvailable())
|
||||
Skip = "ab_server not reachable. Start the Docker container " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile controllogix up) " +
|
||||
"or set AB_SERVER_ENDPOINT to a real PLC.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Theory]</c>-equivalent with the same availability rules as
|
||||
/// <see cref="AbServerFactAttribute"/>. Pair with
|
||||
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory
|
||||
/// row per family.
|
||||
/// </summary>
|
||||
public sealed class AbServerTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public AbServerTheoryAttribute()
|
||||
{
|
||||
if (!AbServerFixture.IsServerAvailable())
|
||||
Skip = "ab_server not reachable. Start the Docker container " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile controllogix up) " +
|
||||
"or set AB_SERVER_ENDPOINT to a real PLC.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family marker for the <c>ab_server</c> Docker compose profile a given test
|
||||
/// targets. The compose file (<c>Docker/docker-compose.yml</c>) is the canonical
|
||||
/// source of truth for which tags a family seeds + which <c>--plc</c> mode the
|
||||
/// simulator boots in; this record just ties a family enum to operator-facing
|
||||
/// notes so fixture + test code can filter / branch by family.
|
||||
/// </summary>
|
||||
/// <param name="Family">OtOpcUa driver family this profile targets.</param>
|
||||
/// <param name="ComposeProfile">The <c>docker compose --profile</c> name that brings
|
||||
/// this family's ab_server up. Matches the service key in the compose file.</param>
|
||||
/// <param name="Notes">Operator-facing description of coverage + any quirks.</param>
|
||||
public sealed record AbServerProfile(
|
||||
AbCipPlcFamily Family,
|
||||
string ComposeProfile,
|
||||
string Notes)
|
||||
{
|
||||
/// <summary>Default ab_server port — matches the compose-file port-map + the
|
||||
/// CIP / EtherNet/IP standard.</summary>
|
||||
public const int DefaultPort = 44818;
|
||||
}
|
||||
|
||||
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 9–12.</summary>
|
||||
public static class KnownProfiles
|
||||
{
|
||||
public static readonly AbServerProfile ControlLogix = new(
|
||||
Family: AbCipPlcFamily.ControlLogix,
|
||||
ComposeProfile: "controllogix",
|
||||
Notes: "Widest-coverage profile — PR 9 baseline. UDTs unit-tested via golden Template Object buffers; ab_server lacks full UDT emulation.");
|
||||
|
||||
public static readonly AbServerProfile CompactLogix = new(
|
||||
Family: AbCipPlcFamily.CompactLogix,
|
||||
ComposeProfile: "compactlogix",
|
||||
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
|
||||
|
||||
public static readonly AbServerProfile Micro800 = new(
|
||||
Family: AbCipPlcFamily.Micro800,
|
||||
ComposeProfile: "micro800",
|
||||
Notes: "--plc=Micro800 mode (unconnected-only, empty path). Driver-side enforcement verified in the unit suite.");
|
||||
|
||||
public static readonly AbServerProfile GuardLogix = new(
|
||||
Family: AbCipPlcFamily.GuardLogix,
|
||||
ComposeProfile: "guardlogix",
|
||||
Notes: "ab_server has no safety subsystem — _S-suffixed seed tag triggers driver-side ViewOnly classification only.");
|
||||
|
||||
public static IReadOnlyList<AbServerProfile> All { get; } =
|
||||
[ControlLogix, CompactLogix, Micro800, GuardLogix];
|
||||
|
||||
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
|
||||
All.FirstOrDefault(p => p.Family == family)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime gate that lets an integration-test class declare which target-server tier
|
||||
/// it requires. Reads <c>AB_SERVER_PROFILE</c> from the environment; tests call
|
||||
/// <see cref="SkipUnless"/> with the profile names they support + skip otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Two tiers today:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>abserver</c> (default) — the Dockerized libplctag <c>ab_server</c>
|
||||
/// simulator. Covers atomic reads / writes / basic discovery across the four
|
||||
/// families (ControlLogix / CompactLogix / Micro800 / GuardLogix).</item>
|
||||
/// <item><c>emulate</c> — Rockwell Studio 5000 Logix Emulate on an operator's
|
||||
/// Windows box, exposed via <c>AB_SERVER_ENDPOINT</c>. Adds real UDT / ALMD /
|
||||
/// AOI / Program-scoped-tag coverage that ab_server can't emulate. Tier-gated
|
||||
/// because Emulate is per-seat licensed + Windows-only + manually launched;
|
||||
/// a stock `dotnet test` run against ab_server must skip Emulate-only classes
|
||||
/// cleanly.</item>
|
||||
/// </list>
|
||||
/// <para>Tests assert their target tier at the top of each <c>[Fact]</c> /
|
||||
/// <c>[Theory]</c> body, mirroring the <c>MODBUS_SIM_PROFILE</c> gate pattern in
|
||||
/// <c>tests/.../Modbus.IntegrationTests/DL205/DL205StringQuirkTests.cs</c>.</para>
|
||||
/// </remarks>
|
||||
public static class AbServerProfileGate
|
||||
{
|
||||
public const string Default = "abserver";
|
||||
public const string Emulate = "emulate";
|
||||
|
||||
/// <summary>Active profile from <c>AB_SERVER_PROFILE</c>; defaults to <see cref="Default"/>.</summary>
|
||||
public static string CurrentProfile =>
|
||||
Environment.GetEnvironmentVariable("AB_SERVER_PROFILE") is { Length: > 0 } raw
|
||||
? raw.Trim().ToLowerInvariant()
|
||||
: Default;
|
||||
|
||||
/// <summary>
|
||||
/// Skip the calling test via <c>Assert.Skip</c> when <see cref="CurrentProfile"/>
|
||||
/// isn't in <paramref name="requiredProfiles"/>. Case-insensitive match.
|
||||
/// </summary>
|
||||
public static void SkipUnless(params string[] requiredProfiles)
|
||||
{
|
||||
foreach (var p in requiredProfiles)
|
||||
if (string.Equals(p, CurrentProfile, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
Assert.Skip(
|
||||
$"Test requires AB_SERVER_PROFILE in {{{string.Join(", ", requiredProfiles)}}}; " +
|
||||
$"current value is '{CurrentProfile}'. " +
|
||||
$"Set AB_SERVER_PROFILE=emulate + point AB_SERVER_ENDPOINT at a Logix Emulate instance to run the golden-box-tier tests.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-unit tests for the profile catalog. Verifies <see cref="KnownProfiles"/>
|
||||
/// stays in sync with <see cref="AbCipPlcFamily"/> + with the compose-file service
|
||||
/// names — a typo in either would surface as a test failure rather than a silent
|
||||
/// "wrong family booted" at runtime. Runs without Docker, so CI without the
|
||||
/// container still exercises these contracts.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbServerProfileTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||
[InlineData(AbCipPlcFamily.GuardLogix, "guardlogix")]
|
||||
public void KnownProfiles_ForFamily_Returns_Expected_ComposeProfile(AbCipPlcFamily family, string expected)
|
||||
{
|
||||
KnownProfiles.ForFamily(family).ComposeProfile.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownProfiles_All_Covers_Every_Family()
|
||||
{
|
||||
var covered = KnownProfiles.All.Select(p => p.Family).ToHashSet();
|
||||
foreach (var family in Enum.GetValues<AbCipPlcFamily>())
|
||||
covered.ShouldContain(family, $"Family {family} is missing a KnownProfiles entry.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultPort_Matches_EtherNetIP_Standard()
|
||||
{
|
||||
AbServerProfile.DefaultPort.ShouldBe(44818);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# ab_server container for the AB CIP integration suite.
|
||||
#
|
||||
# ab_server is a C program in libplctag/libplctag under src/tools/ab_server.
|
||||
# We clone at a pinned commit, build just the ab_server target via CMake,
|
||||
# and copy the resulting binary into a slim runtime stage so the published
|
||||
# image stays small (~60MB vs ~350MB for the build stage).
|
||||
|
||||
# -------- stage 1: build ab_server from source --------
|
||||
FROM debian:bookworm-slim AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
cmake \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pinned tag matches the `ab_server` version AbServerFixture + CI treat as
|
||||
# canonical. Bump deliberately alongside a driver-side change that needs
|
||||
# something newer.
|
||||
ARG LIBPLCTAG_TAG=release
|
||||
RUN git clone --depth 1 --branch "${LIBPLCTAG_TAG}" https://github.com/libplctag/libplctag.git /src
|
||||
|
||||
WORKDIR /src
|
||||
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build --target ab_server --parallel
|
||||
|
||||
# -------- stage 2: runtime --------
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \
|
||||
org.opencontainers.image.description="libplctag ab_server for OtOpcUa AB CIP driver integration tests"
|
||||
|
||||
# libplctag's ab_server is statically linked against libc / libstdc++ on
|
||||
# Debian bookworm; no runtime dependencies beyond what the slim image
|
||||
# already has.
|
||||
COPY --from=build /src/build/bin_dist/ab_server /usr/local/bin/ab_server
|
||||
|
||||
EXPOSE 44818
|
||||
|
||||
# docker-compose.yml overrides the command with per-family flags.
|
||||
CMD ["ab_server", "--plc=ControlLogix", "--path=1,0", "--port=44818", \
|
||||
"--tag=TestDINT:DINT[1]", "--tag=TestREAL:REAL[1]", "--tag=TestBOOL:BOOL[1]"]
|
||||
@@ -0,0 +1,89 @@
|
||||
# AB CIP integration-test fixture — `ab_server` (Docker)
|
||||
|
||||
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` — a
|
||||
MIT-licensed C program that emulates a ControlLogix / CompactLogix CIP
|
||||
endpoint over EtherNet/IP. Docker is the only supported launch path;
|
||||
`ab_server` ships as a source-only tool under libplctag's
|
||||
`src/tools/ab_server/` so the Dockerfile's multi-stage build is the only
|
||||
reproducible way to get a working binary across developer boxes. A fresh
|
||||
clone needs Docker Desktop and nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`Dockerfile`](Dockerfile) | Multi-stage: build from libplctag at pinned tag → copy binary into `debian:bookworm-slim` runtime image |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per family (`controllogix` / `compactlogix` / `micro800` / `guardlogix`); all bind `:44818` |
|
||||
|
||||
## Run
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
# ControlLogix — widest-coverage profile
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\Docker\docker-compose.yml --profile controllogix up
|
||||
|
||||
# Per-family
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile compactlogix up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile micro800 up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile guardlogix up
|
||||
```
|
||||
|
||||
Detached + stop:
|
||||
|
||||
```powershell
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix up -d
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix down
|
||||
```
|
||||
|
||||
First run builds the image (~3-5 minutes — clones libplctag + compiles
|
||||
`ab_server` + its dependencies). Subsequent runs are fast because the
|
||||
multi-stage build layer-caches the checkout + compile.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Default: `localhost:44818` (EtherNet/IP standard; non-privileged)
|
||||
- Override with `AB_SERVER_ENDPOINT=host:port` to point at a real PLC.
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell with a container up:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
`AbServerFixture` TCP-probes `localhost:44818` at collection init +
|
||||
records a skip reason when unreachable, so tests stay green on a fresh
|
||||
clone without the container running. Tests use `[AbServerFact]` /
|
||||
`[AbServerTheory]` which check the same probe.
|
||||
|
||||
## What each family seeds
|
||||
|
||||
Tag sets match `AbServerProfile.cs` exactly — changing seeds in one
|
||||
place means updating both.
|
||||
|
||||
| Family | Seeded tags | Notes |
|
||||
|---|---|---|
|
||||
| ControlLogix | `TestDINT` `TestREAL` `TestBOOL` `TestSINT` `TestString` `TestArray` | Widest-coverage; PR 9 baseline. UDT emulation missing from ab_server |
|
||||
| CompactLogix | `TestDINT` `TestREAL` `TestBOOL` | Narrow ConnectionSize cap enforced driver-side; ab_server accepts any size |
|
||||
| Micro800 | `TestDINT` `TestREAL` | ab_server has no `micro800` mode; falls back to `controllogix` emulation |
|
||||
| GuardLogix | `TestDINT` `SafetyDINT_S` | ab_server has no safety subsystem; `_S` suffix triggers driver-side classification only |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No UDT / CIP Template Object emulation** — `ab_server` covers atomic
|
||||
types only. UDT reads + task #194 whole-UDT optimization verify via
|
||||
unit tests with golden byte buffers.
|
||||
- **Family-specific quirks trust driver-side code** — ab_server emulates
|
||||
a generic Logix CPU; the ConnectionSize cap, empty-path unconnected
|
||||
mode, and safety-partition write rejection all need lab rigs for
|
||||
wire-level proof.
|
||||
|
||||
See [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## References
|
||||
|
||||
- [libplctag on GitHub](https://github.com/libplctag/libplctag)
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md) — coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures
|
||||
@@ -0,0 +1,97 @@
|
||||
# AB CIP integration-test fixture — ab_server (libplctag).
|
||||
#
|
||||
# One service per family. All bind :44818 on the host; only one runs at a
|
||||
# time. Commands mirror the CLI args AbServerProfile.cs constructs for the
|
||||
# native-binary path.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile controllogix up
|
||||
# docker compose --profile compactlogix up
|
||||
# docker compose --profile micro800 up
|
||||
# docker compose --profile guardlogix up
|
||||
services:
|
||||
controllogix:
|
||||
profiles: ["controllogix"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
container_name: otopcua-ab-server-controllogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]",
|
||||
"--tag=TestBOOL:BOOL[1]",
|
||||
"--tag=TestSINT:SINT[1]",
|
||||
"--tag=TestString:STRING[1]",
|
||||
"--tag=TestArray:DINT[16]"
|
||||
]
|
||||
|
||||
compactlogix:
|
||||
profiles: ["compactlogix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-compactlogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server doesn't distinguish CompactLogix from ControlLogix — no
|
||||
# dedicated --plc mode. Driver-side ConnectionSize cap is enforced
|
||||
# separately (see AbServerProfile.CompactLogix Notes).
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]",
|
||||
"--tag=TestBOOL:BOOL[1]"
|
||||
]
|
||||
|
||||
micro800:
|
||||
profiles: ["micro800"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-micro800
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server does have a Micro800 plc mode (unconnected-only, empty path).
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=Micro800",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]"
|
||||
]
|
||||
|
||||
guardlogix:
|
||||
profiles: ["guardlogix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-guardlogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server has no safety subsystem — _S suffix triggers driver-side
|
||||
# classification only.
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=SafetyDINT_S:DINT[1]"
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-box-tier ALMD alarm projection tests against Logix Emulate.
|
||||
/// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage
|
||||
/// (<c>AbCipAlarmProjectionTests</c> with faked InFaulted sequences) to end-to-end
|
||||
/// wire-level coverage — Emulate runs the real ALMD instruction, with real
|
||||
/// rising-edge semantics on <c>InFaulted</c> + <c>Ack</c>, so the driver's poll-based
|
||||
/// projection gets validated against the actual behaviour shops running FT View see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Controller-scope ALMD tag <c>HighTempAlarm</c> — a standard ALMD instruction
|
||||
/// with default member set (<c>In</c>, <c>InFaulted</c>, <c>Acked</c>,
|
||||
/// <c>Severity</c>, <c>Cfg_ProgTime</c>, …).</item>
|
||||
/// <item>A periodic task that drives <c>HighTempAlarm.In</c> false→true→false at a
|
||||
/// cadence the operator can script via a one-shot routine (e.g. a
|
||||
/// <c>SimulateAlarm</c> bit the test case pulses through
|
||||
/// <c>IWritable.WriteAsync</c>).</item>
|
||||
/// <item>Operator writes <c>1</c> to <c>SimulateAlarm</c> to trigger the rising
|
||||
/// edge on <c>HighTempAlarm.In</c>; ladder uses that as the alarm input.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. ab_server has no ALMD
|
||||
/// instruction + no alarm subsystem, so this tier-gated class couldn't produce a
|
||||
/// meaningful result against the default simulator.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateAlmdTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "HighTempAlarm",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "HighTempAlarm",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||||
]),
|
||||
// The "simulate the alarm input" bit the ladder watches.
|
||||
new AbCipTagDefinition(
|
||||
Name: "SimulateAlarm",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "SimulateAlarm",
|
||||
DataType: AbCipDataType.Bool,
|
||||
Writable: true),
|
||||
],
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-almd");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var raised = new TaskCompletionSource<AlarmEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
if (e.Message.Contains("raised")) raised.TrySetResult(e);
|
||||
};
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync(
|
||||
["HighTempAlarm"], TestContext.Current.CancellationToken);
|
||||
|
||||
// Pulse the input bit the ladder watches, then wait for the driver's poll loop
|
||||
// to see InFaulted rise + fire the raise event.
|
||||
_ = await drv.WriteAsync(
|
||||
[new WriteRequest("SimulateAlarm", true)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var got = await Task.WhenAny(raised.Task, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
got.ShouldBe(raised.Task, "driver must surface the ALMD raise within 5 s of the ladder-driven edge");
|
||||
var args = await raised.Task;
|
||||
args.SourceNodeId.ShouldBe("HighTempAlarm");
|
||||
args.AlarmType.ShouldBe("ALMD");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, TestContext.Current.CancellationToken);
|
||||
|
||||
// Reset the bit so the next test run starts from a known state.
|
||||
_ = await drv.WriteAsync(
|
||||
[new WriteRequest("SimulateAlarm", false)],
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-box-tier UDT read tests against Rockwell Studio 5000 Logix Emulate.
|
||||
/// Promotes the whole-UDT-read optimization (task #194) from unit-only coverage
|
||||
/// (golden Template Object byte buffers + <see cref="AbCipUdtMemberLayoutTests"/>)
|
||||
/// to end-to-end wire-level coverage — Emulate's firmware speaks the same CIP
|
||||
/// Template Object responses real hardware does, so the member-offset math + the
|
||||
/// <c>AbCipUdtReadPlanner</c> grouping get validated against production semantics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>
|
||||
/// for the L5X export that seeds this; ship the project once Emulate is on the
|
||||
/// integration host):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>UDT <c>Motor_UDT</c> with members <c>Speed : DINT</c>, <c>Torque : REAL</c>,
|
||||
/// <c>Status : DINT</c> — the member set <see cref="AbCipUdtMemberLayoutTests"/>
|
||||
/// uses as its declared-layout golden reference.</item>
|
||||
/// <item>Controller-scope tag <c>Motor1 : Motor_UDT</c> with seed values
|
||||
/// Speed=<c>1800</c>, Torque=<c>42.5f</c>, Status=<c>0x0001</c>.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With ab_server
|
||||
/// (the default), skips cleanly — ab_server lacks UDT / Template Object emulation
|
||||
/// so this wire-level test couldn't pass against it regardless.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateUdtReadTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
|
||||
"(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt),
|
||||
]),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-udt-smoke");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Whole-UDT read optimization from task #194: one libplctag read on the
|
||||
// parent tag, three decodes from the buffer at member offsets. Asserts
|
||||
// Emulate's Template Object response matches what AbCipUdtMemberLayout
|
||||
// computes from the declared member set.
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Motor1.Speed", "Motor1.Torque", "Motor1.Status"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(1800);
|
||||
Convert.ToSingle(snapshots[1].Value).ShouldBe(42.5f, tolerance: 0.001f);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(0x0001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Logix Emulate project stub
|
||||
|
||||
This folder holds the Studio 5000 project that Logix Emulate loads when running
|
||||
the Emulate-tier integration tests
|
||||
(`tests/.../AbCip.IntegrationTests/Emulate/*.cs`, gated on
|
||||
`AB_SERVER_PROFILE=emulate`).
|
||||
|
||||
**Status today**: stub. The actual `.L5X` export isn't committed yet; once
|
||||
the Emulate PC is running + a project with the required state exists,
|
||||
export to L5X + drop it here as `OtOpcUaAbCipFixture.L5X`.
|
||||
|
||||
## Why L5X, not .ACD
|
||||
|
||||
Studio 5000 ships two save formats: `.ACD` (binary, the runtime project)
|
||||
and `.L5X` (XML export). Ship the L5X because:
|
||||
|
||||
- Text format — reviewable in PR diffs, diffable in git
|
||||
- Reproducible import across Studio 5000 versions
|
||||
- Doesn't carry per-installation state (license watermarks, revision history)
|
||||
|
||||
Reconstruction workflow: Studio 5000 → open project → File → Save As
|
||||
→ `.L5X`. On a fresh Emulate install: File → Open → select the L5X → it
|
||||
rebuilds the ACD from the XML.
|
||||
|
||||
## Required project state
|
||||
|
||||
The Emulate-tier tests rely on this exact tag / UDT set. Missing any of
|
||||
these makes the dependent test fail loudly (TagNotFound, wrong value,
|
||||
wrong type), not skip silently — Emulate is a tier above the Docker
|
||||
simulator; operators who opted into it get opt-in-level coverage
|
||||
expectations.
|
||||
|
||||
### UDT definitions
|
||||
|
||||
| UDT name | Members | Notes |
|
||||
|---|---|---|
|
||||
| `Motor_UDT` | `Speed : DINT`, `Torque : REAL`, `Status : DINT` | Matches `AbCipUdtMemberLayoutTests` declared-layout golden. Member order fixed — Logix Template Object offsets depend on it |
|
||||
|
||||
### Controller tags
|
||||
|
||||
| Tag | Type | Seed value | Purpose |
|
||||
|---|---|---|---|
|
||||
| `Motor1` | `Motor_UDT` | `{Speed=1800, Torque=42.5, Status=0x0001}` | `AbCipEmulateUdtReadTests.WholeUdt_read_decodes_each_member_at_its_Template_Object_offset` |
|
||||
| `HighTempAlarm` | `ALMD` | default ALMD config, `In` tied to `SimulateAlarm` bit | `AbCipEmulateAlmdTests.Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection` |
|
||||
| `SimulateAlarm` | `BOOL` | `0` | Operator-writable bit the ladder routes into `HighTempAlarm.In` — gives the test a clean way to drive the alarm edge without scripting Emulate directly |
|
||||
|
||||
### Program structure
|
||||
|
||||
- One periodic task `MainTask` @ 100 ms
|
||||
- One program `MainProgram`
|
||||
- One routine `MainRoutine` (Ladder) with a single rung:
|
||||
`XIC SimulateAlarm OTE HighTempAlarm.In`
|
||||
|
||||
That's enough ladder for `SimulateAlarm := 1` to raise the alarm + for
|
||||
`SimulateAlarm := 0` to clear it.
|
||||
|
||||
## Tier-level behaviours this project enables
|
||||
|
||||
Coverage the existing Dockerized `ab_server` fixture can't produce —
|
||||
each verified by an `Emulate/*Tests.cs` class gated on
|
||||
`AB_SERVER_PROFILE=emulate`:
|
||||
|
||||
- **CIP Template Object round-trip** — real Logix template bytes,
|
||||
reads produce the same offset layout the CIP Symbol Object decoder +
|
||||
`AbCipUdtMemberLayout` expect.
|
||||
- **ALMD rising-edge semantics** — real Logix ALMD instruction fires
|
||||
`InFaulted` / `Acked` transitions at cycle boundaries, not at our
|
||||
unit-test fake's timer boundaries.
|
||||
- **Optimized vs unoptimized DB behaviour** — Logix 5380/5580 series
|
||||
runs the Studio 5000 project with optimized-DB-equivalent member
|
||||
access; the driver's read path exercises that wire surface.
|
||||
|
||||
Not in scope even with Emulate — needs real hardware:
|
||||
|
||||
- EtherNet/IP embedded-switch behaviour (Stratix 5700, 1756-EN4TR)
|
||||
- CIP Safety across partitions (Emulate 5580 emulates safety within
|
||||
the chassis but not across nodes)
|
||||
- Redundant chassis failover (1756-RM)
|
||||
- Motion control timing
|
||||
- High-speed discrete-input scheduling
|
||||
|
||||
## How to run the Emulate-tier tests
|
||||
|
||||
On the dev box:
|
||||
|
||||
```powershell
|
||||
$env:AB_SERVER_PROFILE = 'emulate'
|
||||
$env:AB_SERVER_ENDPOINT = '10.0.0.42:44818' # replace with the Emulate PC IP
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
With `AB_SERVER_PROFILE` unset or `abserver`, the `Emulate/*Tests.cs`
|
||||
classes skip cleanly; ab_server-backed tests run as usual.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
|
||||
§Logix Emulate golden-box tier — coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||
§Integration host — license + networking notes
|
||||
- Studio 5000 Logix Designer + Logix Emulate product pages on the
|
||||
Rockwell TechConnect portal (licensed; internal link only).
|
||||
@@ -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.AbCip.IntegrationTests</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\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="LogixProject\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user