tests/run.ps1
|
# ps12exe 统一测试入口。 # pwsh tests/run.ps1 # 按改动增量(本地无改动则跑 HEAD~1 的 diff;无 git 则全量) # pwsh tests/run.ps1 -All # 全量 # pwsh tests/run.ps1 -ChangedPathsFile changed.txt # pwsh tests/run.ps1 -Filter '*exe21sp*' -List # pwsh tests/run.ps1 -Group ps12exe -NoCache # pwsh tests/run.ps1 -PrintFingerprint # 仅输出源码指纹(CI 缓存键用) param( [string[]]$ChangedPaths = @(), [string]$ChangedPathsFile, [string[]]$Filter = @(), [string[]]$Group = @(), [switch]$All, [switch]$List, [switch]$NoCache, [int]$ThrottleLimit = 0, [int]$BuildThrottleLimit = 0, [int]$ShardCount = 1, [int]$ShardIndex = 0, [int]$TimeoutSeconds = 0, [switch]$KeepWorkDir, [switch]$PrintFingerprint ) $ErrorActionPreference = 'Stop' $script:AnyFailed = $false $Filter = @($Filter | ForEach-Object { $_ -split ',' } | Where-Object { $_.Trim() } | ForEach-Object { $_.Trim() }) $Group = @($Group | ForEach-Object { $_ -split ',' } | Where-Object { $_.Trim() } | ForEach-Object { $_.Trim() }) $libDir = Join-Path $PSScriptRoot 'lib' . (Join-Path $libDir 'common.ps1') # CI 用:输出编译输入指纹,供 actions/cache 键与构建缓存键同源(避免手写 hashFiles 列表漂移)。 # 只依赖 common.ps1,尽早退出以省去 exec/assert/framework 的加载开销。 if ($PrintFingerprint) { Write-Output (Get-SourceFingerprint -RepoRoot (Get-RepoRoot)) exit 0 } . (Join-Path $libDir 'exec.ps1') . (Join-Path $libDir 'assert.ps1') . (Join-Path $libDir 'framework.ps1') $repoRoot = Get-TestRepoRoot $env:REPO_ROOT = $repoRoot $cases = @(Get-AllTestCases) # ---- 变更解析与选择 ---- if ($ChangedPathsFile -and (Test-Path -LiteralPath $ChangedPathsFile)) { $fromFile = @(Get-Content -LiteralPath $ChangedPathsFile | Where-Object { $_.Trim() }) if ($fromFile.Count) { $ChangedPaths = $fromFile } } if ($All) { $selected = @($cases) $selectionReason = 'all' } else { if (-not $ChangedPaths -or $ChangedPaths.Count -eq 0) { $ChangedPaths = @(Get-GitChangedPaths -RepoRoot $repoRoot) } if (-not $ChangedPaths -or $ChangedPaths.Count -eq 0) { $selected = @($cases) $selectionReason = 'no changes detected -> all' } else { $selected = @(Get-AffectedCases -Cases $cases -ChangedPaths $ChangedPaths -RepoRoot $repoRoot) $selectionReason = "changed: $($ChangedPaths.Count) path(s)" } } if ($Group) { $selected = @($selected | Where-Object { $Group -contains $_.Group }) } if ($Filter) { $selected = @($selected | Where-Object { $name = $_.Name @($Filter | Where-Object { $name -like $_ }).Count -gt 0 }) } # 分片:CI 用 matrix 把用例拆到多个 runner 上并行,理论上限由最慢的一片决定。 if ($ShardCount -lt 1) { throw "ShardCount 必须 >= 1(当前 $ShardCount)" } if ($ShardIndex -lt 0 -or $ShardIndex -ge $ShardCount) { throw "ShardIndex 必须落在 [0, $ShardCount)(当前 $ShardIndex)" } if ($ShardCount -gt 1) { # 基于全部用例计算分片归属,保证同一用例跨 run 落在同一片(分片缓存可复用)。 $shardAssignment = Get-ShardAssignment -Cases $cases -ShardCount $ShardCount $selected = @(Select-ShardCases -Cases $selected -ShardCount $ShardCount -ShardIndex $ShardIndex -Assignment $shardAssignment) $selectionReason += "(分片 $ShardIndex/$ShardCount)" } if ($List) { Write-Output "用例($($selected.Count)/$($cases.Count)),选择依据:$selectionReason" foreach ($c in $selected) { Write-Output (" {0,-10} {1,-42} deps={2}" -f $c.Group, $c.Name, (@($c.Deps) -join ',')) } exit 0 } if ($selected.Count -eq 0) { Write-Output "没有需要运行的用例($selectionReason)。" exit 0 } $cacheRoot = Join-Path $PSScriptRoot '.cache' $buildCacheDir = Join-Path $cacheRoot 'builds' $jobsDir = Join-Path $cacheRoot 'jobs' $workRoot = Join-Path $cacheRoot 'work' foreach ($d in @($cacheRoot, $buildCacheDir, $jobsDir, $workRoot)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } $pwshExe = (Get-Process -Id $PID).Path if (-not $pwshExe) { $pwshExe = (Get-Command pwsh).Source } $workerPs1 = Join-Path $libDir 'worker.ps1' # 测试并发保持保守(GUI/私有控制台等用例对并发敏感);构建并发可适当超订以掩盖 # 进程创建/杀毒扫描/dotnet publish 的 I/O 等待,默认取 CPU 的约 2 倍、上限 8。 $cpuCount = [Environment]::ProcessorCount $throttle = if ($ThrottleLimit -gt 0) { $ThrottleLimit } else { [Math]::Max(1, [Math]::Min($cpuCount, 4)) } $buildThrottle = if ($BuildThrottleLimit -gt 0) { $BuildThrottleLimit } elseif ($ThrottleLimit -gt 0) { $ThrottleLimit } else { [Math]::Max($throttle, [Math]::Min($cpuCount * 2, 8)) } function New-JobFiles { param([string]$Prefix) $id = "$Prefix-" + [guid]::NewGuid().ToString('N') return @{ JobFile = Join-Path $jobsDir "$id.job.json" ResultFile = Join-Path $jobsDir "$id.result.json" LogFile = Join-Path $jobsDir "$id.log.txt" ErrFile = Join-Path $jobsDir "$id.err.txt" } } function Invoke-WorkerJobs { param([array]$Jobs, [int]$Throttle) $results = @() if (-not $Jobs -or $Jobs.Count -eq 0) { return $results } $queue = [System.Collections.Generic.Queue[object]]::new() foreach ($j in $Jobs) { $queue.Enqueue($j) } $running = @() $total = $Jobs.Count $done = 0 while ($queue.Count -gt 0 -or $running.Count -gt 0) { while ($running.Count -lt $Throttle -and $queue.Count -gt 0) { $j = $queue.Dequeue() $j.Started = [DateTime]::UtcNow $argsStr = '-NoProfile -NonInteractive -File "{0}" -Mode {1} -JobFile "{2}" -ResultFile "{3}"' -f $workerPs1, $j.Mode, $j.JobFile, $j.ResultFile $j.Proc = Start-Process -FilePath $pwshExe -ArgumentList $argsStr -PassThru -NoNewWindow -RedirectStandardOutput $j.LogFile -RedirectStandardError $j.ErrFile $running += , $j } Start-Sleep -Milliseconds 120 foreach ($j in @($running)) { $exited = $false try { $exited = $j.Proc.HasExited } catch { $exited = $true } if ($exited) { $running = @($running | Where-Object { $_ -ne $j }) $done++ $res = Read-JsonFile -Path $j.ResultFile if (-not $res) { $res = [pscustomobject]@{ Success = $false; Error = 'worker 未产出结果文件'; Stack = $null; Assertions = 0 } } $results += , @{ Job = $j; Result = $res } Write-Host (" [{0}/{1}] {2}" -f $done, $total, $j.Label) } elseif ((([DateTime]::UtcNow - $j.Started).TotalSeconds) -gt $j.Timeout) { Stop-ProcessTree -ProcessId $j.Proc.Id $running = @($running | Where-Object { $_ -ne $j }) $done++ $results += , @{ Job = $j; Result = [pscustomobject]@{ Success = $false; Error = "超时 $($j.Timeout)s"; Stack = $null; Assertions = 0 } } Write-Host (" [{0}/{1}] {2} (timeout)" -f $done, $total, $j.Label) } } } return $results } function Get-JobLog { param($Job) $text = '' foreach ($f in @($Job.LogFile, $Job.ErrFile)) { if ($f -and (Test-Path -LiteralPath $f)) { $text += (Get-Content -LiteralPath $f -Raw -ErrorAction SilentlyContinue) + "`n" } } return $text } # ---- 构建阶段:内容哈希去重 + 缓存 ---- # 每个构建按自身依赖的组件算指纹:改 CoreCompiler 不会连带失效 CodeDom/TinySharp 构建。 $fingerprintCache = @{} function Get-SpecFingerprint([hashtable]$Spec) { $names = @(Get-BuildFingerprintComponents -Spec $Spec) $id = $names -join '+' if (-not $fingerprintCache.ContainsKey($id)) { $fingerprintCache[$id] = Get-SourceFingerprint -RepoRoot $repoRoot -Components $names } return $fingerprintCache[$id] } $buildPlan = @{} foreach ($c in $selected) { $c.BuildMap = @{} foreach ($entry in (Get-CaseBuildSpecs -Case $c)) { $key = Get-BuildKey -Fingerprint (Get-SpecFingerprint -Spec $entry.Spec) -Spec $entry.Spec $c.BuildMap[$entry.Name] = $key if (-not $buildPlan.ContainsKey($key)) { $outputName = if ($entry.Spec.Output) { [string]$entry.Spec.Output } else { 'out.exe' } $outputDir = Join-Path $buildCacheDir $key $buildPlan[$key] = @{ Key = $key CaseName = $c.Name BuildName = $entry.Name OutputDir = $outputDir OutputName = $outputName } } } } $buildOutputs = @{} $buildJobs = @() $cachedCount = 0 foreach ($key in $buildPlan.Keys) { $bp = $buildPlan[$key] $outPath = Join-Path $bp.OutputDir $bp.OutputName $okMarker = Join-Path $bp.OutputDir '.ok' if (-not $NoCache -and (Test-Path -LiteralPath $okMarker) -and (Test-Path -LiteralPath $outPath)) { $buildOutputs[$key] = $outPath $cachedCount++ continue } if (Test-Path -LiteralPath $bp.OutputDir) { Remove-Item -LiteralPath $bp.OutputDir -Recurse -Force -ErrorAction SilentlyContinue } $files = New-JobFiles -Prefix "build-$($key.Substring(0,8))" Write-JsonFile -Path $files.JobFile -Object @{ Key = $key; CaseName = $bp.CaseName; BuildName = $bp.BuildName; OutputDir = $bp.OutputDir } $files.Mode = 'Build' $files.Label = "build $($bp.CaseName)/$($bp.BuildName)" $files.Timeout = if ($TimeoutSeconds -gt 0) { $TimeoutSeconds } else { 900 } $files.Key = $key $files.OutputName = $bp.OutputName $files.OutputDir = $bp.OutputDir $buildJobs += , $files } if ($buildJobs.Count) { Write-Host "== 构建阶段:$($buildJobs.Count) 个构建(缓存命中 $cachedCount,并行度 $buildThrottle)==" $buildResults = Invoke-WorkerJobs -Jobs $buildJobs -Throttle $buildThrottle foreach ($br in $buildResults) { $j = $br.Job if ($br.Result.Success) { Write-TextFileNoBom -Path (Join-Path $j.OutputDir '.ok') -Text $j.Key $buildOutputs[$j.Key] = Join-Path $j.OutputDir $j.OutputName } else { $script:AnyFailed = $true Write-Output "::error title=BUILD $($j.Label)::$($br.Result.Error)" Write-Output "构建失败:$($j.Label) -> $($br.Result.Error)" $log = (Get-JobLog -Job $j) if ($log) { Write-Output (($log -split "`r?`n" | Select-Object -Last 60) -join "`n") } } } } elseif ($cachedCount) { Write-Host "== 构建阶段:全部命中缓存($cachedCount)==" } # ---- 测试阶段 ---- $testJobs = @() $serialCases = @() foreach ($c in $selected) { $builds = @{} $missing = $false foreach ($name in $c.BuildMap.Keys) { $key = $c.BuildMap[$name] if (-not $buildOutputs.ContainsKey($key) -or -not (Test-Path -LiteralPath $buildOutputs[$key])) { $missing = $true; break } $builds[$name] = $buildOutputs[$key] } if ($missing) { $script:AnyFailed = $true; Write-Output "::error title=TEST $($c.Name)::依赖的构建未成功,跳过用例"; continue } if ($c.Serial) { $serialCases += , @{ Case = $c; Builds = $builds }; continue } $safe = $c.Name -replace '[^\w\.\-]', '_' $workDir = Join-Path $workRoot "$safe-$([guid]::NewGuid().ToString('N').Substring(0,8))" $files = New-JobFiles -Prefix "test-$safe" Write-JsonFile -Path $files.JobFile -Object @{ Name = $c.Name; WorkDir = $workDir; CacheDir = $cacheRoot; Builds = $builds } $files.Mode = 'Test' $files.Label = "test $($c.Name)" $files.Timeout = if ($TimeoutSeconds -gt 0) { $TimeoutSeconds } else { $c.Timeout } $files.Case = $c $files.WorkDir = $workDir $testJobs += , $files } function Convert-TestResults { param([array]$RawResults) $out = @() foreach ($tr in $RawResults) { $j = $tr.Job $ok = [bool]$tr.Result.Success $out += , @{ Name = $j.Case.Name Group = $j.Case.Group Success = $ok Error = [string]$tr.Result.Error Stack = [string]$tr.Result.Stack Assertions = [int]$tr.Result.Assertions Log = (Get-JobLog -Job $j) WorkDir = $j.WorkDir } } return $out } $testResults = @() if ($testJobs.Count) { Write-Host "== 测试阶段:$($testJobs.Count) 个用例(并行度 $throttle)==" $testResults += Convert-TestResults (Invoke-WorkerJobs -Jobs $testJobs -Throttle $throttle) } # 串行用例(会改动共享环境,如注册表),在并行批次之后独占执行。 foreach ($sc in $serialCases) { $c = $sc.Case $safe = $c.Name -replace '[^\w\.\-]', '_' $workDir = Join-Path $workRoot "$safe-$([guid]::NewGuid().ToString('N').Substring(0,8))" $files = New-JobFiles -Prefix "test-$safe" Write-JsonFile -Path $files.JobFile -Object @{ Name = $c.Name; WorkDir = $workDir; CacheDir = $cacheRoot; Builds = $sc.Builds } $files.Mode = 'Test' $files.Label = "test $($c.Name) [serial]" $files.Timeout = if ($TimeoutSeconds -gt 0) { $TimeoutSeconds } else { $c.Timeout } $files.Case = $c $files.WorkDir = $workDir Write-Host "== 串行用例:$($c.Name) ==" $testResults += Convert-TestResults (Invoke-WorkerJobs -Jobs @($files) -Throttle 1) } # ---- 汇总 ---- $failures = @($testResults | Where-Object { -not $_.Success }) foreach ($f in $failures) { $script:AnyFailed = $true Write-CIGitHubReport -Failures @(@{ Name = $f.Name; Error = $f.Error; Stack = $f.Stack; Log = $f.Log }) } $totalAssertions = ($testResults | Measure-Object -Property Assertions -Sum).Sum Write-Host '' Write-Host '==================== 测试汇总 ====================' foreach ($r in ($testResults | Sort-Object Name)) { $status = if ($r.Success) { 'PASS' } else { 'FAIL' } Write-Host (" {0,-6} {1,-12} {2,-42} asserts={3}" -f $status, $r.Group, $r.Name, $r.Assertions) } $passed = @($testResults | Where-Object { $_.Success }).Count $failed = $failures.Count Write-Host ("用例:{0} 通过 / {1} 失败;断言 {2};构建缓存命中 {3}" -f $passed, $failed, $totalAssertions, $cachedCount) if (-not $KeepWorkDir) { foreach ($r in $testResults) { if ($r.WorkDir -and (Test-Path -LiteralPath $r.WorkDir)) { Remove-Item -LiteralPath $r.WorkDir -Recurse -Force -ErrorAction SilentlyContinue } } } if ($script:AnyFailed -or $failed -gt 0) { exit 1 } Write-Output 'Nice CI!' exit 0 |