From 9a172cd6e3f1b645d1b08daf747ce631b208872a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 3 Jul 2026 14:04:55 +0300 Subject: [PATCH 1/2] fix(audit): resolve linting and syntax issues, update actions --- .github/workflows/codeql.yml | 26 +++++++++++++------------- .github/workflows/pages.yml | 2 +- scripts/autopilot-operator.ps1 | 21 +++++++++++---------- scripts/lib/Autopilot.Common.psm1 | 24 ++++++++++++------------ tests/contract-tests.ps1 | 26 +++++++++++++------------- 5 files changed, 50 insertions(+), 49 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8beec42..1def497 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,9 +1,9 @@ name: "CodeQL" on: push: - branches: [ "main", "master" ] + branches: ["main", "master"] pull_request: - branches: [ "main", "master" ] + branches: ["main", "master"] jobs: analyze: name: Analyze @@ -15,15 +15,15 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python' ] + language: ['python'] steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + - name: Checkout repository + uses: actions/checkout@v6 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6c1d78d..0f43fae 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -15,7 +15,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: 3.x diff --git a/scripts/autopilot-operator.ps1 b/scripts/autopilot-operator.ps1 index da5bf1b..a7a79a1 100644 --- a/scripts/autopilot-operator.ps1 +++ b/scripts/autopilot-operator.ps1 @@ -8,9 +8,9 @@ Initialize-Log Assert-Env -Name "ORG" $org = $env:ORG -$maxIssues = [int]($env:MAX_ISSUES ?? 5) -$dryRun = ($env:DRY_RUN ?? "false") -eq "true" -$allowUnverified = ($env:ALLOW_UNVERIFIED ?? "false") -eq "true" +$maxIssues = if ($env:MAX_ISSUES) { [int]$env:MAX_ISSUES } else { 5 } +$dryRun = if ($env:DRY_RUN) { $env:DRY_RUN -eq "true" } else { $false } +$allowUnverified = if ($env:ALLOW_UNVERIFIED) { $env:ALLOW_UNVERIFIED -eq "true" } else { $false } $allowlist = @() if ($env:REPO_ALLOWLIST) { $allowlist = $env:REPO_ALLOWLIST.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ } @@ -23,7 +23,7 @@ Test-Tool -Name "codex" Write-Log "Autopilot operator starting for org: $org" Write-Log "Max issues: $maxIssues Dry run: $dryRun" -function Get-ChangedFiles { +function Get-ChangedFile { $paths = @() foreach ($line in @(git status --porcelain)) { if (-not $line -or $line.Length -lt 4) { continue } @@ -56,7 +56,7 @@ function Assert-SafeChangeSet { if ($changedLines -gt $MaxLines) { throw "Change set has $changedLines changed lines; limit is $MaxLines." } } -function Search-Issues { +function Search-Issue { param([string]$SearchQuery, [int]$First) $gql = @' query($q:String!, $first:Int!) { @@ -83,7 +83,7 @@ query($q:String!, $first:Int!) { $issues = @() $query = "org:$org is:issue label:autofix label:queued -label:blocked -label:risky -label:needs-design -label:try-3" -$issues += Search-Issues -SearchQuery $query -First $maxIssues +$issues += Search-Issue -SearchQuery $query -First $maxIssues if (-not $issues -or $issues.Count -eq 0) { Write-Log "No issues found." @@ -117,7 +117,8 @@ foreach ($issue in $issues) { continue } - $attempt = 1 if ($existingLabels -contains "try-2") { $attempt = 3 } + $attempt = 1 + if ($existingLabels -contains "try-2") { $attempt = 3 } elseif ($existingLabels -contains "try-1") { $attempt = 2 } $attemptLabel = $attemptLabels[$attempt - 1] @@ -234,9 +235,9 @@ foreach ($issue in $issues) { continue } - $filesChanged = @(Get-ChangedFiles) - $maxChangedFiles = [int]($env:MAX_CHANGED_FILES ?? 20) - $maxChangedLines = [int]($env:MAX_CHANGED_LINES ?? 1000) + $filesChanged = @(Get-ChangedFile) + $maxChangedFiles = if ($env:MAX_CHANGED_FILES) { [int]$env:MAX_CHANGED_FILES } else { 20 } + $maxChangedLines = if ($env:MAX_CHANGED_LINES) { [int]$env:MAX_CHANGED_LINES } else { 1000 } Assert-SafeChangeSet -Paths $filesChanged -MaxFiles $maxChangedFiles -MaxLines $maxChangedLines $verification = "skipped" diff --git a/scripts/lib/Autopilot.Common.psm1 b/scripts/lib/Autopilot.Common.psm1 index f37e62c..0c000fb 100644 --- a/scripts/lib/Autopilot.Common.psm1 +++ b/scripts/lib/Autopilot.Common.psm1 @@ -2,7 +2,7 @@ function Write-Log { param([string]$Message, [string]$Level = "INFO") $timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") $line = "[$timestamp] [$Level] $Message" - Write-Host $line + Write-Output $line if ($script:LogFile) { Add-Content -Path $script:LogFile -Value $line } @@ -24,8 +24,8 @@ function Assert-Env { } function Invoke-Gh { - param([string[]]$Args) - $cmd = @("gh") + $Args + param([string[]]$Arguments) + $cmd = @("gh") + $Arguments Write-Log "Running: $($cmd -join ' ')" & $cmd if ($LASTEXITCODE -ne 0) { @@ -34,8 +34,8 @@ function Invoke-Gh { } function Invoke-GhJson { - param([string[]]$Args) - $json = gh @Args 2>$null + param([string[]]$Arguments) + $json = gh @Arguments 2>$null if (-not $json) { return $null } $trimmed = $json.Trim() $objIndex = $trimmed.IndexOf('{') @@ -62,21 +62,21 @@ function Test-Tool { } function Invoke-Checked { - param([string]$Command, [string[]]$Args = @()) - Write-Log "Running: $Command $($Args -join ' ')" - & $Command @Args + param([string]$Command, [string[]]$Arguments = @()) + Write-Log "Running: $Command $($Arguments -join ' ')" + & $Command @Arguments if ($LASTEXITCODE -ne 0) { throw "Command failed: $Command (exit $LASTEXITCODE)" } } function Invoke-CheckedLogged { - param([string]$Command, [string[]]$Args = @(), [string]$LogPath) - Write-Log "Running: $Command $($Args -join ' ')" + param([string]$Command, [string[]]$Arguments = @(), [string]$LogPath) + Write-Log "Running: $Command $($Arguments -join ' ')" if ($LogPath) { - & $Command @Args 2>&1 | Tee-Object -FilePath $LogPath -Append + & $Command @Arguments 2>&1 | Tee-Object -FilePath $LogPath -Append } else { - & $Command @Args + & $Command @Arguments } if ($LASTEXITCODE -ne 0) { throw "Command failed: $Command (exit $LASTEXITCODE)" diff --git a/tests/contract-tests.ps1 b/tests/contract-tests.ps1 index b0adf28..a1b00e1 100644 --- a/tests/contract-tests.ps1 +++ b/tests/contract-tests.ps1 @@ -1,11 +1,11 @@ $ErrorActionPreference = "Stop" -function Assert-Contains { +function Assert-TextMatch { param([string]$Text, [string]$Pattern, [string]$Message) if ($Text -notmatch $Pattern) { throw $Message } } -function Assert-NotContains { +function Assert-TextNotMatch { param([string]$Text, [string]$Pattern, [string]$Message) if ($Text -match $Pattern) { throw $Message } } @@ -15,17 +15,17 @@ $workflow = Get-Content -Raw ".github/workflows/autopilot-operator.yml" $installer = Get-Content -Raw ".github/workflows/autopilot-org-installer.yml" $allWorkflows = (Get-ChildItem -Recurse -File -Include *.yml,*.yaml | ForEach-Object { Get-Content -Raw $_.FullName }) -join "`n" -Assert-Contains $operator 'label:autofix label:queued' "Operator must require autofix and queued labels." -Assert-NotContains $operator 'no:label' "Operator must not execute unlabeled issues." -Assert-Contains $operator '-label:try-3' "Operator must exclude exhausted issues." -Assert-Contains $operator 'BEGIN UNTRUSTED ISSUE CONTENT' "Operator must delimit untrusted prompt content." -Assert-Contains $operator 'Assert-SafeChangeSet' "Operator must validate generated changes." -Assert-Contains $operator 'ALLOW_UNVERIFIED' "Operator must enforce verification by default." -Assert-Contains $workflow 'secrets\.ORG_AUTOPILOT_TOKEN' "Workflow must use an explicit org mutation token." -Assert-NotContains $workflow 'GH_TOKEN: \$\{\{ secrets\.GITHUB_TOKEN \}\}' "Workflow must not use repository token for org mutations." +Assert-TextMatch -Text $operator -Pattern 'label:autofix label:queued' -Message "Operator must require autofix and queued labels." +Assert-TextNotMatch -Text $operator -Pattern 'no:label' -Message "Operator must not execute unlabeled issues." +Assert-TextMatch -Text $operator -Pattern '-label:try-3' -Message "Operator must exclude exhausted issues." +Assert-TextMatch -Text $operator -Pattern 'BEGIN UNTRUSTED ISSUE CONTENT' -Message "Operator must delimit untrusted prompt content." +Assert-TextMatch -Text $operator -Pattern 'Assert-SafeChangeSet' -Message "Operator must validate generated changes." +Assert-TextMatch -Text $operator -Pattern 'ALLOW_UNVERIFIED' -Message "Operator must enforce verification by default." +Assert-TextMatch -Text $workflow -Pattern 'secrets\.ORG_AUTOPILOT_TOKEN' -Message "Workflow must use an explicit org mutation token." +Assert-TextNotMatch -Text $workflow -Pattern 'GH_TOKEN: \$\{\{ secrets\.GITHUB_TOKEN \}\}' -Message "Workflow must not use repository token for org mutations." -Assert-NotContains $installer 'autofix,queued,docs' "Installer must not queue automation before repository opt-in." +Assert-TextNotMatch -Text $installer -Pattern 'autofix,queued,docs' -Message "Installer must not queue automation before repository opt-in." -Assert-NotContains $allWorkflows 'actions/checkout@v4|actions/github-script@v7' "Workflows must not use deprecated Node.js 20 action majors." +Assert-TextNotMatch -Text $allWorkflows -Pattern 'actions/checkout@v4|actions/github-script@v7' -Message "Workflows must not use deprecated Node.js 20 action majors." -Write-Host "Control-plane contract tests passed." +Write-Output "Control-plane contract tests passed." From b65e25ae268c8810beaaafc0464af5f579609e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Fri, 3 Jul 2026 14:27:47 +0300 Subject: [PATCH 2/2] test: add Pester unit suite for operator safety and gh payload logic Cover the highest-blast-radius logic in the org-mutation engine: - Assert-SafeChangeSet sensitive-path and diff-budget guards - Get-ChangedFile porcelain parsing (renames, quoting, dedupe) - Search-Issue GraphQL request construction - Autopilot.Common helpers (Get-RepoName, Invoke-GhJson, Get-LogTail) Operator functions are lifted via AST so no live gh/git runs on load. Add tests/run-tests.ps1 single-command runner and wire Pester into CI. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 11 ++ README.md | 16 ++- tests/Autopilot.Common.Tests.ps1 | 82 +++++++++++++++ tests/Autopilot.Operator.Tests.ps1 | 157 +++++++++++++++++++++++++++++ tests/run-tests.ps1 | 49 +++++++++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 tests/Autopilot.Common.Tests.ps1 create mode 100644 tests/Autopilot.Operator.Tests.ps1 create mode 100644 tests/run-tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb2318..1e476b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,14 @@ jobs: - name: Validate control-plane contracts shell: pwsh run: ./tests/contract-tests.ps1 + + - name: Run Pester unit tests + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force -SkipPublisherCheck + $config = New-PesterConfiguration + $config.Run.Path = './tests' + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $config diff --git a/README.md b/README.md index 11e089b..473bba8 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,25 @@ flowchart LR - Minimal diffs only - no secrets, no destructive operations. - Required supported verification before PR creation, with explicit approved exceptions only. +## Testing + +Run the full suite (workflow YAML validation, control-plane contract tests, and +Pester unit tests) with a single command: + +```powershell +pwsh ./tests/run-tests.ps1 +``` + +Unit tests (Pester 5) cover the safety- and payload-critical logic: +`Assert-SafeChangeSet` (sensitive-path and diff-budget guards), `Get-ChangedFile` +(porcelain parsing), `Search-Issue` (GraphQL request construction), and the +`Autopilot.Common` helpers (`Get-RepoName`, `Invoke-GhJson`, `Get-LogTail`). + ## Workflows | Workflow | Trigger | Purpose | |----------|---------|---------| -| `ci.yml` | push/PR to main | Portfolio CI - YAML validation (ubuntu-latest) | +| `ci.yml` | push/PR to main | Portfolio CI - YAML validation + Pester unit tests (ubuntu-latest) | | `autopilot-operator.yml` | schedule + dispatch | Core operator - scan issues, run Codex, open PRs | | `autopilot-org-installer.yml` | hourly + dispatch | Install intake workflow into opted-in repos | | `autopilot-create-issue.yml` | workflow_run failure | Create intake issue when monitored workflow fails | diff --git a/tests/Autopilot.Common.Tests.ps1 b/tests/Autopilot.Common.Tests.ps1 new file mode 100644 index 0000000..f0fc325 --- /dev/null +++ b/tests/Autopilot.Common.Tests.ps1 @@ -0,0 +1,82 @@ +#requires -Modules Pester + +# Unit tests for the pure/deterministic helper functions in +# scripts/lib/Autopilot.Common.psm1. These functions build gh payloads, +# normalise repo names, and parse tool output — the highest-value logic to +# pin down because the operator mutates OTHER repos based on their results. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:ModulePath = Join-Path $RepoRoot "scripts/lib/Autopilot.Common.psm1" + Import-Module $ModulePath -Force +} + +AfterAll { + Remove-Module Autopilot.Common -Force -ErrorAction SilentlyContinue +} + +Describe "Get-RepoName" { + It "extracts owner/repo from an https clone URL" { + Get-RepoName -RepoUrl "https://github.com/acme/widgets" | Should -Be "acme/widgets" + } + + It "tolerates a trailing slash" { + Get-RepoName -RepoUrl "https://github.com/acme/widgets/" | Should -Be "acme/widgets" + } + + It "handles an ssh-style URL by taking the final two path segments" { + Get-RepoName -RepoUrl "https://github.com/acme/deep/nested/widgets" | Should -Be "nested/widgets" + } +} + +Describe "Invoke-GhJson" { + Context "when gh returns clean JSON" { + BeforeEach { + Mock -CommandName gh -ModuleName Autopilot.Common -MockWith { '{"login":"octocat","type":"User"}' } + } + + It "parses a JSON object into a PSCustomObject" { + $result = Invoke-GhJson -Arguments @("api", "user") + $result.login | Should -Be "octocat" + $result.type | Should -Be "User" + } + } + + Context "when gh emits a leading warning line before the JSON" { + BeforeEach { + # gh sometimes prints noise on stdout before the payload; the + # function must locate the first { or [ and parse from there. + Mock -CommandName gh -ModuleName Autopilot.Common -MockWith { "Warning: something`n[{""number"":7}]" } + } + + It "strips the prefix and parses the array" { + $result = Invoke-GhJson -Arguments @("api", "issues") + $result[0].number | Should -Be 7 + } + } + + Context "when gh returns nothing" { + BeforeEach { + Mock -CommandName gh -ModuleName Autopilot.Common -MockWith { $null } + } + + It "returns null without throwing" { + Invoke-GhJson -Arguments @("api", "nothing") | Should -BeNullOrEmpty + } + } +} + +Describe "Get-LogTail" { + It "returns null when the log file does not exist" { + Get-LogTail -LogPath (Join-Path $TestDrive "missing.log") -Lines 5 | Should -BeNullOrEmpty + } + + It "returns only the last N lines of an existing log" { + $log = Join-Path $TestDrive "sample.log" + 1..10 | ForEach-Object { "line $_" } | Set-Content -Path $log + $tail = Get-LogTail -LogPath $log -Lines 3 + $tail | Should -HaveCount 3 + $tail[-1] | Should -Be "line 10" + $tail[0] | Should -Be "line 8" + } +} diff --git a/tests/Autopilot.Operator.Tests.ps1 b/tests/Autopilot.Operator.Tests.ps1 new file mode 100644 index 0000000..39fb5ca --- /dev/null +++ b/tests/Autopilot.Operator.Tests.ps1 @@ -0,0 +1,157 @@ +#requires -Modules Pester + +# Unit tests for the safety/parse logic inside scripts/autopilot-operator.ps1. +# +# The operator script runs its whole mutation pipeline on load (Assert-Env, +# Test-Tool, Search-Issue, clone, push, ...), so it cannot be dot-sourced +# directly. Instead we lift out just its function definitions via the +# PowerShell AST and evaluate those in an isolated module scope. This keeps +# the functions under test byte-for-byte identical to what ships, with zero +# risk of triggering a live gh/git call. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $operatorPath = Join-Path $RepoRoot "scripts/autopilot-operator.ps1" + + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $operatorPath, [ref]$tokens, [ref]$parseErrors) + + $funcs = $ast.FindAll( + { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, + $true) + + $funcSource = ($funcs | ForEach-Object { $_.Extent.Text }) -join "`n`n" + + # Materialise the extracted functions as a real module so Pester's Mock + # can intercept `git` calls made from inside them. + $script:OpModule = New-Module -Name AutopilotOperatorFns -ScriptBlock ([scriptblock]::Create( + $funcSource + "`nExport-ModuleMember -Function Get-ChangedFile,Assert-SafeChangeSet,Search-Issue")) | Import-Module -PassThru +} + +AfterAll { + Remove-Module AutopilotOperatorFns -Force -ErrorAction SilentlyContinue +} + +Describe "Assert-SafeChangeSet - guard clauses" { + It "rejects an empty change set" { + { Assert-SafeChangeSet -Paths @() -MaxFiles 20 -MaxLines 1000 } | + Should -Throw "*No changed files found*" + } + + It "rejects a change set that exceeds the file limit" { + { Assert-SafeChangeSet -Paths @("a.py", "b.py", "c.py") -MaxFiles 2 -MaxLines 1000 } | + Should -Throw "*3 files; limit is 2*" + } + + It "blocks a .env file anywhere in the tree" { + { Assert-SafeChangeSet -Paths @("src/app.py", "config/.env") -MaxFiles 20 -MaxLines 1000 } | + Should -Throw "*Sensitive path blocked*" + } + + It "blocks private key material by name (id_rsa)" { + { Assert-SafeChangeSet -Paths @("secrets/id_rsa") -MaxFiles 20 -MaxLines 1000 } | + Should -Throw "*Sensitive path blocked*" + } + + It "blocks certificate/key extensions (.pem, .pfx, .key, .p12)" { + foreach ($p in @("deploy/cert.pem", "a/b.pfx", "x/y.key", "z/w.p12")) { + { Assert-SafeChangeSet -Paths @($p) -MaxFiles 20 -MaxLines 1000 } | + Should -Throw "*Sensitive path blocked*" + } + } + + It "normalises backslash separators before matching sensitive paths" { + { Assert-SafeChangeSet -Paths @("deploy\secrets\app.pem") -MaxFiles 20 -MaxLines 1000 } | + Should -Throw "*Sensitive path blocked*" + } + + It "does not flag a benign source file that merely contains 'key' as a substring" { + # 'monkey.py' contains 'key' but is not key material — must not be blocked + # at the path-guard stage. git diff is mocked to report zero churn. + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { "0`t0`tmonkey.py" } + { Assert-SafeChangeSet -Paths @("src/monkey.py") -MaxFiles 20 -MaxLines 1000 } | + Should -Not -Throw + } +} + +Describe "Assert-SafeChangeSet - line budget" { + It "rejects a change set that exceeds the line limit" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { "600`t500`tbig.py" } + { Assert-SafeChangeSet -Paths @("big.py") -MaxFiles 20 -MaxLines 1000 } | + Should -Throw "*1100 changed lines; limit is 1000*" + } + + It "accepts a change set within both limits" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { "10`t5`tsmall.py" } + { Assert-SafeChangeSet -Paths @("small.py") -MaxFiles 20 -MaxLines 1000 } | + Should -Not -Throw + } + + It "ignores binary diff rows (git prints '-' for churn) instead of miscounting" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { "-`t-`timage.png" } + { Assert-SafeChangeSet -Paths @("image.png") -MaxFiles 20 -MaxLines 1000 } | + Should -Not -Throw + } +} + +Describe "Get-ChangedFile - porcelain parsing" { + It "extracts paths from standard porcelain status lines" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { + @(" M src/app.py", "?? new/file.txt") + } + $result = Get-ChangedFile + $result | Should -Contain "src/app.py" + $result | Should -Contain "new/file.txt" + } + + It "resolves the destination path for a rename entry" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { + @("R old/name.py -> new/name.py") + } + Get-ChangedFile | Should -Contain "new/name.py" + } + + It "strips surrounding quotes git adds to paths with spaces" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { + @(' M "path with spaces.py"') + } + Get-ChangedFile | Should -Contain "path with spaces.py" + } + + It "returns a unique, sorted set with no duplicates" { + Mock -CommandName git -ModuleName AutopilotOperatorFns -MockWith { + @(" M b.py", " M a.py", " M b.py") + } + $result = @(Get-ChangedFile) + $result | Should -HaveCount 2 + $result[0] | Should -Be "a.py" + } +} + +Describe "Search-Issue - GraphQL request construction" { + It "sends the exact query and first count through gh api graphql" { + $script:capturedArgs = $null + Mock -CommandName gh -ModuleName AutopilotOperatorFns -MockWith { + $script:capturedArgs = $args + '{"data":{"search":{"nodes":[{"number":1}]}}}' + } + + $nodes = Search-Issue -SearchQuery "org:acme is:issue label:autofix" -First 5 + + # Payload contract: gh must be invoked as `api graphql` with the + # search query bound to -f q=... and the count bound to -F first=... + $joined = $script:capturedArgs -join " " + $joined | Should -Match "api graphql" + $joined | Should -Match "q=org:acme is:issue label:autofix" + $joined | Should -Match "first=5" + + $nodes[0].number | Should -Be 1 + } + + It "returns an empty array when the search payload has no data" { + Mock -CommandName gh -ModuleName AutopilotOperatorFns -MockWith { '{"data":{"search":null}}' } + @(Search-Issue -SearchQuery "org:acme" -First 5) | Should -HaveCount 0 + } +} diff --git a/tests/run-tests.ps1 b/tests/run-tests.ps1 new file mode 100644 index 0000000..a716ccb --- /dev/null +++ b/tests/run-tests.ps1 @@ -0,0 +1,49 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Single entry point for the autopilot-core test suite. + +.DESCRIPTION + Runs, in order: + 1. Python workflow YAML validation (tests/validate_workflows.py) + 2. Control-plane contract tests (tests/contract-tests.ps1) + 3. Pester unit tests (tests/*.Tests.ps1) + + Any failure is fatal (non-zero exit) so this is CI-safe. + +.EXAMPLE + pwsh ./tests/run-tests.ps1 +#> +[CmdletBinding()] +param( + # Skip the Python workflow validator (useful when PyYAML is unavailable). + [switch]$SkipPython +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + if (-not $SkipPython) { + Write-Host "==> Validating workflow YAML" -ForegroundColor Cyan + python tests/validate_workflows.py + if ($LASTEXITCODE -ne 0) { throw "Workflow validation failed (exit $LASTEXITCODE)." } + } + + Write-Host "==> Running control-plane contract tests" -ForegroundColor Cyan + & "$PSScriptRoot/contract-tests.ps1" + + Write-Host "==> Running Pester unit tests" -ForegroundColor Cyan + Import-Module Pester -MinimumVersion 5.0.0 -ErrorAction Stop + + $config = New-PesterConfiguration + $config.Run.Path = $PSScriptRoot + $config.Run.Exit = $true + $config.Output.Verbosity = "Detailed" + $config.TestResult.Enabled = $false + + Invoke-Pester -Configuration $config +} +finally { + Pop-Location +}