Skip to content

Commit f5fa262

Browse files
committed
fix(pwsh): 修复 hosted runner 剩余测试失败
1 parent 8fb1eb8 commit f5fa262

7 files changed

Lines changed: 91 additions & 16 deletions

File tree

‎.trellis/spec/infra/install-orchestrator.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ pnpm provision:full [-NetworkMode China | -Step <id> | -FromStep <id>]
5858
- JSON 模式必须关闭运行进度;stdout 继续只有最终单个 JSON document,stderr 也不得出现 Text 进度行。
5959
- stdout/stderr 必须先并发复制原始字节,再优先按严格 UTF-8 解码;Windows 子进程若产生非法 UTF-8,才允许按当前文化 ANSI code page 兼容解码,最终文本不得包含替换字符。
6060
- 叶子等待必须使用有限轮询以响应中断,但不得把有限轮询解释为安装超时;等待异常或中断时必须终止仍存活的直接子进程树,再释放 `Process`。
61+
- `pwsh -File` 临时 UTF-8 wrapper 不得把 token 数组直接 splat 给目标脚本;数组 splatting 只按位置绑定。wrapper 必须读取目标 `ExternalScript` 参数元数据,把 `-Name value` 与 switch token 重建为 hashtable splatting,再调用真实脚本。
62+
- wrapper 属于内部临时资源,必须在 `finally` 使用不受调用方 `WhatIfPreference` 影响的文件 API 清理;不得让 `Remove-Item -WhatIf` 文本污染 JSON stdout 或留下临时脚本。
6163

6264
### 4. Validation & Error Matrix
6365

@@ -81,6 +83,8 @@ pnpm provision:full [-NetworkMode China | -Step <id> | -FromStep <id>]
8183
| Text 模式叶子持续运行 | 启动时立即且仅一次写安全命令到 stderr;不输出 elapsed heartbeat,最终汇总和结果不变 |
8284
| JSON 模式执行同一叶子 | 不输出任何 Text 进度,stdout 仍可作为单个 JSON document 解析 |
8385
| 等待叶子时发生中断或异常 | 终止仍存活的直接子进程及其后代,不遗留下载或安装进程 |
86+
| `pwsh -File` wrapper 直接 `& $ScriptPath @tokenArray` | 命名参数退化为位置值并触发 `ValidateSet` 等绑定错误;必须重建 hashtable splatting |
87+
| 根调用启用 `-WhatIf` 时清理 wrapper | 仍真实删除内部临时文件,且 JSON stdout 不出现 `What if:` |
8488

8589
### 5. Good/Base/Bad Cases
8690

@@ -103,6 +107,7 @@ pnpm provision:full [-NetworkMode China | -Step <id> | -FromStep <id>]
103107
- package scripts:`provision:list`、Core/Full preview smoke、参数透传,且不得触发 pnpm 依赖安装流程。
104108
- Text 输出:标题、步骤、source restore 与最终状态的多占位符格式必须完整输出,不抛 `FormatException`。
105109
- 进度:Text 仅一次启动行且无 elapsed heartbeat、JSON 静默,以及中断等待后无遗留直接子进程树。
110+
- `pwsh` wrapper:无参数、命名值、switch、长 UTF-8、多流、退出码、中断进程树,以及 `-WhatIf` 下仍清理临时文件且不污染 JSON。
106111
- 代码完成后运行 `pnpm qa` 与 `pnpm test:pwsh:all`。
107112

108113
### 7. Wrong vs Correct

‎.trellis/spec/pwsh-scripts/package/pester-performance.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ node scripts/pester-duration-report.mjs \
3131
- duration JSON 必须包含 `schemaVersion`、`command`、`lane`、`startedAt`、`endedAt`、`elapsedMs`、`exitCode`、`phases`、`nunitPath`、`coveragePath`、`files` 和 `testCases`。
3232
- `test:pwsh:all` 必须在启动 host lane 前检查 Docker CLI、daemon 和 Compose;缺失时输出 `pnpm test:pwsh:full` fallback,不启动长测试。
3333
- `Filter.ExcludeTag` 必须始终是独立字符串数组;PowerShell 条件表达式可能把单元素数组展开为标量,后续 `+=` 会退化成字符串拼接,因此应使用显式数组收集块构造标签。
34-
- hosted runner 回归测试不得假设机器预装 Pester 5.7.1、Scoop 或 WSL 发行版;旧版本模块使用临时 `PSModulePath` fixture,原生命令使用临时 PATH shim,无 WSL 发行版时只跳过依赖真实 guest 的用例。
34+
- hosted runner 回归测试不得假设机器预装 Pester 5.7.1、Scoop、WSL 发行版或仓库忽略的 `bin/` 生成产物;旧版本模块使用临时 `PSModulePath` fixture,原生命令使用临时 PATH shim,生成入口必须在 `$TestDrive` 通过真实生成器创建,无 WSL 发行版时只跳过依赖真实 guest 的用例。
35+
- Windows-only 测试块必须整体标记独立 `windowsOnly` 标签;纯 schema、registry、HTML 与 SSH 数据逻辑继续跨平台执行,不能为消除失败扩大整文件排除。
3536
- `pwsh` 叶子进程必须在写出端固定 UTF-8 标准流编码;父进程只负责并发复制原始字节和严格解码,不能在字符已被当前代码页替换后补救。
3637

