Files
BakNRet/tests/BakNRet.Formats.Tests.ps1
T
Shuery e10503be76 fix: 让 5.1 真正可用(显式编码 + 原生 stderr 处理 + .psd1 夹具带 BOM)
上一提交让 5.1 能解析源码,但 Unit 与 Smoke 在 5.1 上仍然是红的。根因是三类彼此
无关的 5.1/7 行为差,全部实测确认:

1) 不写 -Encoding 时,5.1 的 Get-Content / Set-Content 默认是 ANSI,7 是 UTF-8。
   症状是 UTF-8 字节被按 GBK 解出「璇存槑」这类乱码。62 处补上显式 -Encoding UTF8。
   用 AST 而不是正则定位,避免把注释里的散文也改掉。

2) .psd1 夹具用无 BOM 写,而引擎的 .psd1 读取器(Import-PowerShellDataFile)只能靠
   BOM 判断编码、没有参数可传,于是 5.1 按 ANSI 解。40 处夹具改为带 BOM 写 —— 这正是
   .editorconfig 里 [*.psd1] charset = utf-8-bom 本来就要求的,是夹具违反了自己的约定。
   .cmd 批次文件刻意保持无 BOM:cmd.exe 会被 BOM 弄坏。

3) 5.1 在 $ErrorActionPreference = Stop 下会把原生命令写到 stderr 的内容升级成终止性
   NativeCommandError,7 改了这条。takeown/icacls 的 ACL 复位调用、以及 test.ps1 自己
   调子进程的地方,都需要在 Continue 下跑。

验收:test.ps1 9/9 全绿(Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍)。

