Files
MyAiConfiguration/scripts/tests/test-managed-install.ps1
T
Julian lechnerandClaude Opus 5.5 dec926fee3 feat(install): roll back interrupted syncs from a journal
Plan each sync before writing, record the plan in .ai-config-transaction.json,
and roll an interrupted destination back from its backups on the next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:10 +02:00

198 lines
18 KiB
PowerShell

[CmdletBinding()]
param([switch]$Summary)
$ErrorActionPreference = 'Stop'
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $root 'scripts/lib/manifest.ps1')
. (Join-Path $root 'scripts/lib/install-targets.ps1')
$work = Join-Path ([System.IO.Path]::GetTempPath()) ("ai-config-manifest-test-" + [Guid]::NewGuid())
New-Item $work -ItemType Directory -Force | Out-Null
try {
$source = Join-Path $work 'source'
$destination = Join-Path $work 'destination'
New-Item $source -ItemType Directory -Force | Out-Null
Set-Content (Join-Path $source 'a.md') 'A' -Encoding UTF8 -NoNewline
Set-Content (Join-Path $source 'b.md') 'B' -Encoding UTF8 -NoNewline
Set-Content (Join-Path $source 'c.md') 'C' -Encoding UTF8 -NoNewline
$out1 = @(Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'run1')
if (@($out1 | Where-Object { $_ -like 'CREATE*' }).Count -ne 3) { throw "Expected three CREATE lines on first install: $($out1 -join '; ')" }
$out2 = @(Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'run2')
if (@($out2 | Where-Object { $_ -notlike 'UNCHANGED*' -and $_ -notlike 'SOURCE *' }).Count -ne 0) { throw "Second identical install was not a no-op: $($out2 -join '; ')" }
if (Test-Path (Join-Path $destination 'backups/run2')) { throw 'Idempotent install must not create a backup.' }
Set-Content (Join-Path $destination 'foreign.md') 'mine' -Encoding UTF8 -NoNewline
Remove-Item (Join-Path $source 'b.md') -Force
Remove-Item (Join-Path $source 'c.md') -Force
Set-Content (Join-Path $destination 'c.md') 'locally changed' -Encoding UTF8 -NoNewline
$out3 = @(Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'run3')
if (@($out3 | Where-Object { $_ -match 'REMOVE.*b\.md' }).Count -ne 1) { throw "Expected b.md to be removed: $($out3 -join '; ')" }
if (Test-Path (Join-Path $destination 'b.md')) { throw 'b.md should have been removed: it was untouched by the user and dropped from source.' }
if (@($out3 | Where-Object { $_ -match 'WARN.*c\.md.*changed locally' }).Count -ne 1) { throw "Expected c.md to be flagged as locally modified: $($out3 -join '; ')" }
if (-not (Test-Path (Join-Path $destination 'c.md'))) { throw 'A locally modified stale file must not be removed.' }
if ((Get-Content (Join-Path $destination 'c.md') -Raw) -ne 'locally changed') { throw 'c.md content must be preserved exactly.' }
if ((Get-Content (Join-Path $destination 'foreign.md') -Raw) -ne 'mine') { throw 'A file this setup never installed must never be touched.' }
if (-not (Test-Path (Join-Path $destination 'backups/run3/b.md'))) { throw 'b.md should have been backed up before removal.' }
if (-not (Test-Path (Join-Path $destination 'backups/run3/c.md'))) { throw 'c.md should have been backed up even though it was left in place.' }
Remove-Item (Join-Path $source 'a.md') -Force
$beforeDry = @(Get-ChildItem $destination -Recurse -File | Sort-Object FullName | ForEach-Object FullName)
$outDry = @(Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'dry' -DryRun)
$afterDry = @(Get-ChildItem $destination -Recurse -File | Sort-Object FullName | ForEach-Object FullName)
if (Compare-Object $beforeDry $afterDry) { throw 'A dry run must not change the destination at all.' }
if (@($outDry | Where-Object { $_ -match 'DRYRUN REMOVE.*a\.md' }).Count -ne 1) { throw "Dry run should report the planned removal without performing it: $($outDry -join '; ')" }
$configPath = Join-Path $destination 'config.toml'
$pluginState = @'
[marketplaces.ponytail]
source_type = "git"
source = "https://github.com/DietrichGebert/ponytail.git"
[plugins."ponytail@ponytail"]
enabled = true
[plugins."disabled@market"]
enabled = false
'@
[System.IO.File]::WriteAllText((Join-Path $source 'config.toml'), "approvals_reviewer = 'auto_review'`n`n[tui]`n")
[System.IO.File]::WriteAllText($configPath, "model = 'old'`nmodel_reasoning_effort = 'high'`napprovals_reviewer = 'user'`n$pluginState`n[other]`nvalue = true`n", (New-Object System.Text.UTF8Encoding($true)))
$originalConfig = [System.IO.File]::ReadAllText($configPath)
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'plugins-dry' -DryRun
if ([System.IO.File]::ReadAllText($configPath) -ne $originalConfig) { throw 'Dry run must preserve plugin configuration.' }
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'plugins'
$mergedConfig = [System.IO.File]::ReadAllText($configPath)
if (-not ($mergedConfig -replace "`r`n", "`n").Contains(($pluginState -replace "`r`n", "`n"))) { throw 'Updating config.toml must preserve local marketplace and plugin tables, including disabled plugins.' }
if (-not $mergedConfig.Contains("model = 'old'") -or -not $mergedConfig.Contains("model_reasoning_effort = 'high'") -or -not $mergedConfig.Contains("approvals_reviewer = 'auto_review'") -or $mergedConfig.Contains("approvals_reviewer = 'user'") -or -not $mergedConfig.Contains('[other]')) { throw 'Updating config.toml must preserve local settings and tables and enforce automatic review.' }
if ($mergedConfig.IndexOf("model = 'old'") -gt $mergedConfig.IndexOf('[tui]') -or $mergedConfig.IndexOf("model_reasoning_effort = 'high'") -gt $mergedConfig.IndexOf('[tui]')) { throw 'Model settings must remain in the TOML root table.' }
if ([System.IO.File]::ReadAllText((Join-Path $destination 'backups/plugins/config.toml')) -ne $originalConfig) { throw 'Original plugin configuration must be backed up.' }
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'plugins-repeat'
if ([System.IO.File]::ReadAllText($configPath) -ne $mergedConfig -or (Test-Path (Join-Path $destination 'backups/plugins-repeat'))) { throw 'Preserving plugin configuration must be idempotent.' }
$outSummary = @(Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'summary' -Summary)
if (@($outSummary | Where-Object { $_ -like 'CREATE*' -or $_ -like 'UPDATE*' -or $_ -like 'UNCHANGED*' -or $_ -like 'BACKUP*' }).Count -ne 0) { throw "Summary mode must not print per-file lines: $($outSummary -join '; ')" }
if (@($outSummary | Where-Object { $_ -match '^SYNC .* unchanged' }).Count -ne 1) { throw "Summary mode must print exactly one SYNC tally line: $($outSummary -join '; ')" }
$generated = Join-Path $work 'generated'
$package = Join-Path $generated 'codex-powershell'
$testHome = Join-Path $work 'home'
$legacy = Join-Path $testHome '.codex'
New-Item (Join-Path $package 'skills/example') -ItemType Directory -Force | Out-Null
Set-Content (Join-Path $package 'skills/example/SKILL.md') 'managed skill' -NoNewline
New-Item (Join-Path $legacy 'skills/example') -ItemType Directory -Force | Out-Null
Copy-Item (Join-Path $package 'skills/example/SKILL.md') (Join-Path $legacy 'skills/example/SKILL.md')
Set-Content (Join-Path $legacy 'skills/example/custom.md') 'customized' -NoNewline
Set-Content (Join-Path $legacy 'skills/example/foreign.md') 'foreign' -NoNewline
Write-ManagedManifest (Get-ManagedManifestPath $legacy) @{
'skills/example/SKILL.md' = (Get-Sha256Hash (Join-Path $legacy 'skills/example/SKILL.md'))
'skills/example/custom.md' = ('0' * 64)
}
$targets = @(Get-InstallTargets -Generated $generated -HomePath $testHome -Shell PowerShell -Client Codex)
if ($targets[0].Destination -ne (Join-Path $testHome '.agents/skills')) { throw 'Canonical skills must be installed before legacy copies are removed.' }
$oldSkillBackup = Join-Path $testHome '.agents/skills/backups/old/example'
New-Item $oldSkillBackup -ItemType Directory -Force | Out-Null
Set-Content (Join-Path $oldSkillBackup 'SKILL.md') 'old backup' -NoNewline
$null = Sync-ManagedDestination -Source $targets[0].Source -Destination $targets[0].Destination -Stamp 'migrate-dry' -DryRun
if (-not (Test-Path (Join-Path $oldSkillBackup 'SKILL.md'))) { throw 'Dry run moved an old skill backup.' }
foreach ($item in $targets) { $null = Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp 'migrate' }
if (-not (Test-Path (Join-Path $testHome '.agents/skills/example/SKILL.md'))) { throw 'Canonical skill is missing.' }
if (Test-Path (Join-Path $testHome '.agents/skills/backups')) { throw 'Old skill backups must leave the discoverable skill tree.' }
$skillBackupRoot = Join-Path $testHome '.agents/.ai-config-skill-backups'
if (@(Get-ChildItem -LiteralPath $skillBackupRoot -Filter SKILL.md -Recurse -File | Where-Object { (Get-Content -LiteralPath $_.FullName -Raw) -eq 'old backup' }).Count -ne 1) { throw 'Old skill backup was not preserved outside the skill tree.' }
if (Test-Path (Join-Path $legacy 'skills/example/SKILL.md')) { throw 'Unmodified managed skill must not remain duplicated.' }
if (-not (Test-Path (Join-Path $legacy 'backups/migrate/skills/example/SKILL.md'))) { throw 'Removed legacy skill must have a backup.' }
foreach ($name in @('custom.md', 'foreign.md')) {
if (-not (Test-Path (Join-Path $legacy "skills/example/$name"))) { throw 'Modified and foreign legacy skills must be preserved.' }
}
foreach ($item in $targets) { $null = Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp 'migrate-repeat' }
if (Test-Path (Join-Path $legacy 'backups/migrate-repeat')) { throw 'Skill migration must be idempotent.' }
Set-Content (Join-Path $package 'skills/example/SKILL.md') 'updated managed skill' -NoNewline
foreach ($item in $targets) { $null = Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp 'skill-update' }
$installedSkill = Join-Path $testHome '.agents/skills/example/SKILL.md'
if ((Get-Content -LiteralPath $installedSkill -Raw) -ne 'updated managed skill') { throw 'Managed skill update did not replace the file.' }
if ((Get-Content -LiteralPath (Join-Path $skillBackupRoot 'skill-update/example/SKILL.md') -Raw) -ne 'managed skill') { throw 'New skill backup must be outside the skill tree.' }
if (@(Get-ChildItem -LiteralPath (Split-Path $installedSkill -Parent) -Filter '.ai-config-write-*' -File).Count) { throw 'Managed skill update left a temporary file.' }
Remove-Item -LiteralPath (Join-Path $package 'skills/example/SKILL.md') -Force
$null = Sync-ManagedDestination -Source $targets[0].Source -Destination $targets[0].Destination -Stamp 'skill-remove'
if (Test-Path -LiteralPath $installedSkill) { throw 'Deselected managed skill must be removed.' }
if ((Get-Content -LiteralPath (Join-Path $skillBackupRoot 'skill-remove/example/SKILL.md') -Raw) -ne 'updated managed skill') { throw 'Removed skill backup must be outside the skill tree.' }
Set-Content (Join-Path $source 'concurrency.txt') '__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__' -NoNewline
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'concurrency' -ClaudeConcurrency '7'
if ((Get-Content (Join-Path $destination 'concurrency.txt') -Raw) -ne '7') { throw 'Standalone concurrency placeholder must be resolved.' }
if (@(Get-ChildItem -LiteralPath $destination -Filter '.ai-config-write-*' -File -Recurse).Count) { throw 'Atomic writes left a temporary file.' }
if (Test-Path -LiteralPath (Join-Path $destination '.ai-config.lock')) { throw 'Sync must release its destination lock.' }
$searchSkill = Join-Path $source 'skills/semantic-search/SKILL.md'
New-Item (Split-Path $searchSkill -Parent) -ItemType Directory -Force | Out-Null
Set-Content $searchSkill 'search skill' -NoNewline
$installedSearchSkill = Join-Path $destination 'skills/semantic-search/SKILL.md'
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'search-off' -SemanticRetrievalEnabled $false
if (Test-Path -LiteralPath $installedSearchSkill) { throw 'The semantic-search skill must not be installed while retrieval is disabled.' }
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'search-on' -SemanticRetrievalEnabled $true
if (-not (Test-Path -LiteralPath $installedSearchSkill)) { throw 'The semantic-search skill must be installed while retrieval is enabled.' }
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'search-off-again' -SemanticRetrievalEnabled $false
if (Test-Path -LiteralPath $installedSearchSkill) { throw 'Disabling retrieval must remove the setup-owned semantic-search skill.' }
Remove-Item -LiteralPath (Split-Path $searchSkill -Parent) -Recurse -Force
$lockPath = Join-Path $destination '.ai-config.lock'
[System.IO.File]::WriteAllText($lockPath, "bash other-host 1`n")
$manifestBefore = [System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination))
$blocked = $false
try { $null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'locked' } catch { $blocked = $_.Exception.Message -like 'Another installation or update holds*' }
if (-not $blocked) { throw 'A held destination lock must block a parallel sync.' }
if ([System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination)) -ne $manifestBefore) { throw 'A blocked sync must not write the manifest.' }
$exitedStart = New-Object System.Diagnostics.ProcessStartInfo((Get-Process -Id $PID).Path, '-NoProfile -Command exit 0')
$exitedStart.UseShellExecute = $false
$exitedStart.CreateNoWindow = $true
$exited = [System.Diagnostics.Process]::Start($exitedStart)
$exited.WaitForExit()
[System.IO.File]::WriteAllText($lockPath, "powershell $([Environment]::MachineName) $($exited.Id)`n")
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'stale-lock'
if (Test-Path -LiteralPath $lockPath) { throw 'A stale lock from an exited process must be replaced and released.' }
if (Test-Path -LiteralPath (Join-Path $destination '.ai-config-transaction.json')) { throw 'A completed sync must delete its transaction journal.' }
if ([System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination)).Contains('.ai-config-transaction')) { throw 'The transaction journal must never be listed in the manifest.' }
# Simulate a run with stamp tx2 interrupted after it updated keep.md, removed gone.md, and created new.md and edited.md.
$txSource = Join-Path $work 'tx-source'
$txDestination = Join-Path $work 'tx-destination'
New-Item $txSource -ItemType Directory -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $txSource 'keep.md'), 'v1')
[System.IO.File]::WriteAllText((Join-Path $txSource 'gone.md'), 'G')
$null = Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx1'
New-Item (Join-Path $txDestination 'backups/tx2') -ItemType Directory -Force | Out-Null
Copy-Item (Join-Path $txDestination 'keep.md') (Join-Path $txDestination 'backups/tx2/keep.md')
Move-Item (Join-Path $txDestination 'gone.md') (Join-Path $txDestination 'backups/tx2/gone.md')
[System.IO.File]::WriteAllText((Join-Path $txDestination 'keep.md'), 'v2')
[System.IO.File]::WriteAllText((Join-Path $txDestination 'new.md'), 'N')
[System.IO.File]::WriteAllText((Join-Path $txDestination 'edited.md'), 'edited by the user')
$utf8 = New-Object System.Text.UTF8Encoding($false)
$journalPath = Join-Path $txDestination '.ai-config-transaction.json'
$journal = [ordered]@{ stamp = 'tx2'; files = @(
[ordered]@{ path = 'keep.md'; action = 'update'; backup = 'tx2/keep.md'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('v2')) },
[ordered]@{ path = 'gone.md'; action = 'remove'; backup = 'tx2/gone.md' },
[ordered]@{ path = 'new.md'; action = 'create'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('N')) },
[ordered]@{ path = 'edited.md'; action = 'create'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('E')) },
[ordered]@{ path = 'unreached.md'; action = 'update'; backup = 'tx2/unreached.md'; sha256 = ('0' * 64) }
) }
[System.IO.File]::WriteAllText($journalPath, (ConvertTo-Json -InputObject $journal -Depth 4))
$outTxDry = @(Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx-dry' -DryRun)
if (@($outTxDry | Where-Object { $_ -eq "DRYRUN ROLLBACK $txDestination tx2" }).Count -ne 1) { throw "Dry run must report the pending rollback: $($outTxDry -join '; ')" }
if (-not (Test-Path -LiteralPath $journalPath) -or [System.IO.File]::ReadAllText((Join-Path $txDestination 'keep.md')) -ne 'v2') { throw 'Dry run must not roll back.' }
$outTx = @(Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx3')
if (@($outTx | Where-Object { $_ -eq "ROLLBACK $txDestination tx2" }).Count -ne 1) { throw "Expected a ROLLBACK line: $($outTx -join '; ')" }
if (@($outTx | Where-Object { $_ -match '^WARN .*edited\.md' }).Count -ne 1) { throw "A created file changed since the interruption must be reported: $($outTx -join '; ')" }
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'keep.md')) -ne 'v1') { throw 'Rollback must restore an updated file from its backup.' }
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'gone.md')) -ne 'G') { throw 'Rollback must restore a removed file from its backup.' }
if (Test-Path -LiteralPath (Join-Path $txDestination 'new.md')) { throw 'Rollback must delete a file the interrupted run created.' }
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'edited.md')) -ne 'edited by the user') { throw 'Rollback must keep a created file that changed since.' }
if (Test-Path -LiteralPath (Join-Path $txDestination 'unreached.md')) { throw 'Rollback must skip entries the interrupted run never reached.' }
if (Test-Path -LiteralPath $journalPath) { throw 'Rollback must delete the journal.' }
if (@($outTx | Where-Object { $_ -notlike 'UNCHANGED*' -and $_ -notlike 'SOURCE *' -and $_ -notlike 'ROLLBACK *' -and $_ -notlike 'WARN *' }).Count -ne 0) { throw "The sync after a rollback must find the prior state: $($outTx -join '; ')" }
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration, interrupted-run rollback' }
} finally {
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
}
exit 0