Compare commits

...

3 Commits

Author SHA1 Message Date
Joseph Doherty 758aca2355 Make the e2e write phase work live across all five clients
Running the matrix against a live gateway surfaced several issues:

- The write phase is now opt-in (-VerifyWrite, was -SkipWrite). It runs
  right after register so only a small event backlog precedes the write,
  and asserts the reliable OnWriteComplete signal (the written value is
  not echoed back by a provider-driven attribute like TestChangingInt, so
  the value compare is best-effort).
- Java was launched as bare "gradle", which .NET's Process.Start cannot
  exec (it is gradle.bat) — resolve the launcher and run it via cmd.exe.
- The Java client's MxEventStream queue capacity was 16, which overflows
  on any active session's backlog-replay burst; raised to 1024.
- The Rust stream-events CLI now renders the event family as the proto
  enum name, matching the protobuf-JSON the other four clients emit.

Update docs/GatewayTesting.md for the reworked write phase.

Verified live: the full five-client matrix passes with -VerifyWrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:45:47 -04:00
Joseph Doherty 06030dd1ef Implement MXAccess write commands in the worker
The .proto contract and MxCommandKind already defined Write, Write2,
WriteSecured, and WriteSecured2, but the worker's MxAccessCommandExecutor
had no case for any of them — every write kind fell through to
CreateInvalidRequestReply ("Unsupported MXAccess command kind Write").

Implement all four:

- VariantConverter.ConvertToComValue projects an MxValue into a
  COM-marshalable object (scalars, arrays, null) — the inverse of the
  existing COM-to-MxValue projection.
- IMxAccessServer / MxAccessComServer gain Write/Write2/WriteSecured/
  WriteSecured2, routed to ILMXProxyServer / ILMXProxyServer4.
- MxAccessSession and MxAccessCommandExecutor add the four write paths,
  following the existing ExecuteAdvise pattern; the reply is a plain OK
  reply and the outcome surfaces later as an OnWriteComplete event.

Verified live: a Write now returns PROTOCOL_STATUS_CODE_OK and produces
an OnWriteComplete event where it previously returned InvalidRequest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:45:35 -04:00
Joseph Doherty e355a7674b Add write, parity, auth, and parallel coverage to client e2e matrix
Close the notable gaps in scripts/run-client-e2e-tests.ps1:

- Write round-trip: write a per-client sentinel value to a configurable
  writable attribute, then assert it is echoed back through the event
  stream. Extends the Rust mxgw-cli stream-events output with full
  per-event JSON (itemHandle + protojson-shaped value) so all five
  language clients run an identical value compare.
- Parity: assert an invalid item handle and an unknown session id are
  rejected rather than silently succeeding.
- Auth rejection: assert open-session is rejected with a missing API key
  and, when -RejectScopeApiKeyEnv is supplied, with an insufficient-scope
  key.
- Parallel: -Parallel runs each language client as an isolated child
  process and merges their JSON reports.

