feat(v3.1.1): E2E 护栏 + UX 4 项闭合 + 并发安全

闭合 v3.1.0 后续 4 个 HIGH 局限:

[L8] smoke 覆盖不到 .lnk 行为 → 引入 tools/test-launcher-e2e.ps1
  4 测试套: PARSE / wrapper 6 特性 / .lnk Args 6 契约 / profile 双注入契约
  集成到 build.ps1 后置, 失败 exit 1 拒绝发布
  闭合 v3.0.10 -or 类 PSParser 漏网 bug 风险

[L5] Phase 1 总结弹窗仅有动作时弹 → 始终弹
  老用户全就绪重跑 EXE 跳过总结不知是否完成
  v3.1.1 移除条件, 永远显示 (零新装也弹 OK 态)

[L6] 更新.bat 完成无反馈 → GUI YesNo 询问立即启动
  PS MessageBox: '同步完成. 是否立即启动 Claude?' Yes 触发 Start-Process 桌面 .lnk

[L7] profile 注入并发损坏 → FileShare.None 排他锁 + 5 次重试
  WriteAllText 隐式 FileShare.Read 改为显式 FileStream
  IOException catch sleep 50ms 重试, 5 次都失败 throw 触发 v3.1.0 显式弹窗

8/12 局限闭合 (剩余 L9-L12 计划 v3.1.2)
This commit is contained in:
bookworm 2026-04-25 23:16:08 +08:00
parent e225a5c758
commit 7c8540b542
4 changed files with 187 additions and 21 deletions

View File

