<# .SYNOPSIS Merge gate for orisys-app: lint the change, run both test suites, run the UI check. .DESCRIPTION One command that a change cannot skip. It runs, in order: 1. tools/lint_gate.py the narrow lint rule set over the changed files 2. pytest apps/studio/tests full, with the ``timing`` tests deselected 3. pytest shared/tests full, with the ``timing`` tests deselected 4. apps/studio/tools/ui_check.py the layout / theme / record-channel checks and then holds the run to three contracts: * tools/known_failures.txt must be empty. Any content fails the gate: a known failure list is the thing this gate exists to make unnecessary. * the skip count per suite must stay under tools/skip_budget.txt. A skip that grows past the budget is a suite that stopped testing what it claims. * the collected test count per suite must stay above the floor in the same file. A module that becomes uncollectable silently shrinks the suite; the floor is how that shows up as a failure instead of a faster green run. Every command's output goes to /.scratch//gate-/, and the one-page summary is both printed and written there as summary.md. Platform: **Windows**. The suite renders real Qt widgets offscreen and two tests are platform-sensitive (see docs/test-repair-plan.zh.md ยง3 #11 and #13); Linux numbers are not equivalent. .PARAMETER Task Scratch task id, i.e. the in .scratch//. Defaults to "gate". .PARAMETER Python The interpreter to run the suites with. Defaults to .venv-build/Scripts/python.exe. .PARAMETER SkipLint Run only the suites and the UI check (used when diagnosing a red gate). .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 .NOTES Exit codes: 0 gate passed, 1 gate failed, 2 environment or configuration problem. #> [CmdletBinding()] param( [string]$Task = 'gate', [string]$Python = '', [switch]$SkipLint ) $ErrorActionPreference = 'Stop' $Repo = Split-Path -Parent $PSScriptRoot Set-Location $Repo function Write-Step([string]$Text) { Write-Host '' Write-Host "== $Text" -ForegroundColor Cyan } function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath) { # Every tool here writes progress and Qt diagnostics to stderr, and Windows # PowerShell turns a native command's stderr into error records: with # ``$ErrorActionPreference = 'Stop'`` a Qt warning ("This plugin does not support # raise()") aborted the whole gate. The exit code is the signal; stderr is data. $previous = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { & $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host return $LASTEXITCODE } finally { $ErrorActionPreference = $previous } } # ------------------------------------------------------------------ environment if ([string]::IsNullOrWhiteSpace($Python)) { $Python = Join-Path $Repo '.venv-build\Scripts\python.exe' } if (-not (Test-Path -LiteralPath $Python)) { Write-Host "gate: no interpreter at $Python (set -Python or build .venv-build)" -ForegroundColor Red exit 2 } $RunId = Get-Date -Format 'yyyyMMddTHHmmssZ' $TaskDir = Join-Path $Repo (Join-Path '.scratch' $Task) $Scratch = Join-Path $TaskDir "gate-$RunId" New-Item -ItemType Directory -Force -Path $Scratch | Out-Null # AGENTS.md rule 1: the scratch directory is marked while a task is using it. Set-Content -LiteralPath (Join-Path $TaskDir '.active') -Value "gate $RunId" $KnownFailures = Join-Path $Repo 'tools\known_failures.txt' $BudgetFile = Join-Path $Repo 'tools\skip_budget.txt' $Results = New-Object System.Collections.ArrayList function Add-Result([string]$Name, [string]$Status, [string]$Detail) { [void]$Results.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail }) } # ------------------------------------------------------------------ budgets function Read-Budget([string]$Path) { $values = @{} if (-not (Test-Path -LiteralPath $Path)) { return $values } foreach ($line in Get-Content -LiteralPath $Path) { $text = $line.Trim() if ($text -eq '' -or $text.StartsWith('#')) { continue } if ($text -notmatch '^([A-Za-z_]+)\s*=\s*(\d+)$') { Write-Host "gate: unreadable line in ${Path}: $text" -ForegroundColor Red throw "bad budget line: $text" } $values[$Matches[1]] = [int]$Matches[2] } return $values } # ------------------------------------------------------------------ known failures Write-Step 'known failures' $KnownStatus = 'passed' $KnownDetail = 'tools/known_failures.txt is empty' if (-not (Test-Path -LiteralPath $KnownFailures)) { $KnownStatus = 'failed' $KnownDetail = 'tools/known_failures.txt is missing' } else { $known = (Get-Content -LiteralPath $KnownFailures -Raw) if ($null -eq $known) { $known = '' } if ($known.Trim() -ne '') { $KnownStatus = 'failed' $KnownDetail = 'tools/known_failures.txt is not empty: ' + ($known.Trim() -replace '\r?\n', ' | ') } } Add-Result 'known failures' $KnownStatus $KnownDetail if ($KnownStatus -ne 'passed') { Write-Host $KnownDetail -ForegroundColor Red } else { Write-Host $KnownDetail } # ------------------------------------------------------------------ lint if (-not $SkipLint) { Write-Step 'lint (changed files)' $lintLog = Join-Path $Scratch 'lint.log' $lintExit = Invoke-Native $Python @((Join-Path $Repo 'tools\lint_gate.py')) $lintLog if ($lintExit -eq 0) { Add-Result 'lint' 'passed' 'no findings' } elseif ($lintExit -eq 1) { Add-Result 'lint' 'failed' 'findings in the changed files (see lint.log)' } else { Add-Result 'lint' 'failed' "lint_gate.py exited $lintExit (environment problem, e.g. ruff missing)" } } # ------------------------------------------------------------------ test suites function Invoke-Suite([string]$Name, [string]$Path) { $safe = $Name.Replace(' ', '-') $log = Join-Path $Scratch "$safe.log" $xml = Join-Path $Scratch "$safe.xml" $base = Join-Path $Scratch "pytest-$safe" Write-Step "$Name" $exit = Invoke-Native $Python @( '-m', 'pytest', $Path, '-m', 'not timing', '-p', 'no:cacheprovider', "--basetemp=$base", "--junitxml=$xml" ) $log $counts = [pscustomobject]@{ Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml) } if ($counts.Xml) { try { [xml]$doc = Get-Content -LiteralPath $xml $suites = @($doc.testsuites.testsuite) foreach ($suite in $suites) { $counts.Tests += [int]$suite.tests $counts.Failures += [int]$suite.failures $counts.Errors += [int]$suite.errors $counts.Skipped += [int]$suite.skipped } } catch { $counts.Xml = $false } } return $counts } function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$MaxKey) { # JUnit is authoritative for the result: the interpreter has been observed to crash # in Qt teardown *after* reporting every test (exit 0xC0000005 with "23 passed"), and # a crash that happens after the last assertion is not a failed assertion. The exit # code is still recorded, and a disagreement is called out. if (-not $Counts.Xml) { Add-Result $Name 'failed' "no JUnit report (pytest exit $($Counts.Exit))" return } $detail = "$($Counts.Tests) tests, $($Counts.Failures) failed, $($Counts.Errors) errors, $($Counts.Skipped) skipped" if ($Counts.Failures -ne 0 -or $Counts.Errors -ne 0) { Add-Result $Name 'failed' $detail return } $note = $detail $ok = $true if ($Counts.Exit -ne 0) { $note = "$detail; process exit $($Counts.Exit) (Qt teardown after the summary?)" } if ($Budget.ContainsKey($MinKey)) { $floor = [int]$Budget[$MinKey] if ($Counts.Tests -lt $floor) { $ok = $false $note = "$detail; only $($Counts.Tests) tests collected, floor is $floor" } } if ($Budget.ContainsKey($MaxKey)) { $ceiling = [int]$Budget[$MaxKey] if ($Counts.Skipped -gt $ceiling) { $ok = $false $note = "$detail; more than the $ceiling skips allowed" } } if ($ok) { Add-Result $Name 'passed' $note } else { Add-Result $Name 'failed' $note } } try { $budget = Read-Budget $BudgetFile } catch { Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red exit 2 } if ($budget.Count -eq 0) { Write-Host "gate: no budgets read from $BudgetFile (floors and skip ceilings are part of the gate)" -ForegroundColor Red exit 2 } Write-Step 'studio suite' $studio = Invoke-Suite 'studio suite' 'apps/studio/tests' Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips' Write-Step 'shared suite' $shared = Invoke-Suite 'shared suite' 'shared/tests' Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips' # ------------------------------------------------------------------ ui_check Write-Step 'ui_check' $uiLog = Join-Path $Scratch 'ui_check.log' $uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog # ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the verdict. $uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)' if ($uiSummary) { $match = $uiSummary[-1].Matches[0] $failures = [int]$match.Groups[1].Value $line = $uiSummary[-1].Line.Trim() if ($failures -eq 0) { Add-Result 'ui_check' 'passed' $line } else { Add-Result 'ui_check' 'failed' $line } } else { Add-Result 'ui_check' 'failed' "no 'checks: N ... failures: M' summary (exit $uiExit)" } # ------------------------------------------------------------------ summary $failed = @($Results | Where-Object { $_.Status -ne 'passed' }) $verdict = if ($failed.Count -eq 0) { 'PASS' } else { 'FAIL' } Write-Step "summary: $verdict" foreach ($result in $Results) { $colour = if ($result.Status -eq 'passed') { 'Green' } else { 'Red' } Write-Host (" {0,-16} {1,-7} {2}" -f $result.Name, $result.Status, $result.Detail) -ForegroundColor $colour } $lines = New-Object System.Collections.ArrayList [void]$lines.Add("# Gate $RunId : $verdict") [void]$lines.Add('') [void]$lines.Add("Platform : Windows") [void]$lines.Add("Python : $Python") [void]$lines.Add("Scratch : $Scratch") [void]$lines.Add('') [void]$lines.Add('| Check | Result | Detail |') [void]$lines.Add('|---|---|---|') foreach ($result in $Results) { [void]$lines.Add("| $($result.Name) | $($result.Status) | $($result.Detail) |") } [void]$lines.Add('') [void]$lines.Add('Logs: lint.log, studio-suite.log, shared-suite.log, ui_check.log, and the JUnit XMLs, in this directory.') $summaryPath = Join-Path $Scratch 'summary.md' Set-Content -LiteralPath $summaryPath -Value $lines -Encoding UTF8 Write-Host '' Write-Host "gate: $verdict (summary: $summaryPath)" if ($failed.Count -eq 0) { exit 0 } exit 1