From 79f73e04fdc9df3edd0e6543f3cec579231f82b9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:21:49 -0400 Subject: [PATCH] Issue #49: add cross-language smoke matrix --- .../smoke/cross-language-smoke-matrix.json | 280 ++++++++++++++++++ docs/CrossLanguageSmokeMatrix.md | 98 ++++++ docs/GatewayTesting.md | 8 + .../CrossLanguageSmokeMatrixTests.cs | 276 +++++++++++++++++ 4 files changed, 662 insertions(+) create mode 100644 clients/proto/fixtures/smoke/cross-language-smoke-matrix.json create mode 100644 docs/CrossLanguageSmokeMatrix.md create mode 100644 src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs diff --git a/clients/proto/fixtures/smoke/cross-language-smoke-matrix.json b/clients/proto/fixtures/smoke/cross-language-smoke-matrix.json new file mode 100644 index 0000000..65cdc11 --- /dev/null +++ b/clients/proto/fixtures/smoke/cross-language-smoke-matrix.json @@ -0,0 +1,280 @@ +{ + "schemaVersion": 1, + "fixtureSet": "mxaccess-gateway-cross-language-smoke-matrix", + "description": "Documented command matrix for opt-in cross-language client smoke runs against a live gateway.", + "integrationGate": { + "variable": "MXGATEWAY_INTEGRATION", + "requiredValue": "1" + }, + "defaultInputs": { + "endpointVariable": "MXGATEWAY_ENDPOINT", + "endpointFallback": "localhost:5000", + "apiKeyVariable": "MXGATEWAY_API_KEY", + "itemVariable": "MXGATEWAY_TEST_ITEM", + "itemFallback": "TestChildObject.TestInt", + "eventLimit": 1, + "optionalWriteValueVariable": "MXGATEWAY_TEST_WRITE_VALUE", + "optionalWriteType": "int32" + }, + "requiredOperations": [ + "open-session", + "register", + "add-item", + "advise", + "stream-events", + "close-session" + ], + "optionalOperations": [ + "write" + ], + "jsonComparison": { + "requiredOutputMode": "json", + "commonFields": [ + "language", + "operation", + "sessionId", + "serverHandle", + "itemHandle", + "events", + "closeStatus" + ], + "comparisonFields": [ + "sessionId", + "serverHandle", + "itemHandle", + "eventCount", + "eventFamily", + "workerSequence", + "protocolStatus", + "hresult", + "statuses" + ] + }, + "failureOutput": { + "requiredContextFields": [ + "language", + "endpoint", + "authContext" + ], + "authContext": { + "sourceVariable": "MXGATEWAY_API_KEY", + "redactedValue": "", + "forbiddenLiterals": [ + "mxgw_visible_secret", + "Bearer mxgw_visible_secret" + ] + } + }, + "clients": [ + { + "language": "dotnet", + "displayName": ".NET", + "workingDirectory": ".", + "integrationSkip": { + "variable": "MXGATEWAY_INTEGRATION", + "requiredValue": "1" + }, + "failureContextFields": [ + "language", + "endpoint", + "authContext" + ], + "commands": [ + { + "operation": "open-session", + "command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --client-name mxgw-dotnet-smoke --json" + }, + { + "operation": "register", + "command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --client-name mxgw-dotnet-smoke --json" + }, + { + "operation": "add-item", + "command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --server-handle --item TestChildObject.TestInt --json" + }, + { + "operation": "advise", + "command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --server-handle --item-handle --json" + }, + { + "operation": "stream-events", + "command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --max-events 1 --json" + }, + { + "operation": "close-session", + "command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- close-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --json" + } + ], + "optionalWriteCommand": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --server-handle --item-handle --type int32 --value --json", + "bundledSmokeCommand": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json" + }, + { + "language": "go", + "displayName": "Go", + "workingDirectory": "clients/go", + "integrationSkip": { + "variable": "MXGATEWAY_INTEGRATION", + "requiredValue": "1" + }, + "failureContextFields": [ + "language", + "endpoint", + "authContext" + ], + "commands": [ + { + "operation": "open-session", + "command": "go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -client-session-name mxgw-go-smoke -plaintext -json" + }, + { + "operation": "register", + "command": "go run ./cmd/mxgw-go register -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id -client-name mxgw-go-smoke -plaintext -json" + }, + { + "operation": "add-item", + "command": "go run ./cmd/mxgw-go add-item -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id -server-handle -item TestChildObject.TestInt -plaintext -json" + }, + { + "operation": "advise", + "command": "go run ./cmd/mxgw-go advise -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id -server-handle -item-handle -plaintext -json" + }, + { + "operation": "stream-events", + "command": "go run ./cmd/mxgw-go stream-events -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id -limit 1 -plaintext -json" + }, + { + "operation": "close-session", + "command": "go run ./cmd/mxgw-go close-session -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id -plaintext -json" + } + ], + "optionalWriteCommand": "go run ./cmd/mxgw-go write -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id -server-handle -item-handle -type int32 -value -plaintext -json", + "bundledSmokeCommand": "go run ./cmd/mxgw-go smoke -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -item TestChildObject.TestInt -plaintext -json" + }, + { + "language": "rust", + "displayName": "Rust", + "workingDirectory": "clients/rust", + "integrationSkip": { + "variable": "MXGATEWAY_INTEGRATION", + "requiredValue": "1" + }, + "failureContextFields": [ + "language", + "endpoint", + "authContext" + ], + "commands": [ + { + "operation": "open-session", + "command": "cargo run -p mxgw-cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --client-name mxgw-rust-smoke --json" + }, + { + "operation": "register", + "command": "cargo run -p mxgw-cli -- register --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --client-name mxgw-rust-smoke --json" + }, + { + "operation": "add-item", + "command": "cargo run -p mxgw-cli -- add-item --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --server-handle --item TestChildObject.TestInt --json" + }, + { + "operation": "advise", + "command": "cargo run -p mxgw-cli -- advise --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --server-handle --item-handle --json" + }, + { + "operation": "stream-events", + "command": "cargo run -p mxgw-cli -- stream-events --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --max-events 1 --json" + }, + { + "operation": "close-session", + "command": "cargo run -p mxgw-cli -- close-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --json" + } + ], + "optionalWriteCommand": "cargo run -p mxgw-cli -- write --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id --server-handle --item-handle --value-type int32 --value --json", + "bundledSmokeCommand": "cargo run -p mxgw-cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json" + }, + { + "language": "python", + "displayName": "Python", + "workingDirectory": "clients/python", + "integrationSkip": { + "variable": "MXGATEWAY_INTEGRATION", + "requiredValue": "1" + }, + "failureContextFields": [ + "language", + "endpoint", + "authContext" + ], + "commands": [ + { + "operation": "open-session", + "command": "mxgw-py open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-name mxgw-py-smoke --json" + }, + { + "operation": "register", + "command": "mxgw-py register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --client-name mxgw-py-smoke --json" + }, + { + "operation": "add-item", + "command": "mxgw-py add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle --item TestChildObject.TestInt --json" + }, + { + "operation": "advise", + "command": "mxgw-py advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle --item-handle --json" + }, + { + "operation": "stream-events", + "command": "mxgw-py stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --max-events 1 --json" + }, + { + "operation": "close-session", + "command": "mxgw-py close-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --json" + } + ], + "optionalWriteCommand": "mxgw-py write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle --item-handle --type int32 --value --json", + "bundledSmokeCommand": "mxgw-py smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestChildObject.TestInt --max-events 1 --json" + }, + { + "language": "java", + "displayName": "Java", + "workingDirectory": "clients/java", + "integrationSkip": { + "variable": "MXGATEWAY_INTEGRATION", + "requiredValue": "1" + }, + "failureContextFields": [ + "language", + "endpoint", + "authContext" + ], + "commands": [ + { + "operation": "open-session", + "command": "gradle :mxgateway-cli:run --args=\"open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name mxgw-java-smoke --json\"" + }, + { + "operation": "register", + "command": "gradle :mxgateway-cli:run --args=\"register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --client-name mxgw-java-smoke --json\"" + }, + { + "operation": "add-item", + "command": "gradle :mxgateway-cli:run --args=\"add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle --item TestChildObject.TestInt --json\"" + }, + { + "operation": "advise", + "command": "gradle :mxgateway-cli:run --args=\"advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle --item-handle --json\"" + }, + { + "operation": "stream-events", + "command": "gradle :mxgateway-cli:run --args=\"stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --limit 1 --json\"" + }, + { + "operation": "close-session", + "command": "gradle :mxgateway-cli:run --args=\"close-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --json\"" + } + ], + "optionalWriteCommand": "gradle :mxgateway-cli:run --args=\"write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle --item-handle --type int32 --value --json\"", + "bundledSmokeCommand": "gradle :mxgateway-cli:run --args=\"smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestChildObject.TestInt --json\"" + } + ] +} diff --git a/docs/CrossLanguageSmokeMatrix.md b/docs/CrossLanguageSmokeMatrix.md new file mode 100644 index 0000000..d86206b --- /dev/null +++ b/docs/CrossLanguageSmokeMatrix.md @@ -0,0 +1,98 @@ +# Cross-Language Smoke Matrix + +The cross-language smoke matrix defines the documented commands used to compare +official clients against the same live gateway flow. It is a repository +validation fixture and command reference; normal unit tests validate the matrix +shape without connecting to a gateway. + +The matrix lives in +`clients/proto/fixtures/smoke/cross-language-smoke-matrix.json`. + +## Scope + +The matrix covers the supported client languages: + +- .NET +- Go +- Rust +- Python +- Java + +Each client entry defines commands for the same required operation sequence: + +1. `open-session` +2. `register` +3. `add-item` +4. `advise` +5. `stream-events` +6. `close-session` + +The optional `write` command is documented separately because writing changes +provider state and should only run when the operator supplies a safe test value. + +## Integration Gate + +Cross-language smoke execution is opt-in. Runners should skip the matrix unless +this variable is set: + +```powershell +$env:MXGATEWAY_INTEGRATION = "1" +``` + +The shared inputs are: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `MXGATEWAY_ENDPOINT` | `localhost:5000` | Gateway endpoint used by client CLIs. | +| `MXGATEWAY_API_KEY` | Empty | API key source for authenticated gateway deployments. | +| `MXGATEWAY_TEST_ITEM` | `TestChildObject.TestInt` | MXAccess item used by `add-item`. | +| `MXGATEWAY_TEST_WRITE_VALUE` | Empty | Enables the optional write step when set by a runner. | + +The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's +`api-key-env` flag. They must not embed bearer tokens or raw API keys. + +## JSON Comparison + +Every command in the matrix requests JSON output. A runner can compare the +normalized smoke record across languages with these fields: + +- language, +- operation, +- session id, +- server handle, +- item handle, +- event count, +- event family, +- worker sequence, +- protocol status, +- HRESULT, +- status arrays, +- close status. + +Failure output must include the client language, endpoint, and redacted auth +context. Auth context identifies the source, such as `MXGATEWAY_API_KEY`, but +does not include the secret value. + +## Bundled Smoke Commands + +Each client also exposes a bundled `smoke` command. Those commands are useful +for quick local checks, but the full cross-language matrix uses explicit +operation commands because not every bundled smoke command streams events yet. +The explicit sequence remains the parity baseline for issue-level validation. + +## Validation + +Run the matrix shape tests after changing the smoke matrix: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~CrossLanguageSmokeMatrixTests +``` + +Live execution remains a separate opt-in step because it depends on a running +gateway, the installed MXAccess worker path, and provider state. + +## Related Documentation + +- [Gateway Testing](./GatewayTesting.md) +- [Client Libraries Detailed Design](./client-libraries-design.md) +- [Client Proto Generation](./client-proto-generation.md) diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index ddb78c1..e02b562 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -76,6 +76,13 @@ stdout/stderr lines emitted during the run. ## Focused Commands +Run the cross-language smoke matrix tests after changing the documented client +smoke command list: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~CrossLanguageSmokeMatrixTests +``` + Run the parity fixture matrix tests after changing the integration parity scenario list: @@ -102,6 +109,7 @@ dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj ## Related Documentation +- [Cross-Language Smoke Matrix](./CrossLanguageSmokeMatrix.md) - [Parity Fixture Matrix](./ParityFixtureMatrix.md) - [Gateway Process Design](./gateway-process-design.md) - [Worker Frame Protocol](./WorkerFrameProtocol.md) diff --git a/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs b/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs new file mode 100644 index 0000000..6d042f8 --- /dev/null +++ b/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs @@ -0,0 +1,276 @@ +using System.Text.Json; + +namespace MxGateway.Tests.Contracts; + +public sealed class CrossLanguageSmokeMatrixTests +{ + [Fact] + public void Matrix_DeclaresIntegrationGateAndComparisonShape() + { + using JsonDocument matrix = LoadSmokeMatrix(); + JsonElement root = matrix.RootElement; + + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("mxaccess-gateway-cross-language-smoke-matrix", root.GetProperty("fixtureSet").GetString()); + + JsonElement integrationGate = root.GetProperty("integrationGate"); + Assert.Equal("MXGATEWAY_INTEGRATION", integrationGate.GetProperty("variable").GetString()); + Assert.Equal("1", integrationGate.GetProperty("requiredValue").GetString()); + + JsonElement defaultInputs = root.GetProperty("defaultInputs"); + Assert.Equal("MXGATEWAY_ENDPOINT", defaultInputs.GetProperty("endpointVariable").GetString()); + Assert.Equal("localhost:5000", defaultInputs.GetProperty("endpointFallback").GetString()); + Assert.Equal("MXGATEWAY_API_KEY", defaultInputs.GetProperty("apiKeyVariable").GetString()); + Assert.Equal("MXGATEWAY_TEST_ITEM", defaultInputs.GetProperty("itemVariable").GetString()); + + AssertRequiredFields( + root.GetProperty("jsonComparison").GetProperty("commonFields"), + "language", + "operation", + "sessionId", + "serverHandle", + "itemHandle", + "events", + "closeStatus"); + AssertRequiredFields( + root.GetProperty("failureOutput").GetProperty("requiredContextFields"), + "language", + "endpoint", + "authContext"); + + JsonElement authContext = root.GetProperty("failureOutput").GetProperty("authContext"); + Assert.Equal("MXGATEWAY_API_KEY", authContext.GetProperty("sourceVariable").GetString()); + Assert.Equal("", authContext.GetProperty("redactedValue").GetString()); + AssertForbiddenLiterals(authContext.GetProperty("forbiddenLiterals")); + } + + [Fact] + public void Matrix_CoversEverySupportedClientWithEquivalentSmokeSteps() + { + using JsonDocument matrix = LoadSmokeMatrix(); + JsonElement root = matrix.RootElement; + string[] requiredOperations = GetStrings(root.GetProperty("requiredOperations")); + Dictionary clientsByLanguage = []; + + foreach (JsonElement client in root.GetProperty("clients").EnumerateArray()) + { + string language = client.GetProperty("language").GetString()!; + + Assert.True(clientsByLanguage.TryAdd(language, client), $"Duplicate smoke client '{language}'."); + Assert.Contains(language, ExpectedLanguages); + AssertClientWorkDirectoryExists(client); + AssertIntegrationSkip(client); + AssertRequiredFields(client.GetProperty("failureContextFields"), "language", "endpoint", "authContext"); + AssertSmokeCommands(client, requiredOperations); + AssertOptionalWriteCommand(client.GetProperty("optionalWriteCommand").GetString()!); + AssertCommandUsesJsonAndAuthEnv(client.GetProperty("bundledSmokeCommand").GetString()!); + Assert.Contains("TestChildObject.TestInt", client.GetProperty("bundledSmokeCommand").GetString()!, StringComparison.Ordinal); + } + + Assert.Equal(ExpectedLanguages.OrderBy(language => language, StringComparer.Ordinal), clientsByLanguage.Keys.OrderBy(language => language, StringComparer.Ordinal)); + } + + [Fact] + public void Matrix_KeepsLiveSmokeOptInAndSecretsOutOfCommands() + { + using JsonDocument matrix = LoadSmokeMatrix(); + JsonElement root = matrix.RootElement; + string[] forbiddenLiterals = GetStrings(root.GetProperty("failureOutput").GetProperty("authContext").GetProperty("forbiddenLiterals")); + + foreach (JsonElement client in root.GetProperty("clients").EnumerateArray()) + { + string language = client.GetProperty("language").GetString()!; + + AssertIntegrationSkip(client); + + foreach (JsonElement commandStep in client.GetProperty("commands").EnumerateArray()) + { + AssertNoForbiddenLiterals(language, commandStep.GetProperty("command").GetString()!, forbiddenLiterals); + } + + AssertNoForbiddenLiterals(language, client.GetProperty("optionalWriteCommand").GetString()!, forbiddenLiterals); + AssertNoForbiddenLiterals(language, client.GetProperty("bundledSmokeCommand").GetString()!, forbiddenLiterals); + } + } + + private static readonly string[] ExpectedLanguages = + [ + "dotnet", + "go", + "rust", + "python", + "java", + ]; + + private static void AssertSmokeCommands( + JsonElement client, + string[] requiredOperations) + { + Dictionary commandsByOperation = []; + + foreach (JsonElement commandStep in client.GetProperty("commands").EnumerateArray()) + { + string operation = commandStep.GetProperty("operation").GetString()!; + string command = commandStep.GetProperty("command").GetString()!; + + Assert.True(commandsByOperation.TryAdd(operation, commandStep), $"Duplicate smoke operation '{operation}'."); + AssertCommandUsesJsonAndAuthEnv(command); + Assert.Contains("localhost:5000", command, StringComparison.Ordinal); + AssertOperationPlaceholders(operation, command); + } + + Assert.Equal(requiredOperations.OrderBy(operation => operation, StringComparer.Ordinal), commandsByOperation.Keys.OrderBy(operation => operation, StringComparer.Ordinal)); + } + + private static void AssertOperationPlaceholders( + string operation, + string command) + { + switch (operation) + { + case "open-session": + Assert.Contains("smoke", command, StringComparison.Ordinal); + break; + case "register": + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("smoke", command, StringComparison.Ordinal); + break; + case "add-item": + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("TestChildObject.TestInt", command, StringComparison.Ordinal); + break; + case "advise": + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("", command, StringComparison.Ordinal); + break; + case "stream-events": + Assert.Contains("", command, StringComparison.Ordinal); + Assert.True( + command.Contains("--max-events 1", StringComparison.Ordinal) + || command.Contains("-limit 1", StringComparison.Ordinal) + || command.Contains("--limit 1", StringComparison.Ordinal), + $"Stream command '{command}' must bound event reads."); + break; + case "close-session": + Assert.Contains("", command, StringComparison.Ordinal); + break; + default: + throw new InvalidOperationException($"Unexpected smoke operation '{operation}'."); + } + } + + private static void AssertOptionalWriteCommand(string command) + { + AssertCommandUsesJsonAndAuthEnv(command); + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("", command, StringComparison.Ordinal); + Assert.Contains("int32", command, StringComparison.Ordinal); + } + + private static void AssertCommandUsesJsonAndAuthEnv(string command) + { + Assert.True( + command.Contains("--json", StringComparison.Ordinal) || command.Contains("-json", StringComparison.Ordinal), + $"Command '{command}' must request JSON output."); + Assert.Contains("MXGATEWAY_API_KEY", command, StringComparison.Ordinal); + Assert.Contains("api-key-env", command, StringComparison.Ordinal); + } + + private static void AssertIntegrationSkip(JsonElement client) + { + JsonElement integrationSkip = client.GetProperty("integrationSkip"); + + Assert.Equal("MXGATEWAY_INTEGRATION", integrationSkip.GetProperty("variable").GetString()); + Assert.Equal("1", integrationSkip.GetProperty("requiredValue").GetString()); + } + + private static void AssertClientWorkDirectoryExists(JsonElement client) + { + string workingDirectory = client.GetProperty("workingDirectory").GetString()!; + DirectoryInfo repositoryRoot = FindRepositoryRoot(); + string fullPath = Path.GetFullPath(Path.Combine(repositoryRoot.FullName, workingDirectory)); + + Assert.True(Directory.Exists(fullPath), $"Smoke client working directory '{workingDirectory}' must exist."); + Assert.StartsWith(repositoryRoot.FullName, fullPath, StringComparison.OrdinalIgnoreCase); + } + + private static void AssertRequiredFields( + JsonElement fields, + params string[] expectedFields) + { + HashSet declared = GetStrings(fields).ToHashSet(StringComparer.Ordinal); + + foreach (string expectedField in expectedFields) + { + Assert.Contains(expectedField, declared); + } + } + + private static void AssertForbiddenLiterals(JsonElement forbiddenLiterals) + { + string[] values = GetStrings(forbiddenLiterals); + + Assert.Contains("mxgw_visible_secret", values); + Assert.Contains("Bearer mxgw_visible_secret", values); + } + + private static void AssertNoForbiddenLiterals( + string language, + string command, + string[] forbiddenLiterals) + { + foreach (string forbiddenLiteral in forbiddenLiterals) + { + Assert.DoesNotContain(forbiddenLiteral, command, StringComparison.Ordinal); + } + + Assert.DoesNotContain(" --api-key ", command, StringComparison.Ordinal); + Assert.DoesNotContain(" -api-key ", command, StringComparison.Ordinal); + Assert.Contains("api-key-env", command, StringComparison.Ordinal); + Assert.Contains("MXGATEWAY_API_KEY", command, StringComparison.Ordinal); + Assert.False(command.Contains("Bearer ", StringComparison.Ordinal), $"Smoke command for '{language}' must not include bearer tokens."); + } + + private static string[] GetStrings(JsonElement array) + { + return array + .EnumerateArray() + .Select(element => element.GetString()!) + .ToArray(); + } + + private static JsonDocument LoadSmokeMatrix() + { + return JsonDocument.Parse(File.ReadAllText(Path.Combine(GetSmokeFixtureRoot().FullName, "cross-language-smoke-matrix.json"))); + } + + private static DirectoryInfo GetSmokeFixtureRoot() + { + DirectoryInfo repositoryRoot = FindRepositoryRoot(); + + return new DirectoryInfo(Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "smoke")); + } + + private static DirectoryInfo FindRepositoryRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "AGENTS.md")) + && Directory.Exists(Path.Combine(current.FullName, "src")) + && Directory.Exists(Path.Combine(current.FullName, "clients"))) + { + return current; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory."); + } +}