From 4751c24bde0460914000b99dbf5a4cdab3a89ab9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:42:13 +0000 Subject: [PATCH 1/2] perf(linux): optimize catalog_resolve array membership checks Replaced O(N) string matching membership checks in `catalog_resolve` with O(1) Bash associative array lookups. Also correctly handled `set -u` contexts by using default empty string substitutions for absent keys (`${array[$key]:-}`). This resolves the performance issue inside the topologically sorted dependency resolution of components, allowing catalog parsing and installation lists to be much faster. Co-authored-by: Ch3fUlrich <71930650+Ch3fUlrich@users.noreply.github.com> --- lib/linux/catalog.sh | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/linux/catalog.sh b/lib/linux/catalog.sh index f98ae88..46e5575 100644 --- a/lib/linux/catalog.sh +++ b/lib/linux/catalog.sh @@ -158,14 +158,16 @@ PLAN_AUTO="" catalog_resolve() { local requested=("$@") local -a wanted=() queue=("$@") + local -A wanted_set=() local id dep i while ((${#queue[@]})); do id="${queue[0]}"; queue=("${queue[@]:1}") - [[ " ${wanted[*]} " == *" $id "* ]] && continue + [[ -n "${wanted_set[$id]:-}" ]] && continue i="$(catalog_index_of "$id")" || continue if [[ "${CAT_PROVIDER[i]}" == manual ]]; then ui_err "AutoOS cannot install $id; use its vendor link for manual setup."; return 1; fi wanted+=("$id") + wanted_set["$id"]=1 if [[ -n "${CAT_REQUIRES[i]}" ]]; then IFS=',' read -ra deps <<<"${CAT_REQUIRES[i]}" for dep in "${deps[@]}"; do @@ -174,26 +176,26 @@ catalog_resolve() { fi done - local -a done_list=() visiting=() + local -A done_set=() visiting_set=() local ordered="" _visit() { local node="$1" idx d - [[ " ${done_list[*]} " == *" $node "* ]] && return 0 - if [[ " ${visiting[*]} " == *" $node "* ]]; then + [[ -n "${done_set[$node]:-}" ]] && return 0 + if [[ -n "${visiting_set[$node]:-}" ]]; then ui_err "Dependency cycle in catalog at '$node'"; return 1 fi - visiting+=("$node") + visiting_set["$node"]=1 idx="$(catalog_index_of "$node")" || return 0 if [[ -n "${CAT_REQUIRES[idx]}" ]]; then IFS=',' read -ra ds <<<"${CAT_REQUIRES[idx]}" for d in "${ds[@]}"; do [[ -z "$d" ]] && continue - [[ " ${wanted[*]} " == *" $d "* ]] && { _visit "$d" || return 1; } + [[ -n "${wanted_set[$d]:-}" ]] && { _visit "$d" || return 1; } done fi - visiting=("${visiting[@]/$node}") - done_list+=("$node") + unset "visiting_set[$node]" + done_set["$node"]=1 ordered+="$node " return 0 } From 59545f95d0369019f0a1a7637bd6404d9ae054a2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:34:50 +0000 Subject: [PATCH 2/2] perf(linux): optimize catalog_resolve array membership checks Replaced O(N) string matching membership checks in `catalog_resolve` with O(1) Bash associative array lookups. Also correctly handled `set -u` contexts by using default empty string substitutions for absent keys (`${array[$key]:-}`). This resolves the performance issue inside the topologically sorted dependency resolution of components, allowing catalog parsing and installation lists to be much faster. Co-authored-by: Ch3fUlrich <71930650+Ch3fUlrich@users.noreply.github.com> --- lib/windows/AutoOS.Detect.psm1 | 19 +- tests/run-tests.ps1.orig | 1291 ++++++++++++++++++++++++++++++++ 2 files changed, 1301 insertions(+), 9 deletions(-) create mode 100644 tests/run-tests.ps1.orig diff --git a/lib/windows/AutoOS.Detect.psm1 b/lib/windows/AutoOS.Detect.psm1 index b1934ec..d3c9b44 100644 --- a/lib/windows/AutoOS.Detect.psm1 +++ b/lib/windows/AutoOS.Detect.psm1 @@ -43,20 +43,21 @@ function Get-AutoOSShimDirectory { $home_ = $env:USERPROFILE @( # Scoop: user-scope by default, global when SCOOP_GLOBAL is set. - (Join-Path $home_ 'scoop\shims'), + $(if ($home_) { Join-Path $home_ 'scoop\shims' }), $(if ($env:SCOOP) { Join-Path $env:SCOOP 'shims' }), $(if ($env:SCOOP_GLOBAL) { Join-Path $env:SCOOP_GLOBAL 'shims' }), - (Join-Path $env:ProgramData 'scoop\shims'), + $(if ($env:ProgramData) { Join-Path $env:ProgramData 'scoop\shims' }), # Chocolatey. - (Join-Path $env:ProgramData 'chocolatey\bin'), + $(if ($env:ProgramData) { Join-Path $env:ProgramData 'chocolatey\bin' }), + "C:\ProgramData\chocolatey\bin", # winget's own shim directory for portable packages. - (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links'), - (Join-Path $env:ProgramFiles 'WinGet\Links'), + $(if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links' }), + $(if ($env:ProgramFiles) { Join-Path $env:ProgramFiles 'WinGet\Links' }), # npm -g, pipx/uv and cargo all install here and all rely on PATH. - (Join-Path $env:APPDATA 'npm'), - (Join-Path $home_ '.local\bin'), - (Join-Path $home_ '.cargo\bin'), - (Join-Path $home_ 'bin') + $(if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' }), + $(if ($home_) { Join-Path $home_ '.local\bin' }), + $(if ($home_) { Join-Path $home_ '.cargo\bin' }), + $(if ($home_) { Join-Path $home_ 'bin' }) ) | Where-Object { $_ } } diff --git a/tests/run-tests.ps1.orig b/tests/run-tests.ps1.orig new file mode 100644 index 0000000..fab45f6 --- /dev/null +++ b/tests/run-tests.ps1.orig @@ -0,0 +1,1291 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + AutoOS Windows test suite. + +.DESCRIPTION + Zero dependencies on purpose: the whole point of this repo is to run on a + machine where nothing is installed yet, so the tests must not need Pester. + + No test installs anything. Providers are asserted on the PLANNED command, + never on system state, and PATH tests run against a scratch copy rather + than the real environment. + +.EXAMPLE + pwsh tests\run-tests.ps1 +.EXAMPLE + powershell -File tests\run-tests.ps1 -Filter catalog +#> +[CmdletBinding()] +param([string]$Filter = '') + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$Lib = Join-Path $Root 'lib\windows' + +Import-Module (Join-Path $Lib 'AutoOS.Ui.psm1') -Force -DisableNameChecking +Import-Module (Join-Path $Lib 'AutoOS.Detect.psm1') -Force -DisableNameChecking +Import-Module (Join-Path $Lib 'AutoOS.Catalog.psm1') -Force -DisableNameChecking +Import-Module (Join-Path $Lib 'AutoOS.Install.psm1') -Force -DisableNameChecking +Import-Module (Join-Path $Lib 'AutoOS.Serve.psm1') -Force -DisableNameChecking +Import-Module (Join-Path $Lib 'AutoOS.State.psm1') -Force -DisableNameChecking + +$script:Pass = 0 +$script:Fail = 0 +$script:Skip = 0 +$script:Current = '' +$script:Failures = @() + +$useColor = -not [Console]::IsOutputRedirected +function C { param($t, $code) if ($useColor) { "$([char]27)[${code}m$t$([char]27)[0m" } else { $t } } + +function Describe-Group { param([string]$Name) Write-Host ''; Write-Host (C "-- $Name" '2;38;5;245') } + +function Test-Case { + param([string]$Name, [scriptblock]$Body) + if ($Filter -and $Name -notlike "*$Filter*") { return } + $script:Current = $Name + try { + & $Body + } catch { + $script:Fail++ + $script:Failures += $Name + Write-Host (" " + (C 'X' '1;38;5;167') + " $Name") + Write-Host (" " + (C $_.Exception.Message '2;38;5;245')) + # Without a location a bare .NET message (e.g. a NullReferenceException + # from inside a cmdlet) is almost impossible to trace back to a line. + $where = ($_.ScriptStackTrace -split "`n" | Select-Object -First 2) -join ' | ' + if ($where) { Write-Host (" " + (C $where '2;38;5;245')) } + } +} + +function Pass { $script:Pass++; Write-Host (" " + (C '+' '38;5;71') + " $script:Current") } +function Skip { param($why) $script:Skip++; Write-Host (" " + (C '-' '38;5;179') + " $script:Current " + (C "($why)" '2;38;5;245')) } + +function Assert-Equal { + param($Actual, $Expected) + if ($Actual -eq $Expected) { Pass } else { throw "expected [$Expected] but got [$Actual]" } +} +function Assert-True { + param($Condition, $Message = 'expected true') + if ($Condition) { Pass } else { throw $Message } +} +function Assert-Contains { + param($Collection, $Item) + if (@($Collection) -contains $Item) { Pass } else { throw "[$Item] not found in [$(@($Collection) -join ', ')]" } +} +function Assert-NotContains { + param($Collection, $Item) + if (@($Collection) -notcontains $Item) { Pass } else { throw "[$Item] should not be present" } +} + +Write-Host (C "AutoOS Windows test suite" '2;38;5;245') -NoNewline +Write-Host (C " ($Root)" '2;38;5;245') + +$winCatalog = Get-AutoOSCatalog (Join-Path $Root 'catalog\windows.json') + +# ─── Catalog schema ───────────────────────────────────────────────────────── +Describe-Group 'catalog schema' + +Test-Case 'windows catalog validates' { + $p = @(Test-AutoOSCatalogSchema -Catalog $winCatalog) + if ($p.Count -eq 0) { Pass } else { throw ($p -join '; ') } +} + +Test-Case 'linux catalog validates too' { + $c = Get-AutoOSCatalog (Join-Path $Root 'catalog\linux.json') + $p = @(Test-AutoOSCatalogSchema -Catalog $c) + if ($p.Count -eq 0) { Pass } else { throw ($p -join '; ') } +} + +Test-Case 'a malformed catalog is rejected' { + $bad = @' +{"categories":[{"id":"x","name":"X","components":[ + {"id":"Bad_ID","name":"n","description":"d","provider":"nope","package":"p","requires":["ghost"]}]}]} +'@ | ConvertFrom-Json + $p = @(Test-AutoOSCatalogSchema -Catalog $bad) + $joined = $p -join '; ' + Assert-True ($joined -match 'unknown provider' -and $joined -match 'kebab-case' -and $joined -match 'ghost') ` + "expected provider/kebab/ghost problems, got: $joined" +} + +Test-Case 'every winget component has a non-empty package id' { + $bad = @() + foreach ($cat in $winCatalog.categories) { + foreach ($c in $cat.components) { + if ($c.provider -eq 'winget' -and [string]::IsNullOrWhiteSpace($c.package)) { $bad += $c.id } + } + } + Assert-Equal ($bad -join ',') '' +} + +# ─── Fixture-driven filtering ─────────────────────────────────────────────── +Describe-Group 'component filtering' + +function New-FakeSystem { + param([string]$Arch = 'x64', [bool]$Headless = $false, [double]$Ram = 32, [int]$Cores = 16) + # Mirrors every field Get-AutoOSSystemInfo really returns, so consumers such + # as Get-AutoOSServeState can be tested without touching the live machine. + [pscustomobject]@{ + Arch = $Arch; IsHeadless = $Headless; RamGB = $Ram; CpuCores = $Cores + IsAdmin = $true; HasWinget = $true; HasChoco = $false; FreeDiskGB = 200 + VirtualizationEnabled = $true; OsBuild = 22631; WindowsMajor = 11 + OsName = 'Microsoft Windows 11 Pro'; OsVersion = '10.0.22631' + Manufacturer = 'Contoso'; Model = 'TestBox 9000' + CpuName = 'Contoso Test CPU'; Gpu = 'none'; HasGpu = $false + HasDiscreteGpu = $false; IsLaptop = $false; IsVirtual = $true + UserName = 'testuser'; PsVersion = '5.1.0'; PsEdition = 'Desktop' + IsInteractive = $false; HasScoop = $false; HasGit = $true + HasNode = $true; HasNpm = $true; HasDocker = $false; HasWsl = $false + NodeVersion = 'v22.0.0'; HasMicrophone = $true; Microphone = 'Test Mic' + } +} + +Test-Case 'x64 machine sees the full catalog' { + $a = @(Get-AutoOSAvailableComponents -Catalog $winCatalog -SystemInfo (New-FakeSystem)) + Assert-True ($a.Count -gt 25) "only $($a.Count) components" +} + +Test-Case 'profile membership drives the default ticks' { + $a = @(Get-AutoOSAvailableComponents -Catalog $winCatalog -SystemInfo (New-FakeSystem)) + $light = @($a | Where-Object { 'light' -in $_.Profiles } | ForEach-Object { $_.Id }) + Assert-Contains $light 'claude-code' +} + +Test-Case 'light profile does not include desktop customisation' { + $a = @(Get-AutoOSAvailableComponents -Catalog $winCatalog -SystemInfo (New-FakeSystem)) + $light = @($a | Where-Object { 'light' -in $_.Profiles } | ForEach-Object { $_.Id }) + Assert-NotContains $light 'windhawk' +} + +# ─── Dependency resolution ────────────────────────────────────────────────── +Describe-Group 'dependency resolution' + +$available = @(Get-AutoOSAvailableComponents -Catalog $winCatalog -SystemInfo (New-FakeSystem)) + +Test-Case 'pulls in transitive requirements' { + $plan = @(Resolve-AutoOSPlan -Available $available -SelectedIds @('claude-code')) + Assert-Contains ($plan | ForEach-Object { $_.Id }) 'nodejs' +} + +Test-Case 'orders dependencies before dependents' { + $plan = @(Resolve-AutoOSPlan -Available $available -SelectedIds @('claude-code')) + $ids = @($plan | ForEach-Object { $_.Id }) + Assert-True ($ids.IndexOf('nodejs') -lt $ids.IndexOf('claude-code')) "order was: $($ids -join ' -> ')" +} + +Test-Case 'flags auto-added dependencies' { + $plan = @(Resolve-AutoOSPlan -Available $available -SelectedIds @('claude-code')) + $node = $plan | Where-Object { $_.Id -eq 'nodejs' } + Assert-True $node.AutoAdded 'nodejs should be marked AutoAdded' +} + +Test-Case 'does not flag what was explicitly requested' { + $plan = @(Resolve-AutoOSPlan -Available $available -SelectedIds @('claude-code', 'nodejs')) + $node = $plan | Where-Object { $_.Id -eq 'nodejs' } + Assert-True (-not $node.AutoAdded) 'nodejs was requested, so it is not auto-added' +} + +Test-Case 'resolves a multi-level chain' { + $plan = @(Resolve-AutoOSPlan -Available $available -SelectedIds @('oh-my-posh')) + $ids = @($plan | ForEach-Object { $_.Id }) + foreach ($want in @('powershell7', 'nerd-font', 'oh-my-posh')) { + if ($ids -notcontains $want) { throw "missing $want in $($ids -join ', ')" } + } + Pass +} + +Test-Case 'an empty selection yields an empty plan' { + $plan = @(Resolve-AutoOSPlan -Available $available -SelectedIds @()) + Assert-Equal $plan.Count 0 +} + +Test-Case 'a dependency cycle is reported, not silently dropped' { + $fake = @( + [pscustomobject]@{ Id = 'a'; Name = 'A'; Description = 'd'; Provider = 'winget'; Package = 'A'; Requires = @('b'); Profiles = @(); PostInstall = $null; Prompt = $null; Notes = $null; Category = 'c'; CategoryId = 'c'; Source = $null } + [pscustomobject]@{ Id = 'b'; Name = 'B'; Description = 'd'; Provider = 'winget'; Package = 'B'; Requires = @('a'); Profiles = @(); PostInstall = $null; Prompt = $null; Notes = $null; Category = 'c'; CategoryId = 'c'; Source = $null } + ) + try { + $null = Resolve-AutoOSPlan -Available $fake -SelectedIds @('a') + throw 'expected a cycle error but resolution succeeded' + } catch { + if ($_.Exception.Message -match 'cycle') { Pass } else { throw $_ } + } +} + +# ─── Detection heuristics ─────────────────────────────────────────────────── +Describe-Group 'detection' + +Test-Case 'arm64 maps to the light profile' { + Assert-Equal (Get-AutoOSSuggestedProfile -SystemInfo (New-FakeSystem -Arch 'arm64')) 'light' +} + +Test-Case 'a big x64 desktop maps to workstation' { + Assert-Equal (Get-AutoOSSuggestedProfile -SystemInfo (New-FakeSystem -Ram 32 -Cores 16)) 'workstation' +} + +Test-Case 'an 8 GB laptop is NOT downgraded to light' { + # 8 GB reports ~7.4 GB usable; 'light' is for Pi-class hardware only. + Assert-Equal (Get-AutoOSSuggestedProfile -SystemInfo (New-FakeSystem -Ram 7.4 -Cores 8)) 'ai-coding' +} + +Test-Case 'a headless machine skips desktop profiles' { + Assert-Equal (Get-AutoOSSuggestedProfile -SystemInfo (New-FakeSystem -Headless $true)) 'ai-coding' +} + +Test-Case 'no package manager is a hard blocker' { + $s = New-FakeSystem + $s.HasWinget = $false; $s.HasChoco = $false + $b = @(Get-AutoOSBlockers -SystemInfo $s) + Assert-True (@($b | Where-Object { $_.Severity -eq 'error' }).Count -ge 1) 'expected an error blocker' +} + +Test-Case 'disabled virtualisation is a warning, not a blocker' { + $s = New-FakeSystem + $s.VirtualizationEnabled = $false + $b = @(Get-AutoOSBlockers -SystemInfo $s) + $v = @($b | Where-Object { $_.Message -match 'virtualisation' }) + Assert-Equal $v[0].Severity 'warn' +} + +# ─── "Is it already installed?" ───────────────────────────────────────────── +# Every probe below runs against a synthetic inventory rather than this machine, +# so the suite asserts the same thing on a box where nothing is installed. +# The bar is asymmetric on purpose: a missed install costs a re-run, a false +# positive silently skips something the user asked for. + +function New-FakeInventory { + # Mirrors every field Get-AutoOSInstalledInventory really returns. + param( + [object[]]$Entries = @(), + [string[]]$Paths = @(), + [string[]]$Appx = @(), + [string[]]$Programs = @(), + [string[]]$WingetPackages = @() + ) + [pscustomobject]@{ + Entries = $Entries; Paths = $Paths; Appx = $Appx + Programs = $Programs; WingetPackages = $WingetPackages + } +} + +function New-FakeComponent { + param([string]$Id = 'widget', [string]$Name = 'Widget', + [string]$Provider = 'winget', [string]$Package = 'Contoso.Widget', + [string]$Verify = '') + [pscustomobject]@{ Id = $Id; Name = $Name; Provider = $Provider; Package = $Package; Verify = $Verify } +} + +Test-Case 'a bitness suffix on the registered name still matches' { + # Notepad++ registers as "Notepad++ (64-bit x64)" and went undetected. + Assert-True (Test-AutoOSRegisteredName 'Notepad++ (64-bit x64)' 'Notepad++') 'bitness suffix rejected' +} + +Test-Case 'a release-channel suffix on the registered name still matches' { + # PowerToys registers as "PowerToys (Preview) x64". + Assert-True (Test-AutoOSRegisteredName 'PowerToys (Preview) x64' 'PowerToys') 'preview suffix rejected' +} + +Test-Case 'an architecture-plus-locale suffix still matches' { + # "Mozilla Firefox (x64 en-US)". Widening the pattern for PowerToys dropped + # this case once and silently un-detected Firefox on every English install. + Assert-True (Test-AutoOSRegisteredName 'Mozilla Firefox (x64 en-US)' 'Mozilla Firefox') 'locale suffix rejected' +} + +Test-Case 'a different product sharing a prefix is never matched' { + foreach ($pair in @(@('Steam Tools', 'Steam'), @('Firefox Helper', 'Firefox'), + @('PowerToys Run Plugin', 'PowerToys'), @('Git Extensions', 'Git'))) { + if (Test-AutoOSRegisteredName $pair[0] $pair[1]) { throw "'$($pair[0])' must not match '$($pair[1])'" } + } + Pass +} + +Test-Case 'the shim directories package managers use are probed even when PATH is stale' { + $dirs = @(Get-AutoOSShimDirectory) + foreach ($want in @('scoop\shims', 'chocolatey\bin', 'Microsoft\WinGet\Links')) { + if (-not @($dirs | Where-Object { $_ -like "*$want*" })) { + throw "no probe directory for $want in: $($dirs -join '; ')" + } + } + Pass +} + +Test-Case 'a shim directory off the persistent PATH still resolves a verify command' { + $tmp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) + [void](New-Item -ItemType Directory -Path $tmp -Force) + try { + Set-Content -LiteralPath (Join-Path $tmp 'widget.cmd') -Value '@echo off' + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent -Verify 'widget --version') ` + -Inventory (New-FakeInventory -Paths @($tmp)) + Assert-Equal $status 'installed' + } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } +} + +Test-Case 'a winget package id present in the cached list counts as installed' { + # winget's own record, read once per run - never one probe per catalog row. + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent) ` + -Inventory (New-FakeInventory -WingetPackages @('Contoso.Widget')) + Assert-Equal $status 'installed' +} + +Test-Case 'a winget package id missing from the cached list is not-detected' { + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent -Package 'Contoso.Other') ` + -Inventory (New-FakeInventory -WingetPackages @('Contoso.Widget')) + Assert-Equal $status 'not-detected' +} + +Test-Case 'the winget list is only trusted for winget rows' { + # 'python' appears in that output as a moniker for something else entirely. + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent -Provider 'choco' -Package 'widget') ` + -Inventory (New-FakeInventory -WingetPackages @('widget')) + Assert-Equal $status 'not-detected' +} + +Test-Case 'a per-user install under LOCALAPPDATA\Programs counts as installed' { + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent -Name 'Obsidian' -Package 'Obsidian.Obsidian') ` + -Inventory (New-FakeInventory -Programs @('obsidian')) + Assert-Equal $status 'installed' +} + +Test-Case 'a per-user directory belonging to something else proves nothing' { + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent -Name 'Obsidian' -Package 'Obsidian.Obsidian') ` + -Inventory (New-FakeInventory -Programs @('obsidianhelper')) + Assert-Equal $status 'not-detected' +} + +Test-Case 'a per-user program directory is only believed when it holds an executable' { + # %APPDATA%\JAM Software outlives TreeSize by years: configuration left + # behind is not an installation, which is why only Programs\ is read and + # only when an .exe is actually in it. + $tmp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) + [void](New-Item -ItemType Directory -Path (Join-Path $tmp 'Leftovers') -Force) + [void](New-Item -ItemType Directory -Path (Join-Path $tmp 'RealApp') -Force) + try { + Set-Content -LiteralPath (Join-Path $tmp 'RealApp\app.exe') -Value 'x' + $found = @(Get-AutoOSProgramDirectoryName -Root $tmp) + Assert-True (($found -contains 'realapp') -and ($found -notcontains 'leftovers')) ` + "expected only realapp, got: $($found -join ', ')" + } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } +} + +Test-Case 'an appx package still wins over everything else' { + $c = New-FakeComponent -Name 'Slack' -Package 'SlackTechnologies.Slack' + Add-Member -InputObject $c -NotePropertyName InstalledAppx -NotePropertyValue @('91750D7E.Slack') + $status = Get-AutoOSInstalledStatus -Component $c -Inventory (New-FakeInventory -Appx @('91750D7E.Slack')) + Assert-Equal $status 'installed' +} + +Test-Case 'nothing is guessed when every probe comes up empty' { + $status = Get-AutoOSInstalledStatus -Component (New-FakeComponent) -Inventory (New-FakeInventory) + Assert-Equal $status 'not-detected' +} + +Test-Case 'the disk analyser is WizTree, and treesize is gone' { + $ids = @($available | ForEach-Object { $_.Id }) + Assert-True (($ids -contains 'wiztree') -and ($ids -notcontains 'treesize')) ` + "catalog ids were: $($ids -join ', ')" +} + +Test-Case 'git and the GitHub CLI are both in the workstation profile' { + $ws = @($available | Where-Object { 'workstation' -in $_.Profiles } | ForEach-Object { $_.Id }) + foreach ($want in @('git', 'gh')) { + if ($ws -notcontains $want) { throw "workstation is missing $want" } + } + Pass +} + +Test-Case 'the windows catalog asks for a git identity and wires it to git' { + foreach ($key in @('git_user_name', 'git_user_email')) { + if ($winCatalog.prompts.PSObject.Properties.Name -notcontains $key) { throw "windows catalog never asks $key" } + } + $git = $available | Where-Object { $_.Id -eq 'git' } + Assert-True (($git.Prompt -split '[, ]+') -contains 'git_user_name' -and + ($git.Prompt -split '[, ]+') -contains 'git_user_email') ` + "git prompt was: [$($git.Prompt)]" +} + +Test-Case 'the git post-install step exists and is callable' { + $git = $available | Where-Object { $_.Id -eq 'git' } + Assert-True ($git.PostInstall -and (Get-Command $git.PostInstall -ErrorAction SilentlyContinue)) ` + "post-install '$($git.PostInstall)' is not a loaded function" +} + +# ─── PATH handling (the critical regression) ──────────────────────────────── +Describe-Group 'PATH handling' + +Test-Case 'appending preserves every existing entry' { + # The original Ansible task replaced Path outright, wiping the user's + # environment. This asserts the read-modify-write behaviour directly. + $existing = 'C:\Windows;C:\Windows\System32;C:\Program Files\Git\cmd' + $parts = @($existing -split ';' | Where-Object { $_ -ne '' }) + $toAdd = @('C:\tools\miniconda3', 'C:\tools\miniconda3\Scripts') + foreach ($d in $toAdd) { + if (@($parts | Where-Object { $_.TrimEnd('\') -ieq $d.TrimEnd('\') }).Count -eq 0) { $parts += $d } + } + $result = $parts -join ';' + Assert-True ($result.StartsWith($existing) -and $result -match 'miniconda3') "got: $result" +} + +Test-Case 'appending the same directory twice is a no-op' { + $parts = @('C:\Windows', 'C:\tools\miniconda3') + $before = $parts.Count + $d = 'C:\tools\miniconda3\' # trailing slash must still match + if (@($parts | Where-Object { $_.TrimEnd('\') -ieq $d.TrimEnd('\') }).Count -eq 0) { $parts += $d } + Assert-Equal $parts.Count $before +} + +Test-Case 'Add-AutoOSPathEntry makes no change in dry run' { + Initialize-AutoOSInstaller -DryRun $true -RepoRoot $Root + $before = [Environment]::GetEnvironmentVariable('Path', 'User') + $null = Add-AutoOSPathEntry -Directory @('C:\autoos-test-should-not-persist') + $after = [Environment]::GetEnvironmentVariable('Path', 'User') + Initialize-AutoOSInstaller -DryRun $false -RepoRoot $Root + Assert-Equal $after $before +} + +# ─── UI ───────────────────────────────────────────────────────────────────── +Describe-Group 'ui' + +Test-Case 'colour can be turned off completely' { + Set-AutoOSColor $false + $t = Format-AutoOSColor 'hello' 'accent' + Set-AutoOSColor $true + Assert-Equal $t 'hello' +} + +Test-Case 'colour output wraps in a reset' { + Set-AutoOSColor $true + $t = Format-AutoOSColor 'hello' 'accent' + Assert-True ($t.EndsWith("$([char]27)[0m")) 'missing reset sequence' +} + +Test-Case 'menu cursor skips group headers' { + $rows = New-Object System.Collections.ArrayList + [void]$rows.Add([pscustomobject]@{ Kind = 'header' }) + [void]$rows.Add([pscustomobject]@{ Kind = 'item' }) + [void]$rows.Add([pscustomobject]@{ Kind = 'header' }) + [void]$rows.Add([pscustomobject]@{ Kind = 'item' }) + Assert-Equal (Get-AutoOSNextItemIndex $rows 1 1) 3 +} + +Test-Case 'menu cursor stays put at the end of the list' { + $rows = New-Object System.Collections.ArrayList + [void]$rows.Add([pscustomobject]@{ Kind = 'item' }) + Assert-Equal (Get-AutoOSNextItemIndex $rows 0 1) 0 +} + +Test-Case 'Show-AutoOSRadioMenu returns DefaultId in non-interactive mode' { + $env:AUTOOS_NONINTERACTIVE = '1' + try { + $items = @( + [pscustomobject]@{ Id = 'workstation'; Name = 'workstation'; Description = 'Full dev' }, + [pscustomobject]@{ Id = 'light'; Name = 'light'; Description = 'Minimal' } + ) + $res = Show-AutoOSRadioMenu -Items $items -Title 'Profile' -DefaultId 'light' + Assert-Equal $res 'light' + } finally { + Remove-Item Env:\AUTOOS_NONINTERACTIVE -ErrorAction SilentlyContinue + } +} + +Test-Case 'Read-AutoOSConfirm returns default in non-interactive mode' { + $env:AUTOOS_NONINTERACTIVE = '1' + try { + Assert-True (Read-AutoOSConfirm -Question 'Proceed?' -Default $true) + Assert-True (-not (Read-AutoOSConfirm -Question 'Proceed?' -Default $false)) + } finally { + Remove-Item Env:\AUTOOS_NONINTERACTIVE -ErrorAction SilentlyContinue + } +} + +Test-Case 'log lines are classified for the browser UI' { + Assert-Equal (Get-AutoOSLineLevel ' + installed') 'ok' +} + +Test-Case 'plain log lines get no class' { + Assert-Equal (Get-AutoOSLineLevel 'just some text') '' +} + +# ─── End to end ───────────────────────────────────────────────────────────── +Describe-Group 'end-to-end (dry run only)' + +$setup = Join-Path $Root 'setup.ps1' + +Test-Case '--CheckCatalog succeeds' { + & powershell -NoProfile -ExecutionPolicy Bypass -File $setup -CheckCatalog | Out-Null + Assert-Equal $LASTEXITCODE 0 +} + +Test-Case 'a dry run exits cleanly' { + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File $setup -DryRun -Profile light -Yes -NoColor 2>&1 + Assert-True ($LASTEXITCODE -eq 0) "exit $LASTEXITCODE : $($out | Select-Object -Last 3)" +} + +Test-Case 'a dry run reports the plan' { + $out = (& powershell -NoProfile -ExecutionPolicy Bypass -File $setup -DryRun -Profile light -Yes -NoColor 2>&1) -join "`n" + Assert-True ($out -match 'DRY RUN') 'no dry-run banner in output' +} + +Test-Case 'two consecutive dry runs produce the same plan' { + $re = '^\s+\d+\.\s' + $a = (& powershell -NoProfile -ExecutionPolicy Bypass -File $setup -DryRun -Profile light -Yes -NoColor 2>&1) -match $re + $b = (& powershell -NoProfile -ExecutionPolicy Bypass -File $setup -DryRun -Profile light -Yes -NoColor 2>&1) -match $re + Assert-Equal ($a -join '|') ($b -join '|') +} + +Test-Case 'an unknown component id is rejected' { + $out = (& powershell -NoProfile -ExecutionPolicy Bypass -File $setup -Only 'definitely-not-a-thing' -Yes -NoColor 2>&1) -join "`n" + Assert-True ($LASTEXITCODE -ne 0 -and $out -match 'Unknown component') "exit=$LASTEXITCODE out=$out" +} + +# ─── Run state, verification, undo ────────────────────────────────────────── +Describe-Group 'state, verify and undo' + +Test-Case 'macos catalog validates' { + $c = Get-AutoOSCatalog (Join-Path $Root 'catalog\macos.json') + $p = @(Test-AutoOSCatalogSchema -Catalog $c) + if ($p.Count -eq 0) { Pass } else { throw ($p -join '; ') } +} + +Test-Case 'brew is an accepted provider' { + $c = Get-AutoOSCatalog (Join-Path $Root 'catalog\macos.json') + $p = @(Test-AutoOSCatalogSchema -Catalog $c) + Assert-True (($p -join ';') -notmatch "unknown provider 'brew'") "brew was rejected: $($p -join '; ')" +} + +Test-Case 'cask is projected onto the component' { + $c = Get-AutoOSCatalog (Join-Path $Root 'catalog\macos.json') + $a = @(Get-AutoOSAvailableComponents -Catalog $c -SystemInfo (New-FakeSystem -Arch 'arm64')) + $docker = $a | Where-Object { $_.Id -eq 'docker' } + Assert-True $docker.Cask 'docker should be a cask' +} + +Test-Case 'verify commands survive catalog projection' { + $a = @(Get-AutoOSAvailableComponents -Catalog $winCatalog -SystemInfo (New-FakeSystem)) + $git = $a | Where-Object { $_.Id -eq 'git' } + Assert-Equal $git.Verify 'git --version' +} + +Test-Case 'a component with no verify command is unchecked' { + Assert-Equal (Test-AutoOSComponentWorks -VerifyCommand '' -Name 'x') 'unchecked' +} + +Test-Case 'verification passes for something that is installed' { + Assert-Equal (Test-AutoOSComponentWorks -VerifyCommand 'cmd /c ver' -Name 'cmd') 'verified' +} + +Test-Case 'verification reports unverified for a missing binary' { + Assert-Equal (Test-AutoOSComponentWorks -VerifyCommand 'definitely-not-a-real-binary --version' -Name 'ghost') 'unverified' +} + +Test-Case '-NoVerify skips the check entirely' { + Set-AutoOSVerify $false + $r = Test-AutoOSComponentWorks -VerifyCommand 'definitely-not-a-real-binary' -Name 'ghost' + Set-AutoOSVerify $true + Assert-Equal $r 'unchecked' +} + +Test-Case 'a dry run verifies nothing' { + Assert-Equal (Test-AutoOSComponentWorks -VerifyCommand 'cmd /c ver' -Name 'cmd' -DryRun $true) 'unchecked' +} + +Test-Case 'state survives a save/load round trip' { + $tmp = Join-Path $env:TEMP "autoos-state-test-$([Guid]::NewGuid().ToString('N')).json" + Save-AutoOSState -Path $tmp -ProfileName 'light' -Selected @('git', 'nodejs') ` + -Answers @{ omnigraph_url = 'https://example.invalid' } ` + -Results @{ installed = @('git'); skipped = @('nodejs'); failed = @() } | Out-Null + $back = Import-AutoOSState -Path $tmp + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + Assert-Equal "$($back.Profile)|$($back.Selected -join ',')|$($back.Answers['omnigraph_url'])" ` + 'light|git,nodejs|https://example.invalid' +} + +Test-Case 'a dry run saves no state' { + $tmp = Join-Path $env:TEMP "autoos-state-test-$([Guid]::NewGuid().ToString('N')).json" + Save-AutoOSState -Path $tmp -ProfileName 'light' -Selected @('git') -DryRun $true | Out-Null + $existed = Test-Path $tmp + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + Assert-True (-not $existed) 'dry run wrote a state file' +} + +Test-Case 'backups are grouped newest-per-original' { + $scratch = Join-Path $env:TEMP "autoos-undo-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $scratch -Force | Out-Null + 'original' | Out-File (Join-Path $scratch 'profile.ps1') -Encoding utf8 + 'v1' | Out-File (Join-Path $scratch 'profile.ps1.autoos-backup-20260101-000000') -Encoding utf8 + 'v2' | Out-File (Join-Path $scratch 'profile.ps1.autoos-backup-20260202-000000') -Encoding utf8 + $b = @(Get-AutoOSBackups -SearchRoot $scratch) + Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue + Assert-True ($b.Count -eq 1 -and $b[0].Backup -match '20260202') "got: $($b | ConvertTo-Json -Compress)" +} + +Test-Case 'undo never uninstalls anything' { + # The safety property, asserted on the source rather than by removing software. + $src = Get-Content (Join-Path $Lib 'AutoOS.State.psm1') -Raw + Assert-True ($src -notmatch 'winget\s+uninstall|choco\s+uninstall|npm\s+uninstall') ` + 'the undo path contains an uninstall command' +} + +# ─── Browser UI payload ───────────────────────────────────────────────────── +Describe-Group 'browser UI' + +Test-Case 'every component has a homepage link' { + $bad = @() + foreach ($f in @('windows.json', 'linux.json', 'macos.json')) { + $c = Get-AutoOSCatalog (Join-Path $Root "catalog\$f") + foreach ($cat in $c.categories) { + foreach ($comp in $cat.components) { + if (-not $comp.PSObject.Properties.Name.Contains('homepage')) { $bad += "$f`:$($comp.id)" } + } + } + } + Assert-Equal ($bad -join ',') '' +} + +Test-Case 'a non-URL homepage is rejected' { + $bad = @' +{"categories":[{"id":"x","name":"X","components":[ + {"id":"thing","name":"Thing","description":"d","provider":"winget","package":"p","homepage":"not-a-url"}]}]} +'@ | ConvertFrom-Json + $p = @(Test-AutoOSCatalogSchema -Catalog $bad) + Assert-True (($p -join '; ') -match 'homepage') "expected a homepage complaint, got: $($p -join '; ')" +} + +Test-Case 'the serve payload carries what the UI needs' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + $cc = $state.components | Where-Object { $_.id -eq 'claude-code' } + foreach ($k in @('requires', 'homepage', 'verify', 'category', 'provider')) { + if (-not $cc.Contains($k)) { throw "serve payload is missing '$k'" } + } + Pass +} + +Test-Case 'the serve payload keeps the dependency edges' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + $cc = $state.components | Where-Object { $_.id -eq 'claude-code' } + Assert-Contains $cc.requires 'nodejs' +} + +Test-Case 'the serve payload survives JSON round-tripping' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + $back = ($state | ConvertTo-Json -Depth 8 -Compress) | ConvertFrom-Json + $cc = $back.components | Where-Object { $_.id -eq 'claude-code' } + Assert-Contains @($cc.requires) 'nodejs' +} + +Test-Case 'the Windows payload reports WSL as host, not guest' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + # Windows is never the WSL guest; the field must say so rather than be absent. + Assert-True ($state.wsl.Contains('isWsl') -and -not $state.wsl.isWsl) ` + "wsl block: $($state.wsl | ConvertTo-Json -Compress)" +} + +Test-Case 'the Windows payload reports whether WSL is available' { + $sys = New-FakeSystem + $sys.HasWsl = $true + $state = Get-AutoOSServeState -SystemInfo $sys -Catalog $winCatalog + Assert-True $state.wsl.available 'HasWsl should surface as wsl.available' +} + +Test-Case 'the Windows payload carries an environment field' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + Assert-True ($state.system.Contains('environment')) 'system.environment missing' +} + +Test-Case 'the serve payload labels platform availability' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + $handy = $state.components | Where-Object { $_.id -eq 'handy' } + foreach ($p in @('windows', 'linux', 'macos')) { + if (@($handy.platforms) -notcontains $p) { + throw "handy platforms were: $(@($handy.platforms) -join ',')" + } + } + Pass +} + +Test-Case 'a Windows-only component is labelled as such' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + $wh = $state.components | Where-Object { $_.id -eq 'windhawk' } + Assert-Equal (@($wh.platforms) -join ',') 'windows' +} + +Test-Case 'Handy is offered for dictation in the coding profiles' { + $handy = $null + foreach ($cat in $winCatalog.categories) { + foreach ($c in $cat.components) { if ($c.id -eq 'handy') { $handy = $c } } + } + if (-not $handy) { throw 'handy is not in the catalog' } + Assert-Equal "$($handy.package)|$(($handy.profiles | Sort-Object) -join ',')" ` + 'cjpais.Handy|ai-coding,workstation' +} + +Test-Case 'Handy says it needs a microphone rather than being hidden' { + # It installs fine without one, so it is noted, not filtered out. + $handy = $null + foreach ($cat in $winCatalog.categories) { + foreach ($c in $cat.components) { if ($c.id -eq 'handy') { $handy = $c } } + } + Assert-True ($handy.notes -match 'microphone') "notes were: $($handy.notes)" +} + +Test-Case 'the payload reports the microphone' { + $state = Get-AutoOSServeState -SystemInfo (New-FakeSystem) -Catalog $winCatalog + Assert-Equal $state.system.microphone 'Test Mic' +} + +$serveSource = Get-Content -Path (Join-Path $Root 'lib\windows\AutoOS.Serve.psm1') -Raw -Encoding UTF8 +$pageSource = Get-Content -Path (Join-Path $Root 'web\index.html') -Raw -Encoding UTF8 + +Test-Case 'a busy port moves the server on instead of failing' { + # The listener is opened in a walk, not a single Start(), so an already-taken + # 8777 costs a line of output rather than the whole run. + if ($serveSource -notmatch 'foreach \(\$candidatePort in') { throw 'no port walk in Start-AutoOSServer' } + if ($serveSource -notmatch 'already in use - serving on') { throw 'a moved port is never announced' } + Pass +} + +Test-Case 'a missing URL reservation stops the walk immediately' { + # ERROR_ACCESS_DENIED will not be cured by the next port along; retrying + # nineteen more times just delays the message that actually helps. + Assert-True ($serveSource -match 'ErrorCode -eq 5') 'access-denied is not singled out' +} + +Test-Case 'the installer output is drained without a reader thread' { + # A PowerShell scriptblock has no runspace on a raw .NET thread: it does not + # fail the read, it kills the process. This test is the tripwire for anyone + # reaching for [System.Threading.Thread] here again. + if ($serveSource -match '\[System\.Threading\.Thread\]') { throw 'a raw thread is back in the serve module' } + if ($serveSource -notmatch 'ReadLineAsync') { throw 'nothing is draining the installer output' } + Pass +} + +Test-Case 'the server answers a heartbeat the page can poll' { + if ($serveSource -notmatch "'/api/ping'") { throw 'no /api/ping route' } + if ($pageSource -notmatch '/api/ping') { throw 'the page never polls a heartbeat' } + Pass +} + +Test-Case 'a page whose server has gone tears itself down' { + if ($pageSource -notmatch 'function serverGone') { throw 'the page has no teardown path' } + if ($pageSource -notmatch 'window\.close') { throw 'the page never tries to close itself' } + Pass +} + +Test-Case 'the output can be copied without selecting it by hand' { + if ($pageSource -notmatch 'id="copyLog"') { throw 'no copy button on the output card' } + if ($pageSource -notmatch 'function copyLog'){ throw 'the copy button does nothing' } + # innerText returns nothing while the Output card is collapsed, so the text + # has to be read off the child elements instead. + if ($pageSource -notmatch 'function logText'){ throw 'the log text is not gathered safely' } + if ($pageSource -notmatch 'execCommand') { throw 'no fallback for a non-secure context' } + Pass +} + +Test-Case 'a verify command resolves to the file that will run' { + $hint = Get-AutoOSLaunchHint -Component ([pscustomobject]@{ + Id = 'powershell7'; Name = 'PowerShell 7'; Verify = 'powershell -Command' }) + if ($hint.How -ne 'run powershell') { throw "how was: $($hint.How)" } + Assert-True ($hint.Path -match '\.exe$') "path was: $($hint.Path)" +} + +Test-Case 'a component that is not here reports nothing rather than guessing' { + # A blank is honest. A plausible-looking path that does not exist is worse + # than saying nothing, because it reads like a fact. + $hint = Get-AutoOSLaunchHint -Component ([pscustomobject]@{ + Id = 'autoos-nonesuch-xyz'; Name = 'AutoOS Nonesuch XYZ'; Verify = 'autoos-nonesuch-xyz' }) + if ($hint.How -ne '' -or $hint.Path -ne '') { throw "invented: $($hint.How) / $($hint.Path)" } + Pass +} + +Test-Case 'the report says where things landed' { + $setupSource = Get-Content -Path (Join-Path $Root 'setup.ps1') -Raw -Encoding UTF8 + if ($setupSource -notmatch 'Where to find them') { throw 'no launch section in the report' } + if ($setupSource -notmatch 'Get-AutoOSLaunchHint') { throw 'the report never resolves a location' } + Pass +} + +Test-Case '-Only accepts a comma-separated list through -File' { + # powershell -File passes every argument literally, so "a,b" arrives as one + # string. The browser UI shells out exactly that way, so a multi-component + # install used to fail as "unknown component id(s): a,b". + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File $setup ` + -Only 'git,nodejs' -Yes -NoColor -DryRun 2>&1 + Assert-True ($LASTEXITCODE -eq 0 -and ($out -join "`n") -notmatch 'Unknown component') ` + "exit $LASTEXITCODE : $($out | Select-Object -Last 3)" +} + +Test-Case 'a page whose scripts are blocked says so' { + # Everything on the page is driven by one inline script. Without it the + # header would sit at "connecting..." for ever and explain nothing. + if ($pageSource -notmatch '