Ver Fonte

test(ci): carry the hook budget and raise the Lefthook suite to the lane value

The Windows coverage lane grants DSH_COVERAGE_TEST_TIMEOUT_MS=90000, but two
paths declined it.

scripts/install-lefthook.spec.ts took a describe-level 30_000, restated as a
per-case constant on five cases. Every case drives spawned Git and Node
subprocesses; the slowest costs 7.5 s on an idle host, so the ceiling carried
roughly fourfold headroom and fired on branches that did not touch the file.
The suite takes 90_000 and the redundant constant is removed.

coverageTestTimeoutArgs raised --testTimeout and --expect.poll.timeout but left
--hookTimeout at Vitest's separate 10 s default, which removeFixtureSafely's
documented 10-second Windows retry window meets exactly. Raising only the test
budget would move a contended suite's failure into its teardown.
Yichen Jiang há 2 semanas atrás
pai
commit
90505636cd

+ 6 - 0
.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md
+2026-08-29-windows-lane-hook-and-lefthook-budget.md: 6886e3ad4958d20a88a66df6a9e02f5a60a36a6a
+2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md: 56c1e625d92f01e24f2268deb5d03040278da6af

+ 35 - 0
.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md

@@ -0,0 +1,35 @@
+# Agent Note: Hook budget and Lefthook suite budget on the Windows coverage lane
+
+Status: implemented
+
+English | [中文](2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md)
+
+## Problem
+
+Two facts kept the Windows coverage lane failing on branches that touched neither the suite nor the gate.
+
+[`scripts/install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) took a `describe`-level `{ timeout: 30_000 }`, restated as a `MULTI_PROCESS_TEST_TIMEOUT_MS` constant on five of its cases. Every case builds scratch worktrees and drives them through spawned Git and Node subprocesses, so the suite is bound by process creation rather than by its assertions. On an idle macOS host its slowest case costs 7.5 s, so the ceiling carried roughly fourfold headroom — where the [translation-pairing-merge suite](2026-08-27-translation-pairing-merge-budget.md) fired at 15 s with more than tenfold. Under the self-hosted Windows runners' multi-second process-creation spikes this suite has been observed reporting `Test timed out in 30000ms` on branches that did not touch it, and the two cases observed failing are its slowest and its seventh-slowest.
+
+Separately, `coverageTestTimeoutArgs` in [`scripts/coverage-partitions.ts`](../../../../scripts/coverage-partitions.ts) raised `--testTimeout` and `--expect.poll.timeout` from `DSH_COVERAGE_TEST_TIMEOUT_MS` but left `--hookTimeout` at Vitest's separate 10 s default. Setup and teardown pay the same contention the raised test budget accounts for: [`removeFixtureSafely`](../../../../scripts/test-fixture-cleanup.ts) retries Windows handle release across a documented 10-second window, so an `afterEach` that exercises that window meets the hook default exactly. Raising only the test budget moves a contended suite's failure from the case to its teardown rather than removing it.
+
+## Decision
+
+The Lefthook suite takes `{ timeout: 90_000 }`, matching `DSH_COVERAGE_TEST_TIMEOUT_MS` in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). The per-case constant is deleted rather than raised: it restated the `describe` value, and the translation-pairing-merge note already rejected per-case allowances because a later case added without one silently inherits a different ceiling.
+
+`coverageTestTimeoutArgs` emits `--hookTimeout` beside the other two arguments. One environment variable governs one budget for the work a contended lane must finish, whether that work sits in a case or in its setup and teardown.
+
+## Consequences
+
+A `git` or `node` spawn spike on the shared-volume runners no longer decides either suite's outcome, and a slow fixture teardown no longer fails a suite whose cases all passed. Neither value measures how long the work needs: the Lefthook suite's slowest case completes in about 7.5 s on an idle host, and a raised ceiling does not slow a passing run.
+
+Both budgets widen what counts as an acceptable duration, so a real slowdown into tens of seconds now passes where the previous ceilings would have caught it. That detection is traded away deliberately: those ceilings were firing on host contention rather than on regressions.
+
+The hook change applies wherever `DSH_COVERAGE_TEST_TIMEOUT_MS` is set, which today is the Windows coverage lane alone. Lanes that leave it unset keep every Vitest default, including the 10 s hook budget.
+
+## Alternatives considered
+
+**Give `--hookTimeout` its own environment variable.** Two knobs would describe one property of the host, and a lane that raised one without the other would reproduce this failure in the other direction.
+
+**Shorten the `removeFixtureSafely` retry window instead.** That trades a cleanup failure for temp residue on the shared self-hosted `/tmp`, which has twice exhausted the host's inode capacity.
+
+**Raise only the Lefthook suite and leave the hook default.** The suite's `afterEach` is exactly where its Windows `EPERM` cleanup failures appear, so the raised case budget would have surfaced the same run as a hook timeout.

+ 35 - 0
.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: Windows 覆盖率 lane 的 hook 预算与 Lefthook 套件预算
+
+Status: implemented
+
+[English](2026-08-29-windows-lane-hook-and-lefthook-budget.md) | 中文
+
+## 问题
+
+两件事让 Windows 覆盖率 lane 在既没碰套件、也没碰 gate 的分支上持续失败。
+
+[`scripts/install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 在 `describe` 层取 `{ timeout: 30_000 }`,并以 `MULTI_PROCESS_TEST_TIMEOUT_MS` 常量的形式在其中五个用例上重复了同一个值。每个用例都会建临时 worktree 并通过 spawn 的 Git 与 Node 子进程驱动它,因此这个套件受进程创建约束,而不是受它的断言约束。在空闲的 macOS 主机上,它最慢的用例耗时 7.5 秒,也就是说这个上限只有约四倍余量——而 [translation-pairing-merge 套件](2026-08-27-translation-pairing-merge-budget.zh.md)在十倍以上余量的 15 秒上限下仍然触发。在自托管 Windows runner 数秒级的进程创建尖峰下,这个套件曾在没有改动它的分支上报出 `Test timed out in 30000ms`,而被观察到失败的两个用例正是它最慢的那个和第七慢的那个。
+
+另一件事是 [`scripts/coverage-partitions.ts`](../../../../scripts/coverage-partitions.ts) 里的 `coverageTestTimeoutArgs`:它用 `DSH_COVERAGE_TEST_TIMEOUT_MS` 抬高了 `--testTimeout` 和 `--expect.poll.timeout`,却把 `--hookTimeout` 留在 Vitest 独立的 10 秒默认值上。setup 与 teardown 承受的是被抬高的测试预算所针对的同一种争抢:[`removeFixtureSafely`](../../../../scripts/test-fixture-cleanup.ts) 会在一个注释写明的 10 秒窗口内重试 Windows 句柄释放,因此一个真正用满该窗口的 `afterEach` 恰好撞上 hook 默认值。只抬高测试预算,只是把一个受争抢套件的失败从用例挪到它的 teardown,而不是消除它。
+
+## 决定
+
+Lefthook 套件取 `{ timeout: 90_000 }`,与 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 里的 `DSH_COVERAGE_TEST_TIMEOUT_MS` 一致。逐用例常量被删除而不是被抬高:它只是重述了 `describe` 的取值,而 translation-pairing-merge 的 note 已经否决过逐用例余量——后续新增的用例若不带余量,就会静默继承另一个上限。
+
+`coverageTestTimeoutArgs` 在原有两个参数旁边发出 `--hookTimeout`。一个环境变量管一份预算,覆盖受争抢的 lane 必须完成的工作,无论这份工作位于用例内还是位于它的 setup 与 teardown。
+
+## 后果
+
+共享卷 runner 上一次 `git` 或 `node` 的 spawn 尖峰不再决定这两个套件的结果,一次缓慢的 fixture teardown 也不再让一个用例全部通过的套件失败。两个取值都不是对「需要多久」的测量:Lefthook 套件最慢的用例在空闲主机上约 7.5 秒,而抬高上限不会让一次通过的运行变慢。
+
+两份预算都放宽了「多长算可接受」,因此一个退化到几十秒的真实变慢现在会通过,而此前的上限会拦住它。这项检测能力是有意换掉的:那些上限触发的是宿主机争抢,不是回归。
+
+hook 的改动在所有设置了 `DSH_COVERAGE_TEST_TIMEOUT_MS` 的地方生效,目前仅 Windows 覆盖率 lane 一处。不设置它的 lane 保持全部 Vitest 默认值,包括 10 秒的 hook 预算。
+
+## 备选方案
+
+**给 `--hookTimeout` 单独一个环境变量。**两个旋钮描述宿主机的同一个属性,而只抬高其中一个的 lane 会以相反的方向复现同一个失败。
+
+**改为缩短 `removeFixtureSafely` 的重试窗口。**这是用清理失败换共享自托管 `/tmp` 上的临时目录残留,而该残留已经两次耗尽宿主机的 inode 容量。
+
+**只抬高 Lefthook 套件,保留 hook 默认值。**该套件的 `afterEach` 正是它 Windows `EPERM` 清理失败出现的位置,所以被抬高的用例预算只会把同一次运行改成以 hook 超时的形式暴露。

+ 2 - 1
scripts/coverage-partitions.spec.ts

@@ -90,10 +90,11 @@ describe('coverage partition count', () => {
 })
 
 describe('coverage partition timeout', () => {
-  it('applies one configured timeout to tests and polling', () => {
+  it('applies one configured timeout to tests, polling, and hooks', () => {
     expect(coverageTestTimeoutArgs('30000')).toEqual([
       '--testTimeout=30000',
       '--expect.poll.timeout=30000',
+      '--hookTimeout=30000',
     ])
   })
 

+ 12 - 3
scripts/coverage-partitions.ts

@@ -12,7 +12,7 @@ export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS'
 /** Internal marker that suppresses reports and thresholds inside a partition process. */
 export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE'
 
-/** Environment variable overriding instrumented test and polling timeouts. */
+/** Environment variable overriding instrumented test, polling, and hook timeouts. */
 export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
 
 /** One child command owned by the coverage coordinator. */
@@ -76,14 +76,23 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u
   return parsed
 }
 
-/** Resolve the paired Vitest timeout arguments used by coverage partitions. */
+/**
+ * Resolve the paired Vitest timeout arguments used by coverage partitions.
+ * `--hookTimeout` travels with the test budget because setup and teardown pay
+ * the same host contention the raised test budget accounts for: fixtures that
+ * await child exit or retry Windows handle release spend that cost in
+ * `afterEach`, where Vitest's separate 10 s default would otherwise fail a
+ * suite whose cases all passed.
+ * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults.
+ * @returns the Vitest arguments applying that budget, empty when unset.
+ */
 export function coverageTestTimeoutArgs(raw: string | undefined): string[] {
   if (raw === undefined || raw === '') return []
   const parsed = Number.parseInt(raw, 10)
   if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
     throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
   }
-  return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`]
+  return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`, `--hookTimeout=${raw}`]
 }
 
 /** Remove pnpm's package-script separator before forwarding Vitest arguments. */

+ 14 - 9
scripts/install-lefthook.spec.ts

@@ -23,9 +23,6 @@ const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B
 const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
 const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
 const fixtures: string[] = []
-// Multi-worktree cases spawn several Git and Node subprocesses; native Windows
-// coverage concurrency can delay them without changing installer behavior.
-const MULTI_PROCESS_TEST_TIMEOUT_MS = 30_000
 
 interface Fixture {
   container: string
@@ -211,7 +208,15 @@ function runInstaller(
   })
 }
 
-describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
+// Every case builds scratch worktrees and drives them through spawned Git and
+// Node subprocesses, so the suite is bound by process creation rather than by
+// its assertions. The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the
+// Windows coverage lane passes as --testTimeout: a describe value overrides that
+// flag rather than yielding to it, so a smaller one here lowers what the lane
+// grants every case in this file, none of which carries an allowance of its own.
+// Rationale and the paired hook budget are in
+// .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md.
+describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
   for (const [label, extraEnv] of [
     ['CI', { CI: 'true' }],
     ['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
@@ -290,7 +295,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
     git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
     expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
     expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
-  }, MULTI_PROCESS_TEST_TIMEOUT_MS)
+  })
 
   it('replaces the owned hook path Git copies into a newly added worktree', async () => {
     const fixture = createFixture()
@@ -315,7 +320,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
       '# config=late-linked-worktree-config',
     )
     expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
-  }, MULTI_PROCESS_TEST_TIMEOUT_MS)
+  })
 
   it('serializes concurrent installs and keeps repeated output stable', async () => {
     const fixture = createFixture()
@@ -336,7 +341,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
     expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
     expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
     expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
-  }, MULTI_PROCESS_TEST_TIMEOUT_MS)
+  })
 
   it('waits for a concurrent installer to finish publishing its lock record', async () => {
     const fixture = createFixture()
@@ -374,7 +379,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
     expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
       JSON.stringify(movedHooks),
     )
-  }, MULTI_PROCESS_TEST_TIMEOUT_MS)
+  })
 
   it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
     const fixture = createFixture()
