tests/DeterministicPipeline.Tests.ps1
|
<#
AB#5649 — the deterministic pipeline. The v1 processing phase coordinated background jobs and nested runspaces. Every defect of the v2.5.x wave lived in that coordination, not in the collectors: * Start-Job is asynchronous, so a NotStarted job was excluded from the wait, harvested empty by Receive-Job and then destroyed by Remove-Job — the category vanished from the report with no trace (AB#5629). * The wait loop read $Job.Runspace.IsCompleted. PowerShellAsyncResult has no .Runspace, so the expression was empty and the loop never waited at all. * Get-Job ordering varied, so the same tenant produced different reports run to run. * Each job re-imported the module, leaking module-scope StrictMode back in — which is why the v2.5.3 opt-out had to be applied at 17 entry points rather than 5. These tests pin the properties that replaced it: fixed ordering, contained per-collector failure, and byte-identical output for identical input. They run against a fixture tree, so they need no Azure and no jobs. #> BeforeAll { $script:RepoRoot = Split-Path -Parent $PSScriptRoot . (Join-Path $script:RepoRoot 'src/pipeline/Get-ScoutCollector.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') # ── Fixture collector tree ─────────────────────────────────────────────────────────── # Mirrors the real InventoryModules layout: one directory per category, each holding # collectors that take the ten positional parameters and switch on $Task. $script:FixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("scout-pipeline-" + [guid]::NewGuid().ToString('N')) function New-FixtureCollector { Param($Category, $Name, $Body, $DeclaredCategory) $Dir = Join-Path $script:FixtureRoot $Category if (-not (Test-Path -LiteralPath $Dir)) { $null = New-Item -Path $Dir -ItemType Directory -Force } # Built by concatenation, not -replace: the collector bodies contain $_ and $1-style # sequences that the .NET replacement-string parser would eat. $Lines = @() if ($DeclaredCategory) { # Same-line form, exactly as the 176 shipped collectors write it. $Lines += '<#' $Lines += ".CATEGORY $DeclaredCategory" $Lines += '#>' } $Lines += 'param($SCPath, $Sub, $Intag, $Resources, $Retirements, $Task, $File, $SmaResources, $TableStyle, $Unsupported)' $Lines += "If (`$Task -eq 'Processing') {" $Lines += $Body $Lines += '}' Set-Content -LiteralPath (Join-Path $Dir "$Name.ps1") -Value ($Lines -join "`n") -Encoding UTF8 } # Compute: two healthy collectors. New-FixtureCollector -Category 'Compute' -Name 'VirtualMachines' -Body @' $Resources | Where-Object { $_.TYPE -eq 'vm' } | ForEach-Object { [PSCustomObject]@{ Name = $_.NAME; Kind = 'vm' } } '@ New-FixtureCollector -Category 'Compute' -Name 'Disks' -Body @' $Resources | Where-Object { $_.TYPE -eq 'disk' } | ForEach-Object { [PSCustomObject]@{ Name = $_.NAME; Kind = 'disk' } } '@ # Storage: one healthy collector, plus one that throws — the containment case. New-FixtureCollector -Category 'Storage' -Name 'Accounts' -Body @' $Resources | Where-Object { $_.TYPE -eq 'storage' } | ForEach-Object { [PSCustomObject]@{ Name = $_.NAME; Kind = 'storage' } } '@ New-FixtureCollector -Category 'Storage' -Name 'Exploding' -Body @' throw 'this collector is broken' '@ # Networking: a collector filed under Networking but declaring itself as Security via # the .CATEGORY header — the per-file filtering case. New-FixtureCollector -Category 'Networking' -Name 'Firewalls' -DeclaredCategory 'Security' -Body @' [PSCustomObject]@{ Name = 'fw1'; Kind = 'firewall' } '@ # Empty: a category that legitimately yields nothing. Must NOT produce a cache file. New-FixtureCollector -Category 'Empty' -Name 'Nothing' -Body @' @() '@ $script:SampleResources = @( [PSCustomObject]@{ NAME = 'vm-a'; TYPE = 'vm' } [PSCustomObject]@{ NAME = 'vm-b'; TYPE = 'vm' } [PSCustomObject]@{ NAME = 'disk-a'; TYPE = 'disk' } [PSCustomObject]@{ NAME = 'sa-a'; TYPE = 'storage' } ) } AfterAll { if ($script:FixtureRoot -and (Test-Path -LiteralPath $script:FixtureRoot)) { Remove-Item -LiteralPath $script:FixtureRoot -Recurse -Force -ErrorAction SilentlyContinue } } Describe 'Get-ScoutCollector — discovery' { It 'finds every collector in the tree' { $Found = @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot) $Found.Count | Should -Be 6 } It 'returns collectors in a deterministic order' { # Ordering was previously whatever Get-Job returned, which varied run to run. $First = @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot | ForEach-Object { "$($_.FolderCategory)/$($_.Name)" }) $Second = @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot | ForEach-Object { "$($_.FolderCategory)/$($_.Name)" }) $First | Should -Be $Second $First[0] | Should -Be 'Compute/Disks' # sorted by category, then name } It 'filters at folder level' { $Found = @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot -Category 'Compute') $Found.Count | Should -Be 2 @($Found | ForEach-Object { $_.FolderCategory }) | Should -Not -Contain 'Storage' } It 'honours a .CATEGORY header that differs from the folder' { $Firewalls = @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot | Where-Object { $_.Name -eq 'Firewalls' }) $Firewalls.Count | Should -Be 1 $Firewalls[0].FolderCategory | Should -Be 'Networking' $Firewalls[0].Categories | Should -Contain 'Security' } It 'treats All, $null and empty as no filter' { @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot -Category 'All').Count | Should -Be 6 @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot -Category $null).Count | Should -Be 6 @(Get-ScoutCollector -InventoryRoot $script:FixtureRoot -Category @()).Count | Should -Be 6 } It 'warns rather than throwing when the root does not exist' { $Found = @(Get-ScoutCollector -InventoryRoot (Join-Path $script:FixtureRoot 'no-such-dir') -WarningAction SilentlyContinue) $Found.Count | Should -Be 0 } } Describe 'Invoke-ScoutCollector — per-collector failure containment' { BeforeAll { $script:Context = @{ ScriptRoot = $script:FixtureRoot Subscriptions = @() InTag = $null Resources = $script:SampleResources Retirements = @() Task = 'Processing' File = $null SmaResources = $null TableStyle = $null Unsupported = @() } } It 'runs a collector and returns its rows' { $Collector = Get-ScoutCollector -InventoryRoot $script:FixtureRoot | Where-Object { $_.Name -eq 'VirtualMachines' } $Result = Invoke-ScoutCollector -Collector $Collector -Context $script:Context $Result.Success | Should -BeTrue @($Result.Rows).Count | Should -Be 2 @($Result.Rows | ForEach-Object { $_.Name }) | Should -Be @('vm-a', 'vm-b') } It 'contains a throwing collector instead of letting it abort the run' { # The whole point of AB#5649: under the old pipeline this either emptied the category # silently or took down the batch. $Collector = Get-ScoutCollector -InventoryRoot $script:FixtureRoot | Where-Object { $_.Name -eq 'Exploding' } { Invoke-ScoutCollector -Collector $Collector -Context $script:Context -WarningAction SilentlyContinue } | Should -Not -Throw $Result = Invoke-ScoutCollector -Collector $Collector -Context $script:Context -WarningAction SilentlyContinue $Result.Success | Should -BeFalse $Result.Error.Exception.Message | Should -BeLike '*this collector is broken*' @($Result.Rows).Count | Should -Be 0 } It 'records how long the collector took' { $Collector = Get-ScoutCollector -InventoryRoot $script:FixtureRoot | Where-Object { $_.Name -eq 'Disks' } $Result = Invoke-ScoutCollector -Collector $Collector -Context $script:Context $Result.Duration | Should -BeOfType [timespan] } } Describe 'Write-ScoutCacheFile' { BeforeEach { $script:CacheDir = Join-Path ([System.IO.Path]::GetTempPath()) ("scout-cache-" + [guid]::NewGuid().ToString('N')) } AfterEach { if (Test-Path -LiteralPath $script:CacheDir) { Remove-Item -LiteralPath $script:CacheDir -Recurse -Force -ErrorAction SilentlyContinue } } # NB: no angle brackets in the test name — Pester reads <Word> as a -ForEach placeholder # and fails the test looking for a variable of that name. It 'writes the category JSON file when there are rows' { $Result = Write-ScoutCacheFile -Category 'Compute' -CachePath $script:CacheDir -Data @{ VirtualMachines = @([PSCustomObject]@{ Name = 'vm-a' }) } $Result.Written | Should -BeTrue $Result.RowCount | Should -Be 1 Test-Path -LiteralPath (Join-Path $script:CacheDir 'Compute.json') | Should -BeTrue } It 'writes no file at all for an empty category' { # The reporting phase reads a missing cache file as "nothing of this kind in the # estate". A file full of nulls instead yields empty worksheets. $Result = Write-ScoutCacheFile -Category 'Empty' -CachePath $script:CacheDir -Data @{ Nothing = @() } $Result.Written | Should -BeFalse Test-Path -LiteralPath (Join-Path $script:CacheDir 'Empty.json') | Should -BeFalse } It 'round-trips the collector rows' { $null = Write-ScoutCacheFile -Category 'Compute' -CachePath $script:CacheDir -Data @{ VirtualMachines = @([PSCustomObject]@{ Name = 'vm-a'; Kind = 'vm' }) } $Read = Get-Content -LiteralPath (Join-Path $script:CacheDir 'Compute.json') -Raw | ConvertFrom-Json $Read.VirtualMachines[0].Name | Should -Be 'vm-a' } } Describe 'Invoke-ScoutProcessing — the pipeline end to end' { BeforeEach { $script:RunPath = Join-Path ([System.IO.Path]::GetTempPath()) ("scout-run-" + [guid]::NewGuid().ToString('N')) $null = New-Item -Path (Join-Path $script:RunPath 'ReportCache') -ItemType Directory -Force } AfterEach { if (Test-Path -LiteralPath $script:RunPath) { Remove-Item -LiteralPath $script:RunPath -Recurse -Force -ErrorAction SilentlyContinue } } It 'runs every collector and caches each non-empty category' { $Summary = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -WarningAction SilentlyContinue $Summary.CollectorCount | Should -Be 6 $Cache = Join-Path $script:RunPath 'ReportCache' Test-Path -LiteralPath (Join-Path $Cache 'Compute.json') | Should -BeTrue Test-Path -LiteralPath (Join-Path $Cache 'Storage.json') | Should -BeTrue Test-Path -LiteralPath (Join-Path $Cache 'Networking.json') | Should -BeTrue Test-Path -LiteralPath (Join-Path $Cache 'Empty.json') | Should -BeFalse } It 'completes the run despite a broken collector, and reports it' { $Summary = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -WarningAction SilentlyContinue $Summary.FailureCount | Should -Be 1 $Summary.Failures[0].Collector | Should -Be 'Exploding' # …and its healthy sibling in the SAME category still made it to the cache. Under the # old pipeline the throw took the whole category with it. $Storage = Get-Content -LiteralPath (Join-Path $script:RunPath 'ReportCache/Storage.json') -Raw | ConvertFrom-Json $Storage.Accounts[0].Name | Should -Be 'sa-a' } It 'produces byte-identical cache files across two runs of the same input' { # This is the property the old pipeline could not offer: Compute.json came back 5,158 # bytes on one run and 470 on the next against the same tenant (AB#5629). $RunA = Join-Path $script:RunPath 'a' $RunB = Join-Path $script:RunPath 'b' $null = New-Item -Path (Join-Path $RunA 'ReportCache') -ItemType Directory -Force $null = New-Item -Path (Join-Path $RunB 'ReportCache') -ItemType Directory -Force $null = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $RunA ` -InventoryRoot $script:FixtureRoot -WarningAction SilentlyContinue $null = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $RunB ` -InventoryRoot $script:FixtureRoot -WarningAction SilentlyContinue $FilesA = @(Get-ChildItem -LiteralPath (Join-Path $RunA 'ReportCache') | Sort-Object Name) $FilesB = @(Get-ChildItem -LiteralPath (Join-Path $RunB 'ReportCache') | Sort-Object Name) @($FilesA | ForEach-Object { $_.Name }) | Should -Be @($FilesB | ForEach-Object { $_.Name }) foreach ($File in $FilesA) { $Hash = (Get-FileHash -LiteralPath $File.FullName).Hash $Other = (Get-FileHash -LiteralPath (Join-Path $RunB 'ReportCache' $File.Name)).Hash $Hash | Should -Be $Other -Because "$($File.Name) must be identical across runs" } } It 'honours the category filter' { $Summary = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -Category 'Compute' -WarningAction SilentlyContinue $Summary.CollectorCount | Should -Be 2 Test-Path -LiteralPath (Join-Path $script:RunPath 'ReportCache/Storage.json') | Should -BeFalse } It 'starts no background jobs' { # The regression this whole feature exists to prevent. If a job appears here, the # runspace machinery has crept back in. $Before = @(Get-Job).Count $null = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -WarningAction SilentlyContinue @(Get-Job).Count | Should -Be $Before } It 'returns an empty summary rather than throwing when nothing matches' { $Summary = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -Category 'NoSuchCategory' -WarningAction SilentlyContinue $Summary.CollectorCount | Should -Be 0 $Summary.FailureCount | Should -Be 0 } It 'returns the same property set whether or not any collector matched' { # A summary whose shape depends on which branch produced it throws under StrictMode the # first time a caller reads the missing property — the exact trap behind the v2.5.3 # crash wave. $Full = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -WarningAction SilentlyContinue $Empty = Invoke-ScoutProcessing -Resources $script:SampleResources -DefaultPath $script:RunPath ` -InventoryRoot $script:FixtureRoot -Category 'NoSuchCategory' -WarningAction SilentlyContinue $FullProps = @($Full.PSObject.Properties.Name | Sort-Object) $EmptyProps = @($Empty.PSObject.Properties.Name | Sort-Object) $EmptyProps | Should -Be $FullProps } } Describe 'Start-AZSCExtraJobs — security, policy, advisory and subscriptions, in-process' { BeforeAll { . (Join-Path $script:RepoRoot 'Modules/Private/Processing/Start-AZTIExtraJobs.ps1') # Stand-ins for the four real processing functions and the diagram wrapper. Each # records what it was handed so the argument-passing can be asserted. $script:Seen = @{} function Start-AZSCSecCenterJob { Param($Subscriptions, $Security) $script:Seen['SecArg'] = $Security; 'sec-rows' } function Start-AZSCPolicyJob { Param($Subscriptions, $PolicySetDef, $PolicyAssign, $PolicyDef) 'pol-rows' } function Start-AZSCAdvisoryJob { Param($Advisories) 'adv-rows' } function Start-AZSCSubscriptionJob { Param($Subscriptions, $Resources, $CostData) 'sub-rows' } function Invoke-AZSCDrawIOJob { Param($Subscriptions, $Resources, $Advisories, $DDFile, $DiagramCache, $FullEnv, $ResourceContainers, $Automation, $AZSCModule) } } It 'returns all four results as data instead of leaving them in jobs' { $Result = Start-AZSCExtraJobs -SkipDiagram $true -SkipAdvisory $false -SkipPolicy $false ` -SecurityCenter 'on' -Security @('a') -Subscriptions @('s') -Resources @('r') ` -Advisories @('adv') -PolicyAssign 'pa' -PolicyDef 'pd' -PolicySetDef 'psd' ` -CostData $null -Automation $false $Result.Security | Should -Be 'sec-rows' $Result.Policy | Should -Be 'pol-rows' $Result.Advisory | Should -Be 'adv-rows' $Result.Subscriptions | Should -Be 'sub-rows' } It 'hands Start-AZSCSecCenterJob the security ROWS, not the on/off switch' { # The shipped bug: it was called as -SecurityCenter $SecurityCenter against a parameter # block with no such parameter, so PowerShell routed it to $args, $null crossed the job # boundary as -Security, and the Security Center sheet was empty in every release. $script:Seen = @{} $null = Start-AZSCExtraJobs -SkipDiagram $true -SkipAdvisory $true -SkipPolicy $true ` -SecurityCenter 'on' -Security @('row1', 'row2') -Subscriptions @('s') ` -Resources @('r') -Automation $false $script:Seen['SecArg'] | Should -Be @('row1', 'row2') $script:Seen['SecArg'] | Should -Not -Be 'on' } It 'starts no background jobs' { $Before = @(Get-Job).Count $null = Start-AZSCExtraJobs -SkipDiagram $true -SkipAdvisory $false -SkipPolicy $false ` -SecurityCenter 'on' -Security @('a') -Subscriptions @('s') -Resources @('r') ` -Advisories @('adv') -PolicyAssign 'pa' -PolicyDef 'pd' -PolicySetDef 'psd' ` -Automation $false @(Get-Job).Count | Should -Be $Before } It 'completes the remaining steps when one of them throws' { function Start-AZSCPolicyJob { Param($Subscriptions, $PolicySetDef, $PolicyAssign, $PolicyDef) throw 'policy processing exploded' } $Result = Start-AZSCExtraJobs -SkipDiagram $true -SkipAdvisory $false -SkipPolicy $false ` -SecurityCenter 'on' -Security @('a') -Subscriptions @('s') -Resources @('r') ` -Advisories @('adv') -PolicyAssign 'pa' -PolicyDef 'pd' -PolicySetDef 'psd' ` -Automation $false -WarningAction SilentlyContinue $Result.Policy | Should -BeNullOrEmpty $Result.Advisory | Should -Be 'adv-rows' $Result.Subscriptions | Should -Be 'sub-rows' } It 'skips security processing entirely when SecurityCenter is not requested' { $script:Seen = @{} $Result = Start-AZSCExtraJobs -SkipDiagram $true -SkipAdvisory $true -SkipPolicy $true ` -SecurityCenter '' -Security @('a') -Subscriptions @('s') -Resources @('r') ` -Automation $false $Result.Security | Should -BeNullOrEmpty $script:Seen.ContainsKey('SecArg') | Should -BeFalse } } Describe 'Excel reporting only invokes collectors that have data' { # Found by a live run, not by the suite. The reporting loop guarded on # `@($SmaResources).count -gt 0`, and @($null).Count is 1 — so every collector was invoked # in Reporting mode whether or not it had rows. That was invisible while it was merely # wasted work; it became fatal at the two Identity files whose top-level statement is # Register-AZSCInventoryModule, which is not a real function. The Excel build died. It 'counts an absent cache entry as zero rows, not one' { # The exact PowerShell behaviour behind the bug. @($null) is a one-element array # containing $null, so a bare .Count reports 1 for "nothing". @($null).Count | Should -Be 1 @($null).Where({ $null -ne $_ }).Count | Should -Be 0 } It 'Start-AZSCExcelJob filters nulls out of the row count' { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'src/report/renderers/inventory/Start-AZSCExcelJob.ps1') -Raw $Source | Should -Match '\$ModuleResourceCount\s*=\s*@\(\$SmaResources\)\.Where' $Source | Should -Not -Match '\$ModuleResourceCount\s*=\s*@\(\$SmaResources\)\.count\s*$' } It 'Start-AZSCExcelJob refuses the unimplemented-contract collectors outright' { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'src/report/renderers/inventory/Start-AZSCExcelJob.ps1') -Raw $Source | Should -Match 'Register-AZSCInventoryModule' $Source | Should -Match '-not \$Unsupported' } It 'the two unimplemented collectors are still the only ones using that contract' { # If a third appears, both the pipeline and the reporting loop need to know about it. $Root = Join-Path $script:RepoRoot 'Modules/Public/InventoryModules' $Users = @(Get-ChildItem -LiteralPath $Root -Filter '*.ps1' -Recurse -File | Where-Object { (Get-Content $_.FullName -Raw) -match '(?m)^\s*Register-AZSCInventoryModule' } | ForEach-Object { $_.BaseName } | Sort-Object) $Users | Should -Be @('IdentityProviders', 'SecurityDefaults') } } Describe 'Every report exporter tolerates a cache with no entry for a collector' { # The old pipeline created a hashtable key for EVERY module file, including ones that # produced nothing, so `$CacheData.$ModName` always resolved. The deterministic pipeline # writes keys only for collectors it actually ran — so a skipped or filtered collector # legitimately has no key, and an unguarded read throws under StrictMode: # The property 'IdentityProviders' cannot be found on this object. # All four exporters carried the identical unguarded line. Found by a live run. $ExporterFiles = @( 'src/report/renderers/inventory/Export-AZSCJsonReport.ps1' 'src/report/renderers/inventory/Export-AZSCMarkdownReport.ps1' 'src/report/renderers/inventory/Export-AZSCAsciiDocReport.ps1' 'src/report/renderers/inventory/Export-AZSCPowerBIReport.ps1' ) It '<_> checks the property exists before reading it' -ForEach $ExporterFiles { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot $_) -Raw $Source | Should -Not -Match '(?m)^\s*\$ModResources\s*=\s*\$CacheData\.\$ModName\s*$' $Source | Should -Match 'PSObject\.Properties\.Name -contains \$ModName' } It 'reading a missing property on a PSCustomObject throws under StrictMode' { # The behaviour the guard exists for, pinned so nobody "simplifies" it back. $Obj = [PSCustomObject]@{ Present = 1 } $Name = 'Absent' { Set-StrictMode -Version Latest; $null = $Obj.$Name } | Should -Throw -ExpectedMessage "*cannot be found on this object*" } It 'the guarded form returns $null instead of throwing' { Set-StrictMode -Version Latest $CacheData = [PSCustomObject]@{ Present = 1 } $ModName = 'Absent' $ModResources = if ($CacheData -and $CacheData.PSObject.Properties.Name -contains $ModName) { $CacheData.$ModName } else { $null } $ModResources | Should -BeNullOrEmpty } } Describe 'Start-AZSCDiagramJob builds the diagram lookup in-process' { # AB#5649 — this was 290 lines: two copies of the same 27-type filter behind an if/else on # $Automation, the non-automation copy using 27 separate [PowerShell]::Create() runspaces # to perform 27 array filters, plus its own instance of the $Job.Runspace.IsCompleted # no-op wait. It is now one bucket-sorting pass over $Resources. BeforeAll { . (Join-Path $script:RepoRoot 'Modules/Public/PublicFunctions/Diagram/Start-AZTIDiagramJob.ps1') $script:DiagramResources = @( [PSCustomObject]@{ Type = 'microsoft.network/virtualnetworks'; name = 'vnet1' } [PSCustomObject]@{ Type = 'Microsoft.Network/VirtualNetworks'; name = 'vnet2' } [PSCustomObject]@{ Type = 'microsoft.compute/virtualmachines'; name = 'vm1' } [PSCustomObject]@{ Type = 'Microsoft.Compute/virtualMachineScaleSets'; name = 'vmss1' } [PSCustomObject]@{ Type = 'microsoft.storage/storageaccounts'; name = 'not-a-diagram-type' } ) } It 'returns all 27 keys the diagram builders index by' { $V = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $false @($V.Keys).Count | Should -Be 27 $V.Keys | Should -Contain 'AZVNETs' $V.Keys | Should -Contain 'AppGtw' $V.Keys | Should -Contain 'ANF' } It 'matches resource types case-insensitively, as -eq did' { $V = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $false @($V['AZVNETs']).Count | Should -Be 2 $V['VMSS'].name | Should -Be 'vmss1' } It 'returns $null for a type with no resources, NOT an empty collection' { # Load-bearing. Under StrictMode, member enumeration over an EMPTY collection throws # "property cannot be found", while the same read over $null is safe — the AB#5633 # crash class. Handing the diagram builders empty lists would walk straight back into it. $V = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $false $V['ANF'] | Should -BeNullOrEmpty $null -eq $V['ANF'] | Should -BeTrue } It 'starts no background jobs and creates no runspaces' { $Before = @(Get-Job).Count $null = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $false $null = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $true @(Get-Job).Count | Should -Be $Before } It 'gives automation and non-automation runs identical output' { # They were two hand-maintained copies of the same mapping; that is exactly how the # two collector-discovery implementations drifted apart. $A = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $false $B = Start-AZSCDiagramJob -Resources $script:DiagramResources -Automation $true ($A.Keys | Sort-Object) | Should -Be ($B.Keys | Sort-Object) foreach ($K in $A.Keys) { @($A[$K]).Count | Should -Be @($B[$K]).Count -Because "key $K must match across modes" } } It 'tolerates an empty and a null resource set' { { Start-AZSCDiagramJob -Resources @() -Automation $false } | Should -Not -Throw { Start-AZSCDiagramJob -Resources $null -Automation $false } | Should -Not -Throw } It 'no longer contains the runspace fan-out or the no-op wait' { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'Modules/Public/PublicFunctions/Diagram/Start-AZTIDiagramJob.ps1') -Raw $Tokens = $null $null = [System.Management.Automation.Language.Parser]::ParseInput($Source, [ref]$Tokens, [ref]$null) $Code = ($Tokens | Where-Object { $_.Kind -ne 'Comment' } | ForEach-Object { $_.Text }) -join ' ' $Code | Should -Not -Match 'Start-Job' $Code | Should -Not -Match 'Start-ThreadJob' $Code | Should -Not -Match 'BeginInvoke' $Code | Should -Not -Match 'Runspace' } It 'Start-AZSCDrawIODiagram takes the lookup as a value instead of harvesting a job' { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'Modules/Public/PublicFunctions/Diagram/Start-AZTIDrawIODiagram.ps1') -Raw $Tokens = $null $null = [System.Management.Automation.Language.Parser]::ParseInput($Source, [ref]$Tokens, [ref]$null) $Code = ($Tokens | Where-Object { $_.Kind -ne 'Comment' } | ForEach-Object { $_.Text }) -join ' ' $Code | Should -Match '\$Job = Start-AZSCDiagramJob' $Code | Should -Not -Match "DiagramVariables" } } Describe 'The processing path no longer depends on background jobs' { It 'Start-AZSCProcessOrchestration calls the deterministic pipeline' { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'Modules/Private/Main/Start-AZTIProcessOrchestration.ps1') -Raw $Source | Should -Match 'Invoke-ScoutProcessing' } It 'Start-AZSCProcessOrchestration no longer creates, waits on or harvests jobs' { $Source = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'Modules/Private/Main/Start-AZTIProcessOrchestration.ps1') -Raw # Comments explaining the history are allowed; executable calls are not. $Code = ($Source -split "`r?`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" $Code | Should -Not -Match 'Start-AZSCProcessJob' $Code | Should -Not -Match 'Start-AZSCAutProcessJob' $Code | Should -Not -Match 'Wait-AZSCJob' $Code | Should -Not -Match 'Build-AZSCCacheFiles' $Code | Should -Not -Match 'Get-Job' } } |