Skip to content

Commit 8e73894

Browse files
committed
feat(qa): 实现 Windows 上 QA 提速与降噪设计
- 新增 `test:qa` 命令,采用固定 smoke 测试集与变更感知策略 - 修复 package.json 中 PowerShell 环境变量转义问题 - 在 qa.mjs 中实现变更文件收集与测试路径映射 - 更新 Pester 配置以支持 qa 模式,排除慢测与噪音 - 将 `qa:pwsh` 命令切换为使用新的 `test:qa` 模式
1 parent 2c44686 commit 8e73894

4 files changed

Lines changed: 239 additions & 22 deletions

File tree

PesterConfiguration.ps1

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,36 @@ if ($IsLinux -or $IsMacOS) {
3434

3535

3636
$isCI = [bool]$env:CI
37-
$testMode = if ([string]::IsNullOrWhiteSpace($env:PWSH_TEST_MODE)) { 'full' } else { $env:PWSH_TEST_MODE }
38-
$isFast = $testMode -in @('fast', 'serial', 'debug')
37+
$testMode = if ([string]::IsNullOrWhiteSpace($env:PWSH_TEST_MODE)) { 'full' } else { $env:PWSH_TEST_MODE.Trim().ToLowerInvariant() }
38+
$isQa = $testMode -eq 'qa'
39+
$isFast = $testMode -in @('fast', 'serial', 'debug', 'qa')
3940
$isSerial = $testMode -eq 'serial'
4041
$isDebug = $testMode -eq 'debug'
4142
$isVerbose = -not [string]::IsNullOrWhiteSpace($env:PWSH_TEST_VERBOSE)
4243

43-
$runPaths = @("./psutils", "./tests")
44+
$qaDefaultPaths = @(
45+
"./tests/DeferredLoading.Tests.ps1"
46+
"./tests/losslessToAdaptiveAudio.Tests.ps1"
47+
"./tests/ProfileMode.Tests.ps1"
48+
"./tests/Switch-Mirrors.Tests.ps1"
49+
"./psutils/tests/error.Tests.ps1"
50+
"./psutils/tests/filesystem.Tests.ps1"
51+
"./psutils/tests/font.Tests.ps1"
52+
"./psutils/tests/git.Tests.ps1"
53+
"./psutils/tests/string.Tests.ps1"
54+
"./psutils/tests/win.Tests.ps1"
55+
"./psutils/tests/wrapper.Tests.ps1"
56+
)
57+
58+
$runPaths = if ($isQa) { $qaDefaultPaths } else { @("./psutils", "./tests") }
4459
if (-not [string]::IsNullOrWhiteSpace($env:PWSH_TEST_PATH)) {
4560
$runPaths = $env:PWSH_TEST_PATH -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
4661
}
4762

63+
if ($isQa) {
64+
$excludeTags += 'QaSkip'
65+
}
66+
4867
$config = @{
4968
Run = @{
5069
Path = $runPaths
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# QA 提速与降噪设计(Windows)
2+
3+
## 背景
4+
- 现状 `pnpm qa` 在 Windows 上会触发大量慢测与噪音输出,耗时远超本地快速反馈目标。
5+
- `test:fast` 存在环境变量转义问题,导致模式未生效,实际执行接近全量。
6+
7+
## 目标
8+
- 本地 `pnpm qa` 目标耗时尽量控制在 20 秒以内。
9+
- 输出保留“每个测试文件耗时 + 错误/失败 + 汇总”,减少无关噪音。
10+
- 慢测从 `qa` 中剥离,保留在 `test:fast`/`test:full` 或 CI 场景执行。
11+
12+
## 设计
13+
### 1) 命令分层
14+
- 新增 `test:qa``PWSH_TEST_MODE=qa`)。
15+
- `qa:pwsh``test:fast` 切换为 `test:qa`
16+
- 修复 `test:fast/full/serial/debug` 的 PowerShell 环境变量转义。
17+
18+
### 2) 测试集策略
19+
- `qa` 采用“固定 smoke + changed-aware”策略:
20+
- 固定 smoke:一组低耗时基础测试,始终执行。
21+
- changed-aware:根据改动文件追加相关测试。
22+
- 显式排除已知慢/高噪音测试文件,避免本地质量门被拖慢。
23+
24+
### 3) 调度实现
25+
-`scripts/qa.mjs` 增加:
26+
- 变更文件收集(工作区、暂存区、未跟踪 + 与基线对比)。
27+
- 路径映射规则(`psutils/modules/<name>.psm1 -> psutils/tests/<name>.Tests.ps1`)。
28+
- `PWSH_TEST_PATH` 注入 `qa:pwsh`,由 Pester 只执行目标子集。
29+
30+
### 4) Pester 模式
31+
- `PesterConfiguration.ps1` 增加 `qa` 模式:
32+
- 默认 `Run.Path` 使用 smoke 集合;
33+
- 支持 `PWSH_TEST_PATH` 覆盖;
34+
- 关闭 CodeCoverage;
35+
- 保持 `Output.Verbosity='Normal'`
36+
37+
## 风险与权衡
38+
- 风险:`qa` 覆盖面降低可能漏检非 smoke 问题。
39+
- 缓解:保留 `test:fast`/`test:full` 作为更全面验证入口,CI 中继续执行更重测试。
40+
41+
## 验证标准
42+
- `pnpm qa` 在常见变更场景下显著低于历史耗时。
43+
- `pnpm qa` 输出仍包含文件级耗时与失败摘要。
44+
- `pnpm test:fast` 环境变量生效,不再出现 `\$env:` 解释错误。

package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
"pester:install": "pwsh -NoProfile -Command \"Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck -AllowClobber\"",
2222
"pester:update": "pwsh -NoProfile -Command \"Update-Module -Name Pester -Force -ErrorAction Stop\"",
2323
"test": "pwsh -NoProfile -Command \"Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
24-
"test:full": "pwsh -Command \"\\$env:PWSH_TEST_MODE='full'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
25-
"test:fast": "pwsh -NoProfile -Command \"\\$env:PWSH_TEST_MODE='fast'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
26-
"test:serial": "pwsh -NoProfile -Command \"\\$env:PWSH_TEST_MODE='serial'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
27-
"test:debug": "pwsh -NoProfile -Command \"\\$env:PWSH_TEST_MODE='debug'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
28-
"test:serial:debug": "pwsh -NoProfile -Command \"\\$env:PWSH_TEST_MODE='serial'; \\$env:PWSH_TEST_VERBOSE='1'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
29-
"test:profile": "pwsh -NoProfile -Command \"\\$env:PWSH_TEST_MODE='serial'; \\$env:PWSH_TEST_PATH='./tests/ProfileMode.Tests.ps1'; \\$env:PWSH_TEST_VERBOSE='1'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
30-
"test:slow": "pwsh -NoProfile -Command \"\\$c = ./PesterConfiguration.ps1; \\$c.Filter.Tag = 'Slow'; \\$c.Filter.ExcludeTag = @(\\$c.Filter.ExcludeTag.Value | Where-Object { \\$_ -ne 'Slow' }); Invoke-Pester -Configuration \\$c\"",
24+
"test:full": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='full'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
25+
"test:fast": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='fast'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
26+
"test:qa": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='qa'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
27+
"test:serial": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='serial'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
28+
"test:debug": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='debug'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
29+
"test:serial:debug": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='serial'; $env:PWSH_TEST_VERBOSE='1'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
30+
"test:profile": "pwsh -NoProfile -Command \"$env:PWSH_TEST_MODE='serial'; $env:PWSH_TEST_PATH='./tests/ProfileMode.Tests.ps1'; $env:PWSH_TEST_VERBOSE='1'; Invoke-Pester -Configuration ( ./PesterConfiguration.ps1 )\"",
31+
"test:slow": "pwsh -NoProfile -Command \"$c = ./PesterConfiguration.ps1; $c.Filter.Tag = 'Slow'; $c.Filter.ExcludeTag = @($c.Filter.ExcludeTag.Value | Where-Object { $_ -ne 'Slow' }); Invoke-Pester -Configuration $c\"",
3132
"test:detailed": "pwsh -Command \"Invoke-Pester -Output Detailed\"",
3233
"scripts:install": "pwsh -File ./install.ps1",
3334
"scoop:update": "scoop update -a",
@@ -36,7 +37,7 @@
3637
"test:linux": "docker compose -f docker-compose.pester.yml run --rm pester-fast",
3738
"test:linux:full": "docker compose -f docker-compose.pester.yml run --rm pester-full",
3839
"test:linux:build": "docker compose -f docker-compose.pester.yml build",
39-
"qa:pwsh": "pnpm format:pwsh && pnpm test:fast",
40+
"qa:pwsh": "pnpm format:pwsh && pnpm test:qa",
4041
"qa": "node ./scripts/qa.mjs changed",
4142
"qa:all": "node ./scripts/qa.mjs all",
4243
"qa:verbose": "node ./scripts/qa.mjs changed --verbose",

scripts/qa.mjs

Lines changed: 164 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22

33
import { spawnSync } from 'node:child_process'
4+
import { existsSync } from 'node:fs'
45

56
class CommandFailedError extends Error {
67
constructor(step, command, args, exitCode, spawnError) {
@@ -44,6 +45,30 @@ if (!verbose) {
4445
}
4546

4647
const supportedModes = new Set(['changed', 'all'])
48+
const qaSmokeTestPaths = [
49+
'./tests/DeferredLoading.Tests.ps1',
50+
'./tests/losslessToAdaptiveAudio.Tests.ps1',
51+
'./tests/ProfileMode.Tests.ps1',
52+
'./tests/Switch-Mirrors.Tests.ps1',
53+
'./psutils/tests/error.Tests.ps1',
54+
'./psutils/tests/filesystem.Tests.ps1',
55+
'./psutils/tests/font.Tests.ps1',
56+
'./psutils/tests/git.Tests.ps1',
57+
'./psutils/tests/string.Tests.ps1',
58+
'./psutils/tests/win.Tests.ps1',
59+
'./psutils/tests/wrapper.Tests.ps1',
60+
]
61+
const qaExcludedTestPaths = new Set([
62+
'./psutils/tests/cache.Tests.ps1',
63+
'./psutils/tests/hardware.Tests.ps1',
64+
'./psutils/tests/help.Tests.ps1',
65+
'./psutils/tests/install.Tests.ps1',
66+
'./psutils/tests/network.Tests.ps1',
67+
'./psutils/tests/profile_unix.Tests.ps1',
68+
'./psutils/tests/profile_windows.Tests.ps1',
69+
'./psutils/tests/proxy.Tests.ps1',
70+
'./psutils/tests/test.Tests.ps1',
71+
])
4772

4873
if (!supportedModes.has(mode)) {
4974
console.error(`[qa] unsupported mode: ${mode}`)
@@ -72,14 +97,14 @@ function runCommand(step, command, args, options = {}) {
7297
}
7398
}
7499

75-
function runPnpm(step, pnpmArgs) {
100+
function runPnpm(step, pnpmArgs, options = {}) {
76101
if (runPnpmWithNode) {
77-
runCommand(step, process.execPath, [pnpmExecPath, ...pnpmArgs])
102+
runCommand(step, process.execPath, [pnpmExecPath, ...pnpmArgs], options)
78103
return
79104
}
80105

81106
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
82-
runCommand(step, command, pnpmArgs)
107+
runCommand(step, command, pnpmArgs, options)
83108
}
84109

85110
function runCapture(command, args) {
@@ -165,6 +190,119 @@ function hasPathChanges(pathspecs, sinceRef) {
165190
})
166191
}
167192

193+
function toRepoPath(pathValue) {
194+
return pathValue.replace(/\\/g, '/').replace(/^\.\//, '')
195+
}
196+
197+
function toQaTestPath(pathValue) {
198+
const prefixed = pathValue.startsWith('./') ? pathValue : `./${pathValue}`
199+
return prefixed.replace(/\\/g, '/')
200+
}
201+
202+
function shouldIncludeQaTest(pathValue) {
203+
const qaPath = toQaTestPath(pathValue)
204+
if (qaExcludedTestPaths.has(qaPath)) {
205+
return false
206+
}
207+
const repoPath = toRepoPath(qaPath)
208+
return existsSync(repoPath)
209+
}
210+
211+
function collectChangedFiles(pathspecs, sinceRef) {
212+
const checks = []
213+
214+
if (sinceRef) {
215+
checks.push([
216+
'diff',
217+
'--name-only',
218+
'--diff-filter=ACMRT',
219+
`${sinceRef}...HEAD`,
220+
'--',
221+
...pathspecs,
222+
])
223+
}
224+
225+
checks.push(['diff', '--name-only', '--diff-filter=ACMRT', '--', ...pathspecs])
226+
checks.push([
227+
'diff',
228+
'--name-only',
229+
'--diff-filter=ACMRT',
230+
'--cached',
231+
'--',
232+
...pathspecs,
233+
])
234+
checks.push(['ls-files', '--others', '--exclude-standard', '--', ...pathspecs])
235+
236+
const changed = new Set()
237+
238+
for (const args of checks) {
239+
const result = runCapture('git', args)
240+
if (result.status !== 0 || result.stdout.length === 0) {
241+
continue
242+
}
243+
for (const line of result.stdout.split('\n').filter(Boolean)) {
244+
changed.add(toRepoPath(line))
245+
}
246+
}
247+
248+
return [...changed]
249+
}
250+
251+
function addQaTestPath(selected, testPath) {
252+
const normalized = toQaTestPath(testPath)
253+
if (!shouldIncludeQaTest(normalized)) {
254+
if (verbose) {
255+
console.log(`[qa:verbose] skip qa test path: ${normalized}`)
256+
}
257+
return
258+
}
259+
selected.add(normalized)
260+
}
261+
262+
function resolveQaTestPaths(modeValue, sinceRef, pathspecs) {
263+
const selected = new Set()
264+
for (const testPath of qaSmokeTestPaths) {
265+
addQaTestPath(selected, testPath)
266+
}
267+
268+
if (modeValue !== 'changed') {
269+
return [...selected]
270+
}
271+
272+
const changedFiles = collectChangedFiles(pathspecs, sinceRef)
273+
if (verbose) {
274+
console.log(`[qa:verbose] root changed files: ${changedFiles.length}`)
275+
}
276+
277+
for (const changedFile of changedFiles) {
278+
if (changedFile.startsWith('tests/') && changedFile.endsWith('.Tests.ps1')) {
279+
addQaTestPath(selected, changedFile)
280+
continue
281+
}
282+
283+
if (
284+
changedFile.startsWith('psutils/tests/') &&
285+
changedFile.endsWith('.Tests.ps1')
286+
) {
287+
addQaTestPath(selected, changedFile)
288+
continue
289+
}
290+
291+
if (
292+
changedFile.startsWith('psutils/modules/') &&
293+
changedFile.endsWith('.psm1')
294+
) {
295+
const fileName = changedFile.split('/').pop()
296+
const moduleName = fileName?.replace(/\.psm1$/i, '')
297+
if (moduleName) {
298+
addQaTestPath(selected, `./psutils/tests/${moduleName}.Tests.ps1`)
299+
}
300+
}
301+
}
302+
303+
return [...selected]
304+
}
305+
168306
function runWorkspaceQa(modeValue, sinceRef) {
169307
const recursiveArgs = [
170308
'-r',
@@ -197,28 +335,43 @@ function runWorkspaceQa(modeValue, sinceRef) {
197335
}
198336

199337
function runRootPwshQa(modeValue, sinceRef) {
200-
if (modeValue === 'all') {
201-
console.log('[qa] run root qa:pwsh (all)')
202-
runPnpm('root-qa-pwsh-all', ['run', 'qa:pwsh'])
203-
return
204-
}
205-
206338
const pwshPathspecs = [
207339
'scripts/pwsh',
208340
'profile',
209341
'tests',
342+
'psutils/modules',
343+
'psutils/tests',
210344
'PesterConfiguration.ps1',
211345
'install.ps1',
212346
'Manage-BinScripts.ps1',
213347
]
214348

349+
const qaTestPaths = resolveQaTestPaths(modeValue, sinceRef, pwshPathspecs)
350+
const qaEnv = { ...process.env }
351+
if (qaTestPaths.length > 0) {
352+
qaEnv.PWSH_TEST_PATH = qaTestPaths.join(';')
353+
}
354+
355+
if (verbose) {
356+
console.log(`[qa:verbose] qa test paths (${qaTestPaths.length})`)
357+
for (const testPath of qaTestPaths) {
358+
console.log(`[qa:verbose] ${testPath}`)
359+
}
360+
}
361+
362+
if (modeValue === 'all') {
363+
console.log('[qa] run root qa:pwsh (all)')
364+
runPnpm('root-qa-pwsh-all', ['run', 'qa:pwsh'], { env: qaEnv })
365+
return
366+
}
367+
215368
if (!hasPathChanges(pwshPathspecs, sinceRef)) {
216369
console.log('[qa] skip root qa:pwsh (no changes)')
217370
return
218371
}
219372

220373
console.log('[qa] run root qa:pwsh (changed)')
221-
runPnpm('root-qa-pwsh-changed', ['run', 'qa:pwsh'])
374+
runPnpm('root-qa-pwsh-changed', ['run', 'qa:pwsh'], { env: qaEnv })
222375
}
223376

224377
const sinceRef = mode === 'changed' ? resolveSinceRef() : null
@@ -252,4 +405,4 @@ try {
252405
}
253406

254407
throw error
255-
}
408+
}

0 commit comments

Comments
 (0)