新功能 - 新增 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 才能验,已实跑确认。
370 lines
17 KiB
PowerShell
370 lines
17 KiB
PowerShell
<#
|
||
.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
|