From 7e1f381b398cd47e8c9edb7ee780d0bf63776e5a Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 15:01:42 +0530 Subject: [PATCH 1/7] perf(ci): balance integration shards using vitest duration cache --- .github/workflows/integration-tests.yml | 73 ++++++++++++++++++++- package.json | 2 +- tests/integration/sequencer.ts | 87 +++++++++++++++++++++++++ vitest.config.ts | 7 ++ 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/integration/sequencer.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index b0120f35014..c405aed774e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -4,6 +4,10 @@ name: Integration Tests on: pull_request: branches: ['**'] + push: + # TEMP: vaibhavacharya/ci-speed-opt added for one-time end-to-end validation + # of the warm-cache job on a feature branch. Revert to `[main]` before merge. + branches: [main, vaibhavacharya/ci-speed-opt] jobs: setup: @@ -61,10 +65,67 @@ jobs: joined=$(IFS=,; echo "${entries[*]}") echo "value={\"include\":[$joined]}" >> "$GITHUB_OUTPUT" + warm-cache: + name: Warm vitest duration cache + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Git checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve primary Node.js version + id: node-version + run: echo "value=$(yq eval '.primary_node_version' .github/test-matrix.yml)" >> "$GITHUB_OUTPUT" + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ steps.node-version.outputs.value }} + cache: npm + check-latest: true + + - name: Install PNPM + run: | + corepack enable + corepack prepare pnpm@9.14.2 --activate + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: 2.6.6 + + - name: Install core dependencies + run: npm ci --no-audit + + - name: Build project + run: npm run build + + - name: Prepare tests + run: npm run test:init + + - name: Run tests + run: npm run test:integration + env: + NETLIFY_TEST_ACCOUNT_SLUG: 'netlify-integration-testing' + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN || 'fake-token' }} + NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHOKIDAR_INTERVAL: 20 + CHOKIDAR_USEPOLLING: 1 + + - name: Save vitest duration cache + if: success() + uses: actions/cache/save@v4 + with: + path: node_modules/.vite/vitest + key: vitest-cache-${{ github.sha }} + integration: name: Integration needs: setup - if: needs.setup.outputs.has-code-changes == 'true' + if: github.event_name == 'pull_request' && needs.setup.outputs.has-code-changes == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: @@ -110,6 +171,16 @@ jobs: - name: Prepare tests run: npm run test:init + # Restore historical per-file durations written by the warm-cache job on + # main pushes. Used by tests/integration/sequencer.ts to balance shards. + # Cold start (no cache yet) silently degrades to file-count distribution. + - name: Restore vitest duration cache + uses: actions/cache/restore@v4 + with: + path: node_modules/.vite/vitest + key: vitest-cache-do-not-match + restore-keys: vitest-cache- + - name: Tests run: npm run test:integration -- --coverage --shard=${{ matrix.shard }} env: diff --git a/package.json b/package.json index 2b02ced87f1..a5fc052cc65 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "start": "node ./bin/run.js", "test": "run-s test:unit test:integration test:e2e", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:init": "run-s test:init:*", + "test:init": "run-p test:init:*", "test:init:cli-help": "npm run start -- --help", "test:init:cli-version": "npm run start -- --version", "test:init:hugo-deps": "npm ci --prefix tests/integration/__fixtures__/hugo-site --no-audit", diff --git a/tests/integration/sequencer.ts b/tests/integration/sequencer.ts new file mode 100644 index 00000000000..13148f25a0b --- /dev/null +++ b/tests/integration/sequencer.ts @@ -0,0 +1,87 @@ +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' + +import { BaseSequencer, type TestSpecification } from 'vitest/node' + +/** + * Distributes test files across `--shard` buckets using greedy LPT bin-packing, + * weighted by historical per-file duration from vitest's own results cache. + * Same approach as vitest-dev/vitest#9184 and the `@tenbin/vitest` package. + */ + +interface CachedResult { + duration: number + failed: boolean +} + +interface ResultsFile { + version: string + results: [key: string, CachedResult][] +} + +// Non-zero so files without prior timing data still participate in bin-packing. +// Matches @tenbin/vitest's FALLBACK_DURATION. +const FALLBACK_DURATION = 0.1 + +function loadDurationCache(root: string): Map { + const cacheRoot = path.join(root, 'node_modules', '.vite', 'vitest') + const map = new Map() + let entries: string[] + try { + entries = readdirSync(cacheRoot) + } catch { + return map + } + for (const entry of entries) { + try { + const raw = readFileSync(path.join(cacheRoot, entry, 'results.json'), 'utf8') + const parsed = JSON.parse(raw) as ResultsFile + for (const [key, result] of parsed.results) { + const rel = key.startsWith(':') ? key.slice(1) : key + if (result.duration > 0) map.set(rel, result.duration) + } + if (map.size > 0) return map + } catch { + // try next entry + } + } + return map +} + +function toPosixRelative(root: string, absolutePath: string): string { + return path.relative(root, absolutePath).split(path.sep).join('/') +} + +export class BalancedShardSequencer extends BaseSequencer { + shard(files: TestSpecification[]): Promise { + const shardCfg = this.ctx.config.shard + if (!shardCfg) return Promise.resolve(files) + + const { root } = this.ctx.config + const cache = loadDurationCache(root) + + const weighted = files + .map((spec) => { + const key = toPosixRelative(root, spec.moduleId) + const weight = cache.get(key) ?? FALLBACK_DURATION + return { spec, key, weight } + }) + .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) + + const buckets = Array.from({ length: shardCfg.count }, () => ({ + specs: [] as TestSpecification[], + total: 0, + })) + + for (const { spec, weight } of weighted) { + let target = buckets[0] + for (const bucket of buckets) { + if (bucket.total < target.total) target = bucket + } + target.specs.push(spec) + target.total += weight + } + + return Promise.resolve(buckets[shardCfg.index - 1].specs) + } +} diff --git a/vitest.config.ts b/vitest.config.ts index e2dc214919f..603b4e6f9b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config' +import { BalancedShardSequencer } from './tests/integration/sequencer.js' + export default defineConfig({ test: { include: ['tests/**/*.test.js', 'tests/**/*.test.ts'], @@ -25,6 +27,11 @@ export default defineConfig({ singleThread: true, }, }, + sequence: { + // Distributes files across `--shard` buckets by line count so the heaviest + // test files don't pile into the same shard. See tests/integration/sequencer.ts. + sequencer: BalancedShardSequencer, + }, coverage: { provider: 'v8', reporter: ['text', 'lcov'], From 10233a9d473f34552d8afaa1a1aff7efcfa69012 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 15:15:47 +0530 Subject: [PATCH 2/7] ci: save vitest cache even when warm-cache job has test failures --- .github/workflows/integration-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c405aed774e..a196d961c82 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -116,7 +116,9 @@ jobs: CHOKIDAR_USEPOLLING: 1 - name: Save vitest duration cache - if: success() + # Save even if some tests fail; vitest writes per-file durations as it + # goes, and the sequencer ignores zero-duration entries. + if: always() uses: actions/cache/save@v4 with: path: node_modules/.vite/vitest From 51bb5283912e2fda098aa527cb22bb5556adbe15 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 15:49:25 +0530 Subject: [PATCH 3/7] ci: drop warm-cache job; sequencer keeps round-robin sharding only --- .github/workflows/integration-tests.yml | 75 +--------------------- tests/integration/sequencer.ts | 84 +++---------------------- vitest.config.ts | 4 +- 3 files changed, 11 insertions(+), 152 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a196d961c82..b0120f35014 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -4,10 +4,6 @@ name: Integration Tests on: pull_request: branches: ['**'] - push: - # TEMP: vaibhavacharya/ci-speed-opt added for one-time end-to-end validation - # of the warm-cache job on a feature branch. Revert to `[main]` before merge. - branches: [main, vaibhavacharya/ci-speed-opt] jobs: setup: @@ -65,69 +61,10 @@ jobs: joined=$(IFS=,; echo "${entries[*]}") echo "value={\"include\":[$joined]}" >> "$GITHUB_OUTPUT" - warm-cache: - name: Warm vitest duration cache - if: github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Git checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Resolve primary Node.js version - id: node-version - run: echo "value=$(yq eval '.primary_node_version' .github/test-matrix.yml)" >> "$GITHUB_OUTPUT" - - - name: Use Node.js - uses: actions/setup-node@v6 - with: - node-version: ${{ steps.node-version.outputs.value }} - cache: npm - check-latest: true - - - name: Install PNPM - run: | - corepack enable - corepack prepare pnpm@9.14.2 --activate - - - name: Setup Deno - uses: denoland/setup-deno@v1 - with: - deno-version: 2.6.6 - - - name: Install core dependencies - run: npm ci --no-audit - - - name: Build project - run: npm run build - - - name: Prepare tests - run: npm run test:init - - - name: Run tests - run: npm run test:integration - env: - NETLIFY_TEST_ACCOUNT_SLUG: 'netlify-integration-testing' - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN || 'fake-token' }} - NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CHOKIDAR_INTERVAL: 20 - CHOKIDAR_USEPOLLING: 1 - - - name: Save vitest duration cache - # Save even if some tests fail; vitest writes per-file durations as it - # goes, and the sequencer ignores zero-duration entries. - if: always() - uses: actions/cache/save@v4 - with: - path: node_modules/.vite/vitest - key: vitest-cache-${{ github.sha }} - integration: name: Integration needs: setup - if: github.event_name == 'pull_request' && needs.setup.outputs.has-code-changes == 'true' + if: needs.setup.outputs.has-code-changes == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: @@ -173,16 +110,6 @@ jobs: - name: Prepare tests run: npm run test:init - # Restore historical per-file durations written by the warm-cache job on - # main pushes. Used by tests/integration/sequencer.ts to balance shards. - # Cold start (no cache yet) silently degrades to file-count distribution. - - name: Restore vitest duration cache - uses: actions/cache/restore@v4 - with: - path: node_modules/.vite/vitest - key: vitest-cache-do-not-match - restore-keys: vitest-cache- - - name: Tests run: npm run test:integration -- --coverage --shard=${{ matrix.shard }} env: diff --git a/tests/integration/sequencer.ts b/tests/integration/sequencer.ts index 13148f25a0b..41931b9effd 100644 --- a/tests/integration/sequencer.ts +++ b/tests/integration/sequencer.ts @@ -1,87 +1,19 @@ -import { readFileSync, readdirSync } from 'node:fs' -import path from 'node:path' - import { BaseSequencer, type TestSpecification } from 'vitest/node' /** - * Distributes test files across `--shard` buckets using greedy LPT bin-packing, - * weighted by historical per-file duration from vitest's own results cache. - * Same approach as vitest-dev/vitest#9184 and the `@tenbin/vitest` package. + * Distributes test files across `--shard` buckets via alphabetical round-robin. + * The default sha1-hash sharding clusters slow files (e.g. all `dev/*` tests) + * into the same shard. Round-robin breaks those clusters up so each shard ends + * up with roughly the same total runtime without needing historical timing + * data. See vitest-dev/vitest#9184 for prior art on duration-aware sharding. */ - -interface CachedResult { - duration: number - failed: boolean -} - -interface ResultsFile { - version: string - results: [key: string, CachedResult][] -} - -// Non-zero so files without prior timing data still participate in bin-packing. -// Matches @tenbin/vitest's FALLBACK_DURATION. -const FALLBACK_DURATION = 0.1 - -function loadDurationCache(root: string): Map { - const cacheRoot = path.join(root, 'node_modules', '.vite', 'vitest') - const map = new Map() - let entries: string[] - try { - entries = readdirSync(cacheRoot) - } catch { - return map - } - for (const entry of entries) { - try { - const raw = readFileSync(path.join(cacheRoot, entry, 'results.json'), 'utf8') - const parsed = JSON.parse(raw) as ResultsFile - for (const [key, result] of parsed.results) { - const rel = key.startsWith(':') ? key.slice(1) : key - if (result.duration > 0) map.set(rel, result.duration) - } - if (map.size > 0) return map - } catch { - // try next entry - } - } - return map -} - -function toPosixRelative(root: string, absolutePath: string): string { - return path.relative(root, absolutePath).split(path.sep).join('/') -} - export class BalancedShardSequencer extends BaseSequencer { shard(files: TestSpecification[]): Promise { const shardCfg = this.ctx.config.shard if (!shardCfg) return Promise.resolve(files) - const { root } = this.ctx.config - const cache = loadDurationCache(root) - - const weighted = files - .map((spec) => { - const key = toPosixRelative(root, spec.moduleId) - const weight = cache.get(key) ?? FALLBACK_DURATION - return { spec, key, weight } - }) - .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) - - const buckets = Array.from({ length: shardCfg.count }, () => ({ - specs: [] as TestSpecification[], - total: 0, - })) - - for (const { spec, weight } of weighted) { - let target = buckets[0] - for (const bucket of buckets) { - if (bucket.total < target.total) target = bucket - } - target.specs.push(spec) - target.total += weight - } - - return Promise.resolve(buckets[shardCfg.index - 1].specs) + const sorted = [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)) + const mine = sorted.filter((_, i) => i % shardCfg.count === shardCfg.index - 1) + return Promise.resolve(mine) } } diff --git a/vitest.config.ts b/vitest.config.ts index 603b4e6f9b7..d0ed52e6a88 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,8 +28,8 @@ export default defineConfig({ }, }, sequence: { - // Distributes files across `--shard` buckets by line count so the heaviest - // test files don't pile into the same shard. See tests/integration/sequencer.ts. + // Round-robin sharding instead of vitest's hash-based default, so the + // slow files (e.g. dev/*) don't all land in the same shard. sequencer: BalancedShardSequencer, }, coverage: { From 1ae897a2502e5858501de734f45a4982d51fbb03 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 16:38:11 +0530 Subject: [PATCH 4/7] exp: weight shard sequencer by it()/test() call count --- tests/integration/sequencer.ts | 48 ++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/integration/sequencer.ts b/tests/integration/sequencer.ts index 41931b9effd..c04ae532be2 100644 --- a/tests/integration/sequencer.ts +++ b/tests/integration/sequencer.ts @@ -1,19 +1,51 @@ +import { readFileSync } from 'node:fs' + import { BaseSequencer, type TestSpecification } from 'vitest/node' /** - * Distributes test files across `--shard` buckets via alphabetical round-robin. - * The default sha1-hash sharding clusters slow files (e.g. all `dev/*` tests) - * into the same shard. Round-robin breaks those clusters up so each shard ends - * up with roughly the same total runtime without needing historical timing - * data. See vitest-dev/vitest#9184 for prior art on duration-aware sharding. + * Distributes test files across `--shard` buckets via greedy LPT bin-packing, + * weighted by the number of `it()` / `test()` calls in each file. Heavier + * files land first so each shard ends up with roughly the same total runtime. + * See vitest-dev/vitest#9184 for prior art on duration-aware sharding. */ + +function countTestCases(filePath: string): number { + try { + const src = readFileSync(filePath, 'utf8') + const matches = src.match(/\b(?:test|it)(?:\.\w+)?\(/g) + return matches?.length ?? 1 + } catch { + return 1 + } +} + export class BalancedShardSequencer extends BaseSequencer { shard(files: TestSpecification[]): Promise { const shardCfg = this.ctx.config.shard if (!shardCfg) return Promise.resolve(files) - const sorted = [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)) - const mine = sorted.filter((_, i) => i % shardCfg.count === shardCfg.index - 1) - return Promise.resolve(mine) + const weighted = files + .map((spec) => ({ + spec, + key: spec.moduleId, + weight: countTestCases(spec.moduleId), + })) + .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) + + const buckets = Array.from({ length: shardCfg.count }, () => ({ + specs: [] as TestSpecification[], + total: 0, + })) + + for (const { spec, weight } of weighted) { + let target = buckets[0] + for (const bucket of buckets) { + if (bucket.total < target.total) target = bucket + } + target.specs.push(spec) + target.total += weight + } + + return Promise.resolve(buckets[shardCfg.index - 1].specs) } } From f31c60f1c9465ea098dff8118149e3f41f646df9 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 17:10:31 +0530 Subject: [PATCH 5/7] fix(test): remove shared execaMock race in concurrent clone tests --- .../integration/commands/clone/clone.test.ts | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/tests/integration/commands/clone/clone.test.ts b/tests/integration/commands/clone/clone.test.ts index a4a0a45f0b5..f7e0a207669 100644 --- a/tests/integration/commands/clone/clone.test.ts +++ b/tests/integration/commands/clone/clone.test.ts @@ -1,6 +1,6 @@ import { sep } from 'node:path' -import { describe, expect, it, beforeEach } from 'vitest' +import { describe, expect, it } from 'vitest' import js from 'dedent' import stripAnsi from 'strip-ansi' @@ -11,33 +11,29 @@ import { createMock } from '../../utils/mock-execa.js' import execa from 'execa' import { cliPath } from '../../utils/cli-path.js' -describe.concurrent('clone command', () => { - let execaMock: Awaited>[0] - - beforeEach(async () => { - ;[execaMock] = await createMock(js` - module.exports = function execa(command, args) { - // Mock git clone command - if (command === 'git' && args[0] === 'clone') { - const targetDir = args[2] - return require('fs/promises').mkdir(targetDir, { recursive: true }) - .then(() => { - const stdout = 'Mocked git clone received: ' + command + ' ' + args.join(' ') - console.log(stdout) - return { stdout, stderr: '' } - }) - } - // For any other command, use the real execa - // Normalize paths for Windows - const realExeca = require('${require.resolve('execa').split(sep).join('/')}') - // execa's APi is... really weird - const result = Promise.resolve(realExeca(command, args)) - result.unref = () => {} - return result - } - `) - }) +const EXECA_MOCK_SOURCE = js` + module.exports = function execa(command, args) { + // Mock git clone command + if (command === 'git' && args[0] === 'clone') { + const targetDir = args[2] + return require('fs/promises').mkdir(targetDir, { recursive: true }) + .then(() => { + const stdout = 'Mocked git clone received: ' + command + ' ' + args.join(' ') + console.log(stdout) + return { stdout, stderr: '' } + }) + } + // For any other command, use the real execa + // Normalize paths for Windows + const realExeca = require('${require.resolve('execa').split(sep).join('/')}') + // execa's APi is... really weird + const result = Promise.resolve(realExeca(command, args)) + result.unref = () => {} + return result + } +` +describe.concurrent('clone command', () => { const SITE_INFO_FIXTURE = { id: 'site_id', name: 'test-site', @@ -68,6 +64,7 @@ describe.concurrent('clone command', () => { it.todo('prints an error and exits if not authenticated') it("clones a repo and links it to the one project connected to that repo's HTTPS URL", async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const routes = [...API_ROUTES_FIXTURE] const questions = [ { @@ -132,6 +129,7 @@ To unlink this project, run: netlify unlink`) }) it('clones into the provided `[targetDir]` if arg is provided', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const routes = [...API_ROUTES_FIXTURE] const questions: never[] = [] // no interactive prompts at all @@ -185,6 +183,7 @@ To unlink this project, run: netlify unlink`) }) it('clones into the entered target dir if user enters one when prompted', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const routes = [...API_ROUTES_FIXTURE] const questions = [ { @@ -249,6 +248,7 @@ To unlink this project, run: netlify unlink`) }) it('links to project with given `--id` when provided', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const otherSiteInfo = { id: 'other-site-id', name: 'other-site', @@ -306,6 +306,7 @@ To unlink this project, run: netlify unlink`) }) it('links to project with given `--name` when provided', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const otherSiteInfo = { id: 'other-site-id', name: 'other-site', @@ -367,6 +368,7 @@ To unlink this project, run: netlify unlink`) }) it('prompts user when multiple projects match git repo HTTPS URL', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const otherSiteInfo = { id: 'other-site-id', name: 'other-site', @@ -425,6 +427,7 @@ To unlink this project, run: netlify unlink`) }) it('prints an error and exits when no project is connected to the git repo HTTPS URL', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const otherSiteInfo = { id: 'other-site-id', name: 'other-site', @@ -485,6 +488,7 @@ Run git remote -v to see a list of your git remotes.`) }) it('prints an error and exits given an invalid git repo specifier', async (t) => { + const [execaMock] = await createMock(EXECA_MOCK_SOURCE) const routes = [ { path: 'sites', From ec0e191c693cca42bf7dbc702cee16826a8a6583 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 17:22:22 +0530 Subject: [PATCH 6/7] revert: drop test-count weighting; keep alphabetical round-robin --- tests/integration/sequencer.ts | 48 ++++++---------------------------- 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/tests/integration/sequencer.ts b/tests/integration/sequencer.ts index c04ae532be2..41931b9effd 100644 --- a/tests/integration/sequencer.ts +++ b/tests/integration/sequencer.ts @@ -1,51 +1,19 @@ -import { readFileSync } from 'node:fs' - import { BaseSequencer, type TestSpecification } from 'vitest/node' /** - * Distributes test files across `--shard` buckets via greedy LPT bin-packing, - * weighted by the number of `it()` / `test()` calls in each file. Heavier - * files land first so each shard ends up with roughly the same total runtime. - * See vitest-dev/vitest#9184 for prior art on duration-aware sharding. + * Distributes test files across `--shard` buckets via alphabetical round-robin. + * The default sha1-hash sharding clusters slow files (e.g. all `dev/*` tests) + * into the same shard. Round-robin breaks those clusters up so each shard ends + * up with roughly the same total runtime without needing historical timing + * data. See vitest-dev/vitest#9184 for prior art on duration-aware sharding. */ - -function countTestCases(filePath: string): number { - try { - const src = readFileSync(filePath, 'utf8') - const matches = src.match(/\b(?:test|it)(?:\.\w+)?\(/g) - return matches?.length ?? 1 - } catch { - return 1 - } -} - export class BalancedShardSequencer extends BaseSequencer { shard(files: TestSpecification[]): Promise { const shardCfg = this.ctx.config.shard if (!shardCfg) return Promise.resolve(files) - const weighted = files - .map((spec) => ({ - spec, - key: spec.moduleId, - weight: countTestCases(spec.moduleId), - })) - .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) - - const buckets = Array.from({ length: shardCfg.count }, () => ({ - specs: [] as TestSpecification[], - total: 0, - })) - - for (const { spec, weight } of weighted) { - let target = buckets[0] - for (const bucket of buckets) { - if (bucket.total < target.total) target = bucket - } - target.specs.push(spec) - target.total += weight - } - - return Promise.resolve(buckets[shardCfg.index - 1].specs) + const sorted = [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)) + const mine = sorted.filter((_, i) => i % shardCfg.count === shardCfg.index - 1) + return Promise.resolve(mine) } } From 17fa2beeec646b45b86154f343796533a17d3676 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 17:40:24 +0530 Subject: [PATCH 7/7] chore: trim sequencer comments --- tests/integration/sequencer.ts | 7 ++----- vitest.config.ts | 2 -- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/integration/sequencer.ts b/tests/integration/sequencer.ts index 41931b9effd..0aae6523554 100644 --- a/tests/integration/sequencer.ts +++ b/tests/integration/sequencer.ts @@ -1,11 +1,8 @@ import { BaseSequencer, type TestSpecification } from 'vitest/node' /** - * Distributes test files across `--shard` buckets via alphabetical round-robin. - * The default sha1-hash sharding clusters slow files (e.g. all `dev/*` tests) - * into the same shard. Round-robin breaks those clusters up so each shard ends - * up with roughly the same total runtime without needing historical timing - * data. See vitest-dev/vitest#9184 for prior art on duration-aware sharding. + * Round-robin sharding so slow files (e.g. all `dev/*` tests) don't cluster in + * the same shard like they do under vitest's default sha1-hash distribution. */ export class BalancedShardSequencer extends BaseSequencer { shard(files: TestSpecification[]): Promise { diff --git a/vitest.config.ts b/vitest.config.ts index d0ed52e6a88..dcabcac24be 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,8 +28,6 @@ export default defineConfig({ }, }, sequence: { - // Round-robin sharding instead of vitest's hash-based default, so the - // slow files (e.g. dev/*) don't all land in the same shard. sequencer: BalancedShardSequencer, }, coverage: {