diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml index 10306946dc..bc1ac12bf6 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml @@ -9,8 +9,8 @@ en: 5853599207f6b3bb zh: d58b5e8e19d93a4f /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision: - en: 721ea003ebaef8c7 - zh: e17be423bc4c7568 + en: 5c60e4926ac090af + zh: e34b73ab1f6358e0 /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered: en: 3ad30b2574818a61 zh: d323adddd5065ae4 diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md index 372c8b3110..aeea3533b6 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md @@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged. -The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. +The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which reaches the inline Vitest projects only through `vitest.config.ts` reading `DSH_COVERAGE_TEST_TIMEOUT_MS`: Vitest forwards `--testTimeout` but neither `--hookTimeout` nor `--expect.poll.timeout` into inline projects, so a flag-carried lane budget raises the per-test default alone. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect. diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md index 8539664280..4d90872684 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md @@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子 [ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。 -[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 +[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;该预算只通过 `vitest.config.ts` 读取 `DSH_COVERAGE_TEST_TIMEOUT_MS` 才到达内联 Vitest projects:Vitest 只把 `--testTimeout` 转发给内联 project,不转发 `--hookTimeout` 与 `--expect.poll.timeout`,因此以标志传递的通道预算只会提高单测试默认值。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 [原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db3315e074..c08565e5bc 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -613,6 +613,17 @@ describe('CI workflow', () => { expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'") expect(config.match(/pool: 'forks'/g)).toHaveLength(2) }) + + it('applies the lane test budget inside every Vitest project', () => { + // The coverage and serial lanes grant DSH_COVERAGE_TEST_TIMEOUT_MS; Vitest + // forwards only --testTimeout of the three budgets into inline projects, + // so each project reads the budget from the config (behavior pinned by + // scripts/lane-test-budget.spec.ts). + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).toContain('const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])') + expect(config.match(/^ {10}\.\.\.laneTestBudget,$/gm)).toHaveLength(2) + }) }) describe('Runtime and LLM e2e Blacksmith routing', () => { diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 8d3219b81f..2060608248 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -11,9 +11,10 @@ import { CoveragePartitionCoordinator, assignWeightedPartitions, collectPartitionDurations, - coverageTestTimeoutArgs, + coverageTestTimeoutOptions, forwardedCoverageArgs, parseCoveragePartitionCount, + parseCoverageTestTimeout, parseListOutput, readFileDurations, writeFileDurations, @@ -102,21 +103,24 @@ describe('coverage partition count', () => { }) }) -describe('coverage partition timeout', () => { - it('applies one configured timeout to tests, polling, and hooks', () => { - expect(coverageTestTimeoutArgs('30000')).toEqual([ - '--testTimeout=30000', - '--expect.poll.timeout=30000', - '--hookTimeout=30000', - ]) +describe('lane test budget', () => { + it('applies one configured budget to tests, hooks, and expect.poll', () => { + expect(coverageTestTimeoutOptions('30000')).toEqual({ + testTimeout: 30000, + hookTimeout: 30000, + expect: { poll: { timeout: 30000 } }, + }) }) - it('keeps Vitest defaults when the timeout is absent', () => { - expect(coverageTestTimeoutArgs(undefined)).toEqual([]) + it.each([undefined, ''])('keeps Vitest defaults when the budget is %j', (raw) => { + expect(parseCoverageTestTimeout(raw)).toBeUndefined() + expect(coverageTestTimeoutOptions(raw)).toEqual({}) }) - it('rejects invalid timeout input', () => { - expect(() => coverageTestTimeoutArgs('0')) + it.each(['0', '-1', '2.5', '090', '90000ms', 'many'])('rejects %j', (raw) => { + expect(() => parseCoverageTestTimeout(raw)) + .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + expect(() => coverageTestTimeoutOptions(raw)) .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`) }) }) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index 1dbc89390a..fcfc31b78d 100644 --- a/scripts/coverage-partitions.ts +++ b/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, polling, and hook timeouts. */ +/** Environment variable carrying a lane's per-test, hook, and `expect.poll` budget in milliseconds. */ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' /** @@ -85,22 +85,46 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u } /** - * 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. + * Parse the lane test budget carried by `DSH_COVERAGE_TEST_TIMEOUT_MS`. * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. - * @returns the Vitest arguments applying that budget, empty when unset. + * @returns the budget in milliseconds, or undefined when unset. + * @throws when the value is set and is not a positive integer. */ -export function coverageTestTimeoutArgs(raw: string | undefined): string[] { - if (raw === undefined || raw === '') return [] +export function parseCoverageTestTimeout(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined 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}`, `--hookTimeout=${raw}`] + return parsed +} + +/** The Vitest `test` options one lane budget sets in each inline project. */ +export interface CoverageTestTimeoutOptions { + testTimeout?: number + hookTimeout?: number + expect?: { poll: { timeout: number } } +} + +/** + * Resolve the Vitest test options that apply one lane budget to the per-test, + * hook, and `expect.poll` defaults together. The hook budget 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. + * The root config spreads these into each inline project: Vitest forwards + * only a fixed list of CLI overrides into projects, and `--hookTimeout` and + * `--expect.poll.timeout` are not on it, so a flag-based budget reaches only + * the per-test default. Explicit `describe`, case, and fixture timeouts remain + * authoritative. + * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. + * @returns the options to spread into a project's `test` block, empty when unset. + */ +export function coverageTestTimeoutOptions(raw: string | undefined): CoverageTestTimeoutOptions { + const timeout = parseCoverageTestTimeout(raw) + if (timeout === undefined) return {} + return { testTimeout: timeout, hookTimeout: timeout, expect: { poll: { timeout } } } } /** Remove pnpm's package-script separator before forwarding Vitest arguments. */ diff --git a/scripts/fixtures/lane-test-budget.fixture.ts b/scripts/fixtures/lane-test-budget.fixture.ts new file mode 100644 index 0000000000..0dff9600ca --- /dev/null +++ b/scripts/fixtures/lane-test-budget.fixture.ts @@ -0,0 +1,28 @@ +/** + * Waits that end only when a lane budget below them is in force. Runs only + * inside the Vitest child that scripts/lane-test-budget.spec.ts spawns. + */ +import { beforeEach, describe, expect, it } from 'vitest' + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +describe('lane test budget fixture', () => { + it('per-test budget', async () => { + await sleep(600) + }) + + // Its own case budget stays above the wait so the poll budget, not the + // per-test budget, is what ends the assertion. + it('expect.poll budget', { timeout: 5_000 }, async () => { + const start = Date.now() + await expect.poll(() => Date.now() - start).toBeGreaterThan(500) + }) + + describe('hook budget', () => { + beforeEach(async () => { + await sleep(600) + }) + + it('runs after the hook', () => {}) + }) +}) diff --git a/scripts/lane-test-budget.spec.ts b/scripts/lane-test-budget.spec.ts new file mode 100644 index 0000000000..582698a17b --- /dev/null +++ b/scripts/lane-test-budget.spec.ts @@ -0,0 +1,97 @@ +/** + * DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project. The root + * config spreads the budget into each project because Vitest forwards only a + * fixed list of CLI overrides into projects: --testTimeout is on it, + * --hookTimeout and --expect.poll.timeout are not, so a flag-based budget + * raises only the per-test default. A budget below the fixture's waits must + * end all three inside both projects; unset must keep Vitest's defaults, under + * which the same fixture passes. + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { COVERAGE_TEST_TIMEOUT_ENV } from './coverage-partitions.ts' + +const root = resolve(import.meta.dirname, '..') +const vitestCli = fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)) +const fixture = 'scripts/fixtures/lane-test-budget.fixture.ts' + +// The root config with every project's inventory narrowed to the fixture, so +// the fixture never joins the ordinary inventory and still runs once per +// project. Absolute import in POSIX spelling because the temporary directory +// is outside the repository; the sibling package.json keeps Vite bundling the +// config as ESM, which the repository root's "type" otherwise supplies. +const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-')) +const configPath = join(temporaryRoot, 'vitest.config.ts') +writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8') +writeFileSync(configPath, [ + `import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`, + 'export default {', + ' ...base,', + ' test: {', + ' ...base.test,', + ' projects: (base.test.projects ?? []).map(project => ({', + ' ...project,', + ` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`, + ' })),', + ' },', + '}', + '', +].join('\n'), 'utf8') +afterAll(() => { rmSync(temporaryRoot, { recursive: true, force: true }) }) + +function runFixture(budget: string | undefined): { status: number | null; output: string } { + const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' } + for (const name of Object.keys(env)) { + // The parent worker's Vitest state and the coverage coordinator's own + // variables describe this process, not the child; the Actions reporter + // would otherwise annotate the parent job with the fixture's failures. + if (name.startsWith('VITEST') || name.startsWith('DSH_COVERAGE_') || name === 'GITHUB_ACTIONS') Reflect.deleteProperty(env, name) + } + if (budget !== undefined) env[COVERAGE_TEST_TIMEOUT_ENV] = budget + const child = spawnSync(process.execPath, [vitestCli, 'run', '--config', configPath], { + cwd: root, + env, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }) + if (child.error !== undefined) throw child.error + return { status: child.status, output: `${child.stdout}\n${child.stderr}` } +} + +describe('lane test budget', () => { + it('ends per-test, hook, and expect.poll waits in both inline projects', { timeout: 90_000 }, () => { + const { status, output } = runFixture('200') + + expect(output).toMatch(/\|thread-safe\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) + expect(output).toMatch(/\|process-bound\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) + expect(output).toMatch(/Test Files\s+2 failed \(2\)/) + expect(output).toMatch(/Tests\s+6 failed \(6\)/) + expect(output.match(/Test timed out in 200ms\./g)).toHaveLength(2) + expect(output.match(/Hook timed out in 200ms\./g)).toHaveLength(2) + expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2) + for (const [, polled] of output.matchAll(/expected (\d+) to be greater than 500/g)) { + expect(Number(polled)).toBeLessThan(500) + } + expect(status).toBe(1) + }) + + it('keeps Vitest defaults when the budget is unset', { timeout: 90_000 }, () => { + const { status, output } = runFixture(undefined) + + expect(output).toMatch(/Test Files\s+2 passed \(2\)/) + expect(output).toMatch(/Tests\s+6 passed \(6\)/) + expect(status).toBe(0) + }) + + it('refuses a malformed budget at config load before any test runs', { timeout: 90_000 }, () => { + const { status, output } = runFixture('90000ms') + + expect(output).toContain(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got "90000ms".`) + expect(output).not.toMatch(/lane-test-budget\.fixture\.ts \(/) + expect(status).toBe(1) + }) +}) diff --git a/scripts/run-coverage-partitions.ts b/scripts/run-coverage-partitions.ts index 8626665b96..4b2293c148 100644 --- a/scripts/run-coverage-partitions.ts +++ b/scripts/run-coverage-partitions.ts @@ -2,9 +2,7 @@ import { resolve } from 'node:path' import { COVERAGE_PARTITIONS_ENV, - COVERAGE_TEST_TIMEOUT_ENV, CoveragePartitionCoordinator, - coverageTestTimeoutArgs, forwardedCoverageArgs, parseCoveragePartitionCount, } from './coverage-partitions.ts' @@ -22,9 +20,6 @@ const coordinator = new CoveragePartitionCoordinator({ root: resolve(import.meta.dirname, '..'), partitions, pnpmEntrypoint, - vitestArgs: [ - ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), - ...forwardedCoverageArgs(process.argv.slice(2)), - ], + vitestArgs: forwardedCoverageArgs(process.argv.slice(2)), }) process.exitCode = await coordinator.run() diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9d7a929446..ae31a7cc27 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -518,36 +518,23 @@ describe('gate graph validation', () => { expect(results.some(result => result.status === 'skipped')).toBe(false) }) - 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'))) - - for (const id of ['coverage', 'coverage-exempt-heavy']) { - expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([ - '--testTimeout=15000', - '--expect.poll.timeout=15000', - '--hookTimeout=15000', - ])) + it('leaves the lane test budget to the inherited environment on both coverage gates', () => { + // vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project; + // a CLI flag here would reach only the per-test default and split the + // budget between two owners. + for (const budget of ['15000', undefined]) { + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) + for (const id of ['coverage', 'coverage-exempt-heavy']) { + const gate = gates.find(subject => subject.id === id) + expect(gate?.args).not.toEqual(expect.arrayContaining([ + expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), + ])) + expect(gate?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS') + } } }) - it('keeps Vitest timeout defaults when the coverage override is absent', () => { - const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) - - 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|hookTimeout)=/), - ])) - } - }) - - it('rejects an invalid coverage timeout before starting a gate', () => { - expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))) - .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') - }) - it('selects partitioned coverage only when explicitly configured', () => { const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () => withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage'))) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d0c4773c66..71ef2e3009 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -12,12 +12,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' -import { - COVERAGE_PARTITIONS_ENV, - COVERAGE_TEST_TIMEOUT_ENV, - coverageTestTimeoutArgs, - parseCoveragePartitionCount, -} from './coverage-partitions.ts' +import { COVERAGE_PARTITIONS_ENV, parseCoveragePartitionCount } from './coverage-partitions.ts' import { pnpmInvocation } from './pnpm-invocation.ts' /** A named aggregate exposed by the gate runner. */ @@ -653,9 +648,8 @@ 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, expect.poll, and hook -// defaults together for instrumented lanes whose scheduling overhead exceeds -// those defaults. Explicit fixture timeouts remain authoritative. +// DSH_COVERAGE_TEST_TIMEOUT_MS reaches both gates through the inherited +// environment: vitest.config.ts applies it to every inline project. function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') if (flag === undefined) return { instrumented: [], exempt: [] } @@ -670,7 +664,6 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { function coverageGates(): Gate[] { const workers = coverageWorkerArgs() - const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]) const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) const instrumented = partitions === undefined ? pnpmExec('coverage', [ @@ -678,7 +671,6 @@ function coverageGates(): Gate[] { 'run', '--coverage', ...workers.instrumented, - ...timeouts, ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, @@ -697,7 +689,6 @@ function coverageGates(): Gate[] { 'run', ...coverageExemptHeavySuites.map(suite => suite.filter), ...workers.exempt, - ...timeouts, ], { label: 'test:coverage-exempt-heavy', needs: ['native-system'], diff --git a/vitest.config.ts b/vitest.config.ts index 6c0a46e4f3..46c19df6bb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ import { resolvePwshPath } from './packages/shell/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' -import { COVERAGE_PARTITION_MODE_ENV } from './scripts/coverage-partitions.ts' +import { COVERAGE_PARTITION_MODE_ENV, COVERAGE_TEST_TIMEOUT_ENV, coverageTestTimeoutOptions } from './scripts/coverage-partitions.ts' // Prints exact `path:line:col` records for every uncovered statement, branch // path, and function when a file misses the per-file 100% gate — the built-in @@ -144,6 +144,14 @@ if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coverag } const coveragePartitionMode = coveragePartitionRaw === '1' +// Lanes on shared hosts raise the per-test, hook, and expect.poll defaults +// together through DSH_COVERAGE_TEST_TIMEOUT_MS. The budget lands in each +// inline project below because Vitest forwards only a fixed list of CLI +// overrides into projects: --testTimeout is on it, --hookTimeout and +// --expect.poll.timeout are not. Unset keeps Vitest's defaults; explicit +// describe, case, and fixture timeouts remain authoritative. +const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV]) + // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. @@ -177,6 +185,7 @@ export default defineConfig({ // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, // Linux, and Windows. Forked workers avoid that shared thread path. pool: 'forks', + ...laneTestBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: testIncludes, exclude: [ @@ -192,6 +201,7 @@ export default defineConfig({ name: 'process-bound', execArgv: vitestExecArgv, pool: 'forks', + ...laneTestBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: processBoundTests, exclude: [