param( [Parameter(Mandatory = $true)] [string]$InputPath, [string]$Scenario = "", [string]$ThresholdConfigPath = "", [string]$OutputPath = "" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Resolve-AbsolutePath { param( [string]$Path, [string]$BasePath ) if ([System.IO.Path]::IsPathRooted($Path)) { return [System.IO.Path]::GetFullPath($Path) } return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path)) } function Convert-ToDouble { param([object]$Value) return [double]::Parse( [string]$Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture) } function Get-Percentile { param( [double[]]$Values, [double]$Percentile ) if ($Values.Count -eq 0) { return 0.0 } $sorted = @($Values | Sort-Object) $index = [math]::Max(0, [math]::Ceiling($Percentile * $sorted.Count) - 1) return [double]$sorted[$index] } function Get-PropertyValue { param( [object]$Object, [string]$Name ) if ($null -eq $Object) { return $null } $property = $Object.PSObject.Properties[$Name] if ($null -eq $property) { return $null } return $property.Value } $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\..")) $resolvedInput = Resolve-AbsolutePath -Path $InputPath -BasePath $repoRoot if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) { throw "Performance CSV was not found: $resolvedInput" } $resolvedThresholds = if ([string]::IsNullOrWhiteSpace($ThresholdConfigPath)) { Join-Path $repoRoot "internal/qa/config/performance-thresholds.json" } else { Resolve-AbsolutePath -Path $ThresholdConfigPath -BasePath $repoRoot } $thresholdConfig = Get-Content -LiteralPath $resolvedThresholds -Raw -Encoding UTF8 | ConvertFrom-Json if ($thresholdConfig.schemaVersion -ne 1) { throw "Unsupported performance threshold configuration version: $($thresholdConfig.schemaVersion)" } $allRows = @(Import-Csv -LiteralPath $resolvedInput) $runningRows = @($allRows | Where-Object { $_.Status -eq "running" }) if ($runningRows.Count -eq 0) { throw "Performance CSV contains no running samples: $resolvedInput" } if ([string]::IsNullOrWhiteSpace($Scenario)) { $scenarioProperty = $runningRows[0].PSObject.Properties["Scenario"] $Scenario = if ($null -ne $scenarioProperty -and -not [string]::IsNullOrWhiteSpace([string]$scenarioProperty.Value)) { [string]$scenarioProperty.Value } else { [System.IO.Path]::GetFileNameWithoutExtension($resolvedInput) } } $cpuValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.CpuPercent }) $workingSetValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.WorkingSetMB }) $privateMemoryValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.PrivateMemoryMB }) $handleValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.HandleCount }) $threadValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.ThreadCount }) $metrics = [ordered]@{ SampleCount = $runningRows.Count AverageCpuPercent = [math]::Round(($cpuValues | Measure-Object -Average).Average, 2) P95CpuPercent = [math]::Round((Get-Percentile -Values $cpuValues -Percentile 0.95), 2) PeakCpuPercent = [math]::Round(($cpuValues | Measure-Object -Maximum).Maximum, 2) StartWorkingSetMB = $workingSetValues[0] EndWorkingSetMB = $workingSetValues[-1] PeakWorkingSetMB = [math]::Round(($workingSetValues | Measure-Object -Maximum).Maximum, 2) WorkingSetGrowthMB = [math]::Round($workingSetValues[-1] - $workingSetValues[0], 2) StartPrivateMemoryMB = $privateMemoryValues[0] EndPrivateMemoryMB = $privateMemoryValues[-1] PeakPrivateMemoryMB = [math]::Round(($privateMemoryValues | Measure-Object -Maximum).Maximum, 2) PrivateMemoryGrowthMB = [math]::Round($privateMemoryValues[-1] - $privateMemoryValues[0], 2) HandleGrowth = [int]($handleValues[-1] - $handleValues[0]) PeakHandleCount = [int](($handleValues | Measure-Object -Maximum).Maximum) ThreadGrowth = [int]($threadValues[-1] - $threadValues[0]) PeakThreadCount = [int](($threadValues | Measure-Object -Maximum).Maximum) UnresponsiveSamples = @($runningRows | Where-Object { ([string]$_.Responding).ToLowerInvariant() -eq "false" }).Count UnexpectedExit = @($allRows | Where-Object { $_.Status -eq "exited" }).Count -gt 0 } $scenarioThresholds = Get-PropertyValue -Object $thresholdConfig.scenarios -Name $Scenario $effectiveThresholds = @{} foreach ($property in $thresholdConfig.defaults.PSObject.Properties) { $effectiveThresholds[$property.Name] = $property.Value } if ($null -ne $scenarioThresholds) { foreach ($property in $scenarioThresholds.PSObject.Properties) { $effectiveThresholds[$property.Name] = $property.Value } } $checks = New-Object System.Collections.Generic.List[object] function Add-ThresholdCheck { param( [string]$Name, [double]$Actual, [string]$ThresholdName, [string]$Unit ) $threshold = $effectiveThresholds[$ThresholdName] if ($null -eq $threshold) { return } $limit = Convert-ToDouble $threshold $checks.Add([pscustomobject]@{ Name = $Name Passed = $Actual -le $limit Actual = "$Actual$Unit" Limit = "<= $limit$Unit" }) } $allowUnexpectedExit = [bool]$effectiveThresholds["allowUnexpectedExit"] $checks.Add([pscustomobject]@{ Name = "Unexpected process exit" Passed = $allowUnexpectedExit -or -not $metrics.UnexpectedExit Actual = [string]$metrics.UnexpectedExit Limit = if ($allowUnexpectedExit) { "allowed" } else { "False" } }) Add-ThresholdCheck -Name "Unresponsive samples" -Actual $metrics.UnresponsiveSamples -ThresholdName "maxUnresponsiveSamples" -Unit "" Add-ThresholdCheck -Name "Average CPU" -Actual $metrics.AverageCpuPercent -ThresholdName "maxAverageCpuPercent" -Unit "%" Add-ThresholdCheck -Name "P95 CPU" -Actual $metrics.P95CpuPercent -ThresholdName "maxP95CpuPercent" -Unit "%" Add-ThresholdCheck -Name "Private memory growth" -Actual $metrics.PrivateMemoryGrowthMB -ThresholdName "maxPrivateMemoryGrowthMB" -Unit " MB" Add-ThresholdCheck -Name "Working set growth" -Actual $metrics.WorkingSetGrowthMB -ThresholdName "maxWorkingSetGrowthMB" -Unit " MB" Add-ThresholdCheck -Name "Handle growth" -Actual $metrics.HandleGrowth -ThresholdName "maxHandleGrowth" -Unit "" Add-ThresholdCheck -Name "Thread growth" -Actual $metrics.ThreadGrowth -ThresholdName "maxThreadGrowth" -Unit "" $failedChecks = @($checks | Where-Object { -not $_.Passed }) $result = if ($failedChecks.Count -eq 0) { "PASS" } else { "FAIL" } if ([string]::IsNullOrWhiteSpace($OutputPath)) { $safeScenario = $Scenario -replace '[^a-zA-Z0-9._-]', '_' $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $OutputPath = "internal/qa/out/performance/$timestamp-$safeScenario.md" } $resolvedOutput = Resolve-AbsolutePath -Path $OutputPath -BasePath $repoRoot $outputDirectory = Split-Path $resolvedOutput -Parent if (-not (Test-Path -LiteralPath $outputDirectory)) { New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null } $lines = New-Object System.Collections.Generic.List[string] $lines.Add("# Performance Summary") $lines.Add("") $lines.Add("- Scenario: ``$Scenario``") $lines.Add("- Result: **$result**") $lines.Add("- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')") $lines.Add("- Source CSV: ``$resolvedInput``") $lines.Add("") $lines.Add("## Metrics") $lines.Add("") $lines.Add("| Metric | Value |") $lines.Add("| --- | ---: |") foreach ($entry in $metrics.GetEnumerator()) { $lines.Add("| $($entry.Key) | $($entry.Value) |") } $lines.Add("") $lines.Add("## Threshold Checks") $lines.Add("") $lines.Add("| Check | Result | Actual | Limit |") $lines.Add("| --- | --- | ---: | ---: |") foreach ($check in $checks) { $status = if ($check.Passed) { "PASS" } else { "FAIL" } $lines.Add("| $($check.Name) | $status | $($check.Actual) | $($check.Limit) |") } $lines.Add("") $lines.Add("Resource thresholds left as ``null`` are reported but not used as release gates. Calibrate them after a reviewed Release baseline is available.") Set-Content -LiteralPath $resolvedOutput -Value $lines -Encoding UTF8 Write-Host "Performance report: $resolvedOutput" Write-Host "Result: $result" if ($failedChecks.Count -gt 0) { exit 1 }