Skip to content

Commit 057b2da

Browse files
authored
ci: run coverage in one job again (#2079)
The Coverage lane was split into two matrix shards plus a Coverage Report job that downloaded both blob reports and merged them. That claimed three runner slots per PR and put a barrier in front of the merge: the report job could not start until the slower shard finished, and the blobs it waited on are tens of MB to upload and download. One job asks for one slot and reports its own thresholds where it runs, so the lane finishes when the suite finishes. Everything the split needed goes with it: the shard/merge switches in vitest.config.ts, the blob reporter swap, the zeroed per-shard thresholds, and the env blanking that `test:fuzz-worker` carried only to keep the second leg from inheriting them.
1 parent c794c11 commit 057b2da

6 files changed

Lines changed: 24 additions & 105 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -278,22 +278,11 @@ jobs:
278278
run: node --experimental-strip-types scripts/check-package.ts
279279

280280
# Runs the full unit + provider-integration suites under coverage with
281-
# thresholds, so a separate unit-tests job would rerun the same tests. The
282-
# suite is sharded across runners; each shard writes a blob report and the
283-
# Coverage Report job merges them, evaluates thresholds once over the full
284-
# suite, and produces every coverage artifact (vitest.config.ts carries the
285-
# shard/merge switches).
281+
# thresholds, so a separate unit-tests job would rerun the same tests.
286282
coverage:
287283
name: Coverage
288284
runs-on: ubuntu-latest
289-
timeout-minutes: 15
290-
strategy:
291-
fail-fast: false
292-
matrix:
293-
shard: [1, 2]
294-
env:
295-
AGENT_DEVICE_COVERAGE_SHARD: ${{ matrix.shard }}/2
296-
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
285+
timeout-minutes: 30
297286
steps:
298287
- name: Checkout
299288
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -304,76 +293,31 @@ jobs:
304293
uses: ./.github/actions/setup-node-pnpm
305294

306295
- name: Test changed-line coverage gate
307-
if: matrix.shard == 1
308296
uses: ./.github/actions/run-gate
309297
with: { gate: coverage-model }
310298

311-
- name: Run coverage shard
299+
- name: Run coverage
300+
id: run-coverage
301+
env:
302+
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
312303
uses: ./.github/actions/run-gate
313304
with: { gate: unit-ci }
314305

315306
# The TMPDIR redirection both test lanes depend on (#1593/#1595). The check is a real
316307
# package script that no workflow ran: it is reachable only through `check:unit`, an
317-
# aggregate CI never invokes, so a leak regression could not fail a PR. Runs per shard,
318-
# because a leak lands on whichever runner executed the leaking file.
308+
# aggregate CI never invokes, so a leak regression could not fail a PR. Placed here
309+
# because this is the lane whose instrumented suite would leak a run directory.
319310
- name: Check for leaked temp directories
320311
uses: ./.github/actions/run-gate
321312
with: { gate: tmpdir-leaks }
322313

323-
- name: Upload coverage blob
324-
if: always()
325-
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
326-
with:
327-
name: coverage-blob-${{ matrix.shard }}
328-
path: .vitest-reports/
329-
# The directory is dot-prefixed, which v4 excludes by default.
330-
include-hidden-files: true
331-
if-no-files-found: error
332-
333-
coverage-report:
334-
name: Coverage Report
335-
needs: coverage
336-
runs-on: ubuntu-latest
337-
timeout-minutes: 10
338-
env:
339-
AGENT_DEVICE_COVERAGE_MERGE: '1'
340-
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
341-
steps:
342-
- name: Checkout
343-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
344-
with:
345-
fetch-depth: 0
346-
347-
- name: Setup toolchain
348-
uses: ./.github/actions/setup-node-pnpm
349-
350-
- name: Download coverage blobs
351-
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
352-
with:
353-
pattern: coverage-blob-*
354-
path: .tmp/coverage-blobs
355-
356-
# download-artifact nests each artifact in its own subdirectory; the blob
357-
# merge reads one flat directory.
358-
- name: Collect coverage blobs
359-
run: |
360-
set -euo pipefail
361-
mkdir -p .vitest-reports
362-
find .tmp/coverage-blobs -name '*.json' -exec mv {} .vitest-reports/ \;
363-
ls .vitest-reports
364-
365-
# Reuses the blobs the shards wrote (never reruns tests) and fails when
366-
# merged changed-line coverage < the threshold in
314+
# Reuses the lcov the coverage step just wrote (never runs coverage twice)
315+
# and fails when changed-line coverage < the threshold in
367316
# scripts/coverage-changed/model.ts. The `coverage-waiver` PR label maps to
368317
# the waiver env, which skips the failure but still prints the numbers.
369-
# Gated on the merge step's own outcome (#1781 A5): when it fails,
370-
# lcov.info is never written, so this step would just re-report that
318+
# Gated on the coverage step's own outcome (#1781 A5): when `Run coverage`
319+
# fails, lcov.info is never written, so this step would just re-report that
371320
# failure as its own red ("no lcov report") instead of a coverage verdict.
372-
- name: Merge coverage shards
373-
id: run-coverage
374-
uses: ./.github/actions/run-gate
375-
with: { gate: unit-ci }
376-
377321
- name: Enforce changed-line coverage gate
378322
if: steps.run-coverage.outcome == 'success' && github.event_name == 'pull_request'
379323
env:

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,3 @@ android/ime-helper/dist/
4848
# Workspace package declaration output (tsc -b project references)
4949
packages/*/dist-types/
5050
*.tsbuildinfo
51-
.vitest-reports/

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@
177177
"test:maestro-compat": "vitest run --project unit-core packages/maestro src/daemon/adapters/maestro src/compat/__tests__/replay-input.test.ts",
178178
"test:coverage": "vitest run --coverage --project=!fuzz-worker && pnpm test:fuzz-worker",
179179
"test:coverage:ci": "vitest run --coverage --project=!fuzz-worker && pnpm test:fuzz-worker",
180-
"test:fuzz-worker": "AGENT_DEVICE_COVERAGE_SHARD= AGENT_DEVICE_COVERAGE_MERGE= vitest run --project fuzz-worker",
180+
"test:fuzz-worker": "vitest run --project fuzz-worker",
181181
"test:integration:provider": "vitest run --project provider-integration",
182182
"test:integration:progress": "node --experimental-strip-types scripts/integration-progress.ts",
183183
"test:integration:progress:check": "node --experimental-strip-types scripts/integration-progress.ts --check",

scripts/gate/declarations.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
// A script whose Vitest/node-test invocation the loader cannot read, mapped to the units it
44
// really runs. Empty right now: the one entry was a coverage wrapper, and `test:coverage:ci`
55
// is back to shapes the loader reads directly. It is no longer a single bare run — it is a
6-
// negated `--project` leg plus a nested script whose body carries an env prefix — but
7-
// scripts/gate/model.ts resolves both, so the units still come from the script itself.
6+
// negated `--project` leg plus a nested script — but scripts/gate/model.ts resolves both, so
7+
// the units still come from the script itself.
88
export const OPAQUE_RUNNERS: Readonly<Record<string, readonly string[]>> = {};
99

1010
export const REPORTING_SCRIPTS: Readonly<Record<string, string>> = {

scripts/gate/model.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,17 @@ test('a negated --project subtracts from the configured set, so the skipped one
7272
);
7373
});
7474

75-
// The real `test:coverage:ci` shape: the second leg is a nested script whose body carries an env
76-
// prefix (it blanks the coverage-shard switches). Both indirections have to survive, or the lane
77-
// stops owning the project it hands to that leg.
75+
// The real `test:coverage:ci` shape: a negated `--project` leg, then a second leg that is a
76+
// nested script. Both indirections have to survive, or the lane stops owning the project it
77+
// hands to that leg.
7878
test('the two halves of test:coverage:ci together still own every project', () => {
7979
assert.deepEqual(
8080
scriptUnits(
8181
'test:coverage:ci',
8282
scriptModel({
8383
'test:coverage:ci':
8484
'vitest run --coverage --project=!subprocess-stub && pnpm test:subprocess-stub',
85-
'test:subprocess-stub':
86-
'AGENT_DEVICE_COVERAGE_SHARD= AGENT_DEVICE_COVERAGE_MERGE= vitest run --project subprocess-stub',
85+
'test:subprocess-stub': 'vitest run --project subprocess-stub',
8786
}),
8887
),
8988
['vitest:unit-core', 'vitest:subprocess-stub'],

vitest.config.ts

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,6 @@ const SUBPROCESS_STUB_TESTS: readonly string[] = [
3535
// assumed: the cases execute out of process, which the fork's inspector session never
3636
// instruments, so this file reports the same lines with or without it.
3737
//
38-
// The second leg goes through `test:fuzz-worker`, which blanks AGENT_DEVICE_COVERAGE_SHARD and
39-
// AGENT_DEVICE_COVERAGE_MERGE — the sharding switches read just below. ci.yml sets them as
40-
// *job*-level env over a single `gate: unit-ci` step, so without the blanking both legs inherit
41-
// them and the shard dies: Vitest refuses `--shard=1/2` over a one-file project ("must be a
42-
// smaller than count of test files"), and the blob reporter overwrites the instrumented shard's
43-
// report on its way out, leaving the Coverage Report job nothing to merge. Measured, not
44-
// reasoned: the unguarded leg leaves a 1.4 kB blob holding only this project plus that error.
45-
//
4638
// Membership is by demonstrated failure, not by a property of the code. In particular it is
4739
// NOT "constructs a `node:worker_threads` Worker": `session-replay-runtime-maestro.test.ts`
4840
// does exactly that and stays in `unit-core`, instrumented and green. The proximate cause was
@@ -80,22 +72,8 @@ export const SETUP_FILES = [
8072
'src/__tests__/process-memo-setup.ts',
8173
];
8274

83-
// The CI Coverage lane shards the instrumented suite across runners and merges
84-
// the results on one of them (see ci.yml). AGENT_DEVICE_COVERAGE_SHARD="<i>/<n>"
85-
// turns an invocation into shard i of n writing a blob report; both unset means
86-
// the ordinary full run. AGENT_DEVICE_COVERAGE_MERGE=1 aggregates previously
87-
// written blobs instead of collecting tests — it still evaluates thresholds and
88-
// writes every configured coverage report.
89-
const COVERAGE_SHARD = process.env.AGENT_DEVICE_COVERAGE_SHARD;
90-
const COVERAGE_MERGE = process.env.AGENT_DEVICE_COVERAGE_MERGE === '1';
91-
9275
export default defineConfig({
9376
test: {
94-
...(COVERAGE_SHARD ? { shard: COVERAGE_SHARD } : {}),
95-
...(COVERAGE_MERGE ? { mergeReports: '.vitest-reports' } : {}),
96-
outputFile: COVERAGE_SHARD
97-
? { blob: `.vitest-reports/blob-${COVERAGE_SHARD.split('/')[0]}.json` }
98-
: undefined,
9977
// Redirects TMPDIR to one per-run directory for the whole invocation (all
10078
// projects, every worker) and removes it once at the end — see the file
10179
// for why a single global hook beats per-file cleanup here.
@@ -117,9 +95,7 @@ export default defineConfig({
11795
// assumption without reducing ordinary file-level parallelism.
11896
maxConcurrency: 1,
11997
// Gate reporters for every lane; a `--reporter` flag would replace them, so no lane passes one.
120-
// A coverage shard swaps in the blob reporter alongside the default one: its console output is
121-
// only per-shard noise anyway, and the merge run below needs the blobs to exist.
122-
reporters: COVERAGE_SHARD ? ['default', 'blob'] : ['default', slowTestGateReporter()],
98+
reporters: ['default', slowTestGateReporter()],
12399
projects: [
124100
{
125101
test: {
@@ -251,9 +227,10 @@ export default defineConfig({
251227
coverage: {
252228
provider: 'v8',
253229
reporter: ['text', 'html', 'lcov', 'json-summary'],
254-
// A shard sees roughly half the suite, so its own numbers sit far below
255-
// the gate; thresholds are enforced once on the merged full-suite run.
256-
thresholds: COVERAGE_SHARD ? { statements: 0, lines: 0 } : { statements: 78, lines: 80 },
230+
thresholds: {
231+
statements: 78,
232+
lines: 80,
233+
},
257234
include: ['src/**/*.ts', 'packages/*/src/**/*.ts'],
258235
exclude: [
259236
'src/**/*.test.ts',

0 commit comments

Comments
 (0)