From 811ad17c34c0c8f3417b8b2664547ca646015748 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:22:38 +0000 Subject: [PATCH 1/3] Gate complexity and test strength with PSComplexity and PSMutant Measures what the build did not: whether a function has become too tangled to reason about, and whether a test would notice a fault. - Pin Pester 6.2.0, PSComplexity 0.5.1 and PSMutant 0.5.0 exactly, with InstallModules.ps1 as the only module list (build.ps1 now calls it). - Complexity: 15 cyclomatic / 15 cognitive per unit, with the 32 units already over that recorded in complexity-baseline.json (ratchets down). - Mutation: psmutant.config.json maps each source file to the tests that name its functions, generated and -Check'ed by Build/Update-MutationConfig.ps1. Floor 75% from the first measurement. - Pull request: QualityChanged judges only the changed files, on the pwsh leg, after the tests; skips itself when nothing it measures changed. - Weekly: quality-weekly.yml runs both gates on the whole tree and files (or updates, or closes) a bug per gate. Refs #91 --- .github/workflows/pr-validation.yml | 18 + .github/workflows/quality-weekly.yml | 223 ++++++++++ Build/InstallModules.ps1 | 54 ++- Build/Update-MutationConfig.ps1 | 182 ++++++++ Build/build.ps1 | 16 +- Build/psakeBuild.ps1 | 245 +++++++++++ CONTRIBUTING.md | 39 ++ complexity-baseline.json | 199 +++++++++ psmutant.config.json | 600 +++++++++++++++++++++++++++ 9 files changed, 1563 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/quality-weekly.yml create mode 100644 Build/Update-MutationConfig.ps1 create mode 100644 complexity-baseline.json create mode 100644 psmutant.config.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index d566ab5..d5c2353 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -141,6 +141,24 @@ jobs: - name: Analyze, test and build run: ./Build/build.ps1 -Task TestBuildOnly -BuildVersion '0.0.0' + # Complexity and mutation testing, judged on the files this pull request changed only (issue + # #91); the weekly run covers the whole tree. Both tools need PowerShell 7, so this runs on the + # pwsh leg. It reads PR_CHANGED_FILES from "Determine changed files" above, and each gate skips + # itself, saying so, when the change touches nothing it measures. + - name: Quality gates on changed files + if: matrix.shell == 'pwsh' + run: ./Build/build.ps1 -Task QualityChanged + + - name: Upload quality reports + if: always() && matrix.shell == 'pwsh' + uses: actions/upload-artifact@v7 + with: + name: QualityReports + path: | + buildoutput/quality/ + buildoutput/mutation/ + if-no-files-found: ignore + - name: Check for test results id: test-results if: always() diff --git a/.github/workflows/quality-weekly.yml b/.github/workflows/quality-weekly.yml new file mode 100644 index 0000000..29043e5 --- /dev/null +++ b/.github/workflows/quality-weekly.yml @@ -0,0 +1,223 @@ +name: Weekly Quality + +# Whole-tree complexity (PSComplexity) and mutation testing (PSMutant), see issue #91. +# +# Pull request validation runs the same two gates on the files a pull request changed only. This run +# answers the other question - is the codebase as a whole still at the bar - and turns a "no" into a +# bug: one issue per gate, commented on while it keeps failing and closed when the gate passes again. +# +# The bar itself is not in this file. Complexity is gated on 15 cyclomatic / 15 cognitive against the +# committed complexity-baseline.json; mutation on thresholds.break in psmutant.config.json. +on: + schedule: + - cron: '23 2 * * 1' + workflow_dispatch: {} + +# Measuring only reads the repository. The alert job is the only place 'issues: write' is granted. +permissions: + contents: read + +concurrency: + group: quality-weekly + cancel-in-progress: false + +jobs: + measure: + name: Measure (${{ matrix.gate }}) + runs-on: windows-latest + # Mutation runs the covering tests once per mutant, serially (see "workers" in + # psmutant.config.json), so it is the long pole. 360 is the hosted-runner ceiling. + timeout-minutes: 360 + defaults: + run: + shell: pwsh + strategy: + fail-fast: false + matrix: + gate: [Complexity, Mutate] + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install modules + run: ./Build/InstallModules.ps1 + + # The tests import the module, and several of them need the runtime binaries it downloads on + # first use - the same warm-up PR validation does before its test run. + - name: Warm up Selenium/Edge WebDriver + if: matrix.gate == 'Mutate' + run: | + $module = Import-Module ./OmadaWeb.PS/OmadaWeb.PS.psm1 -Force -PassThru + & $module { Invoke-WebEdgeDriverFramework | Out-Null } + + # A failing gate is an expected outcome here, not a broken run: the verdict file it writes is + # what the alert job acts on. Only a gate that never reached a verdict fails this job. + - name: Run ${{ matrix.gate }} gate + continue-on-error: true + run: ./Build/build.ps1 -Task ${{ matrix.gate }} + + - name: Upload quality reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: Quality-${{ matrix.gate }} + path: | + buildoutput/quality/ + buildoutput/mutation/ + if-no-files-found: ignore + + - name: Require a verdict + run: | + $Name = if ('${{ matrix.gate }}' -eq 'Mutate') { 'mutation.verdict.json' } else { 'complexity.verdict.json' } + if (!(Test-Path -LiteralPath "buildoutput/quality/$Name")) { + Write-Error "The ${{ matrix.gate }} gate did not reach a verdict; see the step above. No bug is filed for a run that measured nothing." + exit 1 + } + + alert: + name: File or close quality bugs + needs: measure + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Download quality reports + uses: actions/download-artifact@v8 + with: + pattern: Quality-* + path: quality/ + merge-multiple: true + + - name: Open, update or close the quality bugs + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + function findFile(dir, name) { + if (!fs.existsSync(dir)) { + return null; + } + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = findFile(full, name); + if (found) { + return found; + } + } + else if (entry.name === name) { + return full; + } + } + return null; + } + + const gates = [ + { + file: 'complexity.verdict.json', + title: 'Weekly quality: complexity gate is failing', + fix: 'Simplify the units listed below until they are within the ceilings, or - for a unit recorded in `complexity-baseline.json` - back under its recorded score. When a unit has improved, lower its baseline entry with `Test-PSComplexity -Path ./OmadaWeb.PS -Recurse -BaselineFile ./complexity-baseline.json -UpdateBaseline` and commit it.', + }, + { + file: 'mutation.verdict.json', + title: 'Weekly quality: mutation score is below the floor', + fix: 'Each surviving mutant is a fault no test noticed. Download the `Quality-Mutate` artifact from the run, read the `survivors` in `buildoutput/mutation/ps-mutation.json`, and add the assertions that kill them - or declare a provably equivalent mutant under `equivalents` in `psmutant.config.json`, with its reason.', + }, + ]; + + // Created on demand: a fresh clone of this repository may not carry these labels, and a + // missing one would fail the create call rather than the gate. + for (const label of [ + { name: 'bug', color: 'd73a4a', description: "Something isn't working" }, + { name: 'quality-gate', color: '5319e7', description: 'Raised automatically by the weekly complexity and mutation run' }, + ]) { + try { + await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, ...label }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + const existing = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'quality-gate', + per_page: 100, + }); + + let failed = false; + for (const gate of gates) { + const file = findFile('quality', gate.file); + if (!file) { + // The measure job already failed for this one; a gate that measured nothing is not a + // statement about the code, so it neither files nor closes a bug. + core.warning(`No verdict for '${gate.title}'; leaving its issue as it is.`); + failed = true; + continue; + } + const verdict = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); + const alert = existing.find(issue => issue.title === gate.title); + + if (verdict.passed) { + if (alert) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: alert.number, + body: `The gate passed again in [run ${context.runId}](${runUrl}). Closing.\n\n${verdict.summary}`, + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: alert.number, + state: 'closed', + state_reason: 'completed', + }); + } + continue; + } + + failed = true; + const detail = Array.isArray(verdict.detail) ? verdict.detail : (verdict.detail ? [verdict.detail] : []); + const body = [ + `The weekly whole-tree run found this gate below the bar. [Run ${context.runId}](${runUrl})`, + '', + verdict.summary, + '', + ...detail.slice(0, 60).map(line => `- ${line}`), + detail.length > 60 ? `- _… and ${detail.length - 60} more in the run's artifact._` : '', + '', + `**What to do.** ${gate.fix}`, + ].join('\n').slice(0, 65000); + + if (alert) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: alert.number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: gate.title, + body, + labels: ['bug', 'quality-gate'], + }); + } + } + + if (failed) { + core.setFailed('At least one quality gate failed or reached no verdict; see the issues labelled quality-gate.'); + } diff --git a/Build/InstallModules.ps1 b/Build/InstallModules.ps1 index 9c504f6..26d5aa6 100644 --- a/Build/InstallModules.ps1 +++ b/Build/InstallModules.ps1 @@ -1,10 +1,55 @@ -try { +# The one place the build's PowerShell modules are declared. Build/build.ps1 runs this script rather +# than keeping a list of its own, and every workflow calls it before anything else. +# +# Pester, PSComplexity and PSMutant are pinned to an exact version. A floor resolves to whatever is +# newest on the gallery on the day the runner installs it, and these three decide whether a build +# passes: PSComplexity's metric has moved for unchanged source between releases, a committed +# complexity baseline records the metric version it was taken with and refuses to be compared across +# a change to it, and a mutation score is only comparable with the previous one when the same Pester +# and the same PSMutant produced both. Upgrading any of them is a deliberate change in a pull request, +# where its effect on the numbers is visible. +# +# PSComplexity and PSMutant require PowerShell 7.0 or later and are skipped under Windows PowerShell +# 5.1; the tasks that use them skip themselves there too. +[CmdletBinding()] +param() + +$PinnedModules = @( + # 6.2.0 still ships a net462 build and declares PowerShell 5.1 as its minimum, so the + # Windows PowerShell leg of PR validation keeps running the same Pester as the pwsh leg. + @{ Name = 'Pester'; RequiredVersion = '6.2.0'; SkipPublisherCheck = $true; MinimumPowerShell = 5 } + @{ Name = 'PSComplexity'; RequiredVersion = '0.5.1'; SkipPublisherCheck = $false; MinimumPowerShell = 7 } + # PSMutant deliberately does not declare Pester as a required module; it runs under the Pester + # already loaded in the session (5.2.0 or later), which is the pinned one above. + @{ Name = 'PSMutant'; RequiredVersion = '0.5.0'; SkipPublisherCheck = $false; MinimumPowerShell = 7 } +) + +try { "Validate Modules" | Write-Host $Modules = Get-Module -ListAvailable - if (-not ($Modules | Where-Object { $_.Name -eq 'Pester' -and $_.Version -ge '5.0' })) { - "Install Pester" | Write-Host - Install-Module -Name Pester -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck + + foreach ($PinnedModule in $PinnedModules) { + if ($PSVersionTable.PSVersion.Major -lt $PinnedModule.MinimumPowerShell) { + "Skip {0}: requires PowerShell {1}.0+ (running {2})" -f $PinnedModule.Name, $PinnedModule.MinimumPowerShell, $PSVersionTable.PSVersion | Write-Host + continue + } + if ($Modules | Where-Object { $_.Name -eq $PinnedModule.Name -and $_.Version -eq [version]$PinnedModule.RequiredVersion }) { + continue + } + "Install {0} {1}" -f $PinnedModule.Name, $PinnedModule.RequiredVersion | Write-Host + $InstallParameters = @{ + Name = $PinnedModule.Name + RequiredVersion = $PinnedModule.RequiredVersion + Repository = 'PSGallery' + Scope = 'CurrentUser' + Force = $true + } + if ($PinnedModule.SkipPublisherCheck) { + $InstallParameters.SkipPublisherCheck = $true + } + Install-Module @InstallParameters } + if ("psake" -notin $Modules.Name) { "Install psake" | Write-Host Install-Module -Name psake -Repository PSGallery -Scope CurrentUser -Force @@ -13,6 +58,7 @@ "Install PSDeploy" | Write-Host Install-Module -Name PSDeploy -Repository PSGallery -Scope CurrentUser -Force } + # 1.22.0+ is required for the PSAvoidAssignmentToAutomaticVariable suppression used in the module. if (-not ($Modules | Where-Object { $_.Name -eq 'PSScriptAnalyzer' -and $_.Version -ge '1.22.0' })) { "Install PSScriptAnalyzer" | Write-Host Install-Module -Name PSScriptAnalyzer -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck diff --git a/Build/Update-MutationConfig.ps1 b/Build/Update-MutationConfig.ps1 new file mode 100644 index 0000000..34ce8c2 --- /dev/null +++ b/Build/Update-MutationConfig.ps1 @@ -0,0 +1,182 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Checks or refreshes the mutate/tests map in psmutant.config.json. +.DESCRIPTION + PSMutant re-runs only the test files mapped to a source file for every mutant it injects into that + file. That per-file scoping is what keeps a mutation run affordable, and it is also what decides + the verdict: a mutant whose covering test is missing from the map survives, and is scored as a + gap in the tests when it is a gap in the map. + + Kept by hand, that map drifts the first time a function moves or a test file is added. This script + derives it from the code instead, so it has exactly one source of truth: + + - every *.ps1 under OmadaWeb.PS/Public and OmadaWeb.PS/Private is a mutate candidate; + - its covering tests are the test files under Tests/Unit and Tests/Integration that name any + function the file defines, matched as a whole command name; + - Tests/E2E is never mapped. Those tests need a real tenant and a real browser, so a mutant they + would kill could never be evaluated in CI. + + A source file that no test names is not mutated - every mutant in it would survive and the score + would measure the map rather than the tests. It is listed under "_untested" instead, so the gap is + visible in the config and in review rather than silently absent from the denominator. + + Only "mutate", "tests" and "_untested" are written. Every other key - thresholds, operators, + workers, equivalents, comments - is preserved as it is, so the policy stays hand-edited and the map + stays generated. + + -Check compares the committed config with what the code says it should be, and fails on any + difference. It is what runs on a pull request, so a new function or a new test file cannot land + with a stale map. +.PARAMETER Check + Report drift and exit non-zero if the committed map differs from the generated one. Changes nothing. +.PARAMETER Update + Rewrite the mutate/tests map in the config file. +.PARAMETER RepositoryRoot + Working tree to read the module sources and tests from. Defaults to the repository this script + lives in. +.PARAMETER ConfigPath + Path to the PSMutant config. Defaults to psmutant.config.json under -RepositoryRoot. +.EXAMPLE + ./Build/Update-MutationConfig.ps1 -Check + + Fails when a function or test file was added, moved or renamed without refreshing the map. +.EXAMPLE + ./Build/Update-MutationConfig.ps1 -Update + + Regenerates the map after such a change. Commit the result. +#> +[CmdletBinding(DefaultParameterSetName = "Check")] +param( + [parameter(Mandatory = $false, ParameterSetName = "Check")] + [switch]$Check, + + [parameter(Mandatory = $true, ParameterSetName = "Update")] + [switch]$Update, + + [parameter(Mandatory = $false)] + [string]$RepositoryRoot = (Split-Path -Path $PSScriptRoot -Parent), + + [parameter(Mandatory = $false)] + [string]$ConfigPath +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ([string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path -Path $RepositoryRoot -ChildPath "psmutant.config.json" +} + +function ConvertTo-RepositoryRelativePath { + param( + [string]$Path + ) + return [System.IO.Path]::GetRelativePath($RepositoryRoot, $Path).Replace('\', '/') +} + +function Get-DefinedFunctionName { + param( + [string]$Path + ) + $Tokens = $null + $ParseErrors = $null + $Ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$Tokens, [ref]$ParseErrors) + if ($ParseErrors) { + throw ("'{0}' does not parse: {1}" -f $Path, ($ParseErrors[0].Message)) + } + $Ast.FindAll({ param($Node) $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | + ForEach-Object { $_.Name } | + Sort-Object -Unique +} + +function Get-MutationMap { + $SourceFiles = @( + foreach ($Folder in "Public", "Private") { + Get-ChildItem -Path (Join-Path -Path $RepositoryRoot -ChildPath "OmadaWeb.PS/$Folder") -Filter "*.ps1" -File -Recurse + } + ) | Sort-Object -Property { ConvertTo-RepositoryRelativePath -Path $_.FullName } + + # Read once: every source file is matched against every test file. + $TestFiles = foreach ($Folder in "Unit", "Integration") { + Get-ChildItem -Path (Join-Path -Path $RepositoryRoot -ChildPath "Tests/$Folder") -Filter "*.Tests.ps1" -File -Recurse | + ForEach-Object { + [PSCustomObject]@{ + Path = ConvertTo-RepositoryRelativePath -Path $_.FullName + Content = Get-Content -LiteralPath $_.FullName -Raw + } + } + } + $TestFiles = @($TestFiles | Sort-Object -Property Path) + + $Mutate = [System.Collections.Generic.List[string]]::new() + $Tests = [ordered]@{} + $Untested = [System.Collections.Generic.List[string]]::new() + + foreach ($SourceFile in $SourceFiles) { + $RelativePath = ConvertTo-RepositoryRelativePath -Path $SourceFile.FullName + $FunctionNames = @(Get-DefinedFunctionName -Path $SourceFile.FullName) + if ($FunctionNames.Count -eq 0) { + $Untested.Add($RelativePath) + continue + } + + # A command name is bounded by anything that cannot be part of one, so Get-Thing does not + # match Get-ThingElse and Set-Body does not match Reset-Body. + $Pattern = '(?", + "cyclomatic": 52, + "cognitive": 83 + }, + { + "file": "OmadaWeb.PS/Private/ConvertTo-RedactedLogString.ps1", + "unit": "ConvertTo-RedactedLogValue", + "cyclomatic": 38, + "cognitive": 52 + }, + { + "file": "OmadaWeb.PS/Private/Get-DataFromWebDriver.ps1", + "unit": "Get-DataFromWebDriver", + "cyclomatic": 73, + "cognitive": 124 + }, + { + "file": "OmadaWeb.PS/Private/Get-DataFromWebView2.ps1", + "unit": "Get-DataFromWebView2", + "cyclomatic": 26, + "cognitive": 48 + }, + { + "file": "OmadaWeb.PS/Private/Get-EntraTenantMismatchDetail.ps1", + "unit": "Get-EntraTenantMismatchDetail", + "cyclomatic": 19, + "cognitive": 23 + }, + { + "file": "OmadaWeb.PS/Private/Get-InstalledModuleInfo.ps1", + "unit": "Get-InstalledModuleInfo", + "cyclomatic": 10, + "cognitive": 16 + }, + { + "file": "OmadaWeb.PS/Private/Get-OAuthClientCertificate.ps1", + "unit": "Get-OAuthClientCertificate", + "cyclomatic": 30, + "cognitive": 50 + }, + { + "file": "OmadaWeb.PS/Private/Get-RetryAfterDelay.ps1", + "unit": "Get-RetryAfterDelay", + "cyclomatic": 16, + "cognitive": 22 + }, + { + "file": "OmadaWeb.PS/Private/Get-WebView2Cookie.ps1", + "unit": "Get-WebView2Cookie", + "cyclomatic": 28, + "cognitive": 67 + }, + { + "file": "OmadaWeb.PS/Private/Get-WebView2LogonPageError.ps1", + "unit": "Get-WebView2LogonPageError", + "cyclomatic": 19, + "cognitive": 21 + }, + { + "file": "OmadaWeb.PS/Private/Initialize-Webview2.ps1", + "unit": "Initialize-WebView2", + "cyclomatic": 40, + "cognitive": 115 + }, + { + "file": "OmadaWeb.PS/Private/Install-EdgeDriver.ps1", + "unit": "Install-EdgeDriver", + "cyclomatic": 11, + "cognitive": 17 + }, + { + "file": "OmadaWeb.PS/Private/Install-WebView2.ps1", + "unit": "Install-WebView2", + "cyclomatic": 26, + "cognitive": 52 + }, + { + "file": "OmadaWeb.PS/Private/Invoke-BrowserAuthentication.ps1", + "unit": "Invoke-BrowserAuthentication", + "cyclomatic": 36, + "cognitive": 50 + }, + { + "file": "OmadaWeb.PS/Private/Invoke-OAuthAuthentication.ps1", + "unit": "Invoke-OAuth2Authentication", + "cyclomatic": 25, + "cognitive": 31 + }, + { + "file": "OmadaWeb.PS/Private/Invoke-OmadaRequest.ps1", + "unit": "Invoke-OmadaRequest", + "cyclomatic": 108, + "cognitive": 164 + }, + { + "file": "OmadaWeb.PS/Private/Invoke-OmadaRetryableRequest.ps1", + "unit": "Invoke-OmadaRetryableRequest", + "cyclomatic": 14, + "cognitive": 27 + }, + { + "file": "OmadaWeb.PS/Private/Invoke-WebEdgeDriverFramework.ps1", + "unit": "Invoke-WebEdgeDriverFramework", + "cyclomatic": 23, + "cognitive": 35 + }, + { + "file": "OmadaWeb.PS/Private/Invoke-WebView2MicrosoftLogin.ps1", + "unit": "Invoke-WebView2MicrosoftLogin", + "cyclomatic": 59, + "cognitive": 105 + }, + { + "file": "OmadaWeb.PS/Private/New-DynamicParam.ps1", + "unit": "New-DynamicParam", + "cyclomatic": 21, + "cognitive": 39 + }, + { + "file": "OmadaWeb.PS/Private/New-EntraSignInUri.ps1", + "unit": "New-EntraSignInUri", + "cyclomatic": 25, + "cognitive": 36 + }, + { + "file": "OmadaWeb.PS/Private/New-OAuthTokenRequestError.ps1", + "unit": "New-OAuthTokenRequestError", + "cyclomatic": 20, + "cognitive": 37 + }, + { + "file": "OmadaWeb.PS/Private/Resolve-EntraSignInScreen.ps1", + "unit": "Resolve-EntraSignInScreen", + "cyclomatic": 50, + "cognitive": 65 + }, + { + "file": "OmadaWeb.PS/Private/Select-EntraMfaMethod.ps1", + "unit": "Select-EntraMfaMethod", + "cyclomatic": 14, + "cognitive": 21 + }, + { + "file": "OmadaWeb.PS/Private/Set-DynamicParameter.ps1", + "unit": "Set-DynamicParameter", + "cyclomatic": 12, + "cognitive": 24 + }, + { + "file": "OmadaWeb.PS/Private/Start-EdgeDriver.ps1", + "unit": "Start-EdgeDriver", + "cyclomatic": 22, + "cognitive": 38 + }, + { + "file": "OmadaWeb.PS/Private/Start-WebView2Login.ps1", + "unit": "Start-WebView2Login", + "cyclomatic": 36, + "cognitive": 67 + }, + { + "file": "OmadaWeb.PS/Private/Stop-OmadaLogin.ps1", + "unit": "Stop-OmadaLogin", + "cyclomatic": 18, + "cognitive": 29 + }, + { + "file": "OmadaWeb.PS/Private/Switch-ToManualLogin.ps1", + "unit": "Switch-ToManualLogin", + "cyclomatic": 17, + "cognitive": 16 + }, + { + "file": "OmadaWeb.PS/Private/Test-OmadaTransientFailure.ps1", + "unit": "Test-OmadaTransientFailure", + "cyclomatic": 9, + "cognitive": 16 + }, + { + "file": "OmadaWeb.PS/Public/Clear-OmadaWebCache.ps1", + "unit": "Clear-OmadaWebCache", + "cyclomatic": 14, + "cognitive": 24 + }, + { + "file": "OmadaWeb.PS/Public/Import-OmadaSession.ps1", + "unit": "Import-OmadaSession", + "cyclomatic": 31, + "cognitive": 37 + } + ] +} diff --git a/psmutant.config.json b/psmutant.config.json new file mode 100644 index 0000000..15739a6 --- /dev/null +++ b/psmutant.config.json @@ -0,0 +1,600 @@ +{ + "$schema": "https://raw.githubusercontent.com/Fortigi/PSMutant/v0.5.0/schemas/v1/config.schema.json", + "_comment": "Mutation testing for OmadaWeb.PS. The policy keys are edited by hand; mutate, tests and _untested are generated by ./Build/Update-MutationConfig.ps1 -Update and verified on every pull request by -Check. Run: ./Build/build.ps1 -Task Mutate (whole tree, weekly) or -Task MutateChanged (pull request).", + "_sandboxSubtreesComment": "The tests import the SOURCE module (their default -ModulePath is OmadaWeb.PS/OmadaWeb.PS.psm1, which dot-sources Public/ and Private/), so a mutant spliced into a source file inside the sandbox is what they load. Build/ is copied because some covered tests read the build scripts and the dependency manifests.", + "sandboxSubtrees": [ + "OmadaWeb.PS", + "Tests", + "Build" + ], + "_workersComment": "1, deliberately. Workers are runspaces in ONE process, and three suites share process-wide state: StrictMode.Tests.ps1 writes $Env:OMADAWEBPS_STRICTMODE, Install-EdgeDriver.Tests.ps1 writes $Env:TEMP and $Env:TMP, and the integration tests bind a fixed port (Start-WebServer.ps1, 9080). In parallel those race and show up as KILLED mutants, inflating the score. Raise this only after those suites are isolated.", + "workers": 1, + "operators": [ + "BinaryOperator", + "BooleanLiteral", + "NumberLiteral", + "NegationRemoval" + ], + "coveredLinesOnly": true, + "timeoutFactor": 4, + "timeoutFloorSeconds": 30, + "_thresholdsComment": "break is the floor both the weekly whole-tree run and the pull request run on changed files are held to; only ever raise it. 75 comes from the first measurement (issue #91): 78.8% (430/546) over the 60 source files whose covering tests could run outside Windows. Recalibrate it from the first weekly run, which measures all mapped files on Windows.", + "thresholds": { + "high": 85, + "low": 70, + "break": 75 + }, + "reportPath": "buildoutput/mutation/ps-mutation.json", + "mutate": [ + "OmadaWeb.PS/Private/Add-ReflectionAssembly.ps1", + "OmadaWeb.PS/Private/Compare-ModuleVersion.ps1", + "OmadaWeb.PS/Private/Confirm-AuthenticodeTrust.ps1", + "OmadaWeb.PS/Private/ConvertFrom-JavaScriptResult.ps1", + "OmadaWeb.PS/Private/ConvertTo-Base64UrlString.ps1", + "OmadaWeb.PS/Private/ConvertTo-JavaScriptLiteral.ps1", + "OmadaWeb.PS/Private/ConvertTo-ModuleVersionInfo.ps1", + "OmadaWeb.PS/Private/ConvertTo-RedactedLogString.ps1", + "OmadaWeb.PS/Private/Expand-DownloadFile.ps1", + "OmadaWeb.PS/Private/Export-OmadaCookieFile.ps1", + "OmadaWeb.PS/Private/Get-DataFromWebDriver.ps1", + "OmadaWeb.PS/Private/Get-DataFromWebView2.ps1", + "OmadaWeb.PS/Private/Get-DependencyLock.ps1", + "OmadaWeb.PS/Private/Get-EntraElementVisibilityScript.ps1", + "OmadaWeb.PS/Private/Get-EntraErrorVerdict.ps1", + "OmadaWeb.PS/Private/Get-EntraSignInProbeScript.ps1", + "OmadaWeb.PS/Private/Get-EntraTenantMismatchDetail.ps1", + "OmadaWeb.PS/Private/Get-GalleryModuleVersion.ps1", + "OmadaWeb.PS/Private/Get-InstalledModuleInfo.ps1", + "OmadaWeb.PS/Private/Get-LockedArtifact.ps1", + "OmadaWeb.PS/Private/Get-NuGetLibraryFolder.ps1", + "OmadaWeb.PS/Private/Get-OAuthClientCertificate.ps1", + "OmadaWeb.PS/Private/Get-OmadaCookieCacheFilePath.ps1", + "OmadaWeb.PS/Private/Get-OmadaCookieExpiry.ps1", + "OmadaWeb.PS/Private/Get-OmadaCookieFileName.ps1", + "OmadaWeb.PS/Private/Get-OmadaDeprecationSchedule.ps1", + "OmadaWeb.PS/Private/Get-OmadaLogonErrorScript.ps1", + "OmadaWeb.PS/Private/Get-OmadaSessionContext.ps1", + "OmadaWeb.PS/Private/Get-OmadaSessionKey.ps1", + "OmadaWeb.PS/Private/Get-OmadaShortHash.ps1", + "OmadaWeb.PS/Private/Get-OmadaSignInAccount.ps1", + "OmadaWeb.PS/Private/Get-OmadaWebCacheItem.ps1", + "OmadaWeb.PS/Private/Get-RetryAfterDelay.ps1", + "OmadaWeb.PS/Private/Get-WebDriverLogonPageError.ps1", + "OmadaWeb.PS/Private/Get-WebView2Cookie.ps1", + "OmadaWeb.PS/Private/Get-WebView2LogonPageError.ps1", + "OmadaWeb.PS/Private/Get-WebView2WindowTitle.ps1", + "OmadaWeb.PS/Private/Import-OmadaCookieFile.ps1", + "OmadaWeb.PS/Private/Initialize-Webview2.ps1", + "OmadaWeb.PS/Private/Install-EdgeDriver.ps1", + "OmadaWeb.PS/Private/Install-NewtonSoftJson.ps1", + "OmadaWeb.PS/Private/Install-Selenium.ps1", + "OmadaWeb.PS/Private/Install-SystemRunTime.ps1", + "OmadaWeb.PS/Private/Install-SystemTextJson.ps1", + "OmadaWeb.PS/Private/Install-WebView2.ps1", + "OmadaWeb.PS/Private/Invoke-BasicAuthentication.ps1", + "OmadaWeb.PS/Private/Invoke-BrowserAuthentication.ps1", + "OmadaWeb.PS/Private/Invoke-DownloadFile.ps1", + "OmadaWeb.PS/Private/Invoke-IntegratedAuthentication.ps1", + "OmadaWeb.PS/Private/Invoke-OAuthAuthentication.ps1", + "OmadaWeb.PS/Private/Invoke-OAuthTokenRequest.ps1", + "OmadaWeb.PS/Private/Invoke-OmadaRequest.ps1", + "OmadaWeb.PS/Private/Invoke-OmadaRetryableRequest.ps1", + "OmadaWeb.PS/Private/Invoke-WebEdgeDriverFramework.ps1", + "OmadaWeb.PS/Private/Invoke-WebView2MicrosoftLogin.ps1", + "OmadaWeb.PS/Private/Invoke-WindowsAuthentication.ps1", + "OmadaWeb.PS/Private/New-DynamicParam.ps1", + "OmadaWeb.PS/Private/New-EntraSignInUri.ps1", + "OmadaWeb.PS/Private/New-OAuthClientAssertion.ps1", + "OmadaWeb.PS/Private/New-OAuthTokenRequestError.ps1", + "OmadaWeb.PS/Private/New-OmadaRequestContext.ps1", + "OmadaWeb.PS/Private/New-OmadaSessionExpiredError.ps1", + "OmadaWeb.PS/Private/New-OmadaWebCacheItem.ps1", + "OmadaWeb.PS/Private/Protect-LogMessage.ps1", + "OmadaWeb.PS/Private/Protect-OmadaSessionPayload.ps1", + "OmadaWeb.PS/Private/Reset-LoginAutomationState.ps1", + "OmadaWeb.PS/Private/Resolve-EntraSignInScreen.ps1", + "OmadaWeb.PS/Private/Resolve-OmadaNativeCommand.ps1", + "OmadaWeb.PS/Private/Save-RemoteFile.ps1", + "OmadaWeb.PS/Private/Select-EntraMfaMethod.ps1", + "OmadaWeb.PS/Private/Set-Body.ps1", + "OmadaWeb.PS/Private/Set-DynamicParameter.ps1", + "OmadaWeb.PS/Private/Set-OmadaCurrentBaseUrl.ps1", + "OmadaWeb.PS/Private/Set-RequestParameter.ps1", + "OmadaWeb.PS/Private/Show-EntraApprovalNumber.ps1", + "OmadaWeb.PS/Private/Start-WebView2Login.ps1", + "OmadaWeb.PS/Private/Stop-OmadaLogin.ps1", + "OmadaWeb.PS/Private/Switch-ToManualLogin.ps1", + "OmadaWeb.PS/Private/Test-EnvironmentSuspended.ps1", + "OmadaWeb.PS/Private/Test-LoginAutomationStalled.ps1", + "OmadaWeb.PS/Private/Test-OmadaConsoleControl.ps1", + "OmadaWeb.PS/Private/Test-OmadaCookieCacheFile.ps1", + "OmadaWeb.PS/Private/Test-OmadaLogonPageError.ps1", + "OmadaWeb.PS/Private/Test-OmadaStaThread.ps1", + "OmadaWeb.PS/Private/Test-SensitiveLogName.ps1", + "OmadaWeb.PS/Private/Test-SignInAccountRecovery.ps1", + "OmadaWeb.PS/Private/Test-WebView2NavigationReady.ps1", + "OmadaWeb.PS/Private/Test-WebView2RuntimeVersion.ps1", + "OmadaWeb.PS/Private/Unprotect-OmadaSessionPayload.ps1", + "OmadaWeb.PS/Private/Write-ModuleVersionStatus.ps1", + "OmadaWeb.PS/Private/Write-OmadaDeprecationWarning.ps1", + "OmadaWeb.PS/Public/Clear-OmadaWebCache.ps1", + "OmadaWeb.PS/Public/Export-OmadaSession.ps1", + "OmadaWeb.PS/Public/Import-OmadaSession.ps1", + "OmadaWeb.PS/Public/Invoke-OmadaRestMethod.ps1", + "OmadaWeb.PS/Public/Invoke-OmadaWebRequest.ps1" + ], + "tests": { + "OmadaWeb.PS/Private/Add-ReflectionAssembly.ps1": [ + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1" + ], + "OmadaWeb.PS/Private/Compare-ModuleVersion.ps1": [ + "Tests/Unit/Compare-ModuleVersion.Tests.ps1" + ], + "OmadaWeb.PS/Private/Confirm-AuthenticodeTrust.ps1": [ + "Tests/Unit/Confirm-AuthenticodeTrust.Tests.ps1", + "Tests/Unit/Install-EdgeDriver.Tests.ps1" + ], + "OmadaWeb.PS/Private/ConvertFrom-JavaScriptResult.ps1": [ + "Tests/Unit/ConvertFrom-JavaScriptResult.Tests.ps1", + "Tests/Unit/Get-WebView2LogonPageError.Tests.ps1" + ], + "OmadaWeb.PS/Private/ConvertTo-Base64UrlString.ps1": [ + "Tests/Unit/ConvertTo-Base64UrlString.Tests.ps1" + ], + "OmadaWeb.PS/Private/ConvertTo-JavaScriptLiteral.ps1": [ + "Tests/Unit/ConvertFrom-JavaScriptResult.Tests.ps1", + "Tests/Unit/ConvertTo-JavaScriptLiteral.Tests.ps1", + "Tests/Unit/Invoke-WebView2MicrosoftLogin.Scripts.Tests.ps1" + ], + "OmadaWeb.PS/Private/ConvertTo-ModuleVersionInfo.ps1": [ + "Tests/Unit/Compare-ModuleVersion.Tests.ps1" + ], + "OmadaWeb.PS/Private/ConvertTo-RedactedLogString.ps1": [ + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Protect-LogMessage.Tests.ps1" + ], + "OmadaWeb.PS/Private/Expand-DownloadFile.ps1": [ + "Tests/Unit/Expand-DownloadFile.Tests.ps1", + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1", + "Tests/Unit/Install-EdgeDriver.Tests.ps1", + "Tests/Unit/Install-WebView2.Tests.ps1" + ], + "OmadaWeb.PS/Private/Export-OmadaCookieFile.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Export-OmadaCookieFile.Tests.ps1", + "Tests/Unit/OmadaCookieCache.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-DataFromWebDriver.ps1": [ + "Tests/Integration/Invoke-OmadaRestMethod.NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1", + "Tests/Unit/StrictMode.Tests.ps1", + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-DataFromWebView2.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.NoInteractiveAuthentication.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.PreferredMfaMethod.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-DependencyLock.ps1": [ + "Tests/Unit/Invoke-DownloadFile.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-EntraElementVisibilityScript.ps1": [ + "Tests/Unit/Invoke-WebView2MicrosoftLogin.Scripts.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-EntraErrorVerdict.ps1": [ + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Get-EntraErrorVerdict.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-EntraSignInProbeScript.ps1": [ + "Tests/Integration/EntraSignInScript.Browser.Tests.ps1", + "Tests/Unit/Get-EntraSignInProbeScript.Tests.ps1", + "Tests/Unit/Invoke-WebView2MicrosoftLogin.Scripts.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-EntraTenantMismatchDetail.ps1": [ + "Tests/Unit/Get-EntraTenantMismatchDetail.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-GalleryModuleVersion.ps1": [ + "Tests/Unit/Get-GalleryModuleVersion.Tests.ps1", + "Tests/Unit/Write-ModuleVersionStatus.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-InstalledModuleInfo.ps1": [ + "Tests/Unit/Write-ModuleVersionStatus.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-LockedArtifact.ps1": [ + "Tests/Unit/DependencyLock.Tests.ps1", + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1", + "Tests/Unit/Install-EdgeDriver.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-NuGetLibraryFolder.ps1": [ + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OAuthClientCertificate.ps1": [ + "Tests/Unit/Get-OAuthClientCertificate.Tests.ps1", + "Tests/Unit/Invoke-OAuth2Authentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaCookieCacheFilePath.ps1": [ + "Tests/Unit/Get-OmadaCookieCacheFilePath.Tests.ps1", + "Tests/Unit/OmadaCookieCache.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaCookieExpiry.ps1": [ + "Tests/Unit/Get-OmadaCookieExpiry.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaCookieFileName.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Get-OmadaCookieFileName.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaDeprecationSchedule.ps1": [ + "Tests/Unit/Write-OmadaDeprecationWarning.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaLogonErrorScript.ps1": [ + "Tests/Unit/CanaryRelyingParty.Tests.ps1", + "Tests/Unit/Get-OmadaLogonErrorScript.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaSessionContext.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.NoInteractiveAuthentication.Tests.ps1", + "Tests/Integration/OmadaSession.Runspace.Tests.ps1", + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/Get-OmadaSessionContext.Tests.ps1", + "Tests/Unit/Invoke-BasicAuthentication.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/Invoke-IntegratedAuthentication.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.PreferredMfaMethod.Tests.ps1", + "Tests/Unit/Invoke-WindowsAuthentication.Tests.ps1", + "Tests/Unit/New-OmadaRequestContext.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1", + "Tests/Unit/Set-Body.Tests.ps1", + "Tests/Unit/Set-RequestParameter.Tests.ps1", + "Tests/Unit/SignInAccount.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaSessionKey.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Get-OmadaCookieFileName.Tests.ps1", + "Tests/Unit/Get-OmadaSessionKey.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.PreferredMfaMethod.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaShortHash.ps1": [ + "Tests/Unit/Get-OmadaCookieCacheFilePath.Tests.ps1", + "Tests/Unit/Get-OmadaShortHash.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaSignInAccount.ps1": [ + "Tests/Unit/Get-OmadaSignInAccount.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-OmadaWebCacheItem.ps1": [ + "Tests/Unit/DpapiScopeWording.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-RetryAfterDelay.ps1": [ + "Tests/Unit/Get-RetryAfterDelay.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-WebDriverLogonPageError.ps1": [ + "Tests/Unit/Get-WebDriverLogonPageError.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-WebView2Cookie.ps1": [ + "Tests/Unit/CanaryRelyingParty.Tests.ps1", + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Get-WebView2Cookie.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-WebView2LogonPageError.ps1": [ + "Tests/Unit/Get-WebView2LogonPageError.Tests.ps1" + ], + "OmadaWeb.PS/Private/Get-WebView2WindowTitle.ps1": [ + "Tests/Unit/Get-WebView2WindowTitle.Tests.ps1" + ], + "OmadaWeb.PS/Private/Import-OmadaCookieFile.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Unit/Export-OmadaCookieFile.Tests.ps1", + "Tests/Unit/OmadaCookieCache.Tests.ps1" + ], + "OmadaWeb.PS/Private/Initialize-Webview2.ps1": [ + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Install-EdgeDriver.ps1": [ + "Tests/Unit/Install-EdgeDriver.Tests.ps1", + "Tests/Unit/Invoke-WebEdgeDriverFramework.Tests.ps1" + ], + "OmadaWeb.PS/Private/Install-NewtonSoftJson.ps1": [ + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1" + ], + "OmadaWeb.PS/Private/Install-Selenium.ps1": [ + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1" + ], + "OmadaWeb.PS/Private/Install-SystemRunTime.ps1": [ + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1" + ], + "OmadaWeb.PS/Private/Install-SystemTextJson.ps1": [ + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1" + ], + "OmadaWeb.PS/Private/Install-WebView2.ps1": [ + "Tests/Unit/BundledWebView2.Tests.ps1", + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/Install-WebView2.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-BasicAuthentication.ps1": [ + "Tests/Unit/Invoke-BasicAuthentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-BrowserAuthentication.ps1": [ + "Tests/Unit/Get-OmadaSignInAccount.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1", + "Tests/Unit/SignInAccount.Tests.ps1", + "Tests/Unit/Write-OmadaDeprecationWarning.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-DownloadFile.ps1": [ + "Tests/Unit/DependencyLock.Tests.ps1", + "Tests/Unit/Expand-DownloadFile.Tests.ps1", + "Tests/Unit/Install-Dependency.ErrorPaths.Tests.ps1", + "Tests/Unit/Install-EdgeDriver.Tests.ps1", + "Tests/Unit/Install-WebView2.Tests.ps1", + "Tests/Unit/Invoke-DownloadFile.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-IntegratedAuthentication.ps1": [ + "Tests/Unit/Invoke-IntegratedAuthentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-OAuthAuthentication.ps1": [ + "Tests/Integration/Invoke-OmadaRestMethod.HeadersCopy.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Invoke-OAuth2Authentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-OAuthTokenRequest.ps1": [ + "Tests/Integration/Invoke-OmadaRestMethod.HeadersCopy.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Invoke-OAuth2Authentication.Tests.ps1", + "Tests/Unit/Invoke-OAuthTokenRequest.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-OmadaRequest.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.HeadersCopy.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.NoInteractiveAuthentication.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Paged.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Retry.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Integration/OmadaSession.Runspace.Tests.ps1", + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.PreferredMfaMethod.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.SkipBodyRedaction.Tests.ps1", + "Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1", + "Tests/Unit/New-OmadaRequestContext.Tests.ps1", + "Tests/Unit/Protect-LogMessage.Tests.ps1", + "Tests/Unit/Resolve-OmadaNativeCommand.Tests.ps1", + "Tests/Unit/Set-OmadaCurrentBaseUrl.Tests.ps1", + "Tests/Unit/Set-RequestParameter.Tests.ps1", + "Tests/Unit/SignInAccount.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-OmadaRetryableRequest.ps1": [ + "Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1", + "Tests/Unit/Resolve-OmadaNativeCommand.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-WebEdgeDriverFramework.ps1": [ + "Tests/Unit/Invoke-WebEdgeDriverFramework.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-WebView2MicrosoftLogin.ps1": [ + "Tests/Integration/EntraSignInScript.Browser.Tests.ps1", + "Tests/Unit/ConvertTo-JavaScriptLiteral.Tests.ps1", + "Tests/Unit/Invoke-WebView2MicrosoftLogin.Scripts.Tests.ps1", + "Tests/Unit/StrictMode.Tests.ps1", + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Invoke-WindowsAuthentication.ps1": [ + "Tests/Unit/Invoke-WindowsAuthentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-DynamicParam.ps1": [ + "Tests/Unit/New-DynamicParam.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-EntraSignInUri.ps1": [ + "Tests/Unit/New-EntraSignInUri.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-OAuthClientAssertion.ps1": [ + "Tests/Unit/New-OAuthClientAssertion.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-OAuthTokenRequestError.ps1": [ + "Tests/Unit/Invoke-OAuth2Authentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-OmadaRequestContext.ps1": [ + "Tests/Unit/Invoke-BasicAuthentication.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/Invoke-IntegratedAuthentication.Tests.ps1", + "Tests/Unit/Invoke-OAuth2Authentication.Tests.ps1", + "Tests/Unit/Invoke-WindowsAuthentication.Tests.ps1", + "Tests/Unit/New-OmadaRequestContext.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1", + "Tests/Unit/Set-Body.Tests.ps1", + "Tests/Unit/Set-RequestParameter.Tests.ps1", + "Tests/Unit/SignInAccount.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-OmadaSessionExpiredError.ps1": [ + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1" + ], + "OmadaWeb.PS/Private/New-OmadaWebCacheItem.ps1": [ + "Tests/Unit/New-OmadaWebCacheItem.Tests.ps1" + ], + "OmadaWeb.PS/Private/Protect-LogMessage.ps1": [ + "Tests/Unit/Protect-LogMessage.Tests.ps1" + ], + "OmadaWeb.PS/Private/Protect-OmadaSessionPayload.ps1": [ + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1" + ], + "OmadaWeb.PS/Private/Reset-LoginAutomationState.ps1": [ + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/StrictMode.Tests.ps1", + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Resolve-EntraSignInScreen.ps1": [ + "Tests/Integration/EntraSignInScript.Browser.Tests.ps1", + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Get-EntraSignInProbeScript.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/Resolve-EntraSignInScreen.Tests.ps1", + "Tests/Unit/StrictMode.Tests.ps1" + ], + "OmadaWeb.PS/Private/Resolve-OmadaNativeCommand.ps1": [ + "Tests/Unit/Resolve-OmadaNativeCommand.Tests.ps1" + ], + "OmadaWeb.PS/Private/Save-RemoteFile.ps1": [ + "Tests/Unit/Invoke-DownloadFile.Tests.ps1" + ], + "OmadaWeb.PS/Private/Select-EntraMfaMethod.ps1": [ + "Tests/Unit/Select-EntraMfaMethod.Tests.ps1", + "Tests/Unit/StrictMode.Tests.ps1" + ], + "OmadaWeb.PS/Private/Set-Body.ps1": [ + "Tests/Unit/Set-Body.Tests.ps1" + ], + "OmadaWeb.PS/Private/Set-DynamicParameter.ps1": [ + "Tests/Integration/Invoke-OmadaRestMethod.HeadersCopy.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/DpapiScopeWording.Tests.ps1", + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/Set-DynamicParameter.Tests.ps1" + ], + "OmadaWeb.PS/Private/Set-OmadaCurrentBaseUrl.ps1": [ + "Tests/Unit/Set-OmadaCurrentBaseUrl.Tests.ps1" + ], + "OmadaWeb.PS/Private/Set-RequestParameter.ps1": [ + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/New-OmadaRequestContext.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/Set-RequestParameter.Tests.ps1", + "Tests/Unit/SignInAccount.Tests.ps1" + ], + "OmadaWeb.PS/Private/Show-EntraApprovalNumber.ps1": [ + "Tests/Unit/Show-EntraApprovalNumber.Tests.ps1", + "Tests/Unit/StrictMode.Tests.ps1", + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Start-WebView2Login.ps1": [ + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/Test-OmadaConsoleControl.Tests.ps1", + "Tests/Unit/Test-OmadaStaThread.Tests.ps1" + ], + "OmadaWeb.PS/Private/Stop-OmadaLogin.ps1": [ + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/Get-EntraErrorVerdict.Tests.ps1", + "Tests/Unit/Stop-OmadaLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Switch-ToManualLogin.ps1": [ + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-EnvironmentSuspended.ps1": [ + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/CanaryRelyingParty.Tests.ps1", + "Tests/Unit/Test-EnvironmentSuspended.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-LoginAutomationStalled.ps1": [ + "Tests/Unit/Switch-ToManualLogin.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-OmadaConsoleControl.ps1": [ + "Tests/Unit/Test-OmadaConsoleControl.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-OmadaCookieCacheFile.ps1": [ + "Tests/Unit/Export-OmadaCookieFile.Tests.ps1", + "Tests/Unit/OmadaCookieCache.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-OmadaLogonPageError.ps1": [ + "Tests/Unit/ConvertTo-RedactedLogString.Tests.ps1", + "Tests/Unit/Test-OmadaLogonPageError.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-OmadaStaThread.ps1": [ + "Tests/Unit/Test-OmadaStaThread.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-SensitiveLogName.ps1": [ + "Tests/Unit/Test-SensitiveLogName.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-SignInAccountRecovery.ps1": [ + "Tests/Unit/Get-DataFromWebView2.Recovery.Tests.ps1", + "Tests/Unit/Test-SignInAccountRecovery.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-WebView2NavigationReady.ps1": [ + "Tests/Unit/Test-WebView2NavigationReady.Tests.ps1" + ], + "OmadaWeb.PS/Private/Test-WebView2RuntimeVersion.ps1": [ + "Tests/Unit/Install-WebView2.Tests.ps1" + ], + "OmadaWeb.PS/Private/Unprotect-OmadaSessionPayload.ps1": [ + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1" + ], + "OmadaWeb.PS/Private/Write-ModuleVersionStatus.ps1": [ + "Tests/Unit/Write-ModuleVersionStatus.Tests.ps1" + ], + "OmadaWeb.PS/Private/Write-OmadaDeprecationWarning.ps1": [ + "Tests/Unit/Invoke-BrowserAuthentication.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.PreferredMfaMethod.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/Write-OmadaDeprecationWarning.Tests.ps1" + ], + "OmadaWeb.PS/Public/Clear-OmadaWebCache.ps1": [ + "Tests/Unit/Clear-OmadaWebCache.Tests.ps1" + ], + "OmadaWeb.PS/Public/Export-OmadaSession.ps1": [ + "Tests/Integration/OmadaSession.Runspace.Tests.ps1", + "Tests/Unit/DpapiScopeWording.Tests.ps1", + "Tests/Unit/Get-OmadaCookieExpiry.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1" + ], + "OmadaWeb.PS/Public/Import-OmadaSession.ps1": [ + "Tests/Integration/OmadaSession.Runspace.Tests.ps1", + "Tests/Unit/DpapiScopeWording.Tests.ps1", + "Tests/Unit/Get-OmadaCookieExpiry.Tests.ps1", + "Tests/Unit/OmadaSession.ExportImport.Tests.ps1", + "Tests/Unit/Resolve-OmadaNativeCommand.Tests.ps1" + ], + "OmadaWeb.PS/Public/Invoke-OmadaRestMethod.ps1": [ + "Tests/Integration/Invoke-OmadaRequest.ReAuth.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.HeadersCopy.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Retry.Tests.ps1", + "Tests/Integration/Invoke-OmadaRestMethod.Tests.ps1", + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Integration/OmadaSession.Runspace.Tests.ps1", + "Tests/Unit/Get-OAuthClientCertificate.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.PreferredMfaMethod.Tests.ps1", + "Tests/Unit/Invoke-OmadaRequest.SkipBodyRedaction.Tests.ps1", + "Tests/Unit/Invoke-OmadaRestMethod.HelpExamples.Tests.ps1", + "Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/Set-RequestParameter.Tests.ps1", + "Tests/Unit/SignInAccount.Tests.ps1" + ], + "OmadaWeb.PS/Public/Invoke-OmadaWebRequest.ps1": [ + "Tests/Integration/Invoke-OmadaWebRequest.Tests.ps1", + "Tests/Unit/NoInteractiveAuthentication.Tests.ps1", + "Tests/Unit/Set-RequestParameter.Tests.ps1" + ] + }, + "_untested": [ + "OmadaWeb.PS/Private/Close-EdgeDriver.ps1", + "OmadaWeb.PS/Private/Confirm-FileHash.ps1", + "OmadaWeb.PS/Private/ConvertTo-OmadaExpiryMoment.ps1", + "OmadaWeb.PS/Private/Get-EdgeProfile.ps1", + "OmadaWeb.PS/Private/Get-OmadaLegacyCookieCacheFile.ps1", + "OmadaWeb.PS/Private/Get-OmadaResponseStatusCode.ps1", + "OmadaWeb.PS/Private/Install-LockedPackageGroup.ps1", + "OmadaWeb.PS/Private/Reset-Timer.ps1", + "OmadaWeb.PS/Private/Start-EdgeDriver.ps1", + "OmadaWeb.PS/Private/Start-EdgeDriverLogin.ps1", + "OmadaWeb.PS/Private/Test-OmadaTransientFailure.ps1" + ] +} From d2bf4e09e3a6191a6c4492dffcb999a4d44590c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:40:57 +0000 Subject: [PATCH 2/3] Write the whole verdict to the step summary, and fix a hard-to-parse sentence The -f operator took the blank line and the summary as format arguments, so only the heading reached GITHUB_STEP_SUMMARY. Refs #91 --- Build/psakeBuild.ps1 | 2 +- CONTRIBUTING.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Build/psakeBuild.ps1 b/Build/psakeBuild.ps1 index 6633fbf..cadaa29 100644 --- a/Build/psakeBuild.ps1 +++ b/Build/psakeBuild.ps1 @@ -467,7 +467,7 @@ function Write-QualityVerdict { detail = @($Detail) } | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding UTF8 if ($Env:GITHUB_STEP_SUMMARY) { - $Lines = @("### {0}: {1}" -f $Gate, $(if ($Passed) { "passed" } else { "FAILED" }), "", $Summary, "") + $Lines = @(("### {0}: {1}" -f $Gate, $(if ($Passed) { "passed" } else { "FAILED" })), "", $Summary, "") $Lines += @($Detail | ForEach-Object { "- {0}" -f $_ }) $Lines -join "`n" | Add-Content -Path $Env:GITHUB_STEP_SUMMARY -Encoding UTF8 } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23ec2f7..7e5b552 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,8 +94,8 @@ Three files are part of the gates and are reviewed like code: - **`psmutant.config.json`** maps each source file to the test files that cover it. The map is generated - a source file is covered by every test file that names one of its functions - and a pull request fails while it is stale. After adding a function, a source file or a test file, run - `./Build/Update-MutationConfig.ps1 -Update` and commit the result. Source files no test names - are listed under `_untested` and are not mutated at all. + `./Build/Update-MutationConfig.ps1 -Update` and commit the result. Source files that no test + names are listed under `_untested` and are not mutated at all. - **`complexity-baseline.json`** only ratchets down. When you simplify a recorded function the gate fails until its entry is lowered: run `Test-PSComplexity -Path ./OmadaWeb.PS -Recurse -BaselineFile ./complexity-baseline.json -UpdateBaseline` From 0560aa3f9b73af1a8bbef39671f33a0409c4a810 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:13:01 +0000 Subject: [PATCH 3/3] Assert the requested backoff delays, not the wall clock between retries The exponential-backoff test measured the gap between attempts, which holds everything else that ran in between as well. On the Windows PowerShell leg of PR validation that overhead was about 0.3s on the first retry, so the first gap (1.32s) came out longer than the second (1.18s) although the requested delays - [0.5s, 1.0s] and [1.0s, 2.0s] - were in order. The test now captures what Invoke-OmadaRetryableRequest asks Start-Sleep for and checks both ranges, as the neighbouring overflow test already does. It still fails when the backoff stops being exponential, and no longer sleeps three seconds. Refs #91 --- .../Invoke-OmadaRetryableRequest.Tests.ps1 | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1 b/Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1 index 6cc4296..ffe34b3 100644 --- a/Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1 +++ b/Tests/Unit/Invoke-OmadaRetryableRequest.Tests.ps1 @@ -516,11 +516,18 @@ Describe 'Invoke-OmadaRetryableRequest' -Tag 'Unit' { It 'Should back off exponentially, waiting longer before the second retry than before the first' { InModuleScope 'OmadaWeb.PS' { - $Script:RetryTestTimestamps = [System.Collections.Generic.List[datetime]]::new() + # The delays the function asks for are asserted, not the wall clock between attempts: + # a measured gap also holds whatever else ran in between, and on a slow runner that + # overhead alone - about 0.3s on the first retry under Windows PowerShell - pushed + # the first gap past the second although the requested delays were in order. + $Script:RetryTestSleeps = [System.Collections.Generic.List[int]]::new() + Mock Start-Sleep { $Script:RetryTestSleeps.Add($Milliseconds) } + + $Script:RetryTestCalls = 0 $Command = { param($Uri) - $Script:RetryTestTimestamps.Add([datetime]::UtcNow) - if ($Script:RetryTestTimestamps.Count -lt 3) { + $Script:RetryTestCalls++ + if ($Script:RetryTestCalls -lt 3) { $Exception = [OmadaWebPSTests.FakeHttpException]::new('throttled') $Exception.Response = [OmadaWebPSTests.FakeResponse]::new() $Exception.Response.StatusCode = 503 @@ -534,11 +541,12 @@ Describe 'Invoke-OmadaRetryableRequest' -Tag 'Unit' { # Equal jitter bounds the first delay to [0.5s, 1.0s] and the second to [1.0s, 2.0s], # so the two ranges cannot overlap however the jitter falls. - $FirstDelay = ($Script:RetryTestTimestamps[1] - $Script:RetryTestTimestamps[0]).TotalSeconds - $SecondDelay = ($Script:RetryTestTimestamps[2] - $Script:RetryTestTimestamps[1]).TotalSeconds - - $FirstDelay | Should -BeGreaterThan 0.4 - $SecondDelay | Should -BeGreaterThan $FirstDelay + $Script:RetryTestSleeps.Count | Should -Be 2 + $Script:RetryTestSleeps[0] | Should -BeGreaterOrEqual 500 + $Script:RetryTestSleeps[0] | Should -BeLessOrEqual 1000 + $Script:RetryTestSleeps[1] | Should -BeGreaterOrEqual 1000 + $Script:RetryTestSleeps[1] | Should -BeLessOrEqual 2000 + $Script:RetryTestSleeps[1] | Should -BeGreaterOrEqual $Script:RetryTestSleeps[0] } } }