清理废弃模块并完善测试基础设施

This commit is contained in:
2026-07-17 23:49:45 +08:00
parent d0d1de4d00
commit 90262aaf6d
102 changed files with 1711 additions and 8175 deletions
+321
View File
@@ -0,0 +1,321 @@
param(
[string]$RepoRoot = "",
[string]$ConfigPath = "",
[string]$ReportPath = ""
)
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 Get-RelativePath {
param(
[string]$Root,
[string]$Path
)
$rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$fullPath = [System.IO.Path]::GetFullPath($Path)
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Path is outside repository root: $fullPath"
}
return $fullPath.Substring($rootPrefix.Length).Replace('\', '/')
}
function Get-TextFiles {
param(
[string]$Root,
[object[]]$Paths
)
$allowedExtensions = @(".h", ".hpp", ".cpp", ".cxx", ".md", ".txt", ".json", ".cmake", ".ps1")
$files = New-Object System.Collections.Generic.List[System.IO.FileInfo]
foreach ($relativePath in $Paths) {
$absolutePath = Resolve-AbsolutePath -Path ([string]$relativePath) -BasePath $Root
if (-not (Test-Path -LiteralPath $absolutePath)) {
continue
}
$item = Get-Item -LiteralPath $absolutePath
if (-not $item.PSIsContainer) {
$files.Add($item)
continue
}
foreach ($file in Get-ChildItem -LiteralPath $absolutePath -Recurse -File) {
if ($allowedExtensions -contains $file.Extension.ToLowerInvariant()) {
$files.Add($file)
}
}
}
return @($files | Sort-Object FullName -Unique)
}
function Invoke-GitDiffCheck {
param([string]$Root)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = "git"
$escapedRoot = $Root.Replace('"', '\"')
$startInfo.Arguments = "-C `"$escapedRoot`" diff --check"
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "Unable to start git for diff validation."
}
$standardOutput = $process.StandardOutput.ReadToEnd()
$standardError = $process.StandardError.ReadToEnd()
$process.WaitForExit()
return [pscustomobject]@{
ExitCode = $process.ExitCode
Output = ($standardOutput + $standardError).Trim()
}
}
$defaultRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
$resolvedRoot = if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
$defaultRoot
} else {
Resolve-AbsolutePath -Path $RepoRoot -BasePath (Get-Location)
}
$resolvedConfig = if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
Join-Path $resolvedRoot "internal/qa/config/static-checks.json"
} else {
Resolve-AbsolutePath -Path $ConfigPath -BasePath $resolvedRoot
}
if (-not (Test-Path -LiteralPath $resolvedConfig -PathType Leaf)) {
throw "Static-check configuration was not found: $resolvedConfig"
}
$config = Get-Content -LiteralPath $resolvedConfig -Raw -Encoding UTF8 | ConvertFrom-Json
if ($config.schemaVersion -ne 1) {
throw "Unsupported static-check configuration version: $($config.schemaVersion)"
}
$results = New-Object System.Collections.Generic.List[object]
function Add-Result {
param(
[string]$Name,
[bool]$Passed,
[string]$Details
)
$results.Add([pscustomobject]@{
Name = $Name
Passed = $Passed
Details = $Details
})
}
$cmakePath = Resolve-AbsolutePath -Path ([string]$config.productionCmakeFile) -BasePath $resolvedRoot
$cmakeText = Get-Content -LiteralPath $cmakePath -Raw -Encoding UTF8
$productionFiles = New-Object System.Collections.Generic.List[System.IO.FileInfo]
foreach ($sourceRoot in $config.productionSourceRoots) {
$sourceRootPath = Resolve-AbsolutePath -Path ([string]$sourceRoot) -BasePath $resolvedRoot
foreach ($file in Get-ChildItem -LiteralPath $sourceRootPath -Recurse -File) {
if ($config.productionExtensions -contains $file.Extension.ToLowerInvariant()) {
$productionFiles.Add($file)
}
}
}
$missingFromCmake = New-Object System.Collections.Generic.List[string]
foreach ($file in $productionFiles) {
$relativePath = Get-RelativePath -Root $resolvedRoot -Path $file.FullName
if (-not $cmakeText.Contains($relativePath)) {
$missingFromCmake.Add($relativePath)
}
}
Add-Result -Name "Production sources are registered in CMake" `
-Passed ($missingFromCmake.Count -eq 0) `
-Details ($(if ($missingFromCmake.Count -eq 0) { "$($productionFiles.Count) source/header files registered" } else { "Missing: " + ($missingFromCmake -join ", ") }))
$missingCmakeReferences = New-Object System.Collections.Generic.List[string]
$cmakeReferencePattern = 'src[\\/][A-Za-z0-9_.\\/-]+\.(?:h|cpp)'
foreach ($match in [regex]::Matches($cmakeText, $cmakeReferencePattern)) {
$relativePath = $match.Value.Replace('\', '/')
if (-not (Test-Path -LiteralPath (Join-Path $resolvedRoot $relativePath) -PathType Leaf)) {
$missingCmakeReferences.Add($relativePath)
}
}
Add-Result -Name "CMake does not reference missing production sources" `
-Passed ($missingCmakeReferences.Count -eq 0) `
-Details ($(if ($missingCmakeReferences.Count -eq 0) { "No missing source references" } else { "Missing: " + (($missingCmakeReferences | Sort-Object -Unique) -join ", ") }))
$qaCmakePath = Resolve-AbsolutePath -Path ([string]$config.qaCmakeFile) -BasePath $resolvedRoot
$qaRoot = Split-Path $qaCmakePath -Parent
$qaCmakeText = Get-Content -LiteralPath $qaCmakePath -Raw -Encoding UTF8
$missingQaTests = New-Object System.Collections.Generic.List[string]
$qaTestCount = 0
foreach ($testRoot in $config.qaTestRoots) {
$testRootPath = Resolve-AbsolutePath -Path ([string]$testRoot) -BasePath $resolvedRoot
foreach ($testFile in Get-ChildItem -LiteralPath $testRootPath -Recurse -Filter "*.cpp" -File) {
++$qaTestCount
$relativeTestPath = Get-RelativePath -Root $qaRoot -Path $testFile.FullName
if (-not $qaCmakeText.Contains($relativeTestPath)) {
$missingQaTests.Add($relativeTestPath)
}
}
}
Add-Result -Name "Internal QA test sources are registered in the QA CMake project" `
-Passed ($missingQaTests.Count -eq 0) `
-Details ($(if ($missingQaTests.Count -eq 0) { "$qaTestCount test translation units registered" } else { "Missing: " + ($missingQaTests -join ", ") }))
$activeFiles = Get-TextFiles -Root $resolvedRoot -Paths $config.activeScanPaths
foreach ($rule in $config.forbiddenReferences) {
$matches = New-Object System.Collections.Generic.List[string]
foreach ($file in $activeFiles) {
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
if ($content -match $rule.pattern) {
$matches.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
}
}
Add-Result -Name "No $($rule.name)" `
-Passed ($matches.Count -eq 0) `
-Details ($(if ($matches.Count -eq 0) { "No active references" } else { "Found in: " + (($matches | Sort-Object -Unique) -join ", ") }))
}
if ($config.forbidQObjectMacro) {
$qObjectFiles = New-Object System.Collections.Generic.List[string]
foreach ($file in $productionFiles) {
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
if ($content -match '\bQ_OBJECT\b') {
$qObjectFiles.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
}
}
Add-Result -Name "Production code remains compatible with CMAKE_AUTOMOC OFF" `
-Passed ($qObjectFiles.Count -eq 0) `
-Details ($(if ($qObjectFiles.Count -eq 0) { "No Q_OBJECT macro found" } else { "Found in: " + ($qObjectFiles -join ", ") }))
}
$missingRequiredPaths = @($config.requiredPaths | Where-Object {
-not (Test-Path -LiteralPath (Resolve-AbsolutePath -Path ([string]$_) -BasePath $resolvedRoot))
})
Add-Result -Name "Required release inputs exist" `
-Passed ($missingRequiredPaths.Count -eq 0) `
-Details ($(if ($missingRequiredPaths.Count -eq 0) { "$($config.requiredPaths.Count) required paths found" } else { "Missing: " + ($missingRequiredPaths -join ", ") }))
$characterManifestPath = Join-Path $resolvedRoot "resources/characters/shiroko/character.json"
$characterProblems = New-Object System.Collections.Generic.List[string]
try {
$character = Get-Content -LiteralPath $characterManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ([string]::IsNullOrWhiteSpace([string]$character.defaultState)) {
$characterProblems.Add("defaultState is empty")
}
foreach ($stateProperty in $character.states.PSObject.Properties) {
$statePath = Join-Path (Split-Path $characterManifestPath -Parent) ([string]$stateProperty.Value.path)
if (-not (Test-Path -LiteralPath $statePath -PathType Container)) {
$characterProblems.Add("state directory missing: $($stateProperty.Name)")
continue
}
if (@(Get-ChildItem -LiteralPath $statePath -Filter "*.png" -File).Count -eq 0) {
$characterProblems.Add("state has no PNG frames: $($stateProperty.Name)")
}
}
} catch {
$characterProblems.Add("manifest parse failed: $($_.Exception.Message)")
}
Add-Result -Name "Default character manifest and frames are complete" `
-Passed ($characterProblems.Count -eq 0) `
-Details ($(if ($characterProblems.Count -eq 0) { "All declared states contain PNG frames" } else { $characterProblems -join "; " }))
$exportScriptPath = Join-Path $resolvedRoot "tools/prepare_github_export.ps1"
$exportScriptText = Get-Content -LiteralPath $exportScriptPath -Raw -Encoding UTF8
$missingExportExclusions = @($config.publicExportExcludedRoots | Where-Object {
$quotedName = '"' + [regex]::Escape([string]$_) + '"'
$exportScriptText -notmatch $quotedName
})
Add-Result -Name "Public GitHub export excludes internal development roots" `
-Passed ($missingExportExclusions.Count -eq 0) `
-Details ($(if ($missingExportExclusions.Count -eq 0) { "Excluded: " + ($config.publicExportExcludedRoots -join ", ") } else { "Not excluded: " + ($missingExportExclusions -join ", ") }))
$releaseIsolationMatches = New-Object System.Collections.Generic.List[string]
foreach ($relativeFile in $config.releaseIsolationFiles) {
$absoluteFile = Resolve-AbsolutePath -Path ([string]$relativeFile) -BasePath $resolvedRoot
$content = Get-Content -LiteralPath $absoluteFile -Raw -Encoding UTF8
foreach ($pattern in $config.releaseForbiddenPatterns) {
if ($content -match $pattern) {
$releaseIsolationMatches.Add("$relativeFile -> $pattern")
}
}
}
Add-Result -Name "Production build and packaging do not depend on internal QA" `
-Passed ($releaseIsolationMatches.Count -eq 0) `
-Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " }))
$infrastructureProblems = New-Object System.Collections.Generic.List[string]
foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) {
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$scriptFile.FullName,
[ref]$tokens,
[ref]$parseErrors) | Out-Null
foreach ($parseError in $parseErrors) {
$infrastructureProblems.Add("$($scriptFile.Name):$($parseError.Extent.StartLineNumber): $($parseError.Message)")
}
}
foreach ($jsonFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/config") -Filter "*.json" -File) {
try {
Get-Content -LiteralPath $jsonFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
} catch {
$infrastructureProblems.Add("$($jsonFile.Name): $($_.Exception.Message)")
}
}
Add-Result -Name "Internal QA scripts and configuration parse successfully" `
-Passed ($infrastructureProblems.Count -eq 0) `
-Details ($(if ($infrastructureProblems.Count -eq 0) { "All PowerShell and JSON files parsed" } else { $infrastructureProblems -join "; " }))
$gitResult = Invoke-GitDiffCheck -Root $resolvedRoot
Add-Result -Name "Git diff whitespace check" `
-Passed ($gitResult.ExitCode -eq 0) `
-Details ($(if ($gitResult.ExitCode -eq 0) { "git diff --check passed" } else { $gitResult.Output }))
$passedCount = @($results | Where-Object Passed).Count
$failedResults = @($results | Where-Object { -not $_.Passed })
foreach ($result in $results) {
$prefix = if ($result.Passed) { "PASS" } else { "FAIL" }
Write-Host "[$prefix] $($result.Name) - $($result.Details)"
}
Write-Host "Static checks: $passedCount passed, $($failedResults.Count) failed."
if (-not [string]::IsNullOrWhiteSpace($ReportPath)) {
$resolvedReportPath = Resolve-AbsolutePath -Path $ReportPath -BasePath $resolvedRoot
$reportDirectory = Split-Path $resolvedReportPath -Parent
if (-not (Test-Path -LiteralPath $reportDirectory)) {
New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add("# Static Check Report")
$lines.Add("")
$lines.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
$lines.Add("")
$lines.Add("| Check | Result | Details |")
$lines.Add("| --- | --- | --- |")
foreach ($result in $results) {
$status = if ($result.Passed) { "PASS" } else { "FAIL" }
$details = ([string]$result.Details).Replace("|", "\|").Replace("`r", " ").Replace("`n", " ")
$lines.Add("| $($result.Name) | $status | $details |")
}
Set-Content -LiteralPath $resolvedReportPath -Value $lines -Encoding UTF8
Write-Host "Report: $resolvedReportPath"
}
if ($failedResults.Count -gt 0) {
exit 1
}
@@ -0,0 +1,203 @@
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
}
+170
View File
@@ -0,0 +1,170 @@
param(
[string]$ProcessName = "QtDesktopPet",
[Alias("Pid")]
[int]$ProcessId = 0,
[ValidateRange(1, 86400)]
[int]$IntervalSeconds = 5,
[ValidateRange(1, 604800)]
[int]$DurationSeconds = 300,
[string]$Scenario = "unspecified",
[string]$OutputPath = "reports/perf"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-TargetProcess {
param(
[string]$Name,
[int]$Id
)
if ($Id -gt 0) {
return Get-Process -Id $Id -ErrorAction SilentlyContinue
}
$processes = @(Get-Process -Name $Name -ErrorAction SilentlyContinue)
if ($processes.Count -eq 0) {
return $null
}
if ($processes.Count -gt 1) {
Write-Warning "Multiple processes named '$Name' were found. Sampling PID $($processes[0].Id). Use -Pid to target a specific process."
}
return $processes[0]
}
function Read-ProcessSnapshot {
param([System.Diagnostics.Process]$Process)
$path = ""
try { $path = $Process.Path } catch { $path = "" }
$responding = ""
try { $responding = $Process.Responding } catch { $responding = "" }
return [pscustomobject]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
Pid = $Process.Id
CpuSeconds = [double]($Process.CPU)
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
HandleCount = $Process.HandleCount
ThreadCount = $Process.Threads.Count
Responding = $responding
Path = $path
}
}
function Resolve-OutputDirectory {
param([string]$Path)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
function New-OutputFilePath {
param(
[string]$Directory,
[string]$Name
)
if (-not (Test-Path -LiteralPath $Directory)) {
New-Item -ItemType Directory -Force -Path $Directory | Out-Null
}
$safeName = $Name -replace '[^a-zA-Z0-9._-]', '_'
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
return Join-Path $Directory "$timestamp-$safeName.csv"
}
$target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
if ($null -eq $target) {
if ($ProcessId -gt 0) {
Write-Error "Process with PID $ProcessId was not found."
} else {
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
}
exit 1
}
$logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount)
$outputDirectory = Resolve-OutputDirectory -Path $OutputPath
$outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)"
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
Write-Host "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
Write-Host "Output: $outputFile"
$previousSnapshot = Read-ProcessSnapshot -Process $target
$previousTime = Get-Date
$firstRow = [pscustomobject]@{
Scenario = $Scenario
SampleIndex = 0
TimestampUtc = $previousSnapshot.TimestampUtc
Pid = $previousSnapshot.Pid
CpuPercent = 0
WorkingSetMB = $previousSnapshot.WorkingSetMB
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
HandleCount = $previousSnapshot.HandleCount
ThreadCount = $previousSnapshot.ThreadCount
Responding = $previousSnapshot.Responding
Path = $previousSnapshot.Path
Status = "running"
}
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
for ($index = 1; $index -le $sampleCount; $index++) {
Start-Sleep -Seconds $IntervalSeconds
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
if ($null -eq $currentProcess) {
[pscustomobject]@{
Scenario = $Scenario
SampleIndex = $index
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
Pid = $target.Id
CpuPercent = ""
WorkingSetMB = ""
PrivateMemoryMB = ""
HandleCount = ""
ThreadCount = ""
Responding = ""
Path = $previousSnapshot.Path
Status = "exited"
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
Write-Warning "Process $($target.Id) exited. Sampling stopped."
break
}
$currentSnapshot = Read-ProcessSnapshot -Process $currentProcess
$currentTime = Get-Date
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
if ($cpuPercent -lt 0) { $cpuPercent = 0 }
[pscustomobject]@{
Scenario = $Scenario
SampleIndex = $index
TimestampUtc = $currentSnapshot.TimestampUtc
Pid = $currentSnapshot.Pid
CpuPercent = [math]::Round($cpuPercent, 2)
WorkingSetMB = $currentSnapshot.WorkingSetMB
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
HandleCount = $currentSnapshot.HandleCount
ThreadCount = $currentSnapshot.ThreadCount
Responding = $currentSnapshot.Responding
Path = $currentSnapshot.Path
Status = "running"
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
$previousSnapshot = $currentSnapshot
$previousTime = $currentTime
}
Write-Host "Sampling finished: $outputFile"