Files
BakNRet/tests/Run-E2E.ps1
T
Shuery 045d51ac9c 引入软件名录:清单写软件名,归档名也用软件名
新功能
- 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里
  直接写软件名即可,归档名也就是软件名(FooClolor.7z),
  不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。
- 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。
- 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、
  Variants(同名目录在多处)、Includes(分文件维护)。
- tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。
- 归档名重复直接报错,不再静默互相覆盖。

两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。

过程中修掉的缺陷
- Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误,
  @pathname 会被静默吃掉(改名后仍用软件名)。
- 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、
  报 "Cannot bind argument to parameter 'Path' because it is an empty string"。
  恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。
- 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份;
  且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。
- 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。
2026-09-21 20:55:19 +08:00

370 lines
17 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
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。
.DESCRIPTION
单元测试只验证函数行为,这个脚本验证整条链路真的能用:
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
3. 解压归档,断言被排除的内容确实不在里面;
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
6. 源路径不存在时记为 missing-source,而不是静默忽略。
全程只在临时目录里操作,不会碰到真实备份。
.EXAMPLE
pwsh -File .\tests\Run-E2E.ps1
#>
[CmdletBinding()]
param(
[string]$WorkRoot,
[switch]$KeepWorkRoot
)
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$projectRoot = Split-Path -Parent $PSScriptRoot
$backupScript = Join-Path $projectRoot 'Backup.ps1'
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
Reset-TestResult
if (-not $WorkRoot) {
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
}
$sourceParent = Join-Path $WorkRoot 'src'
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
$backupDir = Join-Path $WorkRoot 'Backups'
$listPath = Join-Path $WorkRoot 'list.txt'
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
$verifyDir = Join-Path $WorkRoot 'verify'
Write-Host ""
Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
Write-Host " 工作目录:$WorkRoot"
# ============================================================================
# 1. 造数据
# ============================================================================
foreach ($dir in 'logs', 'sub', 'Cache') {
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $source 'keep.txt') -Value 'keep-me' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
# 一个确定性随机二进制文件,用于逐字节对拍
$blob = New-Object byte[] 8192
(New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob)
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
$expectedHashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
$expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash
}
# ============================================================================
# 2. 备份
# ============================================================================
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
$backupExitCode = $LASTEXITCODE
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
Assert-Equal 0 $backupExitCode
}
$archives = @(Get-ChildItem -LiteralPath $backupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
Test-Case '归档已生成' {
Assert-Equal 1 $archives.Count
Assert-True ($archives[0].Length -gt 0) '归档不应为空'
}
$manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作与校验结果' {
Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count
$record = $manifest.items[$archives[0].BaseName]
Assert-True ($null -ne $record) 'manifest 里应有对应条目'
Assert-Equal 'backed-up' $record.action
Assert-Equal $true $record.verified
Assert-Equal 0 $record.exitCode
Assert-Equal $source $record.source
Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4'
}
Test-Case '备份过程写了日志文件' {
$logDir = Join-Path $projectRoot 'logs'
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
}
# ============================================================================
# 3. 解压归档,验证排除真的生效
# ============================================================================
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName)
}
Test-Case '归档里保留了应当保留的内容' {
Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin')
}
Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\logs\a.log') 'logs\ 应被排除'
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
}
# ============================================================================
# 4. 删源后恢复,逐字节对拍
# ============================================================================
Remove-Item -LiteralPath $source -Recurse -Force
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
Assert-FileMissing $source
}
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force
$restoreExitCode = $LASTEXITCODE
Test-Case '恢复退出码为 0' {
Assert-Equal 0 $restoreExitCode
}
Test-Case '恢复出的文件与源逐字节一致' {
foreach ($relative in $expectedHashes.Keys) {
$restored = Join-Path $source $relative
Assert-FileExists $restored
Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative"
}
}
Test-Case '被排除的内容没有被恢复出来' {
Assert-FileMissing (Join-Path $source 'logs\a.log')
Assert-FileMissing (Join-Path $source 'Cache\c.bin')
}
# ============================================================================
# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档
# ============================================================================
$lockSource = Join-Path $sourceParent 'Locked Case'
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
$lockList = Join-Path $WorkRoot 'lock.txt'
New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
# 第一轮:没有占用,归档是"干净"的
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
$cleanExitCode = $LASTEXITCODE
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$cleanSize = $cleanArchive.Length
$lockManifestPath = Join-Path $lockBackupDir 'manifest.json'
$cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName]
Test-Case '干净归档:退出码 0,manifest 记 warnings=false' {
Assert-Equal 0 $cleanExitCode
Assert-Equal 'backed-up' $cleanRecord.action
Assert-Equal $false $cleanRecord.warnings
}
# 第二轮:独占锁住一个文件,真实制造"有文件读不到"的 7z 警告
Set-Content -LiteralPath (Join-Path $lockSource 'b.txt') -Value 'bbb' -Encoding UTF8
$lockedPath = Join-Path $lockSource 'locked.bin'
Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8
$lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
try {
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
$warnExitCode = $LASTEXITCODE
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' {
Assert-Equal 1 $warnExitCode
Assert-Equal $cleanSize $afterArchive.Length '在位归档应与被保护的那份完全一致'
Assert-Equal 'failed' $afterRecord.action
Assert-Equal $false $afterRecord.warnings '在位归档仍然是完整的'
Assert-Equal $true $afterRecord.attemptWarnings '这次尝试确实报了警告'
}
# 明确接受之后才允许覆盖
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings
$acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' {
Assert-Equal 0 $acceptExitCode
Assert-Equal 'backed-up' $acceptedRecord.action
Assert-Equal $true $acceptedRecord.warnings
}
} finally {
$lockStream.Close()
$lockStream.Dispose()
}
# ============================================================================
# 5. DryRun 不写盘
# ============================================================================
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun
$dryRestoreExitCode = $LASTEXITCODE
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
Assert-Equal 0 $dryRestoreExitCode
Assert-FileMissing $source
}
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun
$dryBackupExitCode = $LASTEXITCODE
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
Assert-Equal 0 $dryBackupExitCode
if (Test-Path -LiteralPath $dryBackupDir) {
$written = @(Get-ChildItem -LiteralPath $dryBackupDir -File -ErrorAction SilentlyContinue)
Assert-Equal 0 $written.Count '试运行不应写入任何文件'
}
}
# ============================================================================
# 6. 软件名录:清单里写软件名,归档名就是软件名
# ============================================================================
$catRoot = Join-Path $WorkRoot 'catalog'
$catSource = Join-Path $catRoot 'src'
$catTarget = Join-Path $catSource 'My App' # 真实目录名与软件名刻意不同
$catBackupDir = Join-Path $catRoot 'Backups'
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
$catList = Join-Path $catRoot 'list.txt'
$catConfig = Join-Path $catRoot 'config.psd1'
New-Item -ItemType Directory -Path $catTarget -Force | Out-Null
Set-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Value 'catalog-test' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $catTarget 'skip.bin') -Value 'nope' -Encoding UTF8
[System.IO.File]::WriteAllText($catFile, "@{`n 'my-app' = '$catTarget'`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($catList, "my-app :: skip.bin`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($catConfig, "@{ SoftwareCatalog = '$catFile' }`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$catExitCode = $LASTEXITCODE
$catArchive = Join-Path $catBackupDir 'my-app.7z'
Test-Case '清单里写软件名 -> 归档名就是软件名' {
Assert-Equal 0 $catExitCode
Assert-FileExists $catArchive
}
Test-Case '软件名条目的归档内容与历史布局一致(根目录仍是源目录名)' {
$extract = Join-Path $catRoot 'verify'
New-Item -ItemType Directory -Path $extract -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$extract", $catArchive)
# 归档文件名是软件名 my-app,但包内根目录是源目录名 My App
Assert-FileExists (Join-Path $extract 'My App\data.txt')
Assert-FileMissing (Join-Path $extract 'my-app') '包内不应多出一层软件名'
Assert-FileMissing (Join-Path $extract 'My App\skip.bin') '排除模式以源目录名为前缀,仍然生效'
}
}
Test-Case '@pathname 覆盖:强制用路径命名算法(用独立备份目录,避免污染共享 manifest)' {
$pathList = Join-Path $catRoot 'list-pathname.txt'
$pathNameDir = Join-Path $catRoot 'Backups-pathname'
[System.IO.File]::WriteAllText($pathList, "my-app @pathname`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
# 名录里存的是绝对路径,所以路径命名结果也基于它
$expectedBase = Get-BackupBaseName -RawPath $catTarget
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
}
Test-Case '软件名录条目:删源后能按原路径恢复' {
Remove-Item -LiteralPath $catTarget -Recurse -Force
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $catTarget 'data.txt')
Assert-Equal 'catalog-test' (Get-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Raw).Trim()
}
Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
$dupList = Join-Path $catRoot 'dup.txt'
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $dupList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 1 $LASTEXITCODE '重复条目应让退出码为 1'
}
Test-Case '字面路径不受名录影响,仍走路径命名' {
$literalList = Join-Path $catRoot 'list-literal.txt'
[System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
}
Test-Case '名录里没有该软件名时记为 missing-source,而不是崩掉' {
$badList = Join-Path $catRoot 'bad.txt'
[System.IO.File]::WriteAllText($badList, "no-such-app`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $badList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE '跳过不算失败'
$rec = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['no-such-app']
Assert-True ($null -ne $rec) '应留下记录'
Assert-Equal 'missing-source' $rec.action
}
# ============================================================================
# 7. 失败路径:源不存在时必须留下可核对的记录
# ============================================================================
$missingList = Join-Path $WorkRoot 'missing.txt'
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool
$missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
Assert-Equal 0 $missingExitCode
$after = Read-BaknretManifest -Path $manifestPath
$record = $after.items['definitely-not-here-12345_from_Z_']
Assert-True ($null -ne $record) 'manifest 里应有该条目的记录'
Assert-Equal 'missing-source' $record.action
}
# ============================================================================
if ($KeepWorkRoot) {
Write-Host "`n工作目录保留在:$WorkRoot" -ForegroundColor Yellow
} else {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
}
$failed = Write-TestSummary -Title '端到端验收'
if ($failed -gt 0) { exit 1 }
exit 0