3738
## 4. Validation & Error Matrix
@@ -47,7 +48,8 @@ node scripts/pester-duration-report.mjs \
4748
| 命令显式指定 `-PesterVersion` | artifact 的 `pesterVersion` 与该参数一致 |
4849
| pnpm 等间接入口未显式指定版本 | 依次使用 `PWSH_PESTER_VERSION`、`.pester-version`;两者都不存在时才探测已安装版本 |
4950
| 多个排除标签显示为 `SlowwindowsOnly` | 配置构造失败;必须保留 `Slow`、`windowsOnly` 等独立数组元素 |
50-
| hosted runner 缺少旧 Pester、Scoop 或 WSL guest | 使用隔离模块/命令 fixture;仅真实 WSL guest 用例按能力 Skip |
51+
| hosted runner 缺少旧 Pester、Scoop、WSL guest 或忽略的 `bin/` 生成入口 | 使用隔离模块/命令 fixture;入口由真实生成器写入 `$TestDrive`;仅真实 WSL guest 用例按能力 Skip |
52+
| 非 Windows 执行 Chromium Profile 生命周期或 `.lnk` 快捷方式用例 | 测试分组缺少 `windowsOnly` 标签;应只标记真正依赖 Windows 宿主的分组 |
5153
| Windows 子进程长 UTF-8 输出出现 `?` | 在 `pwsh -File` 临时 wrapper 中先设置 Console UTF-8,再执行真实脚本并透传退出码 |
5254

5355
## 5. Good / Base / Bad Cases
@@ -63,8 +65,9 @@ node scripts/pester-duration-report.mjs \
6365
- Windows 命令发现:present/missing、PATH 变化、Scoop cmd shim、WinGet module export、完整 `CommandAvailability`。
6466
- 编排器:状态机调用顺序与次数;真实 UTF-8、多流、Running、中断进程树清理。
6567
- 配置筛选:Windows 保留独立 `Slow`,Linux/macOS 保留独立 `Slow` 与 `windowsOnly`,禁止出现 `SlowwindowsOnly`。
66-
- hosted runner 隔离:旧 Pester 用标准版本模块目录注入临时 `PSModulePath`;Scoop 用临时 PATH shim;Windows 无 WSL 发行版时 guest smoke 明确 Skip。
67-
- `pwsh` 子进程:临时 UTF-8 wrapper 不出现在展示命令中,且长 stdout/stderr、退出码、`[Running]` 与中断进程树清理合同全部通过。
68+
- hosted runner 隔离:旧 Pester 用标准版本模块目录注入临时 `PSModulePath`;Scoop 用临时 PATH shim;忽略的 bin 入口在 `$TestDrive` 通过真实生成器创建;Windows 无 WSL 发行版时 guest smoke 明确 Skip。
69+
- 平台标签:只给 Chromium Profile 生命周期、进程所有权与 `.lnk` 快捷方式等真实 Windows 宿主分组标记 `windowsOnly`,纯逻辑分组继续在 Ubuntu/macOS 回归。
70+
- `pwsh` 子进程:临时 UTF-8 wrapper 不出现在展示命令中,重建命名值与 switch 的 hashtable splatting,并覆盖长 stdout/stderr、退出码、`[Running]`、中断进程树及 `WhatIf` 清理合同。
6871
- 性能验收:Windows host coverage-on 连续 3 轮,报告中位数、最慢值和 coverage;样本期间不得并发运行其他 Pester。
6972

7073
## 7. Wrong vs Correct

