tests/DeclarativeCollectorCutover.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# AB#5656 — THE CUTOVER. v2.9.0 shipped 124 of 176 collectors as `.psd1` definitions, each proven row-for-row equivalent to its `.ps1` by tests/DeclarativeCollectorEquivalence.Tests.ps1 — and NOTHING USED THEM. Every collector in a live run still executed its hand-written script. Converting is not the same as using: until the pipeline routes to the interpreter, the whole feature delivers a directory of files nobody runs. This file exists because the cutover is, uniquely, INVISIBLE to the equivalence proof. The two paths produce identical rows by construction — that is the point of the proof — so a routing regression that quietly sent every collector back to its `.ps1` would leave the entire suite green. Row comparison can never detect it. So these tests assert the thing rows cannot: WHICH IMPLEMENTATION RAN. The central technique is a booby-trapped fixture — a collector whose `.psd1` is valid and whose `.ps1` throws on sight, or emits a sentinel value no definition ever could. If the run succeeds and carries the declarative values, the script was not executed. That is not inference; there is no other way for the run to have completed. The same fixture, run with the kill switch on, must produce the OPPOSITE result. A kill switch that silently did nothing would also make every test here pass if they only ever asserted the declarative outcome. #> BeforeAll { $script:RepoRoot = Split-Path -Parent $PSScriptRoot . (Join-Path $script:RepoRoot 'src/pipeline/Get-ScoutCollector.ps1') . (Join-Path $script:RepoRoot 'src/pipeline/Get-ScoutCollectorDefinition.ps1') . (Join-Path $script:RepoRoot 'src/pipeline/Invoke-ScoutDeclarativeCollector.ps1') . (Join-Path $script:RepoRoot 'src/pipeline/Invoke-ScoutCollector.ps1') . (Join-Path $script:RepoRoot 'src/pipeline/Write-ScoutCacheFile.ps1') . (Join-Path $script:RepoRoot 'src/pipeline/Invoke-ScoutProcessing.ps1') # The kill switch is process-wide state. Capture whatever the caller had and restore it in # AfterAll — a suite that leaks it would silently disable the cutover for every test file # Pester runs after this one, and they would all still pass. $script:OriginalKillSwitch = $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = $null # ── The booby-trapped fixture estate ────────────────────────────────────────────────── # # A private root per run: ~18 test files in this repo hardcode a shared $env:TEMP\AZSC_* # path and delete it in BeforeAll, so anything predictable here is racing them. $script:FixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("scout-cutover-" + [guid]::NewGuid().ToString('N')) $script:InventoryRoot = Join-Path $script:FixtureRoot 'InventoryModules' $script:DefinitionRoot = Join-Path $script:FixtureRoot 'definitions' function New-CutoverCollector { param([string]$Category, [string]$Name, [string]$Body, [string]$Root) if (-not $Root) { $Root = $script:InventoryRoot } $Dir = Join-Path $Root $Category if (-not (Test-Path -LiteralPath $Dir)) { $null = New-Item -Path $Dir -ItemType Directory -Force } # Concatenated, not formatted: the bodies contain $_ and $1, which -f and -replace eat. $Lines = @( 'param($SCPath, $Sub, $Intag, $Resources, $Retirements, $Task, $File, $SmaResources, $TableStyle, $Unsupported)' "If (`$Task -eq 'Processing') {" $Body '}' ) Set-Content -LiteralPath (Join-Path $Dir "$Name.ps1") -Value ($Lines -join "`n") -Encoding UTF8 } function New-CutoverDefinition { param([string]$Category, [string]$Name, [string]$Text, [string]$Root) if (-not $Root) { $Root = $script:DefinitionRoot } $Dir = Join-Path $Root $Category if (-not (Test-Path -LiteralPath $Dir)) { $null = New-Item -Path $Dir -ItemType Directory -Force } Set-Content -LiteralPath (Join-Path $Dir "$Name.psd1") -Value $Text -Encoding UTF8 } # 1. Sentinel — both implementations run, and they disagree ON PURPOSE. Which value comes # out of the pipeline names the path that executed it. New-CutoverCollector -Category 'Fixture' -Name 'Sentinel' -Body @' foreach ($1 in ($Resources | Where-Object { $_.TYPE -eq 'widget' })) { @{ ID = $1.id; Origin = 'FROM-IMPERATIVE'; 'Resource U' = 1 } } '@ New-CutoverDefinition -Category 'Fixture' -Name 'Sentinel' -Text @' @{ ResourceTypes = @('widget') RowLoopVariable = '1' TagLoop = $null Preamble = '$ResUCount = 1' Fields = @( @{ Name = 'ID'; Expression = '$1.id' } @{ Name = 'Origin'; Expression = "'FROM-DECLARATIVE'" } @{ Name = 'Resource U'; Expression = '$ResUCount' } ) Export = @{ WorksheetName = 'Sentinel' Columns = @('ID', 'Origin', 'Resource U') TagColumns = @() } } '@ # 2. Landmine — the `.ps1` cannot produce a row at all. It throws the instant it is invoked # in Processing. A run that completes with this collector's rows present is proof by # impossibility that the script was never executed; no assertion about row VALUES could # establish that, because both paths are meant to agree on values. New-CutoverCollector -Category 'Fixture' -Name 'Landmine' -Body @' throw 'THE IMPERATIVE SCRIPT RAN' '@ New-CutoverDefinition -Category 'Fixture' -Name 'Landmine' -Text @' @{ ResourceTypes = @('mine') RowLoopVariable = '1' TagLoop = $null Preamble = '$ResUCount = 1' Fields = @( @{ Name = 'ID'; Expression = '$1.id' } @{ Name = 'Resource U'; Expression = '$ResUCount' } ) Export = @{ WorksheetName = 'Landmine' Columns = @('ID', 'Resource U') TagColumns = @() } } '@ # 3. Unconverted — no `.psd1`. The escape hatch (ADR §2.4): 52 of the 176 shipped collectors # are in this state and must keep running exactly as they always have. New-CutoverCollector -Category 'Fixture' -Name 'Unconverted' -Body @' foreach ($1 in ($Resources | Where-Object { $_.TYPE -eq 'legacy' })) { @{ ID = $1.id; Origin = 'FROM-IMPERATIVE'; 'Resource U' = 1 } } '@ # 4. Malformed — a `.psd1` that loads as data but fails schema validation (no Fields). The # collector must still produce its rows, via the script sitting next to it. New-CutoverCollector -Category 'Fixture' -Name 'Malformed' -Body @' foreach ($1 in ($Resources | Where-Object { $_.TYPE -eq 'broken' })) { @{ ID = $1.id; Origin = 'FROM-IMPERATIVE'; 'Resource U' = 1 } } '@ New-CutoverDefinition -Category 'Fixture' -Name 'Malformed' -Text @' @{ ResourceTypes = @('broken') RowLoopVariable = '1' Export = @{ WorksheetName = 'Malformed'; Columns = @('ID') } } '@ $script:Estate = @( [PSCustomObject]@{ id = '/w/1'; NAME = 'w1'; TYPE = 'widget' } [PSCustomObject]@{ id = '/w/2'; NAME = 'w2'; TYPE = 'widget' } [PSCustomObject]@{ id = '/m/1'; NAME = 'm1'; TYPE = 'mine' } [PSCustomObject]@{ id = '/l/1'; NAME = 'l1'; TYPE = 'legacy' } [PSCustomObject]@{ id = '/b/1'; NAME = 'b1'; TYPE = 'broken' } ) function Get-CutoverDescriptor { param([string]$Name) @(Get-ScoutCollector -InventoryRoot $script:InventoryRoot -DefinitionRoot $script:DefinitionRoot | Where-Object { $_.Name -eq $Name })[0] } function New-CutoverContext { @{ ScriptRoot = $script:InventoryRoot; Subscriptions = @(); InTag = $null Resources = $script:Estate; Retirements = @(); Task = 'Processing' File = $null; SmaResources = $null; TableStyle = $null; Unsupported = @() } } function Invoke-CutoverRun { param([switch]$ForceImperative) $RunPath = Join-Path $script:FixtureRoot ([guid]::NewGuid().ToString('N')) $null = New-Item -Path (Join-Path $RunPath 'ReportCache') -ItemType Directory -Force $Summary = Invoke-ScoutProcessing -Resources $script:Estate -DefaultPath $RunPath ` -InventoryRoot $script:InventoryRoot -DefinitionRoot $script:DefinitionRoot ` -ForceImperativeCollectors:$ForceImperative -WarningAction SilentlyContinue $CacheFile = Join-Path $RunPath 'ReportCache/Fixture.json' $Cache = if (Test-Path -LiteralPath $CacheFile) { Get-Content -LiteralPath $CacheFile -Raw | ConvertFrom-Json } else { $null } [PSCustomObject]@{ Summary = $Summary; Cache = $Cache } } } AfterAll { $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = $script:OriginalKillSwitch if ($script:FixtureRoot -and (Test-Path -LiteralPath $script:FixtureRoot)) { Remove-Item -LiteralPath $script:FixtureRoot -Recurse -Force -ErrorAction SilentlyContinue } } Describe 'Cutover — a collector with a definition is executed by the interpreter, not by its .ps1' { It 'runs the definition and never reads the script' { $Descriptor = Get-CutoverDescriptor -Name 'Sentinel' $Descriptor.HasDeclarativeDefinition | Should -BeTrue $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) $Result.Mode | Should -Be 'Declarative' $Result.Success | Should -BeTrue @($Result.Rows).Count | Should -Be 2 @($Result.Rows | ForEach-Object { $_['Origin'] } | Select-Object -Unique) | Should -Be 'FROM-DECLARATIVE' } It 'completes a collector whose .ps1 throws on sight — the script cannot have run' { # The unfakeable assertion. Landmine.ps1's entire Processing branch is `throw`. There is # no execution of it that yields a row, so a successful result with rows means the # interpreter produced them. $Descriptor = Get-CutoverDescriptor -Name 'Landmine' $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) $Result.Success | Should -BeTrue $Result.Mode | Should -Be 'Declarative' @($Result.Rows).Count | Should -Be 1 $Result.Rows[0]['ID'] | Should -Be '/m/1' } It 'keeps running the .ps1 for a collector with no definition' { $Descriptor = Get-CutoverDescriptor -Name 'Unconverted' $Descriptor.HasDeclarativeDefinition | Should -BeFalse $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) $Result.Mode | Should -Be 'Imperative' @($Result.Rows | ForEach-Object { $_['Origin'] }) | Should -Be 'FROM-IMPERATIVE' } It 'routes imperative for a hand-built descriptor that carries no HasDeclarativeDefinition property' { # Hand-built descriptors are real — the equivalence harness and Invoke-CollectorAudit both # make them. "I do not know whether a definition exists" must resolve to the path that has # shipped in every release, not to the new one. $Descriptor = [PSCustomObject]@{ Name = 'Sentinel'; FolderCategory = 'Fixture' Path = (Join-Path $script:InventoryRoot 'Fixture/Sentinel.ps1') } $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) $Result.Mode | Should -Be 'Imperative' @($Result.Rows | ForEach-Object { $_['Origin'] } | Select-Object -Unique) | Should -Be 'FROM-IMPERATIVE' } } Describe 'Cutover — the whole processing pass routes on the definition' { BeforeAll { $script:Run = Invoke-CutoverRun } It 'writes declarative rows into the report cache' { # End to end, through Invoke-ScoutProcessing and Write-ScoutCacheFile — not just through # Invoke-ScoutCollector in isolation. The cache file is what the report is built from. @($script:Run.Cache.Sentinel | ForEach-Object { $_.Origin } | Select-Object -Unique) | Should -Be 'FROM-DECLARATIVE' } It 'has no failures, so the landmine .ps1 never executed anywhere in the pass' { $script:Run.Summary.FailureCount | Should -Be 0 @($script:Run.Cache.Landmine).Count | Should -Be 1 } It 'reports how many collectors ran each way' { # Counted from the RESULT of each collector, so this is a report of what happened rather # than of what the descriptor intended. $script:Run.Summary.DeclarativeCount | Should -Be 2 # Sentinel, Landmine $script:Run.Summary.ImperativeCount | Should -Be 2 # Unconverted, Malformed (fallback) $script:Run.Summary.CollectorCount | Should -Be 4 } It 'still runs the unconverted collector as a script in the same pass' { @($script:Run.Cache.Unconverted | ForEach-Object { $_.Origin }) | Should -Be 'FROM-IMPERATIVE' } } Describe 'Cutover — a definition that fails schema validation falls back instead of losing the worksheet' { It 'runs the .ps1 and reports the fallback' { # A schema-invalid definition is a repo defect that CI catches, so this path should never # fire in a release. If it ever does, losing a whole worksheet when a working script is # sitting beside it would be the worse failure. $Descriptor = Get-CutoverDescriptor -Name 'Malformed' $Descriptor.HasDeclarativeDefinition | Should -BeTrue $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) -WarningAction SilentlyContinue $Result.Mode | Should -Be 'ImperativeFallback' $Result.Success | Should -BeTrue @($Result.Rows | ForEach-Object { $_['Origin'] }) | Should -Be 'FROM-IMPERATIVE' } It 'warns rather than failing silently' { $Descriptor = Get-CutoverDescriptor -Name 'Malformed' $Warnings = @() $null = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) -WarningVariable Warnings ($Warnings -join ' ') | Should -BeLike '*INVALID declarative definition*' } } Describe 'Cutover — the kill switch' { AfterEach { $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = $null } It 'AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS puts every collector back on its .ps1' { $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = '1' $Descriptor = Get-CutoverDescriptor -Name 'Sentinel' $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) $Result.Mode | Should -Be 'Imperative' @($Result.Rows | ForEach-Object { $_['Origin'] } | Select-Object -Unique) | Should -Be 'FROM-IMPERATIVE' } It 'and the landmine then blows up, proving the switch really changed the executing path' { # Without this, "kill switch works" would rest on a sentinel STRING that a broken switch # could coincidentally produce. A collector that can only fail when its script runs # cannot be faked: this failure IS the imperative path executing. $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = 'true' $Descriptor = Get-CutoverDescriptor -Name 'Landmine' $Result = Invoke-ScoutCollector -Collector $Descriptor -Context (New-CutoverContext) -WarningAction SilentlyContinue $Result.Success | Should -BeFalse $Result.Error.Exception.Message | Should -BeLike '*THE IMPERATIVE SCRIPT RAN*' } It 'turns the whole processing pass back to the pre-cutover behaviour' { $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = 'yes' $Run = Invoke-CutoverRun $Run.Summary.DeclarativeCount | Should -Be 0 $Run.Summary.ImperativeCount | Should -Be 4 $Run.Summary.FailureCount | Should -Be 1 # the landmine, exactly as v2.9.0 behaved @($Run.Cache.Sentinel | ForEach-Object { $_.Origin } | Select-Object -Unique) | Should -Be 'FROM-IMPERATIVE' } It 'the -ForceImperativeCollectors parameter does the same without touching the environment' { $Run = Invoke-CutoverRun -ForceImperative $Run.Summary.DeclarativeCount | Should -Be 0 $Run.Summary.ImperativeCount | Should -Be 4 @($Run.Cache.Sentinel | ForEach-Object { $_.Origin } | Select-Object -Unique) | Should -Be 'FROM-IMPERATIVE' } It 'reads <_> as OFF, leaving the cutover enabled' -ForEach @('', ' ', '0', 'false', 'no', 'off', 'maybe') { # A truthiness test on the raw string would make `= '0'` mean "forced imperative", which # is the opposite of what anyone typing it intends. Only the affirmative spellings count. $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = $_ Test-ScoutDeclarativeCutoverDisabled | Should -BeFalse } It 'reads <_> as ON' -ForEach @('1', 'true', 'TRUE', 'yes', 'on', ' true ') { $env:AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS = $_ Test-ScoutDeclarativeCutoverDisabled | Should -BeTrue } It 'is off when the variable is not set at all' { Remove-Item Env:\AZURESCOUT_FORCE_IMPERATIVE_COLLECTORS -ErrorAction SilentlyContinue Test-ScoutDeclarativeCutoverDisabled | Should -BeFalse } } Describe 'Cutover — the shipped estate' { BeforeAll { $script:Shipped = @(Get-ScoutCollector -InventoryRoot (Join-Path $script:RepoRoot 'Modules/Public/InventoryModules')) } It 'routes every collector that has a definition, and only those' { $WithDefinition = @($script:Shipped | Where-Object { $_.HasDeclarativeDefinition }) $OnDisk = @(Get-ChildItem -LiteralPath (Join-Path $script:RepoRoot 'manifests/collectors') -Filter '*.psd1' -Recurse -File) # Not a hardcoded 124: the sibling conversion work adds definitions, and a count pinned # here would fail for the right reason at the wrong time. What must hold is that # discovery sees exactly the definitions that exist. @($WithDefinition).Count | Should -Be @($OnDisk).Count @($WithDefinition).Count | Should -BeGreaterThan 100 } It 'every routed collector loads through the schema validator, so none of them can fall back' { # ImperativeFallback in a release would mean a shipped worksheet silently reverted to the # old path. This is the gate that keeps the fallback branch dead code in practice. foreach ($Collector in @($script:Shipped | Where-Object { $_.HasDeclarativeDefinition })) { { Get-ScoutCollectorDefinition -Path $Collector.DefinitionPath } | Should -Not -Throw -Because "$($Collector.FolderCategory)/$($Collector.Name) would fall back to its .ps1 in a live run" } } It 'a real shipped collector reports Declarative through the real dispatch' { $SqlDb = @($script:Shipped | Where-Object { $_.FolderCategory -eq 'Databases' -and $_.Name -eq 'SQLDB' })[0] $SqlDb.HasDeclarativeDefinition | Should -BeTrue $Fixture = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'tests/fixtures/databases-collector-input.json') -Raw | ConvertFrom-Json $Result = Invoke-ScoutCollector -Collector $SqlDb -Context @{ ScriptRoot = (Join-Path $script:RepoRoot 'Modules/Public/InventoryModules') Subscriptions = $Fixture.subscriptions; InTag = $false; Resources = $Fixture.resources Retirements = $Fixture.retirements; Task = 'Processing'; File = $null SmaResources = $null; TableStyle = $null; Unsupported = $Fixture.unsupported } $Result.Mode | Should -Be 'Declarative' $Result.Success | Should -BeTrue @($Result.Rows).Count | Should -BeGreaterThan 0 } } Describe 'Cutover — a field expression that throws, which the ADR says the two paths handle differently' { # ADR §4.1 records an asymmetry and nothing tested it, because the generated fixtures are # built specifically so no expression throws. It is the one difference that could bite at # cutover, when a real tenant hands a collector a shape no fixture had: # # imperative: `$obj = @{ ... }` fails, $obj keeps the PREVIOUS iteration's value, and the # following `$obj` statement emits that row a SECOND time. # declarative: the row is one `@{ ... }` statement, so a throw emits nothing at all. # # MEASURED, and the ADR's conclusion does not transfer to the live pipeline. A .NET method # exception (`.Substring` past the end — the commonest real shape of this) is a # STATEMENT-terminating error, and a statement-terminating error is caught by an enclosing # try/catch. Invoke-ScoutCollector wraps both paths in one, so in a real run neither path # reaches its next iteration: the collector fails, is contained, and emits nothing, exactly # as it did in v2.9.0. The asymmetry is real only where nothing catches — pinned below, # because it is the reason the containment try/catch is load-bearing rather than defensive. # # So the cutover does NOT change behaviour here. That is a measurement, not a reassurance: # if a future change moved the try/catch inside the row loop to salvage partial output, the # two paths would immediately start disagreeing, and these tests would say so. BeforeAll { # Its OWN roots, not the shared estate above: adding a fifth collector to that tree would # change the DeclarativeCount/ImperativeCount this file asserts elsewhere, and a test # whose fixture depends on Describe execution order is a test that breaks when someone # reorders the file. $script:ThrowInventory = Join-Path $script:FixtureRoot 'throw-inventory' $script:ThrowDefinition = Join-Path $script:FixtureRoot 'throw-definitions' New-CutoverCollector -Root $script:ThrowInventory -Category 'Throwing' -Name 'ShortName' -Body @' foreach ($1 in ($Resources | Where-Object { $_.TYPE -eq 'thrower' })) { $obj = @{ ID = $1.id; Sliced = $1.NAME.Substring(10); 'Resource U' = 1 } $obj } '@ New-CutoverDefinition -Root $script:ThrowDefinition -Category 'Throwing' -Name 'ShortName' -Text @' @{ ResourceTypes = @('thrower') RowLoopVariable = '1' TagLoop = $null Preamble = '$ResUCount = 1' Fields = @( @{ Name = 'ID'; Expression = '$1.id' } @{ Name = 'Sliced'; Expression = '$1.NAME.Substring(10)' } @{ Name = 'Resource U'; Expression = '$ResUCount' } ) Export = @{ WorksheetName = 'Throwing'; Columns = @('ID', 'Sliced', 'Resource U'); TagColumns = @() } } '@ # First and third slice fine; the middle one is too short, so .Substring(10) throws on it # alone. Three resources, not two, so "the row after the bad one" is observable. $script:ThrowEstate = @( [PSCustomObject]@{ id = '/t/1'; NAME = 'a-long-enough-name'; TYPE = 'thrower' } [PSCustomObject]@{ id = '/t/2'; NAME = 'short'; TYPE = 'thrower' } [PSCustomObject]@{ id = '/t/3'; NAME = 'another-long-name'; TYPE = 'thrower' } ) $script:ThrowContext = @{ ScriptRoot = $script:ThrowInventory; Subscriptions = @(); InTag = $null Resources = $script:ThrowEstate; Retirements = @(); Task = 'Processing' File = $null; SmaResources = $null; TableStyle = $null; Unsupported = @() } # A SECOND failure shape, because the two are not handled the same way underneath. A .NET # method exception escapes InvokeWithContext untouched; a PowerShell runtime error (here a # failed [datetime] cast) comes back wrapped in `Exception calling "InvokeWithContext"`. # Only the second needs unwrapping, and a test that exercised only the first would have # let an over-eager unwrap through — which is exactly what happened: unwrapping both # stripped a layer off the Substring case that the imperative path keeps. New-CutoverCollector -Root $script:ThrowInventory -Category 'Throwing' -Name 'CastFail' -Body @' foreach ($1 in ($Resources | Where-Object { $_.TYPE -eq 'caster' })) { $obj = @{ ID = $1.id; When = [datetime]$1.missing; 'Resource U' = 1 } $obj } '@ New-CutoverDefinition -Root $script:ThrowDefinition -Category 'Throwing' -Name 'CastFail' -Text @' @{ ResourceTypes = @('caster') RowLoopVariable = '1' TagLoop = $null Preamble = '$ResUCount = 1' Fields = @( @{ Name = 'ID'; Expression = '$1.id' } @{ Name = 'When'; Expression = '[datetime]$1.missing' } @{ Name = 'Resource U'; Expression = '$ResUCount' } ) Export = @{ WorksheetName = 'Casting'; Columns = @('ID', 'When', 'Resource U'); TagColumns = @() } } '@ $script:CastEstate = @([PSCustomObject]@{ id = '/c/1'; NAME = 'c1'; TYPE = 'caster' }) $script:CastContext = @{ ScriptRoot = $script:ThrowInventory; Subscriptions = @(); InTag = $null Resources = $script:CastEstate; Retirements = @(); Task = 'Processing' File = $null; SmaResources = $null; TableStyle = $null; Unsupported = @() } $script:Discovered = @(Get-ScoutCollector -InventoryRoot $script:ThrowInventory -DefinitionRoot $script:ThrowDefinition) $script:ThrowDescriptor = @($script:Discovered | Where-Object { $_.Name -eq 'ShortName' })[0] $script:CastDescriptor = @($script:Discovered | Where-Object { $_.Name -eq 'CastFail' })[0] } It 'both paths fail the collector identically, so the cutover changes nothing here' { $Imperative = Invoke-ScoutCollector -Collector $script:ThrowDescriptor -Context $script:ThrowContext -Imperative -WarningAction SilentlyContinue $Declarative = Invoke-ScoutCollector -Collector $script:ThrowDescriptor -Context $script:ThrowContext -WarningAction SilentlyContinue $Imperative.Mode | Should -Be 'Imperative' $Declarative.Mode | Should -Be 'Declarative' $Declarative.Success | Should -Be $Imperative.Success $Declarative.Success | Should -BeFalse @($Declarative.Rows).Count | Should -Be @($Imperative.Rows).Count @($Declarative.Rows).Count | Should -Be 0 $Declarative.Error.Exception.Message | Should -Be $Imperative.Error.Exception.Message } It 'reports the collector failure without the interpreter in the message' { # InvokeWithContext is a .NET method call, so an unhandled row-script error comes back as # `Exception calling "InvokeWithContext" with "2" argument(s): "<real message>"`. That # string reaches the customer's run log via Invoke-ScoutCollector's warning, so leaving it # would have changed the text of every contained failure on the release that switches # paths — a diff in the logs with no corresponding change in what failed. $Result = Invoke-ScoutCollector -Collector $script:ThrowDescriptor -Context $script:ThrowContext -WarningAction SilentlyContinue $Result.Error.Exception.Message | Should -Not -BeLike '*InvokeWithContext*' $Result.Error.Exception.Message | Should -BeLike '*startIndex cannot be larger than length of string*' } It 'reports a failed cast identically to the imperative path, wrapper stripped' { # The shape that DOES get an extra layer. Both halves asserted: the wrapper is gone, AND # what is left is character-for-character what the .ps1 reports — stripping too much would # satisfy the first assertion and fail the second. $Imperative = Invoke-ScoutCollector -Collector $script:CastDescriptor -Context $script:CastContext -Imperative -WarningAction SilentlyContinue $Declarative = Invoke-ScoutCollector -Collector $script:CastDescriptor -Context $script:CastContext -WarningAction SilentlyContinue $Imperative.Success | Should -BeFalse $Declarative.Success | Should -BeFalse $Declarative.Error.Exception.Message | Should -Not -BeLike '*InvokeWithContext*' $Declarative.Error.Exception.Message | Should -Be $Imperative.Error.Exception.Message } It 'the failure is contained, not propagated' { # One bad resource costs a worksheet, not the run — the property AB#5649 bought, and it # must hold on the new path too. { Invoke-ScoutCollector -Collector $script:ThrowDescriptor -Context $script:ThrowContext -WarningAction SilentlyContinue } | Should -Not -Throw } It 'and the ADR asymmetry is real underneath — the imperative row loop DOES duplicate' { # A statement-terminating error only lets the loop continue when NOTHING in the call # stack catches it, and Pester wraps every It in a try/catch, so the behaviour cannot be # observed by running the collector here — it would just fail this test. # # A bare runspace is that catch-free stack. Using one is not a relapse into the machinery # AB#5649 deleted; it is the point. The v1 pipeline ran every collector in exactly such a # runspace, so "throw, keep the previous $obj, emit it twice" WAS the shipped behaviour # until AB#5649 wrapped collectors in a try/catch. That is also why this asymmetry cannot # bite at cutover: the containment that changed it is a year older than the interpreter, # and it changed both paths at once. $Shell = [powershell]::Create() try { $null = $Shell.AddScript(@' param($estate) Set-StrictMode -Off $ErrorActionPreference = 'Continue' foreach ($1 in $estate) { $obj = @{ ID = $1.id; Sliced = $1.NAME.Substring(10) } $obj } '@) $null = $Shell.AddParameter('estate', $script:ThrowEstate) $Rows = $Shell.Invoke() # /t/2 threw, so its assignment never ran and $obj still held /t/1's row. @($Rows | ForEach-Object { $_['ID'] }) | Should -Be @('/t/1', '/t/1', '/t/3') $Shell.Streams.Error.Count | Should -Be 1 } finally { $Shell.Dispose() } } } |