diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 421953e..279c6cc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -52,6 +52,33 @@ expert feedback for accuracy: calls where empty strings matter - **Output**: Don't use misleading `[OutputType([PSCustomObject])]` declarations - **Documentation**: Don't use broken comment-based help (missing `<#` opener) +- **Block Scoping**: Don't `return` from `begin` to stop a command. `return` ends the `begin` block and nothing + else; `process` still runs. A teardown that did this printed "cancelled" and deleted everything. Record the + decision in a variable and test it at the top of `process` +- **Confirmation Gates**: Don't rely on `Read-Host` or `ShouldContinue` to stop an unattended run. `Read-Host` + reads end-of-file in a non-interactive session and never matches the confirmation word; `ShouldContinue` hangs + a console and is refused in CI. Treat a session that cannot answer as a refusal, and require `-Force` for an + unattended destructive run +- **Parameter Shadowing**: Don't name a local after a parameter in any letter case. Variable names are + case-insensitive, so a body-level `$settings = @{}` silently overwrites `-Settings`. Inside a nested function + `$PSBoundParameters` is that function's own, so capture what it needs into a plain variable before defining it +- **WhatIf Reach**: Don't enumerate with `ForEach-Object -MemberName` in a command that supports `-WhatIf`; that + parameter set honours `ShouldProcess`, so the enumeration returns nothing under `-WhatIf`. Use a script block. + `$WhatIfPreference` is also inherited by every child scope, which is why a release rehearsal has to pin its + non-destructive steps; see + [module.instructions.md](./instructions/module.instructions.md#the-rehearsal-under--whatif) +- **Background Scope**: Don't read `$script:` state, or call the module's own functions, inside `Start-Job`, a + runspace worker, or a `ForEach-Object -Parallel` block. A job starts without this session's state and + functions, and a worker without its state; neither fails loudly. A seed that did this created 688 computers + with no prefix and silently created none of its 296 users. Pass values through `-ArgumentList`, `$using:`, or a + parameter, and remember that Pester mocks do not reach a worker either +- **Empty Collections**: Don't rely on a nested helper returning `@()`. PowerShell unrolls an empty array on + the way out of a function, so the caller receives `$null` and a splatted `[object[]]` parameter fails to + bind. Wrap the call site with `@(...)`, or return with `Write-Output -NoEnumerate` +- **Response Shapes**: Don't index response data with `['key']` or assume one shape. Graph SDK responses are + hashtables by default and PSObjects with `-OutputType PSObject`; `ConvertFrom-Json` is the reverse, and + `$psobject['key']` returns `$null` with no error. Read through one accessor that handles both; see + [module.instructions.md](./instructions/module.instructions.md#responses-that-arrive-as-hashtables-or-psobjects) ### Script Development Guidelines @@ -622,6 +649,12 @@ Describe "Get-ExampleData" -Tag "Unit" { } ``` +Before the first publish, read +[Releasing to the PowerShell Gallery](./instructions/module.instructions.md#releasing-to-the-powershell-gallery). +A Gallery version can never be deleted, so the publish stages an allowlist rather than the +repository root, the Gallery's metadata limits are measured locally, no URI key is left empty, and +the release script is written knowing that `-WhatIf` reaches every child scope it calls. + ## 🎯 Community Standards Integration ### Automatic Validation @@ -716,7 +749,7 @@ Prefer matching these over inventing a shape. Each is complete, runs, and passes described above: | Need | Example | -|---|---| +| --- | --- | | A complete advanced function | [Basic-Function-Example.ps1](../powershell-standards/Examples/Basic-Function-Example.ps1) | | Module layout and export boundary | [Module-Structure-Example](../powershell-standards/Examples/Module-Structure-Example/) | | Pester 6 tests, including private functions | [Module-Structure-Example/Tests](../powershell-standards/Examples/Module-Structure-Example/Tests/) | diff --git a/.github/instructions/architecturedesign.instructions.md b/.github/instructions/architecturedesign.instructions.md index 5d6ef1a..5d4afba 100644 --- a/.github/instructions/architecturedesign.instructions.md +++ b/.github/instructions/architecturedesign.instructions.md @@ -139,7 +139,11 @@ Implement comprehensive error handling: ### Scalability Patterns -- **Parallel Processing**: ForEach-Object -Parallel for PowerShell 7+ +- **Parallel Processing**: ForEach-Object -Parallel for PowerShell 7+. A parallel block, a runspace + worker, and a `Start-Job` body all start without the caller's `$script:` state; pass what they need + through `$using:`, a parameter, or `-ArgumentList`, and keep a contract test that refuses a `$script:` + read inside one. Measure before adopting: a cmdlet that keeps one session per process, such as the + RSAT `ActiveDirectory` module, fails under runspaces in ways a process per batch does not - **Batch Processing**: Chunked operations for large datasets - **Streaming**: Pipeline-based processing for memory efficiency - **Caching**: Multi-level caching strategies diff --git a/.github/instructions/cicd.instructions.md b/.github/instructions/cicd.instructions.md index 0703029..256b0f1 100644 --- a/.github/instructions/cicd.instructions.md +++ b/.github/instructions/cicd.instructions.md @@ -179,6 +179,13 @@ Configure automated publishing with: - Pre-release versions for development branches - Release notes generation from changelog - API key management through secure secret storage +- A release rehearsal, `Publish-Module.ps1 -WhatIf`, that stages the allowlisted package, imports it + in a fresh process, verifies exports and help, measures the Gallery's metadata limits, and stops + before the upload. Run it on every pull request; the tag-driven release re-runs it and publishes. +- The rules the rehearsal exists to enforce are in + [module.instructions.md](./module.instructions.md#releasing-to-the-powershell-gallery): never + publish the repository root, the `ReleaseNotes`, `Description` and tag limits, no empty URI keys, + and `$WhatIfPreference` reaching every child scope of the release script. ## Security Integration diff --git a/.github/instructions/comments.instructions.md b/.github/instructions/comments.instructions.md index 024a926..58c9c46 100644 --- a/.github/instructions/comments.instructions.md +++ b/.github/instructions/comments.instructions.md @@ -15,7 +15,7 @@ This file defines **what good help content contains**. Where that content is wri whether the function is exported: | Function | Help lives in | Notes | -|---|---|---| +| --- | --- | --- | | Exported (`Public/`) in a module | PlatyPS Markdown under `docs/ModuleName/` | The `.ps1` keeps only `# .EXTERNALHELP` plus a one-line `.SYNOPSIS` | | Private (`Private/`) in a module | Full comment-based help in the `.ps1` | PlatyPS never sees these - the comment block is the only documentation | | Standalone script or function | Full comment-based help in the `.ps1` | No module, no MAML, no PlatyPS | @@ -276,7 +276,7 @@ The sections above are named for comment-based help keywords. In PlatyPS Markdow The content requirements are identical; only the syntax differs: | Comment-based help | PlatyPS Markdown | -|---|---| +| --- | --- | | `.SYNOPSIS` | `## SYNOPSIS` | | `.DESCRIPTION` | `## DESCRIPTION` | | `.PARAMETER Name` | `### -Name` under `## PARAMETERS` | diff --git a/.github/instructions/module.instructions.md b/.github/instructions/module.instructions.md index 0c38b1b..3cd4a3f 100644 --- a/.github/instructions/module.instructions.md +++ b/.github/instructions/module.instructions.md @@ -130,7 +130,11 @@ helpers internal: Tags = @('PowerShell', 'Enterprise', 'Automation', 'Management') LicenseUri = 'https://github.com/organization/modulename/blob/main/LICENSE' ProjectUri = 'https://github.com/organization/modulename' + # Omit IconUri when there is no icon. An empty string is not "no icon": NuGet + # aborts the pack with "IconUrl cannot be empty" and names neither file nor key. IconUri = 'https://github.com/organization/modulename/raw/main/Assets/icon.png' + # The current release only, ending with a pointer to CHANGELOG.md. The Gallery + # refuses more than 10,600 characters, and only at upload. ReleaseNotes = 'Initial release with core functionality' RequireLicenseAcceptance = $false } @@ -139,7 +143,8 @@ helpers internal: # Help Information # Only set this if you actually publish an Updatable Help package to that location. # Update-Help reports an error against a HelpInfoURI with no HelpInfo.xml behind it. - # Shipping MAML in en-US/ does not require this key. + # Shipping MAML in en-US/ does not require this key. Omit it rather than setting it + # to '' - an empty value fails the pack the same way an empty IconUri does. HelpInfoURI = 'https://docs.organization.com/powershell/modulename' } ``` @@ -271,9 +276,153 @@ function Verb-Noun { } ``` +### Functions That Reduce Their Input to One Result + +`ValueFromPipeline` changes what `process` receives. Passed by parameter, a collection arrives in +`process` once, as one argument. Piped, PowerShell unrolls it and `process` runs once per element. A +function that reduces its input to a single result, such as folding a set of policy evaluations into +one outcome, gives a different answer on each path unless it handles both: by parameter it folds the +whole collection, piped it folds each element on its own and emits one wrong result per element. No +error is raised, and every result has the declared output type. + +For such a function: + +- Collect in `process` and fold in `end`. Elements arriving one at a time go into a `List[object]` + and the set is folded once the pipeline closes. +- Decide what a single element arriving alone means, and write that decision into the function. A + whole input that is itself one object (an envelope with a `value` property, a JSON string) is + folded as it arrives, so piping several whole inputs still yields one result each. +- Exclude `[string]` before any `IEnumerable` check. A string enumerates as characters. +- A hashtable is `IEnumerable` too, and is one object rather than a collection. + +```powershell +function ConvertTo-Outcome { + [CmdletBinding()] + [OutputType('Module.Outcome')] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [AllowNull()] + [object]$InputObject + ) + + begin { + # Elements piped in one at a time are held here and folded together in end + $loose = [System.Collections.Generic.List[object]]::new() + } + + process { + # A lone element is a single object without the envelope's value property. A string is + # IEnumerable (of characters) and a hashtable is IEnumerable (of entries), so both are + # settled before the enumerable check. + $isLoneElement = $null -ne $InputObject -and $InputObject -isnot [string] -and + ($InputObject -is [hashtable] -or $InputObject -isnot [System.Collections.IEnumerable]) + if ($isLoneElement) { + if ($InputObject -is [hashtable]) { + $isLoneElement = -not $InputObject.ContainsKey('value') + } else { + $isLoneElement = -not $InputObject.PSObject.Properties['value'] + } + } + + if ($isLoneElement) { + $loose.Add($InputObject) + } else { + # A whole input arrived at once: a collection by parameter, an envelope, a JSON string + Get-FoldedOutcome -Response $InputObject + } + } + + end { + if ($loose.Count -gt 0) { + Get-FoldedOutcome -Response $loose.ToArray() + } + } +} +``` + +Test both paths. `-InputObject $collection` and `$collection | ConvertTo-Outcome` run different code +once the input is enumerable, and the piped test must pipe the shape the real upstream command +produces. A usage example in the README or in help is a test case until one exists that runs it as +written. See the [Unit Test Template](./pester-supporting-docs/unit-test-template.md). + +### Responses That Arrive as Hashtables or PSObjects + +The same JSON reaches a function in two shapes. `Invoke-MgGraphRequest` returns hashtables unless +`-OutputType PSObject` is passed; `ConvertFrom-Json` returns PSObjects unless `-AsHashtable` is +passed (PowerShell 7); a baseline or fixture read back from disk comes through `ConvertFrom-Json`. A +function that reads response data with one syntax is correct for one shape and silently wrong for +the other. Measured on PowerShell 7.6 and Windows PowerShell 5.1 alike: + +| Read | Hashtable | PSObject | +|---|---|---| +| `$response['value']` | the value | `$null` with no error; an error only under `Set-StrictMode` | +| `$response.value` | the value | the value | +| `$response.Count` | the key `Count` when one exists, otherwise the number of entries | the property when one exists, otherwise `1` | +| `$response.missing` | `$null` | `$null` | + +Index syntax reads only the hashtable. Member syntax reads both, but a key named like a hashtable +member (`Count`, `Keys`, `Values`) comes back as the hashtable's own property when the key is absent, +and a scalar's `Count` is `1`, so neither is `$null` when the data is missing. CaOutcome computed +Conditional Access outcomes from nulls, with no error, until every read of response data went +through one accessor: + +```powershell +function Get-ResponseProperty { + <# + .SYNOPSIS + Reads one property from response data in either shape it can arrive in. + + .DESCRIPTION + Private helper. Graph SDK responses are hashtables by default and PSObjects with + -OutputType PSObject; ConvertFrom-Json is the reverse. Indexing a PSObject with a key + returns $null with no error, so every read of anything that came from an API or a file + goes through this function. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object]$InputObject, + + [Parameter(Mandatory)] + [string]$Name + ) + + if ($null -eq $InputObject) { + return $null + } + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($Name)) { + return $InputObject[$Name] + } + return $null + } + $property = $InputObject.PSObject.Properties[$Name] + if ($property) { + return $property.Value + } + return $null +} +``` + +Use it for every read of anything that came from an API or a file. Like any function, it unrolls a +one-element collection on the way out, so wrap a read of a collection such as `value` in `@(...)` +before indexing or counting it. Test the public function with both shapes: the fixture parsed with +`ConvertFrom-Json`, and again with `ConvertFrom-Json -AsHashtable` or through the SDK's hashtable +form. Keep fixtures real: a recorded response with identifiers rewritten, because every shape defect +found this way came from a shape the API returned rather than one anybody imagined. + ### Class Development -Create PowerShell classes with proper validation and methods: +Create PowerShell classes with proper validation and methods. One constraint comes with the dot-source +loader above: **a class or enum defined in `Classes/` must not appear as a type constraint in any +`param()` block.** A type literal in a parameter attribute is resolved when the file is parsed, and a +dot-sourced file cannot see a class defined by a sibling file at that moment. Casts and `::new()` calls +in a function body are fine, because they resolve at run time. One `[ModuleConfiguration]$Config` +parameter breaks the import for every command in the module, not only the one that declares it. Take +`[object]` or `[PSCustomObject]` at the boundary and validate the type in the body, or build the module +into a single file whose classes precede the functions. ```powershell # Classes/ModuleConfiguration.ps1 @@ -553,6 +702,146 @@ Troubleshooting/ └── External-Dependencies.md ``` +## Releasing to the PowerShell Gallery + +A Gallery version is consumed the moment it uploads. It can be unlisted but never deleted, and the +`.nupkg` stays downloadable at its direct URL afterwards. Each rule below comes from a release of a +module built to these standards that passed every local gate and then shipped the wrong thing or +failed at the upload. + +### Stage an allowlist, never publish the repository root + +When the repository is the module root, `Publish-PSResource -Path .` packages the working tree: +`.git`, the tests and their fixtures, the build scripts, and anything else on disk. Measured on one +module: 313 files and 975 KB, all 213 files of `.git` among them, against 46 files and 507 KB for +the module itself. + +Publish from a staging folder that an allowlist fills. A new folder does not ship until it is named, +and the staged copy is imported in a fresh process and checked before anything uploads: + +```powershell +# Build/Publish-Module.ps1 - [CmdletBinding(SupportsShouldProcess)] with -WhatIf as the rehearsal +$shipFiles = 'ModuleName.psd1', 'ModuleName.psm1', 'README.md', 'CHANGELOG.md', 'LICENSE' +$shipFolders = 'Classes', 'Private', 'Public', 'en-US', 'Troubleshooting' + +# out/ is git-ignored. Every step but the publish is pinned -WhatIf:$false; see below for why. +$stage = Join-Path (Join-Path $PSScriptRoot '..\out') 'ModuleName' +if (Test-Path $stage) { + Remove-Item -Path $stage -Recurse -Force -WhatIf:$false +} +New-Item -Path $stage -ItemType Directory -Force -WhatIf:$false | Out-Null +foreach ($item in $shipFiles + $shipFolders) { + Copy-Item -Path (Join-Path $PSScriptRoot "..\$item") -Destination $stage -Recurse -WhatIf:$false +} + +# A fresh process proves the staged copy imports on its own and exports what the manifest says +$exported = pwsh -NoProfile -Command "Import-Module '$stage' -Force; (Get-Module ModuleName).ExportedFunctions.Keys" + +if ($PSCmdlet.ShouldProcess("ModuleName $($manifest.ModuleVersion)", 'Publish to PSGallery')) { + Publish-PSResource -Path $stage -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY +} +``` + +Pass the API key through an environment variable or a SecretManagement secret, never on the command +line of a workflow step, where it lands in the run log. + +### Metadata the Gallery refuses at upload + +Three fields carry a limit that is enforced only on upload, so every local gate passes until the +publish step returns 400: + +| Field | Limit | Whose limit | +| --- | --- | --- | +| `ReleaseNotes` | 10,600 characters | The Gallery's, stated in its error response | +| `Description` | 4,000 characters | NuGet's | +| Package tags | 4,000 characters | NuGet's, applied to the full tag list, not to the manifest's `Tags` key alone | + +The tag list the Gallery stores is the manifest's `Tags` plus what the publish adds: `PSModule`, a +`PSEdition_` tag per compatible edition, `PSIncludes_Function`, and `PSFunction_` and `PSCommand_` +for every exported function. A module with 27 commands carries 61 tags and about 1,900 characters, +so each new command costs about twice its name and a module that keeps adding commands reaches the +limit without its `Tags` key changing. + +`ReleaseNotes` grows the same way when every release prepends its paragraph and none removes one. +Two modules built to these standards were refused at 11,319 and 12,699 characters, each after every +other gate had passed and the tag had been pushed. Keep the current release's notes in the manifest +and end them with a pointer to `CHANGELOG.md`, which ships in the package and already holds the +history. + +Measure all three in the release rehearsal and in a contract test, so a pull request fails rather +than the tag-driven release: + +```powershell +$manifest = Import-PowerShellDataFile -Path ./ModuleName.psd1 +$module = Get-Module -Name ModuleName # the staged copy +$tags = @($manifest.PrivateData.PSData.Tags) + 'PSModule' + 'PSIncludes_Function' + + @($manifest.CompatiblePSEditions | ForEach-Object { "PSEdition_$_" }) + + @($module.ExportedFunctions.Keys | ForEach-Object { "PSFunction_$_"; "PSCommand_$_" }) + +$limits = @{ + Description = @{ Length = $manifest.Description.Length; Limit = 4000 } + Tags = @{ Length = ($tags -join ' ').Length; Limit = 4000 } + ReleaseNotes = @{ Length = $manifest.PrivateData.PSData.ReleaseNotes.Length; Limit = 10600 } +} +foreach ($field in $limits.Keys) { + $measured = $limits[$field] + if ($measured.Length -gt $measured.Limit) { + $message = "The manifest's $field is $($measured.Length) characters; " + + "the Gallery accepts at most $($measured.Limit)" + Write-Error $message -ErrorAction Stop + } +} +``` + +A module that exports cmdlets or DSC resources gains further automatic tags (`PSCmdlet_`, +`PSDscResource_`); build the list the way the publish does for what the module ships. + +### Omit a URI key rather than leaving it empty + +`IconUri = ''` is not "no icon". The nuspec writer emits an empty `` and NuGet aborts the +pack with `IconUrl cannot be empty`, naming neither the manifest nor the key. `HelpInfoURI = ''` +fails the same way. Leave the key out until there is a value, and have the rehearsal check for an +empty one. Setting `IconUri` has no visible effect on the Gallery in any case: the package page +renders the default PowerShell logo and does not fetch the icon. + +### The rehearsal under `-WhatIf` + +`-WhatIf` sets `$WhatIfPreference`, and preference variables are inherited by every child scope: +the cmdlets a script calls and the scripts it calls. In a release script that declares +`SupportsShouldProcess`, `-WhatIf` therefore reaches the staging `Copy-Item`, the help build's +`Export-MamlCommandHelp`, and everything else downstream, not only the publish. The rehearsal then +copies nothing and compiles nothing, and either fails on the empty folder or passes while having +verified nothing. One module's release broke this way twice. + +- Only the publish is gated by `ShouldProcess`. Every other step is pinned `-WhatIf:$false` or runs + with the preference cleared. When adding a step, decide whether it is destructive; if not, pin it. +- A called script that declares `[CmdletBinding()]` without `SupportsShouldProcess` has no `-WhatIf` + parameter to pin. Save and clear the preference around the call: + + ```powershell + $savedWhatIf = $WhatIfPreference + $WhatIfPreference = $false + try { + & (Join-Path $PSScriptRoot 'Build-Help.ps1') + } finally { + $WhatIfPreference = $savedWhatIf + } + ``` + +- Test `-WhatIf` in combination with the script's other switches, not each alone. Both failures + above survived testing because `-WhatIf` and `-SkipHelpBuild` had only ever been exercised + separately. +- A stale local intermediate (a `maml/` left by an earlier run) hides a step that now produces + nothing. Rehearse from a clean checkout, or delete the intermediate first. + +### Tag-driven release + +The release workflow runs on `git tag v && git push origin v`, +re-runs every gate, refuses a tag that disagrees with the manifest's `ModuleVersion`, stages, +verifies, and publishes. When the upload fails for a reason the rehearsal did not measure, nothing +has been consumed: fix the manifest, move the tag to the new merge commit, and release under the same +number. + ## Quality Assurance Requirements ### Pre-Release Checklist @@ -575,10 +864,17 @@ Ensure module meets all quality standards: Prepare module for publication: -- [ ] Module manifest includes all required metadata +- [ ] Module manifest includes all required metadata, and no URI key is set to an empty string - [ ] License file included and referenced - [ ] README provides clear installation and usage instructions -- [ ] Release notes document changes and improvements +- [ ] `ReleaseNotes` covers the current release and points to `CHANGELOG.md` for the rest +- [ ] `Description`, the full tag list and `ReleaseNotes` are measured against the Gallery's limits + in the rehearsal and in a contract test +- [ ] The publish stages an allowlist into a git-ignored folder; `Publish-PSResource` is never + pointed at the repository root +- [ ] `Publish-Module.ps1 -WhatIf` stages, imports and verifies the package with every + non-destructive step pinned against the inherited preference, and has been run together with + the script's other switches - [ ] Version follows semantic versioning principles - [ ] Dependencies clearly documented and available diff --git a/.github/instructions/pester-supporting-docs/cicd-integration.md b/.github/instructions/pester-supporting-docs/cicd-integration.md index 50a8e20..98269aa 100644 --- a/.github/instructions/pester-supporting-docs/cicd-integration.md +++ b/.github/instructions/pester-supporting-docs/cicd-integration.md @@ -63,13 +63,12 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Pester shell: pwsh run: | - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Pester -MinimumVersion $env:PESTER_VERSION -Force -Scope CurrentUser + Install-PSResource Pester -Version "[$env:PESTER_VERSION,)" -Scope CurrentUser -TrustRepository - name: Discovery-only pass shell: pwsh @@ -129,7 +128,7 @@ jobs: shell: powershell steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Cache PowerShell Modules uses: actions/cache@v4 @@ -213,7 +212,7 @@ jobs: } - name: Upload Test Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: test-results-${{ matrix.os }}-${{ matrix.shell }} @@ -227,13 +226,12 @@ jobs: needs: validate runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Pester shell: pwsh run: | - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Pester -MinimumVersion $env:PESTER_VERSION -Force -Scope CurrentUser + Install-PSResource Pester -Version "[$env:PESTER_VERSION,)" -Scope CurrentUser -TrustRepository - name: Run Tests with Coverage shell: pwsh @@ -243,6 +241,9 @@ jobs: $config = New-PesterConfiguration $config.Run.Path = './Tests/Unit' $config.Run.PassThru = $true + # Sequential job, so it is the one that runs shuffled: a fresh seed each run, printed + # at the start and named below when the run fails + $config.Run.Shuffle = $true $config.CodeCoverage.Enabled = $true $config.CodeCoverage.Path = './Public/*.ps1', './Private/*.ps1' # Profiler-based coverage is the v6 default and far faster than breakpoints @@ -257,7 +258,8 @@ jobs: $result = Invoke-Pester -Configuration $config if ($result.FailedCount -gt 0 -or $result.FailedContainersCount -gt 0) { - throw "Tests failed" + $seed = $result.Configuration.Run.ShuffleSeed.Value + throw "Tests failed: $($result.FailedCount) test(s), $($result.FailedContainersCount) container(s). Replay with Run.ShuffleSeed = $seed" } # Enforce the coverage gate yourself @@ -275,7 +277,7 @@ jobs: } - name: Upload Coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: coverage @@ -294,7 +296,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Dependencies shell: pwsh @@ -366,13 +368,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Dependencies shell: pwsh run: | - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Pester -MinimumVersion $env:PESTER_VERSION -Force -Scope CurrentUser + Install-PSResource Pester -Version "[$env:PESTER_VERSION,)" -Scope CurrentUser -TrustRepository - name: Run Performance Tests shell: pwsh @@ -410,7 +411,7 @@ jobs: $metrics | ConvertTo-Json | Out-File performance-metrics.json - name: Upload Performance Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: performance-results path: | @@ -579,8 +580,7 @@ stages: inputs: targetType: 'inline' script: | - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Pester -MinimumVersion 6.2.0 -Force -Scope CurrentUser + Install-PSResource Pester -Version '[6.2.0,)' -Scope CurrentUser -TrustRepository pwsh: true - task: PowerShell@2 @@ -618,7 +618,7 @@ stages: inputs: targetType: 'inline' script: | - Install-Module Pester -Force -Scope CurrentUser + Install-PSResource Pester -Version '[6.2.0,)' -Scope CurrentUser -TrustRepository $config = New-PesterConfiguration $config.Run.Path = './Tests/Security' @@ -701,8 +701,7 @@ pipeline { agent { label 'pwsh' } steps { pwsh ''' - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Pester -MinimumVersion 6.2.0 -Force -Scope CurrentUser + Install-PSResource Pester -Version '[6.2.0,)' -Scope CurrentUser -TrustRepository ./Invoke-Tests.ps1 -TestType All -Environment CI -CodeCoverage ''' diff --git a/.github/instructions/pester-supporting-docs/mocking-patterns.md b/.github/instructions/pester-supporting-docs/mocking-patterns.md index 42ff7b4..75a6e18 100644 --- a/.github/instructions/pester-supporting-docs/mocking-patterns.md +++ b/.github/instructions/pester-supporting-docs/mocking-patterns.md @@ -828,6 +828,41 @@ blocks, run in declaration order, so a second group of mocks can be its own bloc merged into the first. Separate `Context` blocks remain the better split when the two groups serve different tests; 6.0 and 6.1 throw on a second `BeforeEach` in the _same_ block. +### Mocks and Setup Blocks Run at Different Times + +A `BeforeEach` runs before each `It`. A `BeforeAll` in a child `Context` runs once, before the first +`It` in that `Context`, which is before any `BeforeEach` has run. A mock defined in the parent's +`BeforeEach` is therefore not active while the child's `BeforeAll` executes, and a command the setup +calls runs for real, or fails as unmocked, with nothing pointing at the mock one block up. Define a +mock that setup code depends on in an enclosing `BeforeAll`; keep `BeforeEach` for mocks that only +the tests themselves call. + +`Should-Invoke` counts calls made in the current `It` by default. A call made in `BeforeAll` is +outside every `It`, so the assertion reports zero invocations even though the command ran. Assert +it with `-Scope Context` (or `-Scope Describe`), which counts every call made inside that block, +its setup included: + +```powershell +Describe 'Get-ServerHealth' -Tag 'Unit' { + BeforeAll { + # Defined here so the Context's BeforeAll below can rely on it + Mock Get-CimInstance { [PSCustomObject]@{ FreePhysicalMemory = 4GB } } -ModuleName ModuleName + } + + Context 'A server queried once for the whole Context' { + BeforeAll { + # Runs before any BeforeEach; only mocks from enclosing BeforeAll blocks are active here + $script:Health = Get-ServerHealth -ComputerName 'SERVER01' + } + + It 'Queried the server once' { + # The default scope is this It, where nothing was called; the call lives in BeforeAll + Should-Invoke Get-CimInstance -ModuleName ModuleName -Times 1 -Exactly -Scope Context + } + } +} +``` + ### Mocks Do Not Cross Files Pester 6 discovers and runs one file at a time, and under `Run.Parallel` each file gets its own diff --git a/.github/instructions/pester-supporting-docs/pester-configuration.md b/.github/instructions/pester-supporting-docs/pester-configuration.md index ef3e86e..96b7cd8 100644 --- a/.github/instructions/pester-supporting-docs/pester-configuration.md +++ b/.github/instructions/pester-supporting-docs/pester-configuration.md @@ -30,7 +30,7 @@ Use this standardized configuration for consistent test execution: Parallel = $false ParallelThrottleLimit = 0 - # EXPERIMENTAL. Off by default. See "Shuffled Test Order" below. + # EXPERIMENTAL and off by default, but turn it on in CI. See "Shuffled Test Order" below. Shuffle = $false ShuffleSeed = 0 # 0 picks a new seed per run and prints it } @@ -429,6 +429,33 @@ test file. The option is experimental and may change. Enabling it changes only ordering, never which tests run or how they are filtered. +### Run It in CI + +Turn shuffling on in the CI unit-test job, with a fresh seed each run and the seed named in the +failure message. The first shuffled run of one 1,300-test suite failed three tests under seed 1 that +file order had always hidden: each wrote into a folder an earlier test in the same file had created. +Two other seeds passed. The same suite later moved to importing the module once per run instead of +in every file, which makes state visible across files; the shuffled run is what keeps that trade +honest, because a test that passes only after a fresh import fails there with a seed to replay. + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './Tests/Unit' +$config.Run.PassThru = $true +$config.Run.Shuffle = $true # ShuffleSeed stays 0: a new seed each run, printed at the start + +$result = Invoke-Pester -Configuration $config + +if ($result.FailedCount -gt 0 -or $result.FailedContainersCount -gt 0) { + $seed = $result.Configuration.Run.ShuffleSeed.Value + throw "Tests failed: $($result.FailedCount) test(s), $($result.FailedContainersCount) container(s). " + + "Replay this order with Run.ShuffleSeed = $seed" +} +``` + +Document the replay command in the tests README, so whoever reads the failing log knows what to do +with the number. + ## Global Mocks (Experimental) New in 6.1. Normally a mock applies to calls from the scope that defined it, or from the single diff --git a/.github/instructions/pester-supporting-docs/test-structure-guide.md b/.github/instructions/pester-supporting-docs/test-structure-guide.md index e3c74b4..e5aed58 100644 --- a/.github/instructions/pester-supporting-docs/test-structure-guide.md +++ b/.github/instructions/pester-supporting-docs/test-structure-guide.md @@ -73,13 +73,18 @@ discovery-time setup. It cannot rely on a file that happened to be discovered ea BeforeDiscovery { # Only what is needed to BUILD the test tree: -ForEach data, helper commands - Import-Module "$PSScriptRoot/../../../ModuleName.psd1" -Force + if (-not (Get-Module -Name ModuleName)) { + Import-Module "$PSScriptRoot/../../../ModuleName.psd1" + } $script:PublicCommands = (Get-Module ModuleName).ExportedFunctions.Keys } BeforeAll { - # What is needed to RUN the tests - Import-Module "$PSScriptRoot/../../../ModuleName.psd1" -Force + # What is needed to RUN the tests. Import once per run: -Force in every file + # re-imports the module as many times as there are files. + if (-not (Get-Module -Name ModuleName)) { + Import-Module "$PSScriptRoot/../../../ModuleName.psd1" + } . "$PSScriptRoot/../../TestHelpers/TestHelpers.ps1" } ``` @@ -97,11 +102,16 @@ tests need at **run** time goes in a `BeforeAll`: # Pester.BeforeContainer.ps1 at the repository root # Discovery only - keep this only where a -ForEach or BeforeDiscovery needs it -Import-Module "$PSScriptRoot/Source/ModuleName.psd1" -Force +if (-not (Get-Module -Name ModuleName)) { + Import-Module "$PSScriptRoot/Source/ModuleName.psd1" +} -# Run time - what the tests themselves need +# Run time - what the tests themselves need. This file runs before every container, +# so a forced import here costs the same as one in every test file. BeforeAll { - Import-Module "$PSScriptRoot/Source/ModuleName.psd1" -Force + if (-not (Get-Module -Name ModuleName)) { + Import-Module "$PSScriptRoot/Source/ModuleName.psd1" + } . "$PSScriptRoot/Tests/TestHelpers/TestHelpers.ps1" } ``` diff --git a/.github/instructions/pester-supporting-docs/unit-test-template.md b/.github/instructions/pester-supporting-docs/unit-test-template.md index 496cac7..3422b9d 100644 --- a/.github/instructions/pester-supporting-docs/unit-test-template.md +++ b/.github/instructions/pester-supporting-docs/unit-test-template.md @@ -22,14 +22,17 @@ BeforeDiscovery { # Pester 6 discovers and runs one file at a time, so this file must set up # anything it needs at DISCOVERY time - it cannot rely on another file. # Only needed when -ForEach data comes from the module itself. - $ModulePath = Join-Path $PSScriptRoot '..\..\ModuleName.psd1' - Import-Module $ModulePath -Force + if (-not (Get-Module -Name ModuleName)) { + Import-Module (Join-Path $PSScriptRoot '..\..\ModuleName.psd1') + } } BeforeAll { - # Import module under test - $ModulePath = Join-Path $PSScriptRoot '..\..\ModuleName.psd1' - Import-Module $ModulePath -Force + # Import the module under test, once per run. -Force belongs in the module + # contract test alone; here it would cost every file a re-import. + if (-not (Get-Module -Name ModuleName)) { + Import-Module (Join-Path $PSScriptRoot '..\..\ModuleName.psd1') + } # Import test helpers . $PSScriptRoot\..\TestHelpers\TestHelpers.ps1 @@ -63,6 +66,17 @@ Describe "Function-Name" -Tag "Unit", "Public" { $results | Should-BeCollection -Count 3 } + It "Should fold a piped collection into the same single result as the by-parameter call" { + # PowerShell unrolls a piped array, so process runs once per element. A function that + # reduces its input to one result has to collect in process and fold in end, and only + # a piped test proves it does; the by-parameter test above passes either way. The @() + # makes the count read the same whether one object or several come back. + $items = @('Value1', 'Value2', 'Value3') + $piped = @($items | ConvertTo-Summary) + $piped | Should-BeCollection -Count 1 + $piped[0].ItemCount | Should-Be (ConvertTo-Summary -InputObject $items).ItemCount + } + It "Should declare ParameterName as a mandatory string" { Get-Command Function-Name | Should-HaveParameter -ParameterName ParameterName -Type ([string]) -Mandatory @@ -249,7 +263,8 @@ Test all parameter validation scenarios: - Valid input variations - Invalid input rejection -- Pipeline support +- Pipeline support: piped and by parameter, since a function that reduces its input to one result runs + different code on each path - Parameter binding and metadata (`Should-HaveParameter`) - Mandatory parameter enforcement diff --git a/.github/instructions/pester.instructions.md b/.github/instructions/pester.instructions.md index 7f4a4ff..bd76dce 100644 --- a/.github/instructions/pester.instructions.md +++ b/.github/instructions/pester.instructions.md @@ -69,8 +69,15 @@ A second `BeforeAll` (or `BeforeEach`, `AfterAll`, `AfterEach`) in one block thr - [ ] **Mocking**: Mock all external dependencies appropriately - [ ] **Performance**: Include timing and memory usage validation - [ ] **Security**: Test input sanitization and credential handling -- [ ] **Pipeline Support**: Verify pipeline input/output functionality +- [ ] **Pipeline Support**: For every `ValueFromPipeline` parameter, test both `$collection | Verb-Noun` and + `-Parameter $collection`, piping the shape the real upstream command produces. A function that reduces + its input to one result runs different code on each path +- [ ] **Documented Examples**: Every usage example in the README and in help has a test that runs it as written - [ ] **Non-Empty Test Cases**: No `-ForEach`/`-TestCases` expression can yield `$null` or `@()` +- [ ] **Mock Scope**: A mock that a `BeforeAll` depends on is defined in an enclosing `BeforeAll`, and + `Should-Invoke` for a call made in setup passes `-Scope Context` +- [ ] **One Import**: Only the module contract test imports with `-Force`; every other file imports only when + the module is not already loaded, and none removes it afterwards ### Configuration & Execution @@ -80,13 +87,17 @@ A second `BeforeAll` (or `BeforeEach`, `AfterAll`, `AfterEach`) in one block thr ### Experimental Options -Three configuration options are experimental, off by default, and may change. Do not enable them in -generated code unless asked; when a project does enable one, these are the consequences: +Three configuration options are experimental, off by default, and may change. Do not enable +`Run.Parallel` or `Mock.Global` in generated code unless asked. `Run.Shuffle` is the exception: turn +it on in CI with the seed printed, as +[Shuffled Test Order](./pester-supporting-docs/pester-configuration.md#shuffled-test-order-experimental) +describes, because it is what makes importing the module once per run safe. When a project enables +one, these are the consequences: | Option | Effect | Note | | --- | --- | --- | | `Run.Parallel` | One test file per runspace | Requires file-based containers. Runs on PowerShell 7 and, from 6.2, on Windows PowerShell 5.1. Coverage works from 6.1 but is forced onto slower breakpoint mode | -| `Run.Shuffle` | Randomizes file, block, and test order | Fails tests that depend on declaration order. Prints a seed; `Run.ShuffleSeed` replays it. Opt a file out with `#pester:no-shuffle` | +| `Run.Shuffle` | Randomizes file, block, and test order | Fails tests that depend on declaration order. Prints a seed; `Run.ShuffleSeed` replays it. Opt a file out with `#pester:no-shuffle`. Recommended in CI: it found three tests in one suite that passed only because an earlier test had created their folder | | `Mock.Global` | A mock applies to calls from any module in the runspace | `-ModuleName` becomes a resolution hint, not a scope. Does **not** reinstate fall-through - a `-ParameterFilter` guard still needs a default mock | All three are per-run configuration, not per-file, apart from the `#pester:no-parallel` and @@ -124,15 +135,33 @@ Each test file must import the modules it needs and perform its own discovery-ti BeforeDiscovery { # Anything needed to BUILD the test tree (-ForEach data, helper commands) - Import-Module "$PSScriptRoot/../../ModuleName.psd1" -Force + if (-not (Get-Module -Name ModuleName)) { + Import-Module "$PSScriptRoot/../../ModuleName.psd1" + } } BeforeAll { # Anything needed to RUN the tests - Import-Module "$PSScriptRoot/../../ModuleName.psd1" -Force + if (-not (Get-Module -Name ModuleName)) { + Import-Module "$PSScriptRoot/../../ModuleName.psd1" + } } ``` +Self-contained does not mean re-imported. `Import-Module -Force` costs about 190 ms warm and 800 ms +cold; importing a module that is already loaded costs about 9 ms. One suite of 111 files that forced +the import in every `BeforeAll` and removed the module in every `AfterAll` spent 21 seconds of an +80-second run doing nothing else. Import only when the module is not loaded, and do not remove it +afterwards. Keep the forced import in exactly one file, the module contract test, which asserts what +a freshly imported module looks like. + +The trade is that state one file leaves in module scope is visible to the next. That is what the +shuffled CI run exists to catch: a test that passes only after a fresh import, or only before another +file connected, fails there with a seed to replay. The other consequence is local: CI starts a fresh +process, but an interactive session that already holds the module keeps serving the code it loaded, +so after editing the module import it once with `-Force` before `Invoke-Pester`, or run the suite in +a fresh process the way CI does. + When several files share bootstrap, put it in a `Pester.BeforeContainer.ps1` rather than relying on another file having run first. From 6.2 every such file from `Run.RepoRoot` down to the test file's own folder applies, outermost first, so unit and integration tests can each carry their own setup. diff --git a/.github/instructions/platyps.instructions.md b/.github/instructions/platyps.instructions.md index 9a6ab89..8a30650 100644 --- a/.github/instructions/platyps.instructions.md +++ b/.github/instructions/platyps.instructions.md @@ -21,7 +21,7 @@ The original `platyPS` v0.14.2 is **no longer supported**. It is a different mod cmdlet names, and its Markdown uses an older schema. Never generate `platyPS` 0.14 commands: | Do not use (platyPS 0.14.2) | Use instead (Microsoft.PowerShell.PlatyPS 1.x) | -|---|---| +| --- | --- | | `New-MarkdownHelp` | `New-MarkdownCommandHelp` | | `Update-MarkdownHelp` | `Update-MarkdownCommandHelp` | | `New-ExternalHelp` | `Export-MamlCommandHelp` | @@ -133,7 +133,7 @@ keyword. When a separate `<# #>` block follows, that block then wins and the key Tested against PowerShell 7.6, with the same MAML present in `en-US/` in every case: | Arrangement | MAML used | -|---|---| +| --- | --- | | `.EXTERNALHELP` inside the `<# #>` block with `.SYNOPSIS` | yes | | `# .EXTERNALHELP` alone, no other help block | yes | | `# .EXTERNALHELP` alone, then a separate `<# .SYNOPSIS #>` block | yes | @@ -267,7 +267,7 @@ Select-String -Path ./docs/ModuleName/*.md -Pattern '^\s*-?\s*\[.+\]\((?!https?: Both forms are legal, and they are not interchangeable: | Target | Form | Renders as | -|---|---|---| +| --- | --- | --- | | A command in this module, or an `about_` topic | `- [Get-Thing]()` | the plain name | | Anything outside the installed module | `- [Title](https://...)` | the URL | @@ -305,7 +305,7 @@ next regeneration and then reappears in the diff. Exclude the generated help ins ```yaml - name: Lint Markdown - uses: DavidAnson/markdownlint-cli2-action@v14 + uses: DavidAnson/markdownlint-cli2-action@v25 with: globs: | **/*.md diff --git a/.github/instructions/powershell-version.instructions.md b/.github/instructions/powershell-version.instructions.md index 6e0cef8..94d0d22 100644 --- a/.github/instructions/powershell-version.instructions.md +++ b/.github/instructions/powershell-version.instructions.md @@ -191,7 +191,8 @@ instructions should use the PSResourceGet verbs. # ✅ Preferred Install-PSResource -Name Pester -Scope CurrentUser -TrustRepository Find-PSResource -Name PSScriptAnalyzer -Publish-PSResource -Path ./MyModule -ApiKey $env:NUGET_KEY +# Publish a staged allowlist, never the repository root - see module.instructions.md +Publish-PSResource -Path ./out/MyModule -ApiKey $env:NUGET_KEY # ⚠️ PowerShellGet v2 - still functional, use only when targeting 5.1 without PSResourceGet installed Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck @@ -209,6 +210,90 @@ else { } ``` +## Windows PowerShell 5.1 differences to code around + +Everything here was found by running a module that declares `CompatiblePSEditions = @('Desktop', 'Core')` +on 5.1 after its suite had passed on 7. When a module declares the Desktop edition, run the suite under +5.1 in CI; a 5.1 trap otherwise shows on a device before it shows in a pull request. + +### Syntax that parses on 7 and breaks every command on 5.1 + +A single use of any of these in any file the root module dot-sources fails the whole import, so one +convenience in one helper takes every command with it: + +| Construct | 5.1 replacement | +| --- | --- | +| Ternary `$a ? $b : $c` | `if ($a) { $b } else { $c }` | +| Null-coalescing `??` and `??=`, null-conditional `?.` and `?[]` | explicit `if ($null -eq ...)` | +| `ForEach-Object -Parallel` | `Start-Job`, a runspace pool, or sequential | +| `Join-Path a b c` (several child paths) | nested `Join-Path` | +| A 7-only parameter such as `-SkipHttpErrorCheck` | detect it on the cmdlet and branch; see below | + +Two run-time differences the same suites hit: `Group-Object` orders its groups differently, so sort +explicitly before relying on order; and .NET Framework's `ZipFile::CreateFromDirectory` writes +backslash entry names, so build an archive entry by entry with forward slashes when it must open +elsewhere. + +### Reading a failed HTTP response + +`Invoke-WebRequest` and `Invoke-RestMethod` return a 4xx or 5xx response on PowerShell 7 when asked to +with `-SkipHttpErrorCheck`; the body is decoded like any other. On 5.1 the cmdlet throws, the body is +readable once from the exception's response stream, and TLS 1.2 is not negotiated by default. Detect +the capability on the cmdlet, never from a version number, and give callers one error shape: + +```powershell +# Detected once at import and cached in module scope +$script:CanSkipHttpErrorCheck = (Get-Command Invoke-WebRequest).Parameters.ContainsKey('SkipHttpErrorCheck') +if (-not $script:CanSkipHttpErrorCheck) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor + [Net.SecurityProtocolType]::Tls12 +} + +function Invoke-ModuleWebRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Request # splatted into Invoke-WebRequest + ) + + if ($script:CanSkipHttpErrorCheck) { + $response = Invoke-WebRequest @Request -SkipHttpErrorCheck + $status = [int]$response.StatusCode + $body = $response.Content + } else { + try { + $response = Invoke-WebRequest @Request + $status = [int]$response.StatusCode + $body = $response.Content + } catch [System.Net.WebException] { + if ($null -eq $_.Exception.Response) { + throw # a transport failure with no response propagates untouched + } + # .NET Framework's HttpStatusCode has no 429 member; read the integer, not the name + $status = [int]$_.Exception.Response.StatusCode + $reader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + try { + $body = $reader.ReadToEnd() # readable once; keep it + } finally { + $reader.Dispose() + } + } + } + + if ($status -ge 400) { + $message = "HTTP $status from $($Request.Uri): $body" + Write-Error -Message $message -ErrorAction Stop + } + $body +} +``` + +Two more things the suite for that function has to know. A `WebHeaderCollection` assigned from an +`if` expression arrives as an array of its key names, because a statement's output is enumerated on +the way out, so assign it inside the branch. Windows PowerShell rewraps a bare thrown exception and +drops any member added to it, so a test fake that needs `Response` on its exception throws an +`ErrorRecord`. + ## Detecting version at runtime ```powershell diff --git a/.github/prompts/create-test.prompt.md b/.github/prompts/create-test.prompt.md index d0e3ee6..ca898ee 100644 --- a/.github/prompts/create-test.prompt.md +++ b/.github/prompts/create-test.prompt.md @@ -42,7 +42,10 @@ ${selection} - Edge cases and boundary conditions - Error handling verification (using $_ patterns) - Parameter validation testing -- Pipeline input and output behaviour (ValueFromPipeline binding, per-item emission) +- Pipeline input and output behaviour (ValueFromPipeline binding, per-item emission, and for a function + that reduces its input to one result, that `$collection | Verb-Noun` gives the same single answer as + `-Parameter $collection`) +- Every usage example in the README and in help, run as written **3. Enterprise Test Standards ✅** diff --git a/powershell-standards/Examples/Module-Structure-Example/README.md b/powershell-standards/Examples/Module-Structure-Example/README.md index a8ce707..dbe7a87 100644 --- a/powershell-standards/Examples/Module-Structure-Example/README.md +++ b/powershell-standards/Examples/Module-Structure-Example/README.md @@ -89,7 +89,7 @@ instead of the compiled help. See ## Patterns demonstrated | Pattern | Where | -|---|---| +| --- | --- | | Approved verb, descriptive `[OutputType]` | `Public/Get-ExampleData.ps1` | | Pipeline input via `ValueFromPipeline` | `Public/Get-ExampleData.ps1` | | Correlation ID generated once, passed through | All three files | diff --git a/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 b/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 index 6765b16..c3c32e0 100644 --- a/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 +++ b/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 @@ -1,14 +1,13 @@ #Requires -Module Pester BeforeAll { + # The module contract test is the one file that imports with -Force: it asserts + # what a freshly imported module looks like. Other files import only when the + # module is not already loaded, and no file removes it afterwards. $script:ManifestPath = Join-Path $PSScriptRoot '..' 'ModuleExample.psd1' | Resolve-Path Import-Module $script:ManifestPath -Force } -AfterAll { - Remove-Module ModuleExample -Force -ErrorAction SilentlyContinue -} - Describe 'ModuleExample' -Tag 'Unit', 'Example' { Context 'Module contract' { diff --git a/powershell-standards/Examples/README.md b/powershell-standards/Examples/README.md index 6bbe8f5..47a26ae 100644 --- a/powershell-standards/Examples/README.md +++ b/powershell-standards/Examples/README.md @@ -6,7 +6,7 @@ tests. This folder is mirrored into consuming projects by the sync workflow. ## What is here | Example | Demonstrates | -|---|---| +| --- | --- | | [Basic-Function-Example.ps1](./Basic-Function-Example.ps1) | A complete advanced function: pipeline input, parameter validation, correlation IDs, per-item error handling, and a named `[OutputType]` | | [Module-Structure-Example/](./Module-Structure-Example/) | A minimal working module - the Public/Private/Classes layout and the export boundary it creates | | [Testing-Examples/](./Testing-Examples/) | Pester 6 tests for `Basic-Function-Example.ps1`, including CIM mocking and `-RemoveParameterType` |