| name | sampler-build-debug |
| compatibility | Requires PowerShell 5.1+ with the Sampler, InvokeBuild, and Pester 5 modules. |
| description | Debug and troubleshoot Sampler-based PowerShell module builds and Pester 5 test failures. Covers running builds safely (without freezing VSCode), reading Pester results, diagnosing common mock/assertion failures, fixing tests, and reproducing a CI-only failure at the exact commit CI built. USE FOR: build fails, test fails, Pester errors, troubleshoot build, debug tests, run build, Sampler build, module build, Pester mock issues, VSCode freezes during build, passes locally but fails in CI, CI-only test failure, reproduce a CI failure, clean checkout reproduction, GitVersion 0.0.1 fallback version. DO NOT USE FOR: creating new modules from scratch, Azure deployments, CI/CD pipeline config. |
Sampler Build & Test Debugging
Skill for troubleshooting Sampler-based PowerShell module builds and Pester 5 test failures.
When to Use
- Build fails with test errors
- Pester tests fail and need diagnosis
- VSCode becomes unresponsive during builds
- Mock-related test failures (parameter validation, missing commands)
- Need to run builds safely from the integrated terminal
Running Builds Without Freezing VSCode
CRITICAL: Never run ./build.ps1 directly inside the VS Code integrated terminal's
current PowerShell session. The build process imports modules, invokes Pester, and runs
long-lived tasks that can block the PowerShell extension thread and freeze VS Code.
Always invoke build.ps1 in a new, isolated pwsh process.
Recommended: Detached Process with On-Demand Status
IMPORTANT: Even pwsh -NoProfile -NonInteractive -Command '...' can still hang the
VS Code terminal because the terminal synchronously waits for the child process to exit.
If Pester or the build framework writes output that interacts badly with VS Code's terminal
renderer or the PowerShell Extension Host's pipe, the terminal freezes. Always
use the canonical fully detached launcher (Start-Process on Windows, nohup
on non-Windows). Return process/log/result metadata and inspect it only when
status is requested; do not add a foreground polling loop.
# 1. Start build detached — log MUST go to $env:TEMP (NOT output/!)
# Sampler's Clean task deletes everything in output/ and will lock/fail the build.
$runId = [guid]::NewGuid().ToString('N')
$logPath = Join-Path $env:TEMP "sampler-build-$runId.log"
$workingDirectory = $PWD.Path.Replace("'", "''")
$escapedLogPath = $logPath.Replace("'", "''")
$payload = @"
Set-Location -LiteralPath '$workingDirectory'
`$ErrorActionPreference = 'Stop'
try {
& {
.\build.ps1 -Tasks test
} *>&1 | Out-File -LiteralPath '$escapedLogPath' -Encoding utf8
}
catch {
`$_ | Format-List * -Force | Out-String |
Out-File -LiteralPath '$escapedLogPath' -Encoding utf8 -Append
throw
}
"@
$encodedPayload = [Convert]::ToBase64String(
[Text.Encoding]::Unicode.GetBytes($payload)
)
$launcherPath = Join-Path $HOME (
'.copilot/skills/long-running-job-monitor/scripts/Start-DetachedPowerShell.ps1'
)
$launch = & $launcherPath -EncodedCommand $encodedPayload
[pscustomobject]@{
ProcessId = $launch.ProcessId
LogPath = $logPath
Platform = $launch.Platform
ResultPath = $launch.ResultPath
}
Check the returned result marker, PID, and log only on a later status request. Use
long-running-job-monitor when continuing progress reports are required.
Key Rules
- Log files go to
$env:TEMP, NEVER to output/ (Sampler Clean task deletes it mid-build).
- Skip
-ResolveDependency if output/RequiredModules/ already exists and is populated.
- Use
-Tasks test to run only tests, not the full build pipeline.
- Use
-Tasks build to rebuild the module without running tests.
Source & Test File Encoding — UTF-8 with BOM Required
Sampler's QA test suite includes a Missing BOM encoding for non-ASCII encoded file
check. All files under source/ and tests/ that contain any non-ASCII byte
must be saved as UTF-8 with BOM.
Trap: In PowerShell 7.x, Set-Content -Encoding UTF8 writes a BOM-less
file (the default changed from Windows PowerShell 5.1). This silently breaks
Sampler QA. The failure message looks like:
Expected 'UTF8 BOM' encoding for file 'MyFunction.ps1', but got
Missing BOM encoding for non-ASCII encoded file 'MyFunction.ps1'.
Fix — always use the explicit utf8BOM encoding when writing source/test files:
# Wrong (pwsh 7): produces BOM-less UTF-8, breaks Sampler QA
Set-Content -Path 'source/Public/MyFunction.ps1' -Value $content -Encoding UTF8
# Correct
Set-Content -Path 'source/Public/MyFunction.ps1' -Value $content -Encoding utf8BOM
# Bulk re-save an existing tree
Get-ChildItem -Path source,tests -Recurse -Include *.ps1,*.psm1,*.psd1,*.ps1xml |
ForEach-Object {
$c = Get-Content -LiteralPath $_.FullName -Raw
Set-Content -LiteralPath $_.FullName -Value $c -Encoding utf8BOM -NoNewline
}
Files containing only ASCII bytes pass the check regardless of BOM, but any
em-dash, smart-quote, umlaut, or non-ASCII comment character will fail without
a BOM. Save everything with BOM unconditionally.
Diagnosing Test Failures
Step 1: Read the Pester Object
The richest failure data is in the serialized Pester object, not the log file:
$pesterFile = Get-ChildItem 'output\testResults\PesterObject_*.xml' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$pester = Import-Clixml $pesterFile.FullName
$pester.Failed | ForEach-Object {
Write-Host "TEST: $($_.ExpandedName)" -ForegroundColor Red
$_.ErrorRecord | Out-String | Write-Host
}
Step 2: Check the NUnit XML for CI-style Output
$nunit = Get-ChildItem 'output\testResults\NUnitXml_*.xml' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
[xml]$xml = Get-Content $nunit.FullName
$xml.SelectNodes('//test-case[@result="Failed"]') | ForEach-Object {
"$($_.fullname): $($_.failure.message)"
}
Step 3: Read the Summary from the Log
Select-String -LiteralPath $logPath -Pattern 'tests\.ps1|Total run time' |
ForEach-Object { $_.Line.Trim() }
Use the $logPath returned by the detached launcher. Do not infer a fixed log
location.
Reproduce a CI-Only Failure
A test that fails in CI and passes locally is rarely flaky. It is usually the
worktree differing from what CI checked out. Reproduce first, diagnose second.
Step 1: Find the commit CI built
The build log prints the resolved version, and the +n suffix GitVersion emits
is the commit count since the version source. 3.0.0-preview.1+4 means four
commits after the tag GitVersion used, so git log <tag>.. --oneline names the
exact commit. The default branch has usually moved on, so testing HEAD proves
nothing — and a green HEAD is not evidence that the reported failure is fixed.
A CI provider's error annotation often truncates the Pester failure detail. Read
the raw job log or reproduce locally rather than working from the annotation.
Step 2: Test a clean clone, not the worktree
$reproPath = Join-Path $env:TEMP ('ci-repro-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
git clone --quiet . $reproPath
Push-Location $reproPath
git checkout --quiet <commit>
# run the failing test file here
Pop-Location
A clone carries only tracked files. A worktree carries everything git ignores.
Known local-versus-CI divergences
| Divergence | Effect |
|---|
| Gitignored files present locally | A test that reads one passes only in a developer worktree and fails on every clean checkout. |
dotnet-gitversion not installed | Sampler falls back to version 0.0.1, so the built module version differs from the one CI builds. |
Test authoring rule: never hard-code a version sentinel that can equal
Sampler's 0.0.1 fallback. A test that writes 0.0.1 as "the older version"
inverts on any machine without GitVersion — green in CI, red locally, which is
exactly what trains a team to stop running the local gate.
Common Pester 5 Mock Issues
Problem: ValidateScript Calls Internal Functions
Symptom: CommandNotFoundException for functions like Get-ProtectedDataSupportedType
or Test-IsProtectedData when mocking Protect-Data or Unprotect-Data from the
ProtectedData module.
Cause: Pester mocks replace the function body but still enforce the original command's
[ValidateScript()] parameter attributes. If those attributes call module-internal functions,
they fail because the internal functions aren't exported.
Fix: Use -RemoveParameterValidation on the mock:
Mock -ModuleName $moduleName -CommandName Protect-Data `
-RemoveParameterValidation 'InputObject' `
-MockWith { 'ProtectedBlob' }
Mock -ModuleName $moduleName -CommandName Unprotect-Data `
-RemoveParameterValidation 'InputObject' `
-MockWith { 'DecryptedSecret' }
Problem: Regex Doesn't Match Multiline Base64
Symptom: Should -Match '^\[ENC=.*\]$' fails even though the output looks correct.
Cause: Protect-Datum wraps base64 at MaxLineLength (default 100), inserting \r\n.
The . metacharacter does not match newlines by default.
Fix: Use the (?s) dotall flag:
$result | Should -Match '(?s)^\[ENC=.*\]$'
Problem: Mock Not Being Called
Symptom: Should -Invoke fails with "Expected X calls but got 0".
Cause: Mock is defined without -ModuleName, so it's scoped to the test, not the module.
When the module's function calls Protect-Data, it resolves to the real command, not the mock.
Fix: Always specify -ModuleName matching the module under test:
Mock -ModuleName 'Datum.ProtectedData' -CommandName Unprotect-Data -MockWith { 'value' }
# ... call the function ...
Should -Invoke -ModuleName 'Datum.ProtectedData' -CommandName Unprotect-Data -Times 1
Sampler Build Tasks Reference
| Task | Purpose |
|---|
.\build.ps1 -Tasks noop | Bootstrap only — resolve dependencies, no build |
.\build.ps1 -Tasks build | Compile the module to output/builtModule/ |
.\build.ps1 -Tasks test | Build + run all Pester tests |
.\build.ps1 -Tasks pack | Build + create NuGet package |
.\build.ps1 -ResolveDependency | Force re-download of all RequiredModules |
Project-Specific Notes
- Module: Datum.ProtectedData (GUID:
132c634a-1fe1-40f7-b327-5e723a0b23b2)
- Runtime dependency:
ProtectedData (Dave Wyatt) — must be available for mocks to work
- Test structure:
tests/QA/module.tests.ps1 (58 quality tests) + tests/Unit/Public/*.tests.ps1
- Build output:
output/builtModule/Datum.ProtectedData/<version>/
- Code coverage: Currently disabled (
CodeCoverageThreshold: 0 in build.yaml)