@@ -415,7 +420,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
       expect(result.stderr).toContain('non-regular or multiply linked hook entry')
       expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
     }
-  }, MULTI_PROCESS_TEST_TIMEOUT_MS)
+  })
 
   it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
     const fixture = createFixture()

+ 3 - 2
scripts/run-gates.spec.ts

@@ -208,7 +208,7 @@ describe('gate graph validation', () => {
     expect(completeBuiltBin?.after).not.toContain('docs-site-build')
   })
 
-  it('applies one configured test and polling timeout to both coverage gates', () => {
+  it('applies one configured test, polling, and hook timeout to both coverage gates', () => {
     const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
       withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
 
@@ -216,6 +216,7 @@ describe('gate graph validation', () => {
       expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([
         '--testTimeout=15000',
         '--expect.poll.timeout=15000',
+        '--hookTimeout=15000',
       ]))
     }
   })
@@ -226,7 +227,7 @@ describe('gate graph validation', () => {
 
     for (const id of ['coverage', 'coverage-exempt-heavy']) {
       expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([
-        expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout)=/),
+        expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
       ]))
     }
   })

+ 1 - 1
scripts/run-gates.ts

@@ -569,7 +569,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
 // small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
 // total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
 // which keeps the gates from overlapping at all.
-// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll
+// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook
 // defaults together for instrumented lanes whose scheduling overhead exceeds
 // those defaults. Explicit fixture timeouts remain authoritative.
 function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {