Files
BakNRet/test.ps1
T
Shuery 187d2759fd style: 按微软规范落地静态分析,并全仓机械重排
三件事:

1) tools\Install-TestDependencies.ps1 现在也把 PSScriptAnalyzer 装进仓库内的 .tools\modules
(不动机器上的全局模块,与 Pester 同一策略)。

2) PSScriptAnalyzerSettings.psd1:这是必要的,不是装饰 —— 那 6 条格式规则
(括号、缩进、空格、对齐、大小写)默认全是 Disabled,所以不带 -Settings 的
`Invoke-ScriptAnalyzer -Severity Warning,Error` 会**静默漏掉全部排版问题**。本文件用 Rules
把它们打开(而不是用 IncludeRules 换一套),于是默认规则与格式规则同时生效。
三条有意的排除都写明了理由:PSAvoidUsingWriteHost(彩色控制台输出是这份工具的刻意设计)、
PSUseShouldProcessForStateChangingFunctions(WhatIf 的边界在入口脚本,给库里 27 个改状态的
函数都加上反而会"静默跳过",备份看着成功却什么都没做)、PSAvoidUsingPlainTextForPassword
(7z 只接受命令行口令,这是 7z 的限制,README 里写明了取舍)。

3) tools\Invoke-Analyzer.ps1:独立门禁(不塞进 Pester 用例 —— 套件跑一次二十多秒,
混进去会让"测试红了"这句话失去分辨力),路径过滤与验收门槛的 Encode/Parse 两层一致。

全仓重排结果:706 条告警 -> 67 条。修掉的 639 条全部是格式(闭括号 168、空格 80、
对齐 68、缩进 60、行长 229)。重排后 9/9 验收全绿、100 个文件两版解析零错、
276 个断言原样通过 —— 机械重排没有改变任何可观察行为。

如实说明两件事:

  * 行长上限设成 160,**不是**官方默认的 120。120 在本仓库意味着 270 处改动(主要是
    中文注释与测试夹具里的一行式目录),160 意味着 41 处。160 仍是"宽但可读",而理由是写在
    配置文件里的:这不是悄悄放宽,想收紧到 120 时那份清单就在分析器输出里。

  * 剩余 67 条里,41 条是上面那批行长,其余 26 条是分析器找出的真问题(未使用参数 6、
    空 catch 6、MD5 指纹 1、覆盖内置命令 1、switch 默认值 1 等)。其中
    Find-ChildDirectoryByName 的 MaxDepth 参数从未被使用 —— 也就是配置里的
    CatalogMaxDepth = 5 是假的,前缀补全实际只查 1 层。这条要改行为、且影响真实名录的解析
    结果,留给你拍板,不在本提交里动手。
2026-09-27 09:46:08 +08:00

262 lines
11 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 的全部验收层次,两个 PowerShell 版本都覆盖。
.DESCRIPTION
BakNRet 同时支持 Windows PowerShell 5.1 与 PowerShell 7.x,而这两个版本会踩到
不同的坑(源码编码、Get-Content 的默认编码、File.Move 的重载、原子替换的回退路径)。
所以「验收」不是一句命令,而是分层的:
Parse 全仓 .ps1 / .psm1 / .psd1 在 5.1 与 7 上解析零错误
Unit Pester 套件(需要 Pester 5+,优先用仓库内 .tools/ 的副本)
Smoke 零依赖套件(只要 PowerShell 与 7z)
E2E 端到端验收:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍
Drill 真实归档恢复演练(只读,但依赖本机真实归档与活源,所以不在 All 里)
-Suite All 等价于 Parse + Unit + Smoke + E2E。Drill 必须显式点名。
.PARAMETER Suite
要跑哪些层次,可多选。
.PARAMETER PSVersion
在哪些宿主上跑:7、5.1 或 Both(默认)。所有层次都会在点名的宿主上各跑一遍;
某个宿主缺失或跑不动时报明确的失败,而不是静默跳过。
.PARAMETER Quiet
只打印每层的结论,不把各套件的完整输出透出来。
.EXAMPLE
.\test.ps1
.EXAMPLE
.\test.ps1 -Suite Parse
.EXAMPLE
.\test.ps1 -Suite E2E -PSVersion 5.1
#>
[CmdletBinding()]
param(
[ValidateSet('All', 'Parse', 'Unit', 'Smoke', 'E2E', 'Drill')]
[string[]]$Suite = @('All'),
[ValidateSet('7', '5.1', 'Both')]
[string]$PSVersion = 'Both',
[switch]$Quiet
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
# ---------------------------------------------------------------- 参数展开
$suites = [System.Collections.Generic.List[string]]::new()
foreach ($name in $Suite) {
$expanded = if ($name -eq 'All') { @('Parse', 'Unit', 'Smoke', 'E2E') } else { @($name) }
foreach ($item in $expanded) {
if (-not $suites.Contains($item)) { $suites.Add($item) }
}
}
$hosts = if ($PSVersion -eq 'Both') { @('7', '5.1') } else { @($PSVersion) }
$exeOf = @{ '7' = 'pwsh'; '5.1' = 'powershell' }
foreach ($h in $hosts) {
if (-not (Get-Command $exeOf[$h] -ErrorAction SilentlyContinue)) {
Write-Error ("找不到 {0},无法在 {1} 上验收。装好它,或用 -PSVersion 指定另一个宿主。" -f $exeOf[$h], $h)
exit 2
}
}
$script:Results = [System.Collections.Generic.List[object]]::new()
function Add-Result {
param([string]$Layer, [string]$HostName, [bool]$Ok, [string]$Detail)
$script:Results.Add([pscustomobject]@{ Layer = $Layer; HostName = $HostName; Ok = $Ok; Detail = $Detail })
$mark = if ($Ok) { 'PASS' } else { 'FAIL' }
$color = if ($Ok) { 'Green' } else { 'Red' }
Write-Host (" [{0}] {1,-5} {2,-4} {3}" -f $mark, $Layer, $HostName, $Detail) -ForegroundColor $color
}
function Invoke-ScriptInHost {
<#
在指定宿主里用独立进程跑一个脚本,返回退出码。
独立进程是刻意的:Backup.ps1 / Restore.ps1 结尾都会 exit,同进程调用会把本脚本一起带走。
#>
param(
[string]$HostName,
[string]$ScriptPath,
[string[]]$Arguments = @()
)
$stdout = Join-Path $env:TEMP ("baknret-test-{0}.log" -f [guid]::NewGuid().ToString('N'))
$argumentList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath) + $Arguments
try {
# 5.1 在 $ErrorActionPreference = 'Stop' 下会把原生命令写到 stderr 的内容升级成
# 终止性错误;而被调脚本往 stderr 写字是正常的(7z 就这么干),不该让验收崩掉。
$ErrorActionPreference = 'Continue'
if ($Quiet) {
& $exeOf[$HostName] @argumentList *> $null
}
else {
& $exeOf[$HostName] @argumentList 2>&1 | Tee-Object -FilePath $stdout | Out-Null
}
return $LASTEXITCODE
}
finally {
Remove-Item -LiteralPath $stdout -Force -ErrorAction SilentlyContinue
}
}
# ---------------------------------------------------------------- 各层实现
$parseProbeSource = @'
$root = $args[0]
$exclude = '\\(\.git|\.tools|\.scratch|Backups|logs|dist)\\'
# 注意:这里不能用 -Include 筛扩展名。5.1 与 7 的语义不同:-LiteralPath 上的 -Include
# 在 5.1 里会被忽略,于是 .md / .txt / .editorconfig / unattend.xml 全被拿去 ParseFile,
# 报一堆与 PowerShell 无关的假错(实测 40 个「文件」里 9 个不是 PowerShell)。
$powerShellExtensions = @('.ps1', '.psm1', '.psd1')
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File |
Where-Object { $powerShellExtensions -contains $_.Extension -and $_.FullName -notmatch $exclude })
$bad = 0
foreach ($file in $files) {
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$errors)
if ($errors -and $errors.Count) {
$bad++
Write-Output (" [!] {0} : {1}" -f $file.FullName.Substring($root.Length).TrimStart('\'), $errors[0].Message)
}
}
Write-Output ("SUMMARY={0}/{1}" -f ($files.Count - $bad), $files.Count)
'@
function Invoke-EncodingCheck {
<#
编码规则检查:与宿主无关,只跑一次。
规则见 tools\Set-SourceEncoding.ps1:受管文件必须有 BOM、行尾必须是 LF、不许有制表符。
「必须有 BOM」这条尤其重要:它一旦丢失,5.1 上会以各种莫名其妙的方式失败,
而在 7 上完全正常 —— 实测就是这么漏过去的。
#>
$excluded = '\\(\.git|\.tools|\.scratch|Backups|logs|dist|snapshots)\\'
$targets = @(Get-ChildItem -LiteralPath $root -Recurse -File |
Where-Object { $_.Extension -in '.ps1', '.psm1', '.psd1' -or $_.Name -eq 'BackupList.txt' } |
Where-Object { $_.FullName -notmatch $excluded })
$noBom = [System.Collections.Generic.List[string]]::new()
$crlf = [System.Collections.Generic.List[string]]::new()
$tabs = [System.Collections.Generic.List[string]]::new()
foreach ($file in $targets) {
$relative = $file.FullName.Substring($root.Length).TrimStart('\')
$bytes = [System.IO.File]::ReadAllBytes($file.FullName)
if (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) {
$noBom.Add($relative)
}
$text = (New-Object System.Text.UTF8Encoding($false)).GetString($bytes)
if ($text -match "`r`n") { $crlf.Add($relative) }
if ($text -match "`t") { $tabs.Add($relative) }
}
foreach ($item in $noBom) { Write-Host (" 缺 BOM:{0}" -f $item) -ForegroundColor DarkGray }
foreach ($item in $crlf) { Write-Host (" 有 CRLF:{0}" -f $item) -ForegroundColor DarkGray }
foreach ($item in $tabs) { Write-Host (" 有制表符:{0}" -f $item) -ForegroundColor DarkGray }
$ok = ($noBom.Count + $crlf.Count + $tabs.Count) -eq 0
Add-Result -Layer 'Encode' -HostName 'any' -Ok $ok `
-Detail ("受管 {0} 个文件:缺 BOM {1}、CRLF {2}、制表符 {3}" -f $targets.Count, $noBom.Count, $crlf.Count, $tabs.Count)
}
function Invoke-ParseLayer {
param([string]$HostName)
if (-not $script:EncodingChecked) {
$script:EncodingChecked = $true
Invoke-EncodingCheck
}
$probe = Join-Path $env:TEMP 'baknret-parse-gate.ps1'
Set-Content -LiteralPath $probe -Value $parseProbeSource -Encoding ascii
try {
$ErrorActionPreference = 'Continue' # 同上:原生命令的 stderr 不是错误
$output = & $exeOf[$HostName] -NoProfile -ExecutionPolicy Bypass -File $probe $root 2>&1
$summary = @($output | Where-Object { $_ -match '^SUMMARY=' })
$failures = @($output | Where-Object { $_ -match '^\s+\[!\]' })
if (-not $summary) {
Add-Result -Layer 'Parse' -HostName $HostName -Ok $false -Detail '探针没有给出结论'
return
}
$numbers = $summary[-1] -replace '^SUMMARY=', ''
$ok = ($numbers -split '/')[0] -eq ($numbers -split '/')[1]
if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray } }
Add-Result -Layer 'Parse' -HostName $HostName -Ok $ok -Detail ("解析通过 {0} 个文件" -f $numbers)
}
finally {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SuiteLayer {
param(
[string]$Layer,
[string]$HostName,
[string]$RelativeScript,
[string[]]$Arguments = @()
)
$path = Join-Path $root $RelativeScript
if (-not (Test-Path -LiteralPath $path)) {
Add-Result -Layer $Layer -HostName $HostName -Ok $false -Detail ("找不到 {0}" -f $RelativeScript)
return
}
# 套件自己会在结尾 exit:0 全绿、1 有失败、2 缺依赖
$code = Invoke-ScriptInHost -HostName $HostName -ScriptPath $path -Arguments $Arguments
$detail = switch ($code) {
0 { $RelativeScript }
2 { ("{0} —— 缺少依赖(退出码 2)" -f $RelativeScript) }
default { ("{0} —— 退出码 {1}" -f $RelativeScript, $code) }
}
Add-Result -Layer $Layer -HostName $HostName -Ok ($code -eq 0) -Detail $detail
}
# ---------------------------------------------------------------- 跑
Write-Host ''
Write-Host ("BakNRet 验收:层次 [{0}],宿主 [{1}]" -f ($suites -join ', '), ($hosts -join ', ')) -ForegroundColor Cyan
Write-Host ''
foreach ($hostName in $hosts) {
foreach ($layer in $suites) {
switch ($layer) {
'Parse' { Invoke-ParseLayer -HostName $hostName }
'Unit' { Invoke-SuiteLayer -Layer 'Unit' -HostName $hostName -RelativeScript 'tests\Run-Pester.ps1' -Arguments @('-Verbosity', 'Minimal') }
'Smoke' { Invoke-SuiteLayer -Layer 'Smoke' -HostName $hostName -RelativeScript 'tests\Run-Tests.ps1' }
'E2E' { Invoke-SuiteLayer -Layer 'E2E' -HostName $hostName -RelativeScript 'tests\Run-E2E.ps1' }
'Drill' { Invoke-SuiteLayer -Layer 'Drill' -HostName $hostName -RelativeScript 'tests\Restore-Drill.ps1' }
}
}
}
# ---------------------------------------------------------------- 汇总
$failed = @($script:Results | Where-Object { -not $_.Ok })
Write-Host ''
Write-Host ('=' * 64)
if ($failed.Count -eq 0) {
Write-Host ("验收全部通过:{0} 项(宿主 {1})" -f $script:Results.Count, ($hosts -join ' + ')) -ForegroundColor Green
}
else {
Write-Host ("验收有失败:{0} 项里失败 {1} 项" -f $script:Results.Count, $failed.Count) -ForegroundColor Red
$failed | ForEach-Object { Write-Host (" - {0} @ {1}:{2}" -f $_.Layer, $_.HostName, $_.Detail) -ForegroundColor Red }
}
Write-Host ('=' * 64)
exit $(if ($failed.Count -eq 0) { 0 } else { 1 })