From 0081da22d90da99a708b4c710eae6cc6f6539cd6 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 17:58:52 -0400 Subject: [PATCH 1/2] test: Make the psake/Invoke-Build task drift guard able to fail The 'Contains all the tasks that were in the Psake file' test has never compared a task name. Three defects cancelled out: - $IBTasksResult was assigned in a different It block, so it was $null in the block that read it. The Invoke-Build job now runs in the Describe's BeforeAll and both It blocks read the same $script: variable. - Invoke-PSake -docs writes a formatted table, so format records with no Name property crossed the job boundary and every psake task name was $null. Get-PSakeScriptTasks returns task objects, and the job projects the names to strings before they are serialized. - $IBTasksResult.All is an ordered dictionary keyed by task name, so .Name looked up a key that does not exist rather than enumerating the names. The job returns .All.Keys. Renaming Task Sign in IB.tasks.ps1 now fails the test with "but got 'Sign'"; before this change the whole suite stayed green. Closes #215 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- tests/IBTasks.tests.ps1 | 75 ++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/tests/IBTasks.tests.ps1 b/tests/IBTasks.tests.ps1 index 5d7114e..2dd21e0 100644 --- a/tests/IBTasks.tests.ps1 +++ b/tests/IBTasks.tests.ps1 @@ -22,43 +22,64 @@ BeforeAll { } Describe 'Invoke-Build Tasks' { + + # The oldest of this file's drift guards: a task added to psakeFile.ps1 and forgotten in + # IB.tasks.ps1 reaches Invoke-Build consumers only, and neither the settings comparison nor + # the signing comparison below can see a task that exists in one file and not the other. + # + # Both task runners are asked for their task names in a background job, because loading + # either task file sets $PSBPreference read-only and calls Set-BuildEnvironment -Force, + # neither of which belongs in the Pester session. Only serialized objects cross a job + # boundary, so each job projects the names to strings before returning them -- see + # psake/PowerShellBuild#215 for what came back when they did not. + BeforeAll { - $manifest = Import-PowerShellDataFile -Path $env:BHPSModuleManifest - $outputDir = [IO.Path]::Combine($ENV:BHProjectPath, 'Output') - $outputModDir = [IO.Path]::Combine($outputDir, $env:BHProjectName) - $outputModVerDir = [IO.Path]::Combine($outputModDir, $manifest.ModuleVersion) - $ibTasksFilePath = [IO.Path]::Combine($outputModVerDir, 'IB.tasks.ps1') - $psakeFilePath = [IO.Path]::Combine($outputModVerDir, 'psakeFile.ps1') + $manifest = Import-PowerShellDataFile -Path $env:BHPSModuleManifest + $outputPath = [IO.Path]::Combine($env:BHProjectPath, 'Output') + $outputModulePath = [IO.Path]::Combine($outputPath, $env:BHProjectName) + $outputModuleVersionPath = [IO.Path]::Combine($outputModulePath, $manifest.ModuleVersion) + $ibTasksFilePath = [IO.Path]::Combine($outputModuleVersionPath, 'IB.tasks.ps1') + $psakeFilePath = [IO.Path]::Combine($outputModuleVersionPath, 'psakeFile.ps1') + + # Assigned here rather than in the 'Parseable by invoke-build' block below: each It runs + # in its own scope, so a variable one It assigns is not there for the next one to read. + $script:invokeBuildTaskName = Start-Job -ScriptBlock { + # Invoke-Build -WhatIf still writes the task list under a CI host even when the + # output is piped to Out-Null, so every stream is redirected away. + Invoke-Build -File $using:ibTasksFilePath -WhatIf -Result invokeBuildResult -ErrorAction Stop *>$null + + # .All is an ordered dictionary keyed by task name, so $invokeBuildResult.All.Name + # is a lookup for a key called 'Name' -- which no task file defines -- rather than + # an enumeration of the task names. The keys are the task names. + $invokeBuildResult.All.Keys | ForEach-Object { [string]$_ } + } | Wait-Job | Receive-Job + + $script:psakeTaskName = Start-Job -ScriptBlock { + # Get-PSakeScriptTasks returns task objects. Invoke-PSake -docs formats a table to + # the output stream instead, so what crossed the job boundary was format records + # with no Name property, and every name was $null. + Get-PSakeScriptTasks -buildFile $using:psakeFilePath | ForEach-Object { [string]$_.Name } + } | Wait-Job | Receive-Job } - $IBTasksResult = $null It 'IB.tasks.ps1 exists' { - Test-Path $IBTasksFilePath | Should -Be $true + Test-Path $ibTasksFilePath | Should -Be $true } It 'Parseable by invoke-build' { - # Run IB in job to not pollute the environment - # Invoke-Build whatif still outputs in Appveyor in Pester even when directed to out-null. This doesn't happen locally. Redirecting all output to null - $IBTasksResult = Start-Job -ScriptBlock { - Invoke-Build -File $using:IBTasksFilePath -Whatif -Result IBTasksResult -ErrorAction Stop *>$null - $IBTasksResult - } | Wait-Job | Receive-Job - - $IBTasksResult | Should -Not -BeNullOrEmpty + $script:invokeBuildTaskName | Should -Not -BeNullOrEmpty -Because 'Invoke-Build must be able to load IB.tasks.ps1 and report its tasks' } + It 'Contains all the tasks that were in the Psake file' { - # Run psake in job to not pollute the environment - $psakeTaskNames = Start-Job -ScriptBlock { - Invoke-PSake -docs -buildfile $using:psakeFilePath | Where-Object name -notmatch '^(default|\?)$' | ForEach-Object name - } | Wait-Job | Receive-Job + # 'default' and '?' are psake's own entry points rather than tasks converted from the + # psake file; Invoke-Build spells its equivalent '.', and it is not compared either. + $comparableTaskName = $script:psakeTaskName.Where({ $_ -notmatch '^(default|\?)$' }) - $IBTaskNames = $IBTasksResult.all.name - foreach ($taskItem in $psakeTaskNames) { - if ($taskitem -notin $IBTaskNames) { - throw "Task $taskitem was not successfully converted by Convert-PSAke" - } - } - $Psaketasknames | Should -Not -BeNullOrEmpty + $comparableTaskName | Should -Not -BeNullOrEmpty -Because 'psake must still report the tasks psakeFile.ps1 defines' + + $missingFromInvokeBuild = $comparableTaskName.Where({ $_ -notin $script:invokeBuildTaskName }) + + $missingFromInvokeBuild -join ', ' | Should -BeNullOrEmpty -Because 'IB.tasks.ps1 must define every task psakeFile.ps1 defines' } } From a28ac72a600d63e62f0038700f45032e625ca801 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:20:56 -0400 Subject: [PATCH 2/2] test: Compare the psake and Invoke-Build task lists in both directions A task defined in IB.tasks.ps1 and forgotten in psakeFile.ps1 leaves psake consumers without it, which is the same defect as #178 and #193 with the files swapped, and nothing else in the suite catches it. The guard now asserts both directions with a Because of its own, matching the shape the signing comparison in this file already uses, and the It is renamed to say what it does. Each runner's own entry point is excluded on its own side: psake answers to 'default' and '?', Invoke-Build to '.'. Nothing else is excluded. Measured: appending Task ExtraInvokeBuildTask to IB.tasks.ps1 fails with "but got 'ExtraInvokeBuildTask'", and renaming Task Sign there still fails with "but got 'Sign'". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- tests/IBTasks.tests.ps1 | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/IBTasks.tests.ps1 b/tests/IBTasks.tests.ps1 index 2dd21e0..0968871 100644 --- a/tests/IBTasks.tests.ps1 +++ b/tests/IBTasks.tests.ps1 @@ -23,9 +23,11 @@ BeforeAll { Describe 'Invoke-Build Tasks' { - # The oldest of this file's drift guards: a task added to psakeFile.ps1 and forgotten in - # IB.tasks.ps1 reaches Invoke-Build consumers only, and neither the settings comparison nor - # the signing comparison below can see a task that exists in one file and not the other. + # The oldest of this file's drift guards: a task defined in one task file and forgotten in + # the other reaches that runner's consumers only -- the same defect as #178 and #193 with + # the files swapped -- and neither the settings comparison nor the signing comparison below + # can see a task that exists in one file and not the other. The harm is symmetric, so the + # comparison is too. # # Both task runners are asked for their task names in a background job, because loading # either task file sets $PSBPreference read-only and calls Set-BuildEnvironment -Force, @@ -70,16 +72,22 @@ Describe 'Invoke-Build Tasks' { $script:invokeBuildTaskName | Should -Not -BeNullOrEmpty -Because 'Invoke-Build must be able to load IB.tasks.ps1 and report its tasks' } - It 'Contains all the tasks that were in the Psake file' { - # 'default' and '?' are psake's own entry points rather than tasks converted from the - # psake file; Invoke-Build spells its equivalent '.', and it is not compared either. - $comparableTaskName = $script:psakeTaskName.Where({ $_ -notmatch '^(default|\?)$' }) + It 'defines the same tasks in both task files' { + # Each runner adds an entry point of its own that is not a task either file defines: + # psake answers to 'default' and '?', Invoke-Build to '.'. Neither has, or should have, + # a counterpart in the other file, so neither side compares them. Nothing else is + # excluded -- every remaining name must appear in both files. + $comparablePsakeTaskName = $script:psakeTaskName.Where({ $_ -notmatch '^(default|\?)$' }) + $comparableInvokeBuildTaskName = $script:invokeBuildTaskName.Where({ $_ -ne '.' }) - $comparableTaskName | Should -Not -BeNullOrEmpty -Because 'psake must still report the tasks psakeFile.ps1 defines' + $comparablePsakeTaskName | Should -Not -BeNullOrEmpty -Because 'psake must still report the tasks psakeFile.ps1 defines' + $comparableInvokeBuildTaskName | Should -Not -BeNullOrEmpty -Because 'Invoke-Build must still report the tasks IB.tasks.ps1 defines' - $missingFromInvokeBuild = $comparableTaskName.Where({ $_ -notin $script:invokeBuildTaskName }) + $missingFromInvokeBuild = $comparablePsakeTaskName.Where({ $_ -notin $comparableInvokeBuildTaskName }) + $missingFromPsake = $comparableInvokeBuildTaskName.Where({ $_ -notin $comparablePsakeTaskName }) $missingFromInvokeBuild -join ', ' | Should -BeNullOrEmpty -Because 'IB.tasks.ps1 must define every task psakeFile.ps1 defines' + $missingFromPsake -join ', ' | Should -BeNullOrEmpty -Because 'psakeFile.ps1 must define every task IB.tasks.ps1 defines' } }