‎psutils/tests/web.Tests.ps1‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,10 @@ Describe "New-WebShortcut Tests" {
120120
if ($IsLinux) {
121121
# 需要 mock browser path 否则会 fallback 到 Html
122122
Mock Get-BrowserPath { return "/usr/bin/fakebrowser" } -ModuleName web
123-
Mock Test-Path { Microsoft.PowerShell.Management\Test-Path @PSBoundParameters } -ModuleName web
124-
Mock Test-Path { return $true } -ParameterFilter { $Path -eq "/usr/bin/fakebrowser" } -ModuleName web
123+
Mock Test-Path {
124+
param($Path)
125+
return $Path -eq '/usr/bin/fakebrowser' -or [System.IO.File]::Exists($Path) -or [System.IO.Directory]::Exists($Path)
126+
} -ModuleName web
125127
Mock Save-Icon { return $false } -ModuleName web
126128

127129
New-WebShortcut -Url "https://auto-test.com" -Name "AutoLinux" -Type Auto -SaveDir $TestDir

‎scripts/pwsh/install/InstallOrchestrator.psm1‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -750,11 +750,37 @@ param(
750750
[Parameter(Mandatory, Position = 0)]
751751
[string]$ScriptPath,
752752
[Parameter(ValueFromRemainingArguments = $true)]
753-
[object[]]$RemainingArguments
753+
[string[]]$RemainingArguments
754754
)
755755
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
756756
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
757-
& $ScriptPath @RemainingArguments
757+
$command = Get-Command -Name $ScriptPath -CommandType ExternalScript -ErrorAction Stop
758+
$boundParameters = @{}
759+
$remainingTokens = @($RemainingArguments)
760+
if ($remainingTokens.Count -eq 1 -and [string]::IsNullOrEmpty([string]$remainingTokens[0])) {
761+
$remainingTokens = @()
762+
}
763+
for ($index = 0; $index -lt $remainingTokens.Count; $index++) {
764+
$token = [string]$remainingTokens[$index]
765+
if ($token -notmatch '^-(?<Name>[A-Za-z][A-Za-z0-9-]*)$') {
766+
throw "无法解析叶子参数: $token"
767+
}
768+
$parameterName = $Matches.Name
769+
$parameter = $command.Parameters[$parameterName]
770+
if ($null -eq $parameter) {
771+
throw "叶子脚本不支持参数: -$parameterName"
772+
}
773+
if ($parameter.ParameterType -eq [System.Management.Automation.SwitchParameter]) {
774+
$boundParameters[$parameterName] = $true
775+
continue
776+
}
777+
$index++
778+
if ($index -ge $remainingTokens.Count) {
779+
throw "叶子参数缺少值: -$parameterName"
780+
}
781+
$boundParameters[$parameterName] = [string]$remainingTokens[$index]
782+
}
783+
& $ScriptPath @boundParameters
758784
exit $LASTEXITCODE
759785
'@
760786
[System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, [System.Text.UTF8Encoding]::new($false))
@@ -841,8 +867,8 @@ exit $LASTEXITCODE
841867
$process.Dispose()
842868
$stdoutBuffer.Dispose()
843869
$stderrBuffer.Dispose()
844-
if ($wrapperPath -and (Test-Path -LiteralPath $wrapperPath -PathType Leaf)) {
845-
Remove-Item -LiteralPath $wrapperPath -Force -ErrorAction SilentlyContinue
870+
if ($wrapperPath -and [System.IO.File]::Exists($wrapperPath)) {
871+
[System.IO.File]::Delete($wrapperPath)
846872
}
847873
}
848874
}

‎tests/BrowserDebugProfile.Tests.ps1‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,18 @@ if ($RemainingArgs[0] -eq '__complete') { 'profile' }
132132
Should -Invoke Read-BrowserDebugRegistry -Times 0
133133
}
134134

135-
It '通过真实 alias 与 ps1 入口调用内部补全且不终止当前会话' {
136-
$completionPath = Join-Path $script:ToolRoot 'completion.ps1'
137-
$commandPath = Join-Path $script:RepoRoot 'bin/browser-debug.ps1'
135+
It '通过真实生成的 alias 与 ps1 入口调用内部补全且不终止当前会话' {
136+
$generatedRoot = Join-Path $TestDrive 'generated-profile-root'
137+
$generatedToolRoot = Join-Path $generatedRoot 'scripts/pwsh/devops/browser-debug'
138+
New-Item -ItemType Directory -Path $generatedToolRoot -Force | Out-Null
139+
Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'Manage-BinScripts.ps1') -Destination $generatedRoot -Force
140+
Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'psutils/src/config') -Destination (Join-Path $generatedRoot 'psutils/src/config') -Recurse -Force
141+
Copy-Item -Path (Join-Path $script:ToolRoot '*') -Destination $generatedToolRoot -Recurse -Force
142+
& (Join-Path $generatedRoot 'Manage-BinScripts.ps1') -Action sync -Force *> $null
143+
144+
$completionPath = Join-Path $generatedToolRoot 'completion.ps1'
145+
$commandPath = Join-Path $generatedRoot 'bin/browser-debug.ps1'
146+
Test-Path -LiteralPath $commandPath -PathType Leaf | Should -BeTrue
138147
$childCommand = @"
139148
Remove-Item Env:\PWSH_TEST_SKIP_BROWSER_DEBUG_MAIN -ErrorAction SilentlyContinue
140149
. '$($completionPath.Replace("'", "''"))'
@@ -154,7 +163,7 @@ Register-BrowserDebugCompletion -CommandPath '$($commandPath.Replace("'", "''"))
154163
}
155164
}
156165