已知未处理(留待后续提交):tools/lab/** 里还有若干「原生命令 + 2>&1 + Stop」的同类
写法(takeown / icacls / scoop / code / Get-WimInfo)。它们要 Hyper-V 实验机才跑得到,
不在验收门槛内。
2026-09-26 22:16:04 +08:00

646 lines
35 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法,
Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。
.DESCRIPTION
这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约):
* 写法一:直接写 SoftwareCatalog.psd1 里的软件名;
* 写法二:用户手写目录(含 \ / 或 % 就按路径处理);
* 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`;Path 是文件时归档内是名为
`<Slot>` 的文件(没有扩展名);
* 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写;
* `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`;
* `:+` / `@ Include=` 是 `<归档内相对路径>:<宿主机绝对路径>`;
* 同一条目里两个归档项抢同一个包内位置时,必须在归档前就明确报错(Blocking)。
跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会
`exit`,同进程 `&` 调用会把 Pester 宿主一起带走。
.EXAMPLE
pwsh -File .\tests\Run-Pester.ps1
#>
$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue)
$script:HasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue)
BeforeAll {
$script:ProjectRoot = Split-Path -Parent $PSScriptRoot
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
$script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force
$script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道,
# 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。
function Invoke-BaknretScript {
param(
[Parameter(Mandatory = $true)][string]$Script,
[hashtable]$Parameters = @{}
)
$arguments = @('-NoProfile', '-NonInteractive', '-File', $Script)
foreach ($name in ($Parameters.Keys | Sort-Object)) {
$value = $Parameters[$name]
if ($value -is [bool]) {
if ($value) { $arguments += "-$name" }
continue
}
$arguments += "-$name"
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$outFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-out-" + [guid]::NewGuid().ToString('N') + '.txt')
$cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-cmd-" + [guid]::NewGuid().ToString('N') + '.cmd')
$argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ')
$batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n"
[System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false))
$exitCode = $null
$lines = @()
try {
$exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile)
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
} finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{
ExitCode = $exitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
}
}
function Write-ListFile {
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content)
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($true))
return $Path
}
}
AfterAll {
if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) {
Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Describe '软件名录:Slot 形状(新契约)' {
# ============================================================================
BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format'
New-Item -ItemType Directory -Path $script:FormatRoot -Force | Out-Null
$script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB'
foreach ($directory in $script:DirA, $script:DirB) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory"
}
New-Item -ItemType Directory -Path (Join-Path $script:DirA 'Cache') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:DirA 'Cache\c.bin') 'cache'
$script:CfgFile = Join-Path $script:FormatRoot 'settings.json'
Set-Content -Encoding UTF8 -LiteralPath $script:CfgFile '{"a":1}'
# 两个不同父目录下各有一个**同名**子目录 —— 供"归档内同名"冲突测试用
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
foreach ($parent in 'p1', 'p2') {
New-Item -ItemType Directory -Path (Join-Path $script:CollideRoot "$parent\dupdir") -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent
}
$script:MissingDir = Join-Path $script:FormatRoot 'not-here'
$script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @"
@{
'pair' = @{
A = @{ Path = '$script:DirA'; Description = '第一个 Slot' }
B = @{ Path = '$script:DirB'; Description = '第二个 Slot' }
}
'solo' = @{ Only = @{ Path = '$script:DirA' } }
'partial' = @{ Ok = @{ Path = '$script:DirA' }; Gone = @{ Path = '$script:MissingDir' } }
'slotex' = @{ Data = @{ Path = '$script:DirA'; Exclude = '!*Cache,logs\' } }
'fileapp' = @{ Cfg = @{ Path = '$script:CfgFile'; Encrypt = `$true } }
'conflict' = @{ Data = @{ Path = '$script:DirA' } }
'legacyarr' = @('$script:DirA', '$script:DirB')
'legacydirs' = @{ Dirs = @('$script:DirA', '$script:DirB') }
'legacystr' = '$script:DirA'
}
"@
}
It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['pair'].Kind | Should -Be 'Multi'
@($catalog['pair'].Slots).Count | Should -Be 2
(@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B'
$catalog['pair'].Slots[0].Description | Should -Be '第一个 Slot'
$catalog['pair'].Slots[1].Description | Should -Be '第二个 Slot'
$catalog['pair'].Slots[0].Resolved | Should -Be $script:DirA
$catalog['pair'].Slots[1].Resolved | Should -Be $script:DirB
}
It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B'
(@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
(@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog'
$resolved.Items[0].RealPath | Should -Be $script:DirA
$resolved.Items[0].Description | Should -Be '第一个 Slot'
$resolved.Items[1].Description | Should -Be '第二个 Slot'
}
It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
(@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$entry = ConvertFrom-BackupListLine -Line 'slotex'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
(@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$resolved.HasExcludeOverride | Should -BeFalse
}
It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['partial'].Kind | Should -Be 'Partial'
@($catalog['partial'].Missing) | Should -Contain $script:MissingDir
$entry = ConvertFrom-BackupListLine -Line 'partial'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
@($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir
$resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误
}
It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' {
$entry = ConvertFrom-BackupListLine -Line 'fileapp'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].IsFile | Should -BeTrue
$resolved.Items[0].ArchivePath | Should -Be 'Cfg'
$resolved.Items[0].RealPath | Should -Be $script:CfgFile
$resolved.Encrypt | Should -BeTrue
}
It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog.ContainsKey('legacystr') | Should -BeFalse
$catalog.ContainsKey('legacyarr') | Should -BeFalse
}
It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog.ContainsKey('legacydirs') | Should -BeTrue
$catalog['legacydirs'].Kind | Should -Be 'Invalid'
$catalog['legacydirs'].Error | Should -Not -BeNullOrEmpty
@($catalog['legacydirs'].Slots).Count | Should -Be 0
}
It '多 Slot 的名录条目在清单里仍然按软件名命名归档' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair'
}
}
# ============================================================================
Describe '清单修饰符:新契约格式' {
# ============================================================================
BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format'
$script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1'
$script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB'
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
}
It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB"
$entry.Overrides.ContainsKey('Path') | Should -BeTrue
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'Only'
$resolved.Items[0].RealPath | Should -Be $script:DirB
$resolved.Blocking | Should -BeNullOrEmpty
}
It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' {
$entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个'
}
It ':- 排除与 :+ 包含并存,顺序任意' {
$entry = ConvertFrom-BackupListLine -Line "pair :- logs\,!*Cache :+ Mods:$script:DirB"
(@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
(@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$resolved.HasExcludeOverride | Should -BeTrue
$resolved.HasIncludeOverride | Should -BeTrue
@($resolved.Items | ForEach-Object { $_.ArchivePath }) | Should -Contain 'Mods'
$resolved.Blocking | Should -BeNullOrEmpty
}
It '@ Exclude / @ Include / @ Path 与记号写法等价' {
$marks = ConvertFrom-BackupListLine -Line "pair :- logs\ :+ Mods:$script:DirB"
$ats = ConvertFrom-BackupListLine -Line "pair @ Exclude='logs\' @ Include='Mods:$script:DirB'"
(@($marks.ExcludePatterns) -join '|') | Should -Be (@($ats.ExcludePatterns) -join '|')
(@($marks.Includes) -join '|') | Should -Be (@($ats.Includes) -join '|')
}
It ':encrypt / :!encrypt 覆盖名录里的加密默认值' {
$base = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog -MaxDepth 3
$base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt
$off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3
$off.Encrypt | Should -BeFalse
$on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3
$on.Encrypt | Should -BeTrue
}
It '遗留写法 @encrypt / @pathname / @root= 仍可解析' {
(ConvertFrom-BackupListLine -Line 'pair @encrypt').Overrides['Encrypt'] | Should -BeTrue
(ConvertFrom-BackupListLine -Line 'pair @pathname').Flags | Should -Contain 'pathname'
(ConvertFrom-BackupListLine -Line 'pair @root=Bar').Flags | Should -Contain 'root=Bar'
# @pathname 对软件名条目也会改用真实路径命名
$entry = ConvertFrom-BackupListLine -Line 'pair @pathname'
$expected = Get-BackupBaseName -RawPath $script:DirA
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be $expected
}
It '同一行里重复写同类记号会累积(不静默丢掉前一条规则)' {
# `:+ a :+ b` 与 `:+ a,b` 等价:两条规则都生效。
# 静默丢掉前一条排除/追加规则是这工具最不该犯的错,所以这里是"累加"语义。
$entry = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB,More:$script:DirA"
(@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA"
$repeated = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB :+ More:$script:DirA"
(@($repeated.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA"
$excludes = ConvertFrom-BackupListLine -Line 'pair :- logs\ :- !*Cache :- temp\'
(@($excludes.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache|temp\'
# @ Exclude= 与 :- 也是累加关系
$mixed = ConvertFrom-BackupListLine -Line "pair @ Exclude='a' :- b"
(@($mixed.ExcludePatterns) -join '|') | Should -Be 'a|b'
}
It '行尾说明与缺少目标的行' {
$entry = ConvertFrom-BackupListLine -Line 'pair :- logs\ # 日志可再生'
$entry.Comment | Should -Be '日志可再生'
(@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\'
ConvertFrom-BackupListLine -Line ':- logs\' | Should -BeNullOrEmpty
}
}
# ============================================================================
Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:SlotRoot = Join-Path $script:Sandbox 'slots-e2e'
$script:SlotAppOne = Join-Path $script:SlotRoot 'apps\AppOne'
$script:SlotAppTwo = Join-Path $script:SlotRoot 'apps\AppTwo'
$script:SlotCfgDir = Join-Path $script:SlotRoot 'apps\AppCfg'
$script:SlotInclude = Join-Path $script:SlotRoot 'psmodules'
New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'Cache') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'sub') -Force | Out-Null
New-Item -ItemType Directory -Path $script:SlotAppTwo -Force | Out-Null
New-Item -ItemType Directory -Path $script:SlotCfgDir -Force | Out-Null
New-Item -ItemType Directory -Path $script:SlotInclude -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') 'one'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') 'deep'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') 'cache'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') 'two'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotCfgDir 'settings.json') '{"a":1}'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') 'mod'
$appOne = $script:SlotAppOne
$appTwo = $script:SlotAppTwo
$cfgFile = Join-Path $script:SlotCfgDir 'settings.json'
$includeDir = $script:SlotInclude
$script:SlotCatalog = Write-ListFile -Path (Join-Path $script:SlotRoot 'cat.psd1') -Content @"
@{
'appkit' = @{
Cfg = @{ Path = '$cfgFile' }
Data = @{ Path = '$appOne'; Exclude = '!*Cache' }
Extra = @{ Path = '$appTwo'; Include = 'Modules:$includeDir' }
}
}
"@
$script:SlotConfig = Write-ListFile -Path (Join-Path $script:SlotRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:SlotCatalog' }"
$script:SlotList = Write-ListFile -Path (Join-Path $script:SlotRoot 'list.txt') -Content "appkit`n"
$script:SlotBackupDir = Join-Path $script:SlotRoot 'Backups'
$script:SlotBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
Force = $true
QuietTool = $true
}
$script:SlotManifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json')
$script:SlotArchive = @(Get-ChildItem -LiteralPath $script:SlotBackupDir -File -Filter *.7z)[0]
}
It '备份退出码 0,归档名就是软件名' {
$script:SlotBackupRun.ExitCode | Should -Be 0
$script:SlotArchive.BaseName | Should -Be 'appkit'
}
It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' {
$top = @(Get-ArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules'
}
It 'manifest.layouts 记下每个归档项是目录还是文件' {
$record = $script:SlotManifest.items['appkit']
$record.action | Should -Be 'backed-up'
$record.roots | Should -Contain 'Data'
(@($record.layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join ',') | Should -Be 'Cfg:file,Data:dir,Extra:dir,Modules:dir'
}
It '归档内容:<Slot>\<内容> 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效' {
$verify = Join-Path $script:SlotRoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:SlotArchive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'Data\one.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Data\sub\deep.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Data\Cache\c.bin') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify 'Extra\two.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Modules\mod.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Cfg') -PathType Leaf | Should -BeTrue
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $verify 'Cfg') -Raw).Trim() | Should -Be '{"a":1}'
}
It '真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位' {
Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force
Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
Force = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '恢复成功: appkit'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') -Raw).Trim() | Should -Be 'deep'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') -Raw).Trim() | Should -Be 'two'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') -Raw).Trim() | Should -Be 'mod'
# 被 Slot 排除的缓存没有进过归档,自然也不会被恢复出来
Test-Path -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') | Should -BeFalse
}
It '文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录)' {
# 目标文件被删掉了,名录解析只能得到 IsFile=false;判据要靠 manifest 的 layouts。
$restored = Join-Path $script:SlotCfgDir 'settings.json'
(Test-Path -LiteralPath $restored -PathType Leaf) | Should -BeTrue
(Get-Content -Encoding UTF8 -LiteralPath $restored -Raw).Trim() | Should -Be '{"a":1}'
}
It '恢复之后 manifest 记下 lastRestoreAt' {
$manifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json')
$manifest.items['appkit'].lastRestoreAt | Should -Not -BeNullOrEmpty
}
}
# ============================================================================
Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# Slot 布局是重构后才有的,Backups\ 里还躺着按旧布局(包内直接是 <源目录名>\...)
# 生成的归档。恢复这类归档时必须回退到"把 <目标末级名> 解到目标父目录"的旧语义。
BeforeAll {
$script:LegacyRoot = Join-Path $script:Sandbox 'legacy-layout'
$script:LegacyHolder = Join-Path $script:LegacyRoot 'holder'
$script:LegacyDestParent = Join-Path $script:LegacyRoot 'dest'
$script:LegacyLeaf = 'My Code Space'
New-Item -ItemType Directory -Path (Join-Path $script:LegacyHolder $script:LegacyLeaf) -Force | Out-Null
New-Item -ItemType Directory -Path $script:LegacyDestParent -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:LegacyHolder "$script:LegacyLeaf\legacy.txt") 'old-layout'
$destPath = Join-Path $script:LegacyDestParent $script:LegacyLeaf
$script:LegacyCatalog = Write-ListFile -Path (Join-Path $script:LegacyRoot 'cat.psd1') -Content @"
@{
'oldapp' = @{ SlotX = @{ Path = '$destPath' } }
}
"@
$script:LegacyConfig = Write-ListFile -Path (Join-Path $script:LegacyRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:LegacyCatalog' }"
$script:LegacyList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'list.txt') -Content "oldapp`n"
$script:LegacyBackupDir = Join-Path $script:LegacyRoot 'Backups'
New-Item -ItemType Directory -Path $script:LegacyBackupDir -Force | Out-Null
# 手工造一个旧布局归档:顶层就是源目录名,不是 Slot 名。
$script:LegacyArchive = Join-Path $script:LegacyBackupDir 'oldapp.7z'
(Invoke-ExternalCommand -FilePath $script:SevenZip `
-ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $script:LegacyArchive, $script:LegacyLeaf) `
-WorkingDirectory $script:LegacyHolder) | Should -Be 0
}
It '归档确实是旧布局:顶层是源目录名而不是 Slot 名' {
$top = @(Get-ArchiveTopLevelNames -ArchivePath $script:LegacyArchive -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be $script:LegacyLeaf
}
It '归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位' {
# 归档里没有 SlotX,但有旧布局的 <目标末级名>;恢复端必须先问归档"这条路径在不在",
# 不能靠 7z 的退出码猜(7z 对不存在的条目同样返回 0)。
Remove-Item -LiteralPath (Join-Path $script:LegacyDestParent $script:LegacyLeaf) -Recurse -Force -ErrorAction SilentlyContinue
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:LegacyList
BackupDir = $script:LegacyBackupDir
ConfigPath = $script:LegacyConfig
Force = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '按旧布局回退'
Test-Path -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") | Should -BeTrue
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") -Raw).Trim() | Should -Be 'old-layout'
}
It '归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复")' {
# 用 :: 覆盖把目标换成一个归档里根本不存在的末级名:两条路都走不通,
# 必须报失败并说明原因,而不是打一句"恢复成功"却一个文件都没落地。
$missingList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'missing-list.txt') `
-Content "oldapp :: $script:LegacyRoot\dest2\Nothing Here`n"
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $missingList
BackupDir = $script:LegacyBackupDir
ConfigPath = $script:LegacyConfig
Force = $true
}
$run.ExitCode | Should -Be 1
$run.Output | Should -Match '既没有'
Test-Path -LiteralPath (Join-Path $script:LegacyRoot 'dest2\Nothing Here') | Should -BeFalse
}
}
# ============================================================================
Describe '集成:归档内路径冲突会被拒绝执行' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:RejectRoot = Join-Path $script:Sandbox 'collide-e2e'
$script:RejectCatalog = Join-Path $script:RejectRoot 'cat.psd1'
$p1 = Join-Path $script:RejectRoot 'p1\dupdir'
$p2 = Join-Path $script:RejectRoot 'p2\dupdir'
foreach ($directory in $p1, $p2) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $directory 'x.txt') 'x'
}
# Slot 叫 Data,Include 也要放进包内的 Data -> 同一个位置,必须报错
Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @{ Data = @{ Path = '$p1' } }`n}`n" | Out-Null
$script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide :+ Data:$p2`n"
$script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n"
$script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups'
$script:RejectRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:RejectList
BackupDir = $script:RejectBackupDir
ConfigPath = $script:RejectConfig
Force = $true
QuietTool = $true
}
}
It '退出码 1,且给出"归档内路径冲突"的原因,不生成归档' {
$script:RejectRun.ExitCode | Should -Be 1
$script:RejectRun.Output | Should -Match '归档内路径冲突'
@(Get-ChildItem -LiteralPath $script:RejectBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue).Count | Should -Be 0
}
It 'manifest 里记下这次是 failed,并带上原因' {
$manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json')
$record = $manifest.items['collide']
$record.action | Should -Be 'failed'
$record.reason | Should -Match '归档内路径冲突'
}
}
# ============================================================================
Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# 这是"合并/改名条目"的真实后果:归档还在,manifest 里也还留着历史记录,
# 但清单里已经没有任何条目指向它 —— Restore.ps1 按条目名找归档,所以它恢复不到。
# 审计如果拿 manifest 当"已知",这种归档会被历史记录永远遮住,正是最该报的情况。
BeforeAll {
$script:OrphanRoot = Join-Path $script:Sandbox 'orphan-e2e'
$script:OrphanSource = Join-Path $script:OrphanRoot 'src\My App'
New-Item -ItemType Directory -Path $script:OrphanSource -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello'
$script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') `
-Content "@{ 'my-app' = @{ Default = @{ Path = '$script:OrphanSource' } } }`n"
$script:OrphanConfig = Write-ListFile -Path (Join-Path $script:OrphanRoot 'config.psd1') `
-Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n"
$script:OrphanList = Join-Path $script:OrphanRoot 'list.txt'
$script:OrphanBackupDir = Join-Path $script:OrphanRoot 'Backups'
# 第一次:清单里有 my-app
Write-ListFile -Path $script:OrphanList -Content "my-app`n" | Out-Null
$null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig
Force = $true
QuietTool = $true
}
# 第二次:把条目从清单里拿掉,归档留在磁盘上
Write-ListFile -Path $script:OrphanList -Content "# 清单里没有条目了`n" | Out-Null
$script:OrphanRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig
QuietTool = $true
}
}
It '归档确实还在磁盘上,manifest 里也还留着历史记录' {
Test-Path -LiteralPath (Join-Path $script:OrphanBackupDir 'my-app.7z') | Should -BeTrue
$manifest = Read-BaknretManifest -Path (Join-Path $script:OrphanBackupDir 'manifest.json')
$manifest.items.Contains('my-app') | Should -BeTrue
}
It '第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录' {
$script:OrphanRun.ExitCode | Should -Be 0
$script:OrphanRun.Output | Should -Match '孤儿归档'
$script:OrphanRun.Output | Should -Match 'my-app\.7z'
$script:OrphanRun.Output | Should -Match 'manifest 里还留着它的历史记录'
}
}
# ============================================================================
Describe 'manifest 一致性:archive 字段只在文件真的存在时才写' {
# ============================================================================
BeforeAll {
$script:SyncRoot = Join-Path $script:Sandbox 'sync'
New-Item -ItemType Directory -Path $script:SyncRoot -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SyncRoot 'real.7z') 'x'
$script:SyncManifestPath = Join-Path $script:SyncRoot 'manifest.json'
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$manifest.items['real'] = [pscustomobject]@{ archive = 'real.7z'; action = 'backed-up'; source = 'real' }
$manifest.items['gone'] = [pscustomobject]@{ archive = 'gone.7z'; action = 'missing-source'; source = 'gone' }
$manifest.items['none'] = [pscustomobject]@{ archive = $null; action = 'missing-source'; source = 'none' }
Write-BaknretManifest -Path $script:SyncManifestPath -Manifest $manifest | Out-Null
}
It '存在的归档保留 archive,不存在的被清空' {
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared.Count | Should -Be 1
$cleared[0] | Should -Be 'gone'
$manifest.items['real'].archive | Should -Be 'real.7z'
$manifest.items['gone'].archive | Should -BeNullOrEmpty
$manifest.items['none'].archive | Should -BeNullOrEmpty
}
It '清空 archive 时保留条目本身的历史(source / action 不动)' {
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$null = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$manifest.items['gone'].source | Should -Be 'gone'
$manifest.items['gone'].action | Should -Be 'missing-source'
}
It '人工删掉归档之后再同步一次,记录会被纠正过来' {
Remove-Item -LiteralPath (Join-Path $script:SyncRoot 'real.7z') -Force
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared | Should -Contain 'real'
$manifest.items['real'].archive | Should -BeNullOrEmpty
}
}