[CmdletBinding()] param( [string[]]$Clients = @("dotnet", "go", "rust", "python", "java"), [int]$MachineStart = 1, [int]$MachineEnd = 20, [string[]]$Attributes = @( "ProtectedValue", "TestChangingInt", "TestBoolArray", "TestIntArray", "TestDateTimeArray", "TestStringArray" ), [string]$Endpoint = "localhost:5000", [string]$ApiKeyEnv = "MXGATEWAY_API_KEY", [string]$SqlServer = "localhost", [string]$Database = "ZB", [int]$EventLimit = 5, [switch]$SkipStream, [switch]$DryRun, [string]$ReportPath ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $discoveryScript = Join-Path $PSScriptRoot "discover-testmachine-tags.ps1" $validClients = @("dotnet", "go", "rust", "python", "java") $Clients = @($Clients | ForEach-Object { $_ -split "," } | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $Attributes = @($Attributes | ForEach-Object { $_ -split "," } | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($Clients.Count -eq 0) { throw "At least one client is required." } if ($Attributes.Count -eq 0) { throw "At least one attribute is required." } foreach ($client in $Clients) { if ($validClients -notcontains $client) { throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')." } } if ([string]::IsNullOrWhiteSpace($ReportPath)) { $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $ReportPath = Join-Path $repoRoot "artifacts/e2e/testmachine-client-e2e-$timestamp.json" } function ConvertTo-HttpEndpoint { param([string]$Value) if ($Value.StartsWith("http://", [StringComparison]::OrdinalIgnoreCase) -or $Value.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { return $Value } return "http://$Value" } function ConvertTo-HostEndpoint { param([string]$Value) $hostValue = $Value if ($hostValue.StartsWith("http://", [StringComparison]::OrdinalIgnoreCase)) { $hostValue = $hostValue.Substring(7) } elseif ($hostValue.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { $hostValue = $hostValue.Substring(8) } return $hostValue.TrimEnd("/") } function Join-CommandLine { param( [string]$FilePath, [string[]]$Arguments ) $parts = @($FilePath) + $Arguments return ($parts | ForEach-Object { ConvertTo-NativeArgument -Value $_ }) -join " " } function ConvertTo-NativeArgument { param([string]$Value) if ($Value -notmatch '[\s"]') { return $Value } return '"' + $Value.Replace('\', '\\').Replace('"', '\"') + '"' } function Invoke-NativeCommand { param( [string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory, [hashtable]$Environment = @{} ) $process = [System.Diagnostics.Process]::new() $process.StartInfo.FileName = $FilePath $process.StartInfo.Arguments = ($Arguments | ForEach-Object { ConvertTo-NativeArgument -Value $_ }) -join " " $process.StartInfo.WorkingDirectory = $WorkingDirectory $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true $process.StartInfo.UseShellExecute = $false foreach ($entry in $Environment.GetEnumerator()) { $process.StartInfo.Environment[$entry.Key] = [string]$entry.Value } $commandLine = Join-CommandLine -FilePath $FilePath -Arguments $Arguments if ($DryRun) { Write-Host "[dry-run] $commandLine" return [pscustomobject]@{ commandLine = $commandLine exitCode = 0 stdout = "{}" stderr = "" } } [void]$process.Start() $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $result = [pscustomobject]@{ commandLine = $commandLine exitCode = $process.ExitCode stdout = $stdout stderr = $stderr } if ($result.exitCode -ne 0) { throw "Command failed with exit code $($result.exitCode): $commandLine`n$stderr`n$stdout" } return $result } function Read-JsonObject { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } try { return $Text | ConvertFrom-Json } catch { $jsonLines = @() foreach ($line in ($Text -split "`r?`n")) { $trimmed = $line.Trim() if ($trimmed.StartsWith("{") -and $trimmed.EndsWith("}")) { $jsonLines += ($trimmed | ConvertFrom-Json) } } return $jsonLines } } function Get-OpenSessionId { param( [string]$Client, [object]$Json ) switch ($Client) { "dotnet" { return $Json.sessionId } "go" { return $Json.reply.sessionId } "rust" { return $Json.sessionId } "python" { return $Json.sessionId } "java" { return $Json.reply.sessionId } } } function Get-ServerHandle { param( [string]$Client, [object]$Json ) switch ($Client) { "dotnet" { return [int]$Json.register.serverHandle } "go" { return [int]$Json.reply.register.serverHandle } "rust" { return [int]$Json.serverHandle } "python" { return [int]$Json.serverHandle } "java" { return [int]$Json.reply.register.serverHandle } } } function Get-ItemHandle { param( [string]$Client, [object]$Json ) switch ($Client) { "dotnet" { return [int]$Json.addItem.itemHandle } "go" { return [int]$Json.reply.addItem.itemHandle } "rust" { return [int]$Json.itemHandle } "python" { return [int]$Json.itemHandle } "java" { return [int]$Json.reply.addItem.itemHandle } } } function Get-StreamEventCount { param( [string]$Client, [object]$Json ) switch ($Client) { "dotnet" { return @($Json.events).Count } "go" { return @($Json).Count } "rust" { return [int]$Json.eventCount } "python" { return @($Json.events).Count } "java" { return @($Json).Count } } } function Get-ClientCommand { param( [string]$Client, [string]$Operation, [hashtable]$Values ) $httpEndpoint = ConvertTo-HttpEndpoint -Value $Endpoint $hostEndpoint = ConvertTo-HostEndpoint -Value $Endpoint $clientName = "mxgw-$Client-e2e" switch ($Client) { "dotnet" { $arguments = @( "run", "--project", "clients/dotnet/MxGateway.Client.Cli", "--", $Operation, "--endpoint", $httpEndpoint, "--api-key-env", $ApiKeyEnv, "--timeout", "60s", "--json" ) if ($Operation -eq "open-session") { $arguments += @("--client-name", $clientName) } elseif ($Operation -eq "register") { $arguments += @("--session-id", $Values.sessionId, "--client-name", $clientName) } elseif ($Operation -eq "add-item") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) } elseif ($Operation -eq "advise") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") } elseif ($Operation -eq "stream-events") { $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit") } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } return [pscustomobject]@{ file = "dotnet"; args = $arguments; cwd = $repoRoot; env = @{} } } "go" { $arguments = @( "run", "./cmd/mxgw-go", $Operation, "-endpoint", $hostEndpoint, "-api-key-env", $ApiKeyEnv, "-plaintext", "-json" ) if ($Operation -eq "open-session") { $arguments += @("-client-session-name", $clientName) } elseif ($Operation -eq "register") { $arguments += @("-session-id", $Values.sessionId, "-client-name", $clientName) } elseif ($Operation -eq "add-item") { $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item", $Values.item) } elseif ($Operation -eq "advise") { $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)") } elseif ($Operation -eq "stream-events") { $arguments += @("-session-id", $Values.sessionId, "-limit", "$EventLimit") } elseif ($Operation -eq "close-session") { $arguments += @("-session-id", $Values.sessionId) } return [pscustomobject]@{ file = "go"; args = $arguments; cwd = (Join-Path $repoRoot "clients/go"); env = @{} } } "rust" { $arguments = @( "run", "-p", "mxgw-cli", "--", $Operation, "--endpoint", $httpEndpoint, "--api-key-env", $ApiKeyEnv, "--json" ) if ($Operation -eq "open-session") { $arguments += @("--client-name", $clientName) } elseif ($Operation -eq "register") { $arguments += @("--session-id", $Values.sessionId, "--client-name", $clientName) } elseif ($Operation -eq "add-item") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) } elseif ($Operation -eq "advise") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") } elseif ($Operation -eq "stream-events") { $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit") } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } return [pscustomobject]@{ file = "cargo"; args = $arguments; cwd = (Join-Path $repoRoot "clients/rust"); env = @{} } } "python" { $arguments = @( "-m", "mxgateway_cli", $Operation, "--endpoint", $hostEndpoint, "--api-key-env", $ApiKeyEnv, "--plaintext", "--json" ) if ($Operation -eq "open-session") { $arguments += @("--client-name", $clientName) } elseif ($Operation -eq "register") { $arguments += @("--session-id", $Values.sessionId, "--client-name", $clientName) } elseif ($Operation -eq "add-item") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) } elseif ($Operation -eq "advise") { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") } elseif ($Operation -eq "stream-events") { $arguments += @("--session-id", $Values.sessionId, "--max-events", "$EventLimit", "--timeout", "15") } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } $env = @{ PYTHONPATH = Join-Path $repoRoot "clients/python/src" } return [pscustomobject]@{ file = "py"; args = @("-3.12") + $arguments; cwd = (Join-Path $repoRoot "clients/python"); env = $env } } "java" { $cliArgs = @( $Operation, "--endpoint", $hostEndpoint, "--api-key-env", $ApiKeyEnv, "--plaintext", "--json" ) if ($Operation -eq "open-session") { $cliArgs += @("--client-session-name", $clientName) } elseif ($Operation -eq "register") { $cliArgs += @("--session-id", $Values.sessionId, "--client-name", $clientName) } elseif ($Operation -eq "add-item") { $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item", $Values.item) } elseif ($Operation -eq "advise") { $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)") } elseif ($Operation -eq "stream-events") { $cliArgs += @("--session-id", $Values.sessionId, "--limit", "$EventLimit") } 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 = @{} } } } } function Invoke-ClientOperation { param( [string]$Client, [string]$Operation, [hashtable]$Values = @{} ) $command = Get-ClientCommand -Client $Client -Operation $Operation -Values $Values $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 } } } } "stream-events" { return [pscustomobject]@{ eventCount = 1; events = @([pscustomobject]@{ workerSequence = 1 }) } } default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } } } } return Read-JsonObject -Text $result.stdout } $discoveryJson = & $discoveryScript ` -MachineStart $MachineStart ` -MachineEnd $MachineEnd ` -Attributes $Attributes ` -SqlServer $SqlServer ` -Database $Database ` -Json $convertedTags = $discoveryJson | ConvertFrom-Json $tags = @($convertedTags) if ($tags.Count -eq 1 -and $tags[0] -is [System.Array]) { $tags = @($tags[0]) } $expectedTagCount = (($MachineEnd - $MachineStart + 1) * $Attributes.Count) if ($tags.Count -ne $expectedTagCount) { $found = $tags | Group-Object -Property tagName | ForEach-Object { "$($_.Name)=$($_.Count)" } throw "Expected $expectedTagCount discovered test tags, found $($tags.Count): $($found -join ', ')" } $run = [ordered]@{ schemaVersion = 1 endpoint = $Endpoint apiKeyEnv = $ApiKeyEnv machineStart = $MachineStart machineEnd = $MachineEnd attributes = $Attributes eventLimit = $EventLimit skipStream = [bool]$SkipStream startedAt = (Get-Date).ToUniversalTime().ToString("O") discoveredTags = $tags clients = @() } $hadFailure = $false 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 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 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 { $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 } $run.completedAt = (Get-Date).ToUniversalTime().ToString("O") $run.success = -not $hadFailure if (-not $DryRun) { $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 e2e report to $ReportPath" } if ($hadFailure) { exit 1 }