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>
This commit is contained in:
Joseph Doherty
2026-05-19 11:55:51 -04:00
parent cd92048f4e
commit e355a7674b
3 changed files with 697 additions and 161 deletions
+48 -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,
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,44 @@ 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 {
json!({
"family": event.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).