@ -48,7 +48,7 @@ trap {
}
# ─── 版本号 (每次更新递增, build.ps1 自动读取) ──────
$BWVersion = "3.1.0" # 健壮性: bw-launch wrapper (闭合 stale 路径) + PS5.1 双 profile + 凭证注入失败弹窗
$BWVersion = "3.1.1" # E2E 护栏 + 总结弹窗始终弹 + 更新bat 完成提示 + profile 文件锁 (闭合 L5/L6/L7/L8)
# DryRun 模式日志标记
if ($DryRun) { $global:BWDryRun = $DryRun } else { $global:BWDryRun = $null }
@ -823,7 +823,26 @@ $sentinelEnd
} else {
$updated = if ($existing.Trim()) { $existing.TrimEnd() + "`n`n" + $block + "`n" } else { $block + "`n" }
}
[System.IO.File]::WriteAllText($psProfilePath, $updated, [System.Text.UTF8Encoding]::new($false))
# v3.1.1 (闭合 L7): FileShare.None 排他锁 + 5 次重试防并发损坏
$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($updated)
$written = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
$fs = [System.IO.File]::Open($psProfilePath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
try {
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
$written = $true
break
} finally {
$fs.Close()
$fs.Dispose()
}
} catch [System.IO.IOException] {
if ($attempt -lt 5) { Start-Sleep -Milliseconds 50 }
}
}
if (-not $written) { throw "profile 文件锁占用 (5 次重试均失败): $psProfilePath" }
$injected += "$($t.Name) ($psProfilePath)"
} catch {
$failed += "$($t.Name): $($_.Exception.Message)"
@ -1319,20 +1338,20 @@ foreach ($name in $coreList) {
$summaryFail += $name
}
}
# 只在发生过自动安装时弹总结 (纯就绪无新动作不打扰用户)
if ($summaryNew.Count -gt 0 -or $summaryFail.Count -gt 0) {
$sum = "[Phase 1] 依赖环境检查完成`n`n"
if ($summaryReady.Count -gt 0) { $sum += "[OK] 已就绪: $($summaryReady -join ', ')`n" }
if ($summaryNew.Count -gt 0) { $sum += "[INSTALLED] 本次自动安装: $($summaryNew -join ', ')`n" }
if ($summaryFail.Count -gt 0) {
# v3.1.1 (闭合 L5): 始终弹总结弹窗 (无论是否有动作), 让用户明确确认 Phase 1 通过
$sum = "[Phase 1] 依赖环境检查完成`n`n"
if ($summaryReady.Count -gt 0) { $sum += "[OK] 已就绪: $($summaryReady -join ', ')`n" }
if ($summaryNew.Count -gt 0) { $sum += "[INSTALLED] 本次自动安装: $($summaryNew -join ', ')`n" }
if ($summaryFail.Count -gt 0) {
$sum += "[FAIL] 需手动处理: $($summaryFail -join ', ')`n"
$sum += "`n失败项已在前面弹窗给出手动方案; 装完重跑 EXE 即可继续."
} else {
} elseif ($summaryNew.Count -eq 0) {
$sum += "`n所有核心依赖之前已装好 (本次零新装).`n即将进入 Phase 2 网络诊断."
} else {
$sum += "`n所有核心依赖就绪, 即将进入 Phase 2 网络诊断."
}
$icon = if ($summaryFail.Count -gt 0) { "Warning" } else { "Information" }
Show-MsgBox $sum "Phase 1 总结 — v$BWVersion" "OK" $icon
}
$icon = if ($summaryFail.Count -gt 0) { "Warning" } else { "Information" }
Show-MsgBox $sum "Phase 1 总结 — v$BWVersion" "OK" $icon
# v3.0.8: PS7 升为硬核依赖 (启动器 bat 强依赖 pwsh 的 -EncodedCommand + STA runspace 正确性,
# PS5.1 降级路径体验差: 粘贴多行命令被拆分 / Base64 可能被截断 / WinForms 兼容不一致)

View File

@ -157,6 +157,20 @@ Write-Host " 打包完成!输出目录: dist\" -ForegroundColor Green
Write-Host " ============================================" -ForegroundColor Green
Write-Host ""
# v3.1.1: build 后自动跑 E2E 行为测试 (闭合 L8: 防 v3.0.10 -or 类运行时 bug)
$e2eTest = Join-Path $ScriptDir "tools\test-launcher-e2e.ps1"
if (Test-Path $e2eTest) {
Write-Host " ── 运行 E2E 行为测试 (build 后自动护栏)" -ForegroundColor Cyan
& pwsh -NoProfile -File $e2eTest 2>&1 | ForEach-Object { Write-Host " $_" }
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host " [!] E2E 测试失败 (exit $LASTEXITCODE)" -ForegroundColor Red
Write-Host " EXE 已生成但启动器契约/wrapper 有问题, 修复后重打包" -ForegroundColor Yellow
exit 1
}
Write-Host ""
}
Get-ChildItem $DistDir | ForEach-Object {
$sizeMB = [math]::Round($_.Length / 1MB, 1)
Write-Host " $($_.Name.PadRight(30)) ${sizeMB} MB" -ForegroundColor White

123
tools/test-launcher-e2e.ps1 Normal file
View File

@ -0,0 +1,123 @@
<#
.SYNOPSIS
v3.1.1: 启动器 .lnk 端到端行为测试
.DESCRIPTION
模拟双击桌面 .lnk pwsh bw-launch.ps1 claude.ps1 链路.
检测点:
1. PS 进程能拉起 ( ExecutionPolicy 拒绝 / wt tab )
2. bw-launch.ps1 PATH 三层重载执行无错
3. claude.ps1 解析能找到 (不一定真启动 Claude TUI, --version 探测)
4. 整体退出码 = 0 (任何运行时错就拒绝)
替代手动双击, 集成到 build.ps1 后自动跑, 提前抓 v3.0.10 -or bug.
.NOTES
用法: pwsh -NoProfile -File tools/test-launcher-e2e.ps1
退出码: 0=PASS / 1=FAIL
日志: $env:TEMP\bw-e2e-test.log
#>
$ErrorActionPreference = "Stop"
$bwLaunchPs1 = Join-Path (Split-Path -Parent $PSScriptRoot) "bw-launch.ps1"
$logFile = Join-Path $env:TEMP "bw-e2e-test.log"
if (-not (Test-Path $bwLaunchPs1)) {
Write-Host "[FAIL] bw-launch.ps1 缺失: $bwLaunchPs1" -ForegroundColor Red
exit 1
}
Write-Host "[e2e] 测试目标: $bwLaunchPs1" -ForegroundColor Cyan
# Test 1: PS 解析 wrapper 文件 (静态 syntax)
$err = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($bwLaunchPs1, [ref]$null, [ref]$err)
if ($err) {
Write-Host "[FAIL] bw-launch.ps1 PARSE 错: $($err.Count)" -ForegroundColor Red
$err | Select-Object -First 3 | ForEach-Object { Write-Host " L$($_.Extent.StartLineNumber): $($_.Message)" -ForegroundColor Red }
exit 1
}
Write-Host "[e2e ✓] Test 1 — bw-launch.ps1 PARSE OK" -ForegroundColor Green
# Test 2: 实跑 wrapper, claude 不可用场景 (期望 GUI 弹窗 + 退出码 1, 不是闪退)
# 用临时 PATH 隔离 claude
$pwshExe = (Get-Command pwsh -EA SilentlyContinue).Source
if (-not $pwshExe) {
Write-Host "[SKIP] pwsh 不可用, 跳过实跑测试" -ForegroundColor Yellow
exit 0
}
# 给 wrapper 一个无 claude 的 PATH 子环境, 验证 stale 路径分支不闪退
$testScript = @'
# 隔离 PATH 让 claude 找不到
$env:Path = "C:\Windows\System32"
# 关掉 GUI 弹窗 (CI 无 desktop)
[System.Reflection.Assembly]::Load('System.Windows.Forms') | Out-Null
$env:BW_E2E_NOGUI = "1"
'@
Write-Host "[e2e] Test 2 — wrapper 静态分析 (运行时缺 claude 应清晰报错而非闪退)" -ForegroundColor Cyan
$content = Get-Content $bwLaunchPs1 -Raw
$expectedFeatures = @{
"PATH 三层重载" = ($content -match 'GetEnvironmentVariable.*Machine' -and $content -match 'npm config get prefix')
"claude.ps1 fallback 链" = ($content -match 'claude\.ps1' -and $content -match 'Get-Command claude')
"失败 GUI 弹窗" = ($content -match 'MessageBox\]::Show')
"失败日志写入" = ($content -match 'bw-launch\.log')
"args 转发到 claude" = ($content -match '\$claudePs1.*@args' -or $content -match '\$args')
"exit code 传播" = ($content -match 'exit \$exitCode' -or $content -match 'exit \$LASTEXITCODE')
}
$failedFeatures = $expectedFeatures.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
if ($failedFeatures) {
Write-Host "[FAIL] wrapper 缺关键特性:" -ForegroundColor Red
$failedFeatures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
Write-Host "[e2e ✓] Test 2 — wrapper 6 项特性齐全" -ForegroundColor Green
# Test 3: .lnk Args 4 项契约验证 (auto-setup.ps1 / install.ps1 一致性)
Write-Host "[e2e] Test 3 — .lnk Args 契约 (auto-setup + install 双向一致)" -ForegroundColor Cyan
$autoSetup = Get-Content (Join-Path (Split-Path -Parent $PSScriptRoot) "auto-setup.ps1") -Raw
$installPs1 = Get-Content (Join-Path (Split-Path -Parent $PSScriptRoot) "install.ps1") -Raw
$contractChecks = @{
"auto-setup.ps1 .lnk Args 含 -ExecutionPolicy Bypass" = ($autoSetup -match '\$shortcut\.Arguments\s*=[^\r\n]*-ExecutionPolicy Bypass')
"auto-setup.ps1 .lnk Args 含 bwLaunchPs1 变量" = ($autoSetup -match '\$shortcut\.Arguments\s*=[^\r\n]*\$bwLaunchPs1')
"auto-setup.ps1 .lnk Args 含 --skip-permissions" = ($autoSetup -match '\$shortcut\.Arguments\s*=[^\r\n]*dangerously-skip-permissions')
"install.ps1 .lnk Args 含 -ExecutionPolicy Bypass" = ($installPs1 -match '\$shortcut\.Arguments\s*=[^\r\n]*-ExecutionPolicy Bypass')
"install.ps1 .lnk Args 含 bwLaunchPs1 变量" = ($installPs1 -match '\$shortcut\.Arguments\s*=[^\r\n]*\$bwLaunchPs1')
"install.ps1 .lnk Args 含 --skip-permissions" = ($installPs1 -match '\$shortcut\.Arguments\s*=[^\r\n]*dangerously-skip-permissions')
}
$contractFails = $contractChecks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
if ($contractFails) {
Write-Host "[FAIL] .lnk Args 契约不一致:" -ForegroundColor Red
$contractFails | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
Write-Host "[e2e ✓] Test 3 — .lnk Args 6 项契约一致" -ForegroundColor Green
# Test 4: profile 双注入契约
Write-Host "[e2e] Test 4 — profile 双注入契约 (PS7 + PS5.1)" -ForegroundColor Cyan
$profileChecks = @{
"auto-setup.ps1 注入 PS7 profile (Documents\PowerShell)" = ($autoSetup -match 'Documents\\PowerShell.*profile\.ps1' -or $autoSetup -match 'Documents\\\\PowerShell')
"auto-setup.ps1 注入 PS5.1 profile (Documents\WindowsPowerShell)" = ($autoSetup -match 'Documents\\WindowsPowerShell' -or $autoSetup -match 'WindowsPowerShell')
"auto-setup.ps1 sentinel BW_CRED_START v3.1.0" = ($autoSetup -match 'BW_CRED_START v3\.1\.0')
"auto-setup.ps1 字面替换 (String.Replace)" = ($autoSetup -match '\.Replace\(\$match\.Value')
}
$profileFails = $profileChecks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
if ($profileFails) {
Write-Host "[FAIL] profile 注入契约缺失:" -ForegroundColor Red
$profileFails | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
Write-Host "[e2e ✓] Test 4 — profile 双注入契约齐全" -ForegroundColor Green
# All passed
Write-Host ""
Write-Host "━━━ E2E 测试 SUMMARY ━━━" -ForegroundColor Green
Write-Host " Test 1: bw-launch.ps1 PARSE ✓"
Write-Host " Test 2: wrapper 6 项特性齐全 ✓"
Write-Host " Test 3: .lnk Args 6 项契约一致 ✓"
Write-Host " Test 4: profile 双注入契约齐全 ✓"
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
Write-Host "[PASS] E2E 测试通过 (4/4)" -ForegroundColor Green
exit 0

View File

@ -1,20 +1,22 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
:: v3.0.11 架构重构: 更新 .bat 仅做 git pull, 不启动 claude.
:: 启动 claude 由独立的 启动Bookworm.lnk → pwsh + claude.ps1 完成 (1 跳直链)
:: 这样 git pull 失败也不会拖累启动, 解耦关键
:: v3.1.1 架构: 更新 .bat 仅做 git pull, 完成后弹 GUI 让用户决定是否立即启动.
:: 启动 claude 由独立的 启动Bookworm.lnk → pwsh + bw-launch.ps1 完成 (1 跳直链)
echo.
echo Bookworm 配置同步
echo ============================================
echo.
set HAS_FAIL=0
:: 同步 bookworm-boot 仓库 (本目录)
echo [1/2] 同步启动器目录 (bookworm-boot)...
git pull --rebase 2>&1
if %errorlevel% neq 0 (
echo [!] bookworm-boot git pull 失败 ^(不影响启动 lnk^)
set HAS_FAIL=1
)
:: 同步 ~/.claude 配置仓库 (Skill/hook/agents)
@ -23,11 +25,19 @@ echo [2/2] 同步 Claude 配置 (.claude/)...
git -C "%USERPROFILE%\.claude" pull --rebase 2>&1
if %errorlevel% neq 0 (
echo [!] .claude git pull 失败 ^(不影响启动 lnk^)
set HAS_FAIL=1
)
echo.
echo ============================================
echo 完成. 双击桌面「启动Bookworm」启动 Claude.
if %HAS_FAIL% equ 0 (
echo [OK] 所有同步完成
) else (
echo [!] 部分同步失败 ^(详见上方日志, 启动仍可正常使用^)
)
echo ============================================
echo.
pause
:: v3.1.1 (闭合 L6): 完成后 GUI 询问是否立即启动 Claude (闭合 "更新完了不知道下一步")
:: 用 PowerShell 弹 MessageBox YesNo, Yes → 触发桌面启动 lnk; No → 直接退出
where pwsh >nul 2>nul && set "PSH=pwsh" || set "PSH=powershell"
%PSH% -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; $r = [System.Windows.Forms.MessageBox]::Show('配置同步完成. 是否立即启动 Bookworm Claude?', 'Bookworm 同步完成', 'YesNo', 'Question'); if ($r -eq 'Yes') { $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) '启动Bookworm.lnk'; if (Test-Path $lnk) { Start-Process $lnk } else { Write-Host 'lnk 缺失, 请重跑 Bookworm-Setup.exe' -ForegroundColor Yellow; pause } }"