Update docs/GatewayTesting.md for the new phases and flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:55:51 -04:00
13 changed files with 1595 additions and 167 deletions
@@ -260,7 +260,12 @@ public final class MxGatewayClient implements AutoCloseable {
* @return an iterator-style stream of events
*/
public MxEventStream streamEvents(StreamEventsRequest request) {
MxEventStream stream = new MxEventStream(16);
// The buffer must absorb the gateway's session-backlog replay burst,
// which arrives far faster than the iterator drains it. A small queue
// overflows on any moderately active session; 1024 covers a realistic
// backlog while still bounding memory and preserving overflow
// detection for a genuinely unbounded stream.
MxEventStream stream = new MxEventStream(1024);
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options).streamEvents(request, stream.observer());
return stream;
}
+53 -14
View File
@@ -17,12 +17,12 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::StreamExt;
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use mxgateway_client::generated::mxaccess_gateway::v1::{
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, OpenSessionRequest,
PingCommand, StreamEventsRequest,
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamEventsRequest,
};
use mxgateway_client::{
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, CLIENT_VERSION,
GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, MxValueProjection,
CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};
use serde_json::json;
use serde_json::Value;
@@ -451,7 +451,7 @@ async fn run(cli: Cli) -> Result<(), Error> {
after_worker_sequence,
})
.await?;
let mut events = Vec::new();
let mut events: Vec<Value> = Vec::new();
let mut event_count = 0usize;
while event_count < max_events {
let Some(event) = stream.next().await else {
@@ -460,21 +460,17 @@ async fn run(cli: Cli) -> Result<(), Error> {
let event = event?;
event_count += 1;
if jsonl {
println!(
"{}",
json!({
"workerSequence": event.worker_sequence,
"family": event.family,
})
);
println!("{}", event_to_json(&event));
} else if json {
events.push(event);
events.push(event_to_json(&event));
} else {
println!("{} {}", event.worker_sequence, event.family);
}
}
if json {
println!("{}", json!({ "eventCount": event_count }));
// `eventCount` is preserved for back-compat; `events` carries
// the per-event detail the cross-language e2e matrix compares.
println!("{}", json!({ "eventCount": event_count, "events": events }));
}
}
Command::Write {
@@ -841,6 +837,49 @@ fn print_deploy_event(event: &DeployEvent, use_json: bool) {
}
}
/// Render a streamed [`MxEvent`] as a JSON object. The scalar value is
/// projected into protojson-style `*Value` keys so the cross-language e2e
/// matrix can extract and compare event values uniformly across all five
/// client CLIs.
fn event_to_json(event: &MxEvent) -> Value {
// Render the family as the proto enum name (e.g. MX_EVENT_FAMILY_ON_WRITE_COMPLETE)
// so it matches the protobuf-JSON the .NET/Go/Java/Python CLIs emit.
let family = MxEventFamily::try_from(event.family)
.map(|family| family.as_str_name().to_owned())
.unwrap_or_else(|_| event.family.to_string());
json!({
"family": family,
"sessionId": event.session_id,
"serverHandle": event.server_handle,
"itemHandle": event.item_handle,
"quality": event.quality,
"workerSequence": event.worker_sequence,
"value": event.value.as_ref().map(event_value_to_json),
})
}
/// Project an [`MxValue`] into a protojson-shaped JSON object whose single
/// key names the scalar kind (`int32Value`, `stringValue`, ...), matching
/// the protobuf-JSON the .NET/Go/Java CLIs emit.
fn event_value_to_json(value: &ProtoMxValue) -> Value {
match MxValue::from_proto(value.clone()).projection() {
MxValueProjection::Bool(inner) => json!({ "boolValue": inner }),
MxValueProjection::Int32(inner) => json!({ "int32Value": inner }),
// protojson renders 64-bit integers as strings; mirror that here.
MxValueProjection::Int64(inner) => json!({ "int64Value": inner.to_string() }),
MxValueProjection::Float(inner) => json!({ "floatValue": inner }),
MxValueProjection::Double(inner) => json!({ "doubleValue": inner }),
MxValueProjection::String(inner) => json!({ "stringValue": inner }),
MxValueProjection::Timestamp(ts) => {
json!({ "timestampValue": { "seconds": ts.seconds, "nanos": ts.nanos } })
}
MxValueProjection::Array(_) => json!({ "arrayValue": {} }),
MxValueProjection::Raw(bytes) => json!({ "rawValue": { "byteCount": bytes.len() } }),
MxValueProjection::Null => json!({ "isNull": true }),
MxValueProjection::Unset => Value::Null,
}
}
/// Parse a small but practically-complete subset of RFC3339:
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
+49 -5
View File
@@ -171,11 +171,39 @@ powershell -ExecutionPolicy Bypass -File scripts/discover-testmachine-tags.ps1 -
```
`scripts/run-client-e2e-tests.ps1` drives the .NET, Go, Rust, Python, and Java
client CLIs through a live gateway session. For each client it opens one
session, registers, verifies `SubscribeBulk` and `UnsubscribeBulk` on a bounded
tag subset, adds and advises every discovered test tag, reads a bounded event
stream, then closes the session in a `finally` path. The script writes a JSON
report under `artifacts/e2e/`.
client CLIs through a live gateway session. The gateway and worker are assumed
to be already running at `-Endpoint`; the script does not start or stop them.
For each client it runs these phases, then closes the session in a `finally`
path and writes a JSON report under `artifacts/e2e/`:
1. **Session + register** — opens one session and registers.
2. **Bulk** — verifies `SubscribeBulk` / `UnsubscribeBulk` on a bounded tag
subset (skip with `-SkipBulk`).
3. **Add-item / advise** — adds and advises every discovered test tag.
4. **Stream** — asserts a bounded event stream delivers at least one event
(skip with `-SkipStream`).
5. **Parity** — asserts MXAccess error paths are rejected rather than silently
succeeding: an invalid item handle and an unknown session id (skip with
`-SkipParity`).
6. **Auth rejection** — asserts `open-session` is rejected when the API key is
missing, and (when `-RejectScopeApiKeyEnv` names an insufficient-scope key)
when the key lacks the required scope. Skip with `-SkipAuth`.
7. **Write round-trip***opt-in (`-VerifyWrite`).* Runs right after
`register`: adds and advises a configurable writable attribute
(`-WriteAttribute`, default `TestChangingInt`), writes a per-client
sentinel value, then streams events and asserts an `OnWriteComplete` event
for that item is observed — proof the write round-tripped through the
gateway, worker, and MXAccess provider. The written value being echoed back
in an `OnDataChange` is recorded best-effort (`echoObserved`): a
provider-driven attribute such as `TestChangingInt` accepts the write but
immediately overwrites it, so no data-change carries the value back. The
Rust `stream-events` CLI emits full per-event JSON (`family`, `itemHandle`,
`value`) so all five clients apply the same checks.
It is opt-in because it mutates live tag state. The phase fails fast if the
write command is rejected — e.g. against a gateway whose worker predates
write support (`MxAccessCommandExecutor` returning `InvalidRequest` for
`Write`/`Write2`/`WriteSecured`/`WriteSecured2`).
Build the gateway and worker, start the gateway, and provide a valid API key
before running the client e2e script:
@@ -192,9 +220,25 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Clien
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -BulkTagCount 10
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipStream
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipBulk
# Write round-trip (opt-in): point at a writable scalar attribute and its
# value type.
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyWrite -WriteAttribute TestChangingInt -WriteType int32
# Auth rejection: also assert an insufficient-scope key is denied.
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -RejectScopeApiKeyEnv MXGATEWAY_READONLY_API_KEY
# Run all five clients concurrently as isolated child processes.
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Parallel
# Validate the flow offline (prints commands, contacts no gateway).
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -DryRun
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -Endpoint localhost:5000 -ApiKeyEnv MXGATEWAY_API_KEY
```
When `-VerifyWrite` is enabled, the write round-trip fails loudly if the write
command is rejected, if `-WriteAttribute` does not name a writable scalar
attribute, or if no `OnWriteComplete` event is observed for the written item
within `-WriteEchoMaxEvents` (default 200) streamed events. Raise
`-WriteEchoMaxEvents` if the gateway's per-session event backlog is large
enough to push `OnWriteComplete` past that bound.
## Focused Commands
Run the cross-language smoke matrix tests after changing the documented client
+664 -143
View File
@@ -1,3 +1,17 @@
<#
.SYNOPSIS
Cross-language client e2e matrix for the MXAccess Gateway.
.DESCRIPTION
Drives the .NET, Go, Rust, Python, and Java client CLIs against a running
gateway + worker. For each language the script exercises session open/close,
register, bulk subscribe/unsubscribe, per-tag add-item/advise, event
streaming, a write round-trip with value assertion, error-path (parity)
checks, and API-key auth rejection.
The gateway and worker are assumed to be already running at -Endpoint; the
script does not start or stop them.
#>
[CmdletBinding()]
param(
[string[]]$Clients = @("dotnet", "go", "rust", "python", "java"),
@@ -19,8 +33,29 @@ param(
[int]$BulkTagCount = 6,
[switch]$SkipStream,
[switch]$SkipBulk,
# Write round-trip. Opt-in because it mutates live tag state: it writes a
# sentinel value to -WriteAttribute and asserts an OnWriteComplete event
# confirms the write reached the MXAccess provider.
[switch]$VerifyWrite,
[string]$WriteAttribute = "TestChangingInt",
[string]$WriteType = "int32",
[int]$WriteValueBase = 424200,
[int]$WriteEchoMaxEvents = 200,
# Error-path (parity) checks.
[switch]$SkipParity,
# API-key auth rejection checks.
[switch]$SkipAuth,
# Optional env var holding an API key whose scopes are insufficient for
# open-session; when supplied the auth phase also asserts that key is
# rejected (PermissionDenied) on top of the always-on missing-key check.
[string]$RejectScopeApiKeyEnv,
# Run each language client concurrently as an isolated child process.
[switch]$Parallel,
[switch]$DryRun,
[string]$ReportPath
[string]$ReportPath,
# Internal: set by -Parallel on each spawned child so it always writes its
# report (even under -DryRun) for the parent to merge. Not for direct use.
[switch]$EmitReport
)
Set-StrictMode -Version Latest
@@ -56,6 +91,10 @@ if ($BulkTagCount -lt 1) {
throw "BulkTagCount must be greater than zero."
}
if ($WriteEchoMaxEvents -lt 1) {
throw "WriteEchoMaxEvents must be greater than zero."
}
foreach ($client in $Clients) {
if ($validClients -notcontains $client) {
throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')."
@@ -116,7 +155,10 @@ function Invoke-NativeCommand {
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory,
[hashtable]$Environment = @{}
[hashtable]$Environment = @{},
# When set, a non-zero exit code is returned to the caller instead of
# throwing. Used by the parity and auth phases, which expect failure.
[switch]$AllowFailure
)
$process = [System.Diagnostics.Process]::new()
@@ -155,7 +197,7 @@ function Invoke-NativeCommand {
stderr = $stderr
}
if ($result.exitCode -ne 0) {
if ($result.exitCode -ne 0 -and -not $AllowFailure) {
throw "Command failed with exit code $($result.exitCode): $commandLine`n$stderr`n$stdout"
}
@@ -243,6 +285,25 @@ function Get-StreamEventCount {
}
}
# Returns the per-event objects from a stream-events reply so the write
# round-trip can inspect their values. Mirrors Get-StreamEventCount: .NET,
# Rust, and Python aggregate under `events`; Go and Java emit one event
# object per line (Read-JsonObject collapses NDJSON into an array).
function Get-StreamEvents {
param(
[string]$Client,
[object]$Json
)
switch ($Client) {
"dotnet" { return @($Json.events) }
"go" { return @($Json) }
"rust" { return @($Json.events) }
"python" { return @($Json.events) }
"java" { return @($Json) }
}
}
function Get-PropertyValue {
param(
[object]$Object,
@@ -263,6 +324,71 @@ function Get-PropertyValue {
return $null
}
# Extracts the item handle from a streamed event, tolerating camelCase
# (protobuf-JSON) and snake_case (some MessageToDict shapes).
function Get-EventItemHandle {
param([object]$Event)
$handle = Get-PropertyValue -Object $Event -Names @("itemHandle", "item_handle")
if ($null -eq $handle) {
return $null
}
return [int]$handle
}
# Extracts the event family as a string. All five CLIs render it as the
# protobuf enum name (e.g. MX_EVENT_FAMILY_ON_WRITE_COMPLETE).
function Get-EventFamily {
param([object]$Event)
return [string](Get-PropertyValue -Object $Event -Names @("family"))
}
# Extracts the scalar payload from a streamed event's MxValue as a string.
# The MxValue oneof renders to one protobuf-JSON `*Value` key; all five
# CLIs (after the Rust stream-events extension) emit the same key names.
function Get-EventScalar {
param([object]$Event)
$value = Get-PropertyValue -Object $Event -Names @("value")
if ($null -eq $value) {
return $null
}
foreach ($key in @("boolValue", "int32Value", "int64Value", "floatValue", "doubleValue", "stringValue")) {
$property = $value.PSObject.Properties[$key]
if ($null -ne $property -and $null -ne $property.Value) {
return [string]$property.Value
}
}
return $null
}
# Compares a written value against an observed event scalar. Numeric values
# are compared numerically (so 42 matches 42.0); everything else compares as
# a trimmed, case-insensitive string.
function Test-ValueEquals {
param(
[string]$Expected,
[string]$Observed
)
if ([string]::IsNullOrWhiteSpace($Observed)) {
return $false
}
$expectedNumber = 0.0
$observedNumber = 0.0
if ([double]::TryParse($Expected, [ref]$expectedNumber) -and
[double]::TryParse($Observed, [ref]$observedNumber)) {
return $expectedNumber -eq $observedNumber
}
return [string]::Equals($Expected.Trim(), $Observed.Trim(), [StringComparison]::OrdinalIgnoreCase)
}
function Get-BulkResults {
param(
[string]$Client,
@@ -315,12 +441,20 @@ function Get-ClientCommand {
param(
[string]$Client,
[string]$Operation,
[hashtable]$Values
[hashtable]$Values,
# The environment variable the client reads the API key from. Defaults
# to the run-wide -ApiKeyEnv; the auth phase overrides it to drive a
# missing-key or insufficient-scope rejection.
[string]$ApiKeyEnvName = $ApiKeyEnv
)
$httpEndpoint = ConvertTo-HttpEndpoint -Value $Endpoint
$hostEndpoint = ConvertTo-HostEndpoint -Value $Endpoint
$clientName = "mxgw-$Client-e2e"
$streamMaxEvents = if ($Values.ContainsKey("maxEvents")) { [int]$Values.maxEvents } else { $EventLimit }
# Python's stream-events call ends on a wall-clock timeout; give it enough
# headroom to drain a large write-echo budget.
$pythonStreamTimeout = [Math]::Max(15, [int][Math]::Ceiling($streamMaxEvents / 4.0))
switch ($Client) {
"dotnet" {
@@ -328,7 +462,7 @@ function Get-ClientCommand {
"run", "--project", "clients/dotnet/MxGateway.Client.Cli", "--",
$Operation,
"--endpoint", $httpEndpoint,
"--api-key-env", $ApiKeyEnv,
"--api-key-env", $ApiKeyEnvName,
"--timeout", "60s",
"--json"
)
@@ -344,8 +478,10 @@ function Get-ClientCommand {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items)
} elseif ($Operation -eq "unsubscribe-bulk") {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles)
} elseif ($Operation -eq "write") {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
} elseif ($Operation -eq "stream-events") {
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit")
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents")
} elseif ($Operation -eq "close-session") {
$arguments += @("--session-id", $Values.sessionId)
}
@@ -355,7 +491,7 @@ function Get-ClientCommand {
$arguments = @(
"run", "./cmd/mxgw-go", $Operation,
"-endpoint", $hostEndpoint,
"-api-key-env", $ApiKeyEnv,
"-api-key-env", $ApiKeyEnvName,
"-plaintext",
"-json"
)
@@ -371,8 +507,10 @@ function Get-ClientCommand {
$arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-items", $Values.items)
} elseif ($Operation -eq "unsubscribe-bulk") {
$arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handles", $Values.itemHandles)
} elseif ($Operation -eq "write") {
$arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)", "-type", $Values.valueType, "-value", $Values.value)
} elseif ($Operation -eq "stream-events") {
$arguments += @("-session-id", $Values.sessionId, "-limit", "$EventLimit")
$arguments += @("-session-id", $Values.sessionId, "-limit", "$streamMaxEvents")
} elseif ($Operation -eq "close-session") {
$arguments += @("-session-id", $Values.sessionId)
}
@@ -382,7 +520,7 @@ function Get-ClientCommand {
$arguments = @(
"run", "-p", "mxgw-cli", "--", $Operation,
"--endpoint", $httpEndpoint,
"--api-key-env", $ApiKeyEnv,
"--api-key-env", $ApiKeyEnvName,
"--json"
)
if ($Operation -eq "open-session") {
@@ -397,8 +535,11 @@ function Get-ClientCommand {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items)
} elseif ($Operation -eq "unsubscribe-bulk") {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles)
} elseif ($Operation -eq "write") {
# Rust names the type flag --value-type, unlike the other CLIs.
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--value-type", $Values.valueType, "--value", $Values.value)
} elseif ($Operation -eq "stream-events") {
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit")
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents")
} elseif ($Operation -eq "close-session") {
$arguments += @("--session-id", $Values.sessionId)
}
@@ -408,7 +549,7 @@ function Get-ClientCommand {
$arguments = @(
"-m", "mxgateway_cli", $Operation,
"--endpoint", $hostEndpoint,
"--api-key-env", $ApiKeyEnv,
"--api-key-env", $ApiKeyEnvName,
"--plaintext",
"--json"
)
@@ -424,8 +565,10 @@ function Get-ClientCommand {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items)
} elseif ($Operation -eq "unsubscribe-bulk") {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles)
} elseif ($Operation -eq "write") {
$arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
} elseif ($Operation -eq "stream-events") {
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit", "--timeout", "15")
$arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout")
} elseif ($Operation -eq "close-session") {
$arguments += @("--session-id", $Values.sessionId)
}
@@ -438,7 +581,7 @@ function Get-ClientCommand {
$cliArgs = @(
$Operation,
"--endpoint", $hostEndpoint,
"--api-key-env", $ApiKeyEnv,
"--api-key-env", $ApiKeyEnvName,
"--plaintext",
"--json"
)
@@ -454,54 +597,529 @@ function Get-ClientCommand {
$cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--items", $Values.items)
} elseif ($Operation -eq "unsubscribe-bulk") {
$cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handles", $Values.itemHandles)
} elseif ($Operation -eq "write") {
$cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value)
} elseif ($Operation -eq "stream-events") {
$cliArgs += @("--session-id", $Values.sessionId, "--limit", "$EventLimit")
$cliArgs += @("--session-id", $Values.sessionId, "--limit", "$streamMaxEvents")
} elseif ($Operation -eq "close-session") {
$cliArgs += @("--session-id", $Values.sessionId)
}
$arguments = @("--quiet", ":mxgateway-cli:run", "--args=$($cliArgs -join ' ')")
return [pscustomobject]@{ file = "gradle"; args = $arguments; cwd = (Join-Path $repoRoot "clients/java"); env = @{} }
# Gradle ships as gradle.bat on Windows; .NET's Process.Start
# (UseShellExecute=false) cannot launch a batch file directly, so
# resolve the launcher and run it through cmd.exe.
$gradleCommand = Get-Command "gradle.bat", "gradle.cmd", "gradle.exe", "gradle" `
-ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $gradleCommand) {
throw "The 'gradle' command was not found on PATH; the Java client e2e flow requires Gradle."
}
return [pscustomobject]@{
file = "cmd.exe"
args = @("/c", $gradleCommand.Source) + $arguments
cwd = (Join-Path $repoRoot "clients/java")
env = @{}
}
}
}
}
# Synthesizes a dry-run JSON reply for an operation so the flow can be
# validated without a live gateway.
function Get-DryRunReply {
param(
[string]$Client,
[string]$Operation,
[hashtable]$Values
)
switch ($Operation) {
"open-session" { return [pscustomobject]@{ sessionId = "dry-run-session-$Client"; reply = [pscustomobject]@{ sessionId = "dry-run-session-$Client" } } }
"register" { return [pscustomobject]@{ serverHandle = 1; register = [pscustomobject]@{ serverHandle = 1 }; reply = [pscustomobject]@{ register = [pscustomobject]@{ serverHandle = 1 } } } }
"add-item" { return [pscustomobject]@{ itemHandle = 1; addItem = [pscustomobject]@{ itemHandle = 1 }; reply = [pscustomobject]@{ addItem = [pscustomobject]@{ itemHandle = 1 } } } }
"write" { return [pscustomobject]@{ ok = $true; operation = "write"; reply = [pscustomobject]@{} } }
"subscribe-bulk" {
$results = @($Values.items -split "," | ForEach-Object -Begin { $index = 1 } -Process {
[pscustomobject]@{ itemHandle = $index++; tagAddress = $_; wasSuccessful = $true }
})
return [pscustomobject]@{ subscribeBulk = [pscustomobject]@{ results = $results }; results = $results }
}
"unsubscribe-bulk" {
$results = @($Values.itemHandles -split "," | ForEach-Object {
[pscustomobject]@{ itemHandle = [int]$_; wasSuccessful = $true }
})
return [pscustomobject]@{ unsubscribeBulk = [pscustomobject]@{ results = $results }; results = $results }
}
"stream-events" {
# Synthesize an OnDataChange (carrying the written value) and an
# OnWriteComplete so the write round-trip assertion passes under
# -DryRun. The reply is shaped per client: Go and Java emit one
# event object per line (Read-JsonObject collapses NDJSON to a
# bare array), the others aggregate the events under `events`.
$itemHandle = if ($Values.ContainsKey("echoItemHandle")) { [int]$Values.echoItemHandle } else { 1 }
$echoValue = if ($Values.ContainsKey("echoValue")) { $Values.echoValue } else { 1 }
$dataEvent = [pscustomobject]@{
workerSequence = 1
family = "MX_EVENT_FAMILY_ON_DATA_CHANGE"
itemHandle = $itemHandle
value = [pscustomobject]@{ int32Value = $echoValue }
}
$writeCompleteEvent = [pscustomobject]@{
workerSequence = 2
family = "MX_EVENT_FAMILY_ON_WRITE_COMPLETE"
itemHandle = $itemHandle
}
$events = @($dataEvent, $writeCompleteEvent)
switch ($Client) {
"go" { return ,$events }
"java" { return ,$events }
"rust" { return [pscustomobject]@{ eventCount = $events.Count; events = $events } }
default { return [pscustomobject]@{ events = $events } }
}
}
default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } }
}
}
function Invoke-ClientOperation {
param(
[string]$Client,
[string]$Operation,
[hashtable]$Values = @{}
[hashtable]$Values = @{},
[string]$ApiKeyEnvName = $ApiKeyEnv
)
$command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values
$command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values -ApiKeyEnvName $ApiKeyEnvName
$result = Invoke-NativeCommand `
-FilePath $command.file `
-Arguments $command.args `
-WorkingDirectory $command.cwd `
-Environment $command.env
if ($DryRun) {
switch ($Operation) {
"open-session" { return [pscustomobject]@{ sessionId = "dry-run-session-$Client"; reply = [pscustomobject]@{ sessionId = "dry-run-session-$Client" } } }
"register" { return [pscustomobject]@{ serverHandle = 1; register = [pscustomobject]@{ serverHandle = 1 }; reply = [pscustomobject]@{ register = [pscustomobject]@{ serverHandle = 1 } } } }
"add-item" { return [pscustomobject]@{ itemHandle = 1; addItem = [pscustomobject]@{ itemHandle = 1 }; reply = [pscustomobject]@{ addItem = [pscustomobject]@{ itemHandle = 1 } } } }
"subscribe-bulk" {
$results = @($Values.items -split "," | ForEach-Object -Begin { $index = 1 } -Process {
[pscustomobject]@{ itemHandle = $index++; tagAddress = $_; wasSuccessful = $true }
})
return [pscustomobject]@{ subscribeBulk = [pscustomobject]@{ results = $results }; results = $results }
}
"unsubscribe-bulk" {
$results = @($Values.itemHandles -split "," | ForEach-Object {
[pscustomobject]@{ itemHandle = [int]$_; wasSuccessful = $true }
})
return [pscustomobject]@{ unsubscribeBulk = [pscustomobject]@{ results = $results }; results = $results }
}
"stream-events" { return [pscustomobject]@{ eventCount = 1; events = @([pscustomobject]@{ workerSequence = 1 }) } }
default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } }
}
return Get-DryRunReply -Client $Client -Operation $Operation -Values $Values
}
return Read-JsonObject -Text $result.stdout
}
# Runs a client operation that is expected to fail, returning the raw process
# result (exit code + stderr) without throwing. Under -DryRun a synthetic
# failure is returned so the parity and auth phases can be exercised offline.
function Invoke-ClientOperationExpectingFailure {
param(
[string]$Client,
[string]$Operation,
[hashtable]$Values = @{},
[string]$ApiKeyEnvName = $ApiKeyEnv
)
if ($DryRun) {
$command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values -ApiKeyEnvName $ApiKeyEnvName
Write-Host "[dry-run] $(Join-CommandLine -FilePath $command.file -Arguments $command.args)"
return [pscustomobject]@{ exitCode = 1; stdout = ""; stderr = "[dry-run] synthetic expected failure" }
}
$command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values -ApiKeyEnvName $ApiKeyEnvName
return Invoke-NativeCommand `
-FilePath $command.file `
-Arguments $command.args `
-WorkingDirectory $command.cwd `
-Environment $command.env `
-AllowFailure
}
# Runs the full e2e flow for a single language client and returns the result
# record. Discovered tags are passed in so the (slow) SQL discovery runs once.
function Invoke-ClientFlow {
param(
[string]$Client,
[object[]]$Tags
)
Write-Host "Running $Client client e2e flow against $($Tags.Count) discovered tags."
$sessionId = $null
$serverHandle = $null
$clientResult = [ordered]@{
language = $Client
sessionId = $null
serverHandle = $null
bulk = $null
addedItems = @()
eventCount = 0
write = $null
parity = @()
auth = @()
closed = $false
error = $null
}
try {
$openJson = Invoke-ClientOperation -Client $Client -Operation "open-session"
$sessionId = Get-OpenSessionId -Client $Client -Json $openJson
if ([string]::IsNullOrWhiteSpace($sessionId)) {
throw "The $Client open-session command did not return a session id."
}
$clientResult.sessionId = $sessionId
$registerJson = Invoke-ClientOperation -Client $Client -Operation "register" -Values @{
sessionId = $sessionId
}
$serverHandle = Get-ServerHandle -Client $Client -Json $registerJson
$clientResult.serverHandle = $serverHandle
# --- Write round-trip + value assertion ---------------------------
# Runs right after register, before the bulk and add-item phases, so
# only a small backlog of events precedes the write. The gateway
# replays the per-session event buffer from the start, so the
# post-write OnWriteComplete must be reachable within the bounded
# -WriteEchoMaxEvents window.
if ($VerifyWrite) {
$writeTag = @($Tags | Where-Object {
$_.attributeName -eq $WriteAttribute
}) | Select-Object -First 1
if ($null -eq $writeTag) {
Write-Warning "$Client write phase skipped: no discovered tag has attribute '$WriteAttribute'."
} else {
$writeAddJson = Invoke-ClientOperation -Client $Client -Operation "add-item" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
item = $writeTag.fullTagReference
}
$writeItemHandle = Get-ItemHandle -Client $Client -Json $writeAddJson
Invoke-ClientOperation -Client $Client -Operation "advise" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $writeItemHandle
} | Out-Null
$sentinelValue = "$($WriteValueBase + $script:clientFlowIndex)"
Invoke-ClientOperation -Client $Client -Operation "write" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $writeItemHandle
valueType = $WriteType
value = $sentinelValue
} | Out-Null
$writeStreamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values @{
sessionId = $sessionId
maxEvents = $WriteEchoMaxEvents
echoItemHandle = $writeItemHandle
echoValue = $sentinelValue
}
$writeEvents = @(Get-StreamEvents -Client $Client -Json $writeStreamJson)
$writeItemEvents = @($writeEvents | Where-Object {
(Get-EventItemHandle -Event $_) -eq $writeItemHandle
})
# The reliable write round-trip signal: MXAccess fires
# OnWriteComplete once the write reaches the provider. The
# value echo is best-effort — a provider-driven attribute
# (e.g. a simulated counter) accepts the write but does not
# hold the value, so no OnDataChange carries it back.
$writeCompleteEvent = $writeItemEvents | Where-Object {
(Get-EventFamily -Event $_) -match "WRITE_COMPLETE"
} | Select-Object -First 1
$echoEvent = $writeItemEvents | Where-Object {
Test-ValueEquals -Expected $sentinelValue -Observed (Get-EventScalar -Event $_)
} | Select-Object -First 1
if ($null -eq $writeCompleteEvent) {
throw ("$Client write round-trip failed: wrote $WriteType=$sentinelValue to " +
"'$($writeTag.fullTagReference)' (item handle $writeItemHandle) but no " +
"OnWriteComplete event was observed in $($writeEvents.Count) streamed event(s). " +
"Increase -WriteEchoMaxEvents, or drop -VerifyWrite.")
}
$clientResult.write = [ordered]@{
attributeName = $WriteAttribute
fullTagReference = $writeTag.fullTagReference
itemHandle = $writeItemHandle
valueType = $WriteType
value = $sentinelValue
writeCompleteObserved = $true
echoObserved = ($null -ne $echoEvent)
}
}
}
if (-not $SkipBulk) {
$bulkTags = @($Tags | Select-Object -First ([Math]::Min($BulkTagCount, $Tags.Count)))
$bulkItems = ($bulkTags | ForEach-Object { $_.fullTagReference }) -join ","
$subscribeBulkJson = Invoke-ClientOperation -Client $Client -Operation "subscribe-bulk" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
items = $bulkItems
}
$subscribeResults = @(Get-BulkResults -Client $Client -Operation "subscribe-bulk" -Json $subscribeBulkJson)
Assert-BulkResults -Client $Client -Operation "subscribe-bulk" -Results $subscribeResults -ExpectedCount $bulkTags.Count
$bulkItemHandles = @(Get-BulkItemHandles -Results $subscribeResults)
if ($bulkItemHandles.Count -ne $bulkTags.Count) {
throw "$Client subscribe-bulk returned $($bulkItemHandles.Count) usable item handle(s); expected $($bulkTags.Count)."
}
$unsubscribeBulkJson = Invoke-ClientOperation -Client $Client -Operation "unsubscribe-bulk" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandles = $bulkItemHandles -join ","
}
$unsubscribeResults = @(Get-BulkResults -Client $Client -Operation "unsubscribe-bulk" -Json $unsubscribeBulkJson)
Assert-BulkResults -Client $Client -Operation "unsubscribe-bulk" -Results $unsubscribeResults -ExpectedCount $bulkItemHandles.Count
$clientResult.bulk = [ordered]@{
tagCount = $bulkTags.Count
subscribedCount = $subscribeResults.Count
unsubscribedCount = $unsubscribeResults.Count
itemHandles = $bulkItemHandles
}
}
foreach ($tag in $Tags) {
$addJson = Invoke-ClientOperation -Client $Client -Operation "add-item" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
item = $tag.fullTagReference
}
$itemHandle = Get-ItemHandle -Client $Client -Json $addJson
Invoke-ClientOperation -Client $Client -Operation "advise" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $itemHandle
} | Out-Null
$clientResult.addedItems += [ordered]@{
tagName = $tag.tagName
attributeName = $tag.attributeName
fullTagReference = $tag.fullTagReference
itemHandle = $itemHandle
protectedWriteRequired = $tag.attributeName -eq "ProtectedValue"
}
}
# --- Event streaming ----------------------------------------------
if (-not $SkipStream) {
$streamJson = Invoke-ClientOperation -Client $Client -Operation "stream-events" -Values @{
sessionId = $sessionId
}
$clientResult.eventCount = Get-StreamEventCount -Client $Client -Json $streamJson
if ($clientResult.eventCount -lt 1) {
throw "The $Client stream-events command returned no events."
}
}
# --- Error-path (parity) checks -----------------------------------
# MXAccess parity: an invalid item handle and an unknown session must
# both be rejected rather than silently succeeding.
if (-not $SkipParity) {
$parityChecks = @(
[ordered]@{
check = "invalid-item-handle"
operation = "advise"
values = @{ sessionId = $sessionId; serverHandle = $serverHandle; itemHandle = 2147483647 }
},
[ordered]@{
check = "unknown-session"
operation = "register"
values = @{ sessionId = [guid]::NewGuid().ToString() }
}
)
foreach ($parityCheck in $parityChecks) {
$parityResult = Invoke-ClientOperationExpectingFailure `
-Client $Client -Operation $parityCheck.operation -Values $parityCheck.values
$passed = $parityResult.exitCode -ne 0
$clientResult.parity += [ordered]@{
check = $parityCheck.check
operation = $parityCheck.operation
exitCode = $parityResult.exitCode
passed = $passed
}
if (-not $passed) {
throw "$Client parity check '$($parityCheck.check)' expected $($parityCheck.operation) to fail but it exited 0."
}
}
}
# --- API-key auth rejection ---------------------------------------
# Runs after a working session is established, so a non-zero exit is
# an auth rejection rather than the gateway being unreachable.
if (-not $SkipAuth) {
$authChecks = @(
[ordered]@{ check = "missing-api-key"; apiKeyEnv = $script:missingKeyEnvName }
)
if (-not [string]::IsNullOrWhiteSpace($RejectScopeApiKeyEnv)) {
$authChecks += [ordered]@{ check = "insufficient-scope"; apiKeyEnv = $RejectScopeApiKeyEnv }
}
foreach ($authCheck in $authChecks) {
$authResult = Invoke-ClientOperationExpectingFailure `
-Client $Client -Operation "open-session" -ApiKeyEnvName $authCheck.apiKeyEnv
$passed = $authResult.exitCode -ne 0
$clientResult.auth += [ordered]@{
check = $authCheck.check
exitCode = $authResult.exitCode
passed = $passed
}
if (-not $passed) {
throw "$Client auth check '$($authCheck.check)' expected open-session to be rejected but it exited 0."
}
}
}
} catch {
$clientResult.error = $_.Exception.Message
Write-Warning "$Client e2e flow failed: $($clientResult.error)"
} finally {
if (-not [string]::IsNullOrWhiteSpace($sessionId)) {
try {
Invoke-ClientOperation -Client $Client -Operation "close-session" -Values @{
sessionId = $sessionId
} | Out-Null
$clientResult.closed = $true
} catch {
$clientResult.error = "$($clientResult.error) close-session failed: $($_.Exception.Message)"
}
}
}
return $clientResult
}
# Forwards every run parameter to a single-client child invocation used by
# -Parallel. -Parallel itself is intentionally omitted so the child runs the
# serial path.
function Get-ChildArgumentList {
param(
[string]$Client,
[string]$ChildReportPath
)
$childArgs = @(
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath,
"-Clients", $Client,
"-MachineStart", "$MachineStart",
"-MachineEnd", "$MachineEnd",
"-Attributes", ($Attributes -join ","),
"-Endpoint", $Endpoint,
"-ApiKeyEnv", $ApiKeyEnv,
"-SqlServer", $SqlServer,
"-Database", $Database,
"-EventLimit", "$EventLimit",
"-BulkTagCount", "$BulkTagCount",
"-WriteAttribute", $WriteAttribute,
"-WriteType", $WriteType,
"-WriteValueBase", "$WriteValueBase",
"-WriteEchoMaxEvents", "$WriteEchoMaxEvents",
"-ReportPath", $ChildReportPath,
"-EmitReport"
)
if (-not [string]::IsNullOrWhiteSpace($RejectScopeApiKeyEnv)) {
$childArgs += @("-RejectScopeApiKeyEnv", $RejectScopeApiKeyEnv)
}
if ($SkipStream) { $childArgs += "-SkipStream" }
if ($SkipBulk) { $childArgs += "-SkipBulk" }
if ($VerifyWrite) { $childArgs += "-VerifyWrite" }
if ($SkipParity) { $childArgs += "-SkipParity" }
if ($SkipAuth) { $childArgs += "-SkipAuth" }
if ($DryRun) { $childArgs += "-DryRun" }
return $childArgs
}
# An env var name that is guaranteed not to be set in this process, used to
# drive the missing-API-key auth rejection.
$script:missingKeyEnvName = "MXGW_E2E_MISSING_KEY_" + ([guid]::NewGuid().ToString("N"))
# --- Parallel mode: fan out one isolated child process per client ----------
if ($Parallel -and $Clients.Count -gt 1) {
Write-Host "Running $($Clients.Count) client e2e flows in parallel."
$childRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("mxgw-e2e-" + ([guid]::NewGuid().ToString("N")))
New-Item -ItemType Directory -Path $childRoot -Force | Out-Null
$children = @()
foreach ($client in $Clients) {
$childReport = Join-Path $childRoot "$client.json"
$childLog = Join-Path $childRoot "$client.log"
$childArgs = Get-ChildArgumentList -Client $client -ChildReportPath $childReport
$process = Start-Process -FilePath "pwsh" -ArgumentList $childArgs `
-RedirectStandardOutput $childLog -RedirectStandardError "$childLog.err" `
-NoNewWindow -PassThru
$children += [pscustomobject]@{
client = $client
process = $process
report = $childReport
log = $childLog
}
}
foreach ($child in $children) {
$child.process.WaitForExit()
}
$mergedClients = @()
$discoveredTags = @()
$hadFailure = $false
foreach ($child in $children) {
foreach ($logPath in @($child.log, "$($child.log).err")) {
if ((Test-Path $logPath) -and -not [string]::IsNullOrWhiteSpace((Get-Content -Raw -Path $logPath))) {
Write-Host "----- $($child.client) -----"
Get-Content -Path $logPath | ForEach-Object { Write-Host $_ }
}
}
if ($child.process.ExitCode -ne 0) {
$hadFailure = $true
}
if (Test-Path $child.report) {
$childRun = Get-Content -Raw -Path $child.report | ConvertFrom-Json
$mergedClients += @($childRun.clients)
if ($discoveredTags.Count -eq 0) {
$discoveredTags = @($childRun.discoveredTags)
}
} else {
$hadFailure = $true
$mergedClients += [pscustomobject]@{
language = $child.client
error = "Child process exited $($child.process.ExitCode) without writing a report."
}
}
}
$run = [ordered]@{
schemaVersion = 1
endpoint = $Endpoint
apiKeyEnv = $ApiKeyEnv
machineStart = $MachineStart
machineEnd = $MachineEnd
attributes = $Attributes
eventLimit = $EventLimit
bulkTagCount = $BulkTagCount
skipStream = [bool]$SkipStream
skipBulk = [bool]$SkipBulk
verifyWrite = [bool]$VerifyWrite
skipParity = [bool]$SkipParity
skipAuth = [bool]$SkipAuth
writeAttribute = $WriteAttribute
parallel = $true
discoveredTags = $discoveredTags
clients = $mergedClients
completedAt = (Get-Date).ToUniversalTime().ToString("O")
success = -not $hadFailure
}
$reportDirectory = Split-Path -Parent $ReportPath
if (-not [string]::IsNullOrWhiteSpace($reportDirectory)) {
New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null
}
$run | ConvertTo-Json -Depth 8 | Set-Content -Path $ReportPath -Encoding UTF8
Write-Host "Wrote merged e2e report to $ReportPath"
Remove-Item -Path $childRoot -Recurse -Force -ErrorAction SilentlyContinue
if ($hadFailure) {
exit 1
}
return
}
# --- Serial mode -----------------------------------------------------------
$discoveryJson = & $discoveryScript `
-MachineStart $MachineStart `
-MachineEnd $MachineEnd `
@@ -534,129 +1152,32 @@ $run = [ordered]@{
bulkTagCount = $BulkTagCount
skipStream = [bool]$SkipStream
skipBulk = [bool]$SkipBulk
verifyWrite = [bool]$VerifyWrite
skipParity = [bool]$SkipParity
skipAuth = [bool]$SkipAuth
writeAttribute = $WriteAttribute
parallel = $false
startedAt = (Get-Date).ToUniversalTime().ToString("O")
discoveredTags = $tags
clients = @()
}
$hadFailure = $false
$script:clientFlowIndex = 0
foreach ($client in $Clients) {
Write-Host "Running $client client e2e flow against $($tags.Count) discovered tags."
$sessionId = $null
$serverHandle = $null
$clientResult = [ordered]@{
language = $client
sessionId = $null
serverHandle = $null
bulk = $null
addedItems = @()
eventCount = 0
closed = $false
error = $null
}
try {
$openJson = Invoke-ClientOperation -Client $client -Operation "open-session"
$sessionId = Get-OpenSessionId -Client $client -Json $openJson
if ([string]::IsNullOrWhiteSpace($sessionId)) {
throw "The $client open-session command did not return a session id."
}
$clientResult.sessionId = $sessionId
$registerJson = Invoke-ClientOperation -Client $client -Operation "register" -Values @{
sessionId = $sessionId
}
$serverHandle = Get-ServerHandle -Client $client -Json $registerJson
$clientResult.serverHandle = $serverHandle
if (-not $SkipBulk) {
$bulkTags = @($tags | Select-Object -First ([Math]::Min($BulkTagCount, $tags.Count)))
$bulkItems = ($bulkTags | ForEach-Object { $_.fullTagReference }) -join ","
$subscribeBulkJson = Invoke-ClientOperation -Client $client -Operation "subscribe-bulk" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
items = $bulkItems
}
$subscribeResults = @(Get-BulkResults -Client $client -Operation "subscribe-bulk" -Json $subscribeBulkJson)
Assert-BulkResults -Client $client -Operation "subscribe-bulk" -Results $subscribeResults -ExpectedCount $bulkTags.Count
$bulkItemHandles = @(Get-BulkItemHandles -Results $subscribeResults)
if ($bulkItemHandles.Count -ne $bulkTags.Count) {
throw "$client subscribe-bulk returned $($bulkItemHandles.Count) usable item handle(s); expected $($bulkTags.Count)."
}
$unsubscribeBulkJson = Invoke-ClientOperation -Client $client -Operation "unsubscribe-bulk" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandles = $bulkItemHandles -join ","
}
$unsubscribeResults = @(Get-BulkResults -Client $client -Operation "unsubscribe-bulk" -Json $unsubscribeBulkJson)
Assert-BulkResults -Client $client -Operation "unsubscribe-bulk" -Results $unsubscribeResults -ExpectedCount $bulkItemHandles.Count
$clientResult.bulk = [ordered]@{
tagCount = $bulkTags.Count
subscribedCount = $subscribeResults.Count
unsubscribedCount = $unsubscribeResults.Count
itemHandles = $bulkItemHandles
}
}
foreach ($tag in $tags) {
$addJson = Invoke-ClientOperation -Client $client -Operation "add-item" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
item = $tag.fullTagReference
}
$itemHandle = Get-ItemHandle -Client $client -Json $addJson
Invoke-ClientOperation -Client $client -Operation "advise" -Values @{
sessionId = $sessionId
serverHandle = $serverHandle
itemHandle = $itemHandle
} | Out-Null
$clientResult.addedItems += [ordered]@{
tagName = $tag.tagName
attributeName = $tag.attributeName
fullTagReference = $tag.fullTagReference
itemHandle = $itemHandle
protectedWriteRequired = $tag.attributeName -eq "ProtectedValue"
}
}
if (-not $SkipStream) {
$streamJson = Invoke-ClientOperation -Client $client -Operation "stream-events" -Values @{
sessionId = $sessionId
}
$clientResult.eventCount = Get-StreamEventCount -Client $client -Json $streamJson
if ($clientResult.eventCount -lt 1) {
throw "The $client stream-events command returned no events."
}
}
} catch {
$clientResult = Invoke-ClientFlow -Client $client -Tags $tags
if (-not [string]::IsNullOrWhiteSpace($clientResult.error)) {
$hadFailure = $true
$clientResult.error = $_.Exception.Message
Write-Warning "$client e2e flow failed: $($clientResult.error)"
} finally {
if (-not [string]::IsNullOrWhiteSpace($sessionId)) {
try {
Invoke-ClientOperation -Client $client -Operation "close-session" -Values @{
sessionId = $sessionId
} | Out-Null
$clientResult.closed = $true
} catch {
$hadFailure = $true
$clientResult.error = "$($clientResult.error) close-session failed: $($_.Exception.Message)"
}
}
}
$run.clients += $clientResult
$script:clientFlowIndex++
}
$run.completedAt = (Get-Date).ToUniversalTime().ToString("O")
$run.success = -not $hadFailure
if (-not $DryRun) {
if (-not $DryRun -or $EmitReport) {
$reportDirectory = Split-Path -Parent $ReportPath
if (-not [string]::IsNullOrWhiteSpace($reportDirectory)) {
New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null
@@ -46,6 +46,78 @@ public sealed class VariantConverterTests
Assert.Equal("VT_DATE", converted.VariantType);
}
/// <summary>Verifies that scalar MxValue kinds convert to the matching boxed CLR type for a COM write.</summary>
[Fact]
public void ConvertToComValue_WithInt32_ReturnsBoxedInt()
{
object? result = _converter.ConvertToComValue(new MxValue { Int32Value = 123 });
Assert.Equal(123, Assert.IsType<int>(result));
}
/// <summary>Verifies that a boolean MxValue converts to a boxed bool for a COM write.</summary>
[Fact]
public void ConvertToComValue_WithBool_ReturnsBoxedBool()
{
object? result = _converter.ConvertToComValue(new MxValue { BoolValue = true });
Assert.True(Assert.IsType<bool>(result));
}
/// <summary>Verifies that a string MxValue converts to a string for a COM write.</summary>
[Fact]
public void ConvertToComValue_WithString_ReturnsString()
{
object? result = _converter.ConvertToComValue(new MxValue { StringValue = "abc" });
Assert.Equal("abc", Assert.IsType<string>(result));
}
/// <summary>Verifies that a timestamp MxValue converts to a UTC DateTime the COM marshaler renders as VT_DATE.</summary>
[Fact]
public void ConvertToComValue_WithTimestamp_ReturnsUtcDateTime()
{
DateTime dateTime = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
object? result = _converter.ConvertToComValue(
new MxValue { TimestampValue = ProtobufTimestamp.FromDateTime(dateTime) });
Assert.Equal(dateTime, Assert.IsType<DateTime>(result));
}
/// <summary>Verifies that an MXAccess-null MxValue converts to a CLR null.</summary>
[Fact]
public void ConvertToComValue_WithNull_ReturnsNull()
{
object? result = _converter.ConvertToComValue(new MxValue { IsNull = true });
Assert.Null(result);
}
/// <summary>Verifies that an integer-array MxValue converts to an int array the COM marshaler renders as a SAFEARRAY.</summary>
[Fact]
public void ConvertToComValue_WithInt32Array_ReturnsInt32Array()
{
MxValue value = new()
{
ArrayValue = new MxArray
{
Int32Values = new Int32Array { Values = { 1, 2, 3 } },
},
};
object? result = _converter.ConvertToComValue(value);
Assert.Equal(new[] { 1, 2, 3 }, Assert.IsType<int[]>(result));
}
/// <summary>Verifies that an MxValue with no value kind set cannot be converted for a COM write.</summary>
[Fact]
public void ConvertToComValue_WithNoKind_Throws()
{
Assert.Throws<ArgumentException>(() => _converter.ConvertToComValue(new MxValue()));
}
/// <summary>Verifies that file time values with expected time data type are converted to protobuf timestamps.</summary>
[Fact]
public void Convert_WithFileTimeAndExpectedTime_ProjectsTimestamp()
@@ -379,10 +379,10 @@ public sealed class AlarmCommandExecutorTests
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) { }
public void Suspend(int serverHandle, int itemHandle) { }
public void Activate(int serverHandle, int itemHandle) { }
public void Write(int serverHandle, int itemHandle, object value, int userId) { }
public void Write2(int serverHandle, int itemHandle, object value, object timestampValue, int userId) { }
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value) { }
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, object timestampValue) { }
public void Write(int serverHandle, int itemHandle, object? value, int userId) { }
public void Write2(int serverHandle, int itemHandle, object? value, object? timestampValue, int userId) { }
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) { }
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestampValue) { }
public int AuthenticateUser(string userName, string password) => 0;
public int ArchestrAUserToId(string userName) => 0;
}
@@ -134,5 +134,26 @@ public sealed class MxAccessComServerTests
{
calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
}
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}");
}
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}");
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}");
}
public void WriteSecured2(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
calls.Add($"WriteSecured2:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}:{timestamp}");
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
using MxGateway.Worker.Sta;
@@ -617,6 +618,143 @@ public sealed class MxAccessCommandExecutorTests
Assert.Null(factory.FakeComObject.AdviseServerHandle);
}
/// <summary>Verifies that Write dispatches the converted value to MXAccess on the STA thread.</summary>
[Fact]
public async Task DispatchAsync_Write_CallsMxAccessOnStaWithConvertedValue()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 70);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
"write", serverHandle: 70, itemHandle: 700, value: 123, userId: 5));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.Write, reply.Kind);
Assert.Equal(70, fakeComObject.WriteServerHandle);
Assert.Equal(700, fakeComObject.WriteItemHandle);
Assert.Equal(123, fakeComObject.WriteValue);
Assert.Equal(5, fakeComObject.WriteUserId);
Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId);
}
/// <summary>Verifies that Write2 forwards the converted value and timestamp to MXAccess.</summary>
[Fact]
public async Task DispatchAsync_Write2_ForwardsValueAndTimestamp()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 71);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command(
"write2", serverHandle: 71, itemHandle: 710, value: 456, timestamp: timestamp, userId: 6));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.Write2, reply.Kind);
Assert.Equal(710, fakeComObject.WriteItemHandle);
Assert.Equal(456, fakeComObject.WriteValue);
Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
Assert.Equal(6, fakeComObject.WriteUserId);
}
/// <summary>Verifies that WriteSecured forwards the operator and verifier user ids to MXAccess.</summary>
[Fact]
public async Task DispatchAsync_WriteSecured_ForwardsUserIds()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 72);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
"write-secured", serverHandle: 72, itemHandle: 720, value: 789, currentUserId: 11, verifierUserId: 22));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.WriteSecured, reply.Kind);
Assert.Equal(720, fakeComObject.WriteItemHandle);
Assert.Equal(789, fakeComObject.WriteValue);
Assert.Equal(11, fakeComObject.WriteCurrentUserId);
Assert.Equal(22, fakeComObject.WriteVerifierUserId);
}
/// <summary>Verifies that WriteSecured2 forwards user ids, value, and timestamp to MXAccess.</summary>
[Fact]
public async Task DispatchAsync_WriteSecured2_ForwardsUserIdsValueAndTimestamp()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 73);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command(
"write-secured2", serverHandle: 73, itemHandle: 730, value: 1011,
timestamp: timestamp, currentUserId: 33, verifierUserId: 44));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.WriteSecured2, reply.Kind);
Assert.Equal(1011, fakeComObject.WriteValue);
Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
Assert.Equal(33, fakeComObject.WriteCurrentUserId);
Assert.Equal(44, fakeComObject.WriteVerifierUserId);
}
/// <summary>Verifies that Write without a payload returns an invalid request error.</summary>
[Fact]
public async Task DispatchAsync_WriteWithoutPayload_ReturnsInvalidRequest()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 74);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
"session-1",
"missing-write-payload",
new MxCommand
{
Kind = MxCommandKind.Write,
}));
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
Assert.Null(fakeComObject.WriteServerHandle);
}
/// <summary>Verifies that Write without a value returns an invalid request error.</summary>
[Fact]
public async Task DispatchAsync_WriteWithoutValue_ReturnsInvalidRequest()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 75);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
"session-1",
"missing-write-value",
new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = 75,
ItemHandle = 750,
},
}));
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
Assert.Null(fakeComObject.WriteServerHandle);
}
private static StaCommand CreateRegisterCommand(
string correlationId,
string clientName)
@@ -729,6 +867,126 @@ public sealed class MxAccessCommandExecutorTests
});
}
private static MxValue CreateIntegerValue(int value)
{
return new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = value,
};
}
private static MxValue CreateTimestampValue(DateTime timestamp)
{
return new MxValue
{
DataType = MxDataType.Time,
VariantType = "VT_DATE",
TimestampValue = Timestamp.FromDateTime(timestamp),
};
}
private static StaCommand CreateWriteCommand(
string correlationId,
int serverHandle,
int itemHandle,
int value,
int userId)
{
return new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = CreateIntegerValue(value),
UserId = userId,
},
});
}
private static StaCommand CreateWrite2Command(
string correlationId,
int serverHandle,
int itemHandle,
int value,
DateTime timestamp,
int userId)
{
return new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.Write2,
Write2 = new Write2Command
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = CreateIntegerValue(value),
TimestampValue = CreateTimestampValue(timestamp),
UserId = userId,
},
});
}
private static StaCommand CreateWriteSecuredCommand(
string correlationId,
int serverHandle,
int itemHandle,
int value,
int currentUserId,
int verifierUserId)
{
return new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = currentUserId,
VerifierUserId = verifierUserId,
Value = CreateIntegerValue(value),
},
});
}
private static StaCommand CreateWriteSecured2Command(
string correlationId,
int serverHandle,
int itemHandle,
int value,
DateTime timestamp,
int currentUserId,
int verifierUserId)
{
return new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.WriteSecured2,
WriteSecured2 = new WriteSecured2Command
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = currentUserId,
VerifierUserId = verifierUserId,
Value = CreateIntegerValue(value),
TimestampValue = CreateTimestampValue(timestamp),
},
});
}
private static StaCommand CreateUnAdviseCommand(
string correlationId,
int serverHandle,
@@ -1080,6 +1338,118 @@ public sealed class MxAccessCommandExecutorTests
throw adviseSupervisoryException;
}
}
/// <summary>Gets the server handle passed to the most recent write, if called.</summary>
public int? WriteServerHandle { get; private set; }
/// <summary>Gets the item handle passed to the most recent write, if called.</summary>
public int? WriteItemHandle { get; private set; }
/// <summary>Gets the value passed to the most recent write, if called.</summary>
public object? WriteValue { get; private set; }
/// <summary>Gets the timestamp passed to the most recent timestamped write, if called.</summary>
public object? WriteTimestamp { get; private set; }
/// <summary>Gets the user id passed to the most recent Write/Write2, if called.</summary>
public int? WriteUserId { get; private set; }
/// <summary>Gets the current user id passed to the most recent secured write, if called.</summary>
public int? WriteCurrentUserId { get; private set; }
/// <summary>Gets the verifier user id passed to the most recent secured write, if called.</summary>
public int? WriteVerifierUserId { get; private set; }
/// <summary>Gets the thread ID on which the most recent write was called.</summary>
public int? WriteThreadId { get; private set; }
/// <summary>Writes a value to an item and tracks the operation.</summary>
/// <param name="serverHandle">Server handle for the write.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="value">Value to write.</param>
/// <param name="userId">MXAccess user id for the write.</param>
public void Write(
int serverHandle,
int itemHandle,
object? value,
int userId)
{
operationNames.Add($"Write:{serverHandle}:{itemHandle}");
WriteServerHandle = serverHandle;
WriteItemHandle = itemHandle;
WriteValue = value;
WriteUserId = userId;
WriteThreadId = Environment.CurrentManagedThreadId;
}
/// <summary>Writes a timestamped value to an item and tracks the operation.</summary>
/// <param name="serverHandle">Server handle for the write.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="value">Value to write.</param>
/// <param name="timestamp">Source timestamp for the write.</param>
/// <param name="userId">MXAccess user id for the write.</param>
public void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId)
{
operationNames.Add($"Write2:{serverHandle}:{itemHandle}");
WriteServerHandle = serverHandle;
WriteItemHandle = itemHandle;
WriteValue = value;
WriteTimestamp = timestamp;
WriteUserId = userId;
WriteThreadId = Environment.CurrentManagedThreadId;
}
/// <summary>Performs a secured write to an item and tracks the operation.</summary>
/// <param name="serverHandle">Server handle for the write.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="currentUserId">Operator user id.</param>
/// <param name="verifierUserId">Verifier user id.</param>
/// <param name="value">Value to write.</param>
public void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value)
{
operationNames.Add($"WriteSecured:{serverHandle}:{itemHandle}");
WriteServerHandle = serverHandle;
WriteItemHandle = itemHandle;
WriteCurrentUserId = currentUserId;
WriteVerifierUserId = verifierUserId;
WriteValue = value;
WriteThreadId = Environment.CurrentManagedThreadId;
}
/// <summary>Performs a secured timestamped write to an item and tracks the operation.</summary>
/// <param name="serverHandle">Server handle for the write.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="currentUserId">Operator user id.</param>
/// <param name="verifierUserId">Verifier user id.</param>
/// <param name="value">Value to write.</param>
/// <param name="timestamp">Source timestamp for the write.</param>
public void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp)
{
operationNames.Add($"WriteSecured2:{serverHandle}:{itemHandle}");
WriteServerHandle = serverHandle;
WriteItemHandle = itemHandle;
WriteCurrentUserId = currentUserId;
WriteVerifierUserId = verifierUserId;
WriteValue = value;
WriteTimestamp = timestamp;
WriteThreadId = Environment.CurrentManagedThreadId;
}
}
/// <summary>Factory for creating fake MXAccess COM objects in tests.</summary>
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
using System.Linq;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
@@ -118,6 +119,63 @@ public sealed class VariantConverter
}
}
/// <summary>
/// Converts an <see cref="MxValue"/> into a CLR object suitable for an
/// MXAccess COM write. The COM marshaler boxes the returned value into the
/// matching VARIANT, so this is the inverse of <see cref="Convert(object?)"/>.
/// </summary>
/// <param name="value">Protobuf value to convert.</param>
/// <returns>A COM-marshalable value, or <see langword="null"/> for an MXAccess null.</returns>
public object? ConvertToComValue(MxValue value)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}
if (value.IsNull)
{
return null;
}
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => value.BoolValue,
MxValue.KindOneofCase.Int32Value => value.Int32Value,
MxValue.KindOneofCase.Int64Value => value.Int64Value,
MxValue.KindOneofCase.FloatValue => value.FloatValue,
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
MxValue.KindOneofCase.StringValue => value.StringValue,
// The COM marshaler renders a DateTime as VT_DATE; MXAccess accepts
// it as the timestamped-write time argument.
MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTime(),
MxValue.KindOneofCase.ArrayValue => ConvertToComArray(value.ArrayValue),
MxValue.KindOneofCase.RawValue => throw new ArgumentException(
"MxValue raw payloads cannot be written to MXAccess.", nameof(value)),
_ => throw new ArgumentException(
"MxValue has no value kind set; nothing to write.", nameof(value)),
};
}
private static Array ConvertToComArray(MxArray array)
{
return array.ValuesCase switch
{
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
MxArray.ValuesOneofCase.TimestampValues =>
array.TimestampValues.Values.Select(timestamp => timestamp.ToDateTime()).ToArray(),
MxArray.ValuesOneofCase.RawValues => throw new ArgumentException(
"MxArray raw payloads cannot be written to MXAccess.", nameof(array)),
_ => throw new ArgumentException(
"MxArray has no element values set; nothing to write.", nameof(array)),
};
}
private static MxValue ConvertScalar(
object value,
MxDataType expectedDataType)
@@ -56,4 +56,56 @@ public interface IMxAccessServer
void AdviseSupervisory(
int serverHandle,
int itemHandle);
/// <summary>Writes a value to an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
void Write(
int serverHandle,
int itemHandle,
object? value,
int userId);
/// <summary>Writes a value with an explicit source timestamp to an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId);
/// <summary>Performs a secured/verified write to an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value);
/// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp);
}
@@ -140,6 +140,74 @@ public sealed class MxAccessComServer : IMxAccessServer
AsProxyServer4().AdviseSupervisory(serverHandle, itemHandle);
}
/// <inheritdoc />
public void Write(
int serverHandle,
int itemHandle,
object? value,
int userId)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.Write(serverHandle, itemHandle, value, userId);
return;
}
AsProxyServer().Write(serverHandle, itemHandle, value!, userId);
}
/// <inheritdoc />
public void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.Write2(serverHandle, itemHandle, value, timestamp, userId);
return;
}
AsProxyServer4().Write2(serverHandle, itemHandle, value!, timestamp!, userId);
}
/// <inheritdoc />
public void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
return;
}
AsProxyServer().WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value!);
}
/// <inheritdoc />
public void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
return;
}
AsProxyServer4().WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value!, timestamp!);
}
private ILMXProxyServer AsProxyServer()
{
return mxAccessComObject as ILMXProxyServer
@@ -74,6 +74,10 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
MxCommandKind.Advise => ExecuteAdvise(command),
MxCommandKind.UnAdvise => ExecuteUnAdvise(command),
MxCommandKind.AdviseSupervisory => ExecuteAdviseSupervisory(command),
MxCommandKind.Write => ExecuteWrite(command),
MxCommandKind.Write2 => ExecuteWrite2(command),
MxCommandKind.WriteSecured => ExecuteWriteSecured(command),
MxCommandKind.WriteSecured2 => ExecuteWriteSecured2(command),
MxCommandKind.AddItemBulk => ExecuteAddItemBulk(command),
MxCommandKind.AdviseItemBulk => ExecuteAdviseItemBulk(command),
MxCommandKind.RemoveItemBulk => ExecuteRemoveItemBulk(command),
@@ -223,6 +227,108 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateOkReply(command);
}
private MxCommandReply ExecuteWrite(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write)
{
return CreateInvalidRequestReply(command, "Write command payload is required.");
}
WriteCommand writeCommand = command.Command.Write;
if (writeCommand.Value is null)
{
return CreateInvalidRequestReply(command, "Write command value is required.");
}
session.Write(
writeCommand.ServerHandle,
writeCommand.ItemHandle,
variantConverter.ConvertToComValue(writeCommand.Value),
writeCommand.UserId);
return CreateOkReply(command);
}
private MxCommandReply ExecuteWrite2(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2)
{
return CreateInvalidRequestReply(command, "Write2 command payload is required.");
}
Write2Command write2Command = command.Command.Write2;
if (write2Command.Value is null)
{
return CreateInvalidRequestReply(command, "Write2 command value is required.");
}
if (write2Command.TimestampValue is null)
{
return CreateInvalidRequestReply(command, "Write2 command timestamp value is required.");
}
session.Write2(
write2Command.ServerHandle,
write2Command.ItemHandle,
variantConverter.ConvertToComValue(write2Command.Value),
variantConverter.ConvertToComValue(write2Command.TimestampValue),
write2Command.UserId);
return CreateOkReply(command);
}
private MxCommandReply ExecuteWriteSecured(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured)
{
return CreateInvalidRequestReply(command, "WriteSecured command payload is required.");
}
WriteSecuredCommand writeSecuredCommand = command.Command.WriteSecured;
if (writeSecuredCommand.Value is null)
{
return CreateInvalidRequestReply(command, "WriteSecured command value is required.");
}
session.WriteSecured(
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle,
writeSecuredCommand.CurrentUserId,
writeSecuredCommand.VerifierUserId,
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
return CreateOkReply(command);
}
private MxCommandReply ExecuteWriteSecured2(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2)
{
return CreateInvalidRequestReply(command, "WriteSecured2 command payload is required.");
}
WriteSecured2Command writeSecured2Command = command.Command.WriteSecured2;
if (writeSecured2Command.Value is null)
{
return CreateInvalidRequestReply(command, "WriteSecured2 command value is required.");
}
if (writeSecured2Command.TimestampValue is null)
{
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required.");
}
session.WriteSecured2(
writeSecured2Command.ServerHandle,
writeSecured2Command.ItemHandle,
writeSecured2Command.CurrentUserId,
writeSecured2Command.VerifierUserId,
variantConverter.ConvertToComValue(writeSecured2Command.Value),
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue));
return CreateOkReply(command);
}
private MxCommandReply ExecuteAddItemBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItemBulk)
@@ -227,6 +227,78 @@ public sealed class MxAccessSession : IDisposable
MxAccessAdviceKind.Supervisory);
}
/// <summary>Writes a value to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
public void Write(
int serverHandle,
int itemHandle,
object? value,
int userId)
{
ThrowIfDisposed();
mxAccessServer.Write(serverHandle, itemHandle, value, userId);
}
/// <summary>Writes a value with an explicit source timestamp to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
public void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId)
{
ThrowIfDisposed();
mxAccessServer.Write2(serverHandle, itemHandle, value, timestamp, userId);
}
/// <summary>Performs a secured/verified write to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write.</param>
public void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value)
{
ThrowIfDisposed();
mxAccessServer.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
/// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
public void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp)
{
ThrowIfDisposed();
mxAccessServer.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
}
/// <summary>Adds multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="tagAddresses">Enumerable of item definitions to add.</param>