diff --git a/PiHoleShell/Private/Misc.ps1 b/PiHoleShell/Private/Misc.ps1 index 3b0c02e..54027c0 100644 --- a/PiHoleShell/Private/Misc.ps1 +++ b/PiHoleShell/Private/Misc.ps1 @@ -137,6 +137,28 @@ function ConvertTo-PiHoleFriendlyErrorMessage { } function Remove-PiHoleCurrentAuthSession { + <# +.SYNOPSIS +Delete session + +.DESCRIPTION +Deletes the session identified by the given Sid. This is the internal best-effort logout every +other function in this module calls automatically from its own finally block when it finishes - +it never produces output, even on failure (a warning only). Shown here for completeness since it +is still a publicly exported function, not because you need to call it yourself. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Sid +The session ID to delete, as returned when logging in + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.EXAMPLE +Remove-PiHoleCurrentAuthSession -PiHoleServer "http://pihole.domain.com:8080" -Sid $Sid + #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "", Justification = "It removes sessions from PiHole only")] [CmdletBinding()] param ( diff --git a/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 b/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 index 9d1c146..c5aabcc 100644 --- a/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 +++ b/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 @@ -1,7 +1,11 @@ function Get-PiHoleCurrentAuthSession { <# .SYNOPSIS -List of all current sessions including their validity and further information about the client such as the IP address and user agent. +List of all current sessions + +.DESCRIPTION +List of all current sessions including their validity and further information about the client +such as the IP address and user agent. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" diff --git a/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 b/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 index e34fff4..d45042f 100644 --- a/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 +++ b/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 @@ -1,7 +1,11 @@ function Remove-PiHoleAuthSession { <# .SYNOPSIS -Using this endpoint, a session can be deleted by its ID. +Delete session by ID + +.DESCRIPTION +Deletes a session by its ID, as shown by Get-PiHoleCurrentAuthSession, rather than the caller's +own current session. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" @@ -12,8 +16,11 @@ The API Password you generated from your PiHole server .PARAMETER IgnoreSsl Ignore SSL when interacting with the PiHole API +.PARAMETER Id +The ID of the session to delete, as shown by Get-PiHoleCurrentAuthSession + .EXAMPLE -Get-PiHoleCurrentAuthSession -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" +Remove-PiHoleAuthSession -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Id 3 #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Does not change state')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] diff --git a/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 b/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 index 8a7692b..42a246c 100644 --- a/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 +++ b/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 @@ -21,6 +21,9 @@ True or False, if you set it to False when Blocking was set to true, it will dis .PARAMETER TimeInSeconds How long the opposite setting should last, in seconds +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 b/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 index c93bc09..6f9e260 100644 --- a/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 +++ b/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 @@ -3,6 +3,30 @@ function Get-PiHoleGroup { .SYNOPSIS Get groups +.DESCRIPTION +Returns all groups configured on your Pi-hole. Lists and clients can be assigned to groups to +apply blocking rules selectively rather than globally. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER GroupName +The name of a specific group to return. If omitted, every group is returned + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + +.EXAMPLE +Get-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -GroupName "Kids" #> [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/groups/-name-')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] diff --git a/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 b/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 index 4d8c7de..8ddb971 100644 --- a/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 +++ b/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 @@ -1,7 +1,10 @@ function Search-PiHoleListDomain { <# .SYNOPSIS -https://ftl.pi-hole.net/master/docs/#get-/search/-domain- +Search domains in Pi-hole's lists + +.DESCRIPTION +Searches Pi-hole's allow/deny lists for a domain, returning which list(s) it was found in. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 index 1117f7e..9345b0b 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsQueryType { <# .SYNOPSIS Get query types + +.DESCRIPTION Request a breakdown of query types (A, AAAA, ...) .PARAMETER PiHoleServer diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 index e8104fc..9c36705 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 @@ -1,6 +1,9 @@ function Get-PiHoleStatsRecentBlocked { <# .SYNOPSIS +Get most recently blocked domain + +.DESCRIPTION Request most recently blocked domain .PARAMETER PiHoleServer @@ -12,6 +15,9 @@ The API Password you generated from your PiHole server .PARAMETER MaxResult How many results should be returned +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 index 9712c83..8598870 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsSummary { <# .SYNOPSIS Get overview of Pi-hole activity + +.DESCRIPTION Request various query, system, and FTL properties .PARAMETER PiHoleServer diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 index 79a43e5..eb4444a 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsTopClient { <# .SYNOPSIS Get top clients + +.DESCRIPTION Request the top clients (by query count) .PARAMETER PiHoleServer @@ -16,6 +18,9 @@ How many results should be returned .PARAMETER Blocked If true, returns top clients by blocked queries instead of total queries +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 index 81031ef..460a0d8 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsTopDomain { <# .SYNOPSIS Get top domains + +.DESCRIPTION Request the top domains (by query count) .PARAMETER PiHoleServer @@ -16,6 +18,9 @@ How many results should be returned .PARAMETER Blocked If true, returns top domains by blocked queries instead of total queries +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/README.md b/README.md index 188f251..e4dac0d 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ See [docs/EXAMPLES.md](docs/EXAMPLES.md) for real, captured output from every fu | `Add-PiHoleList` | Add a new list | | `Get-PiHoleList` | Get lists | | `Remove-PiHoleList` | Remove a list | -| `Search-PiHoleListDomain` | _No description yet_ | +| `Search-PiHoleListDomain` | Search domains in Pi-hole's lists | | `Update-PiHoleList` | Update a list | ### Domain Management @@ -153,11 +153,11 @@ See [docs/EXAMPLES.md](docs/EXAMPLES.md) for real, captured output from every fu | `Get-PiHoleStatsDatabaseTopDomain` | Get top domains (long-term database) | | `Get-PiHoleStatsDatabaseUpstream` | Get metrics about Pi-hole's upstream destinations (long-term database) | | `Get-PiHoleStatsQuerySuggestions` | Get query filter suggestions | -| `Get-PiHoleStatsQueryType` | Get query types Request a breakdown of query types (A, AAAA, ...) | -| `Get-PiHoleStatsRecentBlocked` | Request most recently blocked domain | -| `Get-PiHoleStatsSummary` | Get overview of Pi-hole activity Request various query, system, and FTL properties | -| `Get-PiHoleStatsTopClient` | Get top clients Request the top clients (by query count) | -| `Get-PiHoleStatsTopDomain` | Get top domains Request the top domains (by query count) | +| `Get-PiHoleStatsQueryType` | Get query types | +| `Get-PiHoleStatsRecentBlocked` | Get most recently blocked domain | +| `Get-PiHoleStatsSummary` | Get overview of Pi-hole activity | +| `Get-PiHoleStatsTopClient` | Get top clients | +| `Get-PiHoleStatsTopDomain` | Get top domains | | `Get-PiHoleStatsUpstream` | Get metrics about Pi-hole's upstream destinations | ### Configuration & Diagnostics @@ -207,8 +207,8 @@ Session handling is automatic for every command above, but these are available f |---|---| | `Get-PiHoleAuthStatus` | Check if authentication is required | | `Get-PiHoleAuthTotp` | Suggest new TOTP credentials | -| `Get-PiHoleCurrentAuthSession` | List of all current sessions including their validity and further information about the client such as the IP address and user agent. | -| `Remove-PiHoleAuthSession` | Using this endpoint, a session can be deleted by its ID. | +| `Get-PiHoleCurrentAuthSession` | List of all current sessions | +| `Remove-PiHoleAuthSession` | Delete session by ID | ## Testing diff --git a/azuredevops-pihole-pester-tests.yml b/azuredevops-pihole-pester-tests.yml index c954956..51c5219 100644 --- a/azuredevops-pihole-pester-tests.yml +++ b/azuredevops-pihole-pester-tests.yml @@ -10,187 +10,195 @@ pr: - main - develop -pool: - name: mmadeja-dt - variables: - group: dns3 - name: testResultsFile value: '$(System.DefaultWorkingDirectory)/TestResults/pester.xml' -steps: - - pwsh: | - # Pinned to major version 5 specifically (not just ">= 5.0.0", which would also accept a - # newer major version like 6.x if one happens to be installed on the agent - Pester's - # result-object shape isn't guaranteed compatible across major versions, and the rest of - # this pipeline (including the failed-test summary below) depends on Pester 5's shape). - $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 } | Sort-Object Version -Descending | Select-Object -First 1 - if (-not $pester) { - Write-Host "Installing Pester 5..." - Install-Module -Name Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force -SkipPublisherCheck -Scope CurrentUser - } - else { - Write-Host "Using Pester $($pester.Version)" - } - displayName: 'Ensure Pester 5 is available' - - - pwsh: | - $configContent = @" - `$PiHoleServer = [uri]'$env:PIHOLE_SERVER' - `$PiHoleToken = '$env:PIHOLE_TOKEN' - `$PiHoleIgnoreSsl = $env:PIHOLE_IGNORE_SSL - "@ - Set-Content -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Value $configContent -Encoding utf8 - displayName: 'Write integration test config' - env: - PIHOLE_SERVER: $(PiHoleServer) - PIHOLE_TOKEN: $(PiHoleToken) - PIHOLE_IGNORE_SSL: $(PiHoleIgnoreSsl) - - - pwsh: | - Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force - - # On a PR build, scope the run to tests for the files that actually changed - tests/ mirrors - # PiHoleShell/Public// 1:1, so a changed function maps directly to its test file(s). - # Any ambiguity (shared/private code, module manifest, shared test helpers, or the diff itself - # failing) falls back to the full suite rather than risk silently skipping real coverage. - # Direct pushes to develop/main (not PR builds) always run the full suite. - $runFullSuite = $true - $testPaths = [System.Collections.Generic.List[string]]::new() - - if ($env:BUILD_REASON -eq 'PullRequest') { - try { - # The default checkout is shallow, which leaves no common ancestor with the target - # branch to diff against ("no merge base") - unshallow first so the diff can work. - if ((git rev-parse --is-shallow-repository) -eq 'true') { - git fetch --unshallow --quiet - if ($LASTEXITCODE -ne 0) { throw "git fetch --unshallow failed" } - } +jobs: + - job: PesterTests + pool: + name: mmadeja-dt + # The full suite (run whenever a shared/core file changes, or on a direct push to develop/ + # main) takes ~50-60 minutes on this agent's real Pi Zero W test server - right at, and + # apparently now sometimes past, Azure DevOps' default 60 minute job timeout. Raised with + # headroom rather than tuned precisely, since runtime varies with the test server's own + # state (accumulated groups/lists/history from previous runs) and isn't worth chasing + # exactly. Only valid nested under an explicit job, not as a pipeline-level top-level key. + timeoutInMinutes: 120 + steps: + - pwsh: | + # Pinned to major version 5 specifically (not just ">= 5.0.0", which would also accept a + # newer major version like 6.x if one happens to be installed on the agent - Pester's + # result-object shape isn't guaranteed compatible across major versions, and the rest of + # this pipeline (including the failed-test summary below) depends on Pester 5's shape). + $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 } | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $pester) { + Write-Host "Installing Pester 5..." + Install-Module -Name Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force -SkipPublisherCheck -Scope CurrentUser + } + else { + Write-Host "Using Pester $($pester.Version)" + } + displayName: 'Ensure Pester 5 is available' + + - pwsh: | + $configContent = @" + `$PiHoleServer = [uri]'$env:PIHOLE_SERVER' + `$PiHoleToken = '$env:PIHOLE_TOKEN' + `$PiHoleIgnoreSsl = $env:PIHOLE_IGNORE_SSL + "@ + Set-Content -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Value $configContent -Encoding utf8 + displayName: 'Write integration test config' + env: + PIHOLE_SERVER: $(PiHoleServer) + PIHOLE_TOKEN: $(PiHoleToken) + PIHOLE_IGNORE_SSL: $(PiHoleIgnoreSsl) + + - pwsh: | + Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force + + # On a PR build, scope the run to tests for the files that actually changed - tests/ mirrors + # PiHoleShell/Public// 1:1, so a changed function maps directly to its test file(s). + # Any ambiguity (shared/private code, module manifest, shared test helpers, or the diff itself + # failing) falls back to the full suite rather than risk silently skipping real coverage. + # Direct pushes to develop/main (not PR builds) always run the full suite. + $runFullSuite = $true + $testPaths = [System.Collections.Generic.List[string]]::new() + + if ($env:BUILD_REASON -eq 'PullRequest') { + try { + # The default checkout is shallow, which leaves no common ancestor with the target + # branch to diff against ("no merge base") - unshallow first so the diff can work. + if ((git rev-parse --is-shallow-repository) -eq 'true') { + git fetch --unshallow --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch --unshallow failed" } + } - # Fetch with an explicit refspec so origin/ is created even if the - # checkout's default fetch refspec is restricted to a single branch. - $targetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH -replace '^refs/heads/', '' - git fetch origin "${targetBranch}:refs/remotes/origin/${targetBranch}" --quiet - if ($LASTEXITCODE -ne 0) { throw "git fetch failed for '$targetBranch'" } - - $changedFiles = git diff --name-only "origin/$targetBranch...HEAD" - if ($LASTEXITCODE -ne 0) { throw "git diff failed against 'origin/$targetBranch'" } - - if ($changedFiles) { - $runFullSuite = $false - - foreach ($file in $changedFiles) { - if ($runFullSuite) { break } - - if ($file -eq 'PiHoleShell/PiHoleShell.psm1') { - # A pure addition to the export list can't break any existing, already- - # passing test, so only fall back to the full suite when an existing - # export was actually removed. Comparing whole diff lines doesn't work - # here since several exports share one line - adding a new function to - # an existing category's line shows as that whole line being removed - # and re-added, even though nothing already there was dropped. Instead, - # extract the quoted names from just the removed lines and just the - # added lines and compare those sets: a name that reappears in the - # added lines was only reformatted, not actually removed. - $manifestDiff = git diff "origin/$targetBranch...HEAD" -- $file - if ($LASTEXITCODE -ne 0) { throw "git diff failed for '$file'" } - - $removedLines = ($manifestDiff | Where-Object { $_ -match '^-' -and $_ -notmatch '^---' }) -join "`n" - $addedLines = ($manifestDiff | Where-Object { $_ -match '^\+' -and $_ -notmatch '^\+\+\+' }) -join "`n" - $removedNames = [regex]::Matches($removedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } - $addedNames = [regex]::Matches($addedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } - - $trulyRemoved = $removedNames | Where-Object { $_ -notin $addedNames } - if ($trulyRemoved) { - Write-Host "Export(s) removed from $file ($($trulyRemoved -join ', ')) - running the full suite" - $runFullSuite = $true + # Fetch with an explicit refspec so origin/ is created even if the + # checkout's default fetch refspec is restricted to a single branch. + $targetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH -replace '^refs/heads/', '' + git fetch origin "${targetBranch}:refs/remotes/origin/${targetBranch}" --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch failed for '$targetBranch'" } + + $changedFiles = git diff --name-only "origin/$targetBranch...HEAD" + if ($LASTEXITCODE -ne 0) { throw "git diff failed against 'origin/$targetBranch'" } + + if ($changedFiles) { + $runFullSuite = $false + + foreach ($file in $changedFiles) { + if ($runFullSuite) { break } + + if ($file -eq 'PiHoleShell/PiHoleShell.psm1') { + # A pure addition to the export list can't break any existing, already- + # passing test, so only fall back to the full suite when an existing + # export was actually removed. Comparing whole diff lines doesn't work + # here since several exports share one line - adding a new function to + # an existing category's line shows as that whole line being removed + # and re-added, even though nothing already there was dropped. Instead, + # extract the quoted names from just the removed lines and just the + # added lines and compare those sets: a name that reappears in the + # added lines was only reformatted, not actually removed. + $manifestDiff = git diff "origin/$targetBranch...HEAD" -- $file + if ($LASTEXITCODE -ne 0) { throw "git diff failed for '$file'" } + + $removedLines = ($manifestDiff | Where-Object { $_ -match '^-' -and $_ -notmatch '^---' }) -join "`n" + $addedLines = ($manifestDiff | Where-Object { $_ -match '^\+' -and $_ -notmatch '^\+\+\+' }) -join "`n" + $removedNames = [regex]::Matches($removedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } + $addedNames = [regex]::Matches($addedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } + + $trulyRemoved = $removedNames | Where-Object { $_ -notin $addedNames } + if ($trulyRemoved) { + Write-Host "Export(s) removed from $file ($($trulyRemoved -join ', ')) - running the full suite" + $runFullSuite = $true + } + else { + Write-Host "Only additions in $file - no full-suite fallback needed" + } } - else { - Write-Host "Only additions in $file - no full-suite fallback needed" + elseif ($file -match '^PiHoleShell/Private/' -or + $file -eq 'PiHoleShell/PiHoleShell.psd1' -or + $file -eq 'PiHoleShell/Public/Authentication/Request-PiHoleAuth.ps1' -or + $file -eq 'tests/Initialize-PiHoleTestData.ps1' -or + $file -eq 'tests/IntegrationConfig.example.ps1') { + Write-Host "Shared/core file changed ($file) - running the full suite" + $runFullSuite = $true } - } - elseif ($file -match '^PiHoleShell/Private/' -or - $file -eq 'PiHoleShell/PiHoleShell.psd1' -or - $file -eq 'PiHoleShell/Public/Authentication/Request-PiHoleAuth.ps1' -or - $file -eq 'tests/Initialize-PiHoleTestData.ps1' -or - $file -eq 'tests/IntegrationConfig.example.ps1') { - Write-Host "Shared/core file changed ($file) - running the full suite" - $runFullSuite = $true - } - elseif ($file -match '^PiHoleShell/Public/([^/]+)/([^/]+)\.ps1$') { - $category = $Matches[1] - $function = $Matches[2] - $testDir = "tests/$category" - if (Test-Path $testDir) { - Get-ChildItem -Path $testDir -Filter "$function.*Tests.ps1" | ForEach-Object { - $testPaths.Add($_.FullName) + elseif ($file -match '^PiHoleShell/Public/([^/]+)/([^/]+)\.ps1$') { + $category = $Matches[1] + $function = $Matches[2] + $testDir = "tests/$category" + if (Test-Path $testDir) { + Get-ChildItem -Path $testDir -Filter "$function.*Tests.ps1" | ForEach-Object { + $testPaths.Add($_.FullName) + } } } + elseif ($file -match '^tests/.*\.Tests\.ps1$' -and (Test-Path $file)) { + $testPaths.Add((Resolve-Path $file).Path) + } + # Anything else (README, workflows, tools/, etc.) has no test impact - ignored. } - elseif ($file -match '^tests/.*\.Tests\.ps1$' -and (Test-Path $file)) { - $testPaths.Add((Resolve-Path $file).Path) - } - # Anything else (README, workflows, tools/, etc.) has no test impact - ignored. } } + catch { + Write-Warning "Could not compute changed-file test scope, falling back to the full suite: $($_.Exception.Message)" + $runFullSuite = $true + } + } + + $config = New-PesterConfiguration + # Exit is handled manually below (after printing the failed-test summary) instead of + # letting Invoke-Pester call exit itself, which would skip that summary entirely. + $config.Run.Exit = $false + $config.Run.PassThru = $true + $config.Output.Verbosity = 'Detailed' + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = '$(testResultsFile)' + + $testPaths = $testPaths | Select-Object -Unique + if ($runFullSuite -or $testPaths.Count -eq 0) { + Write-Host "Running the full test suite" + $config.Run.Path = './tests' } - catch { - Write-Warning "Could not compute changed-file test scope, falling back to the full suite: $($_.Exception.Message)" - $runFullSuite = $true + else { + Write-Host "Running scoped tests for changed files:" + $testPaths | ForEach-Object { Write-Host " $_" } + $config.Run.Path = $testPaths } - } - - $config = New-PesterConfiguration - # Exit is handled manually below (after printing the failed-test summary) instead of - # letting Invoke-Pester call exit itself, which would skip that summary entirely. - $config.Run.Exit = $false - $config.Run.PassThru = $true - $config.Output.Verbosity = 'Detailed' - $config.TestResult.Enabled = $true - $config.TestResult.OutputFormat = 'NUnitXml' - $config.TestResult.OutputPath = '$(testResultsFile)' - - $testPaths = $testPaths | Select-Object -Unique - if ($runFullSuite -or $testPaths.Count -eq 0) { - Write-Host "Running the full test suite" - $config.Run.Path = './tests' - } - else { - Write-Host "Running scoped tests for changed files:" - $testPaths | ForEach-Object { Write-Host " $_" } - $config.Run.Path = $testPaths - } - - $result = Invoke-Pester -Configuration $config - - if ($result.FailedCount -gt 0) { - Write-Host "" - Write-Host "==================================================" - Write-Host " FAILED TESTS ($($result.FailedCount))" - Write-Host "==================================================" - foreach ($test in $result.Failed) { + + $result = Invoke-Pester -Configuration $config + + if ($result.FailedCount -gt 0) { Write-Host "" - Write-Host "[-] $($test.ExpandedPath)" - foreach ($errorRecord in $test.ErrorRecord) { - Write-Host " $($errorRecord.Exception.Message)" + Write-Host "==================================================" + Write-Host " FAILED TESTS ($($result.FailedCount))" + Write-Host "==================================================" + foreach ($test in $result.Failed) { + Write-Host "" + Write-Host "[-] $($test.ExpandedPath)" + foreach ($errorRecord in $test.ErrorRecord) { + Write-Host " $($errorRecord.Exception.Message)" + } } + Write-Host "" } - Write-Host "" - } - - exit $result.FailedCount - displayName: 'Run Pester tests' - workingDirectory: '$(System.DefaultWorkingDirectory)' - - - pwsh: | - Remove-Item -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Force -ErrorAction SilentlyContinue - displayName: 'Clean up integration test config' - condition: always() - - - task: PublishTestResults@2 - displayName: 'Publish test results' - condition: succeededOrFailed() - inputs: - testResultsFormat: 'NUnit' - testResultsFiles: '$(testResultsFile)' - failTaskOnFailedTests: false + + exit $result.FailedCount + displayName: 'Run Pester tests' + workingDirectory: '$(System.DefaultWorkingDirectory)' + + - pwsh: | + Remove-Item -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Force -ErrorAction SilentlyContinue + displayName: 'Clean up integration test config' + condition: always() + + - task: PublishTestResults@2 + displayName: 'Publish test results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(testResultsFile)' + failTaskOnFailedTests: false