157-
Describe 'browser-debug Profile 生命周期' {
166+
Describe 'browser-debug Profile 生命周期' -Tag 'windowsOnly' {
158167
BeforeEach {
159168
$script:RegistryPath = Join-Path $TestDrive 'registry.json'
160169
$script:ProfileRoot = Join-Path $TestDrive 'profiles'
@@ -559,7 +568,7 @@ Describe 'browser-debug User Data 克隆' {
559568
}
560569
}
561570

562-
Describe 'browser-debug 快捷方式' {
571+
Describe 'browser-debug 快捷方式' -Tag 'windowsOnly' {
563572
It 'Local 与 LAN 快捷方式只引用 Profile 名称、显式模式并确认切换' {
564573
$pwshPath = (Get-Command pwsh.exe -ErrorAction Stop).Source
565574
$profile = [pscustomobject]@{ name = 'demo'; browserPath = $pwshPath; cdpPort = 9333 }

‎tests/Install.Tests.ps1‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ param([string]$Preset, [switch]$WhatIf)
6666
$verifyRelativePath = if ($IsWindows) { 'windows/99verifyInstall.ps1' } else { 'linux/99verifyInstall.ps1' }
6767
$verifyPath = Join-Path $script:TempRoot $verifyRelativePath
6868
New-Item -ItemType Directory -Path (Split-Path -Parent $verifyPath) -Force | Out-Null
69-
Set-Content -LiteralPath $verifyPath -Encoding utf8NoBOM -Value "Write-Output 'verify-leaf-noise'`n"
69+
Set-Content -LiteralPath $verifyPath -Encoding utf8NoBOM -Value "param([ValidateSet('Core', 'Full')][string]`$Preset)`nWrite-Output 'verify-leaf-noise'`n"
7070
}
7171

7272
if (-not $IsWindows) {

‎tests/InstallOrchestrator.Tests.ps1‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,36 @@ $stderrText = 'stderr-start|' + ('错误诊断-边界|' * 2500) + 'stderr-end'
441441
$result.Stderr | Should -Not -Match ([char]0xfffd)
442442
}
443443

444+
It 'binds named values and switches through the UTF-8 wrapper and removes it under WhatIf' {
445+
$fixturePath = Join-Path $script:FixtureRoot 'named-arguments.ps1'
446+
Set-Content -LiteralPath $fixturePath -Encoding utf8NoBOM -Value @'
447+
[CmdletBinding(SupportsShouldProcess)]
448+
param(
449+
[ValidateSet('Core', 'Full')]
450+
[string]$Preset,
451+
[switch]$Enabled
452+
)
453+
[pscustomobject]@{ Preset = $Preset; Enabled = [bool]$Enabled } | ConvertTo-Json -Compress
454+
'@
455+
$module = Get-Module InstallOrchestrator
456+
$temporaryPattern = Join-Path ([System.IO.Path]::GetTempPath()) 'powershellScripts-install-*.ps1'
457+
$wrappersBefore = @(Get-ChildItem -Path $temporaryPattern -ErrorAction SilentlyContinue).FullName
458+
459+
$result = & $module {
460+
param($Path)
461+
$WhatIfPreference = $true
462+
Invoke-InstallLeafProcess -Runner pwsh -ScriptPath $Path -ArgumentList @('-Preset', 'Core', '-Enabled')
463+
} $fixturePath
464+
$document = $result.Stdout | ConvertFrom-Json
465+
466+
$result.ExitCode | Should -Be 0
467+
$document.Preset | Should -Be 'Core'
468+
$document.Enabled | Should -BeTrue
469+
$result.Stdout | Should -Not -Match '^What if:'
470+
$wrappersAfter = @(Get-ChildItem -Path $temporaryPattern -ErrorAction SilentlyContinue).FullName
471+
@($wrappersAfter | Where-Object { $_ -notin $wrappersBefore }) | Should -BeNullOrEmpty
472+
}
473+
444474
It 'decodes a round-trippable Windows ANSI output fallback without replacement characters' -Skip:(-not $IsWindows) {
445475
$text = '本地编码诊断'
446476
$codePage = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage

0 commit comments

Comments
 (0)