1// @effect-diagnostics nodeBuiltinImport:off - vitest loads this sequencer from the config,
2// outside any Effect runtime, and only needs path.relative.
3import * as NodePath from "node:path";
4
5import { BaseSequencer, type TestSpecification } from "vite-plus/test/node";
6
7import shardWeights from "./shardWeights.json" with { type: "json" };
8
9// What a file costs beyond its recorded test time: CI spends about 0.2s per
10// server test file on imports and setup, which the recorded times leave out.
11const FILE_OVERHEAD_SECONDS = 0.25;
12
13const recordedSeconds: Readonly<Record<string, number>> = shardWeights;
14
15/**
16 * Splits server test files across `--shard` runs by recorded duration. Vitest's
17 * default split hashes file paths into equal-count shards, which can put the
18 * slowest files on one runner. Here the longest file goes to the lightest shard
19 * first. Every shard derives the same split from the same file list, so each
20 * file still runs in exactly one shard. Files without a recorded time count as
21 * fast files. Record fresh times with `node scripts/update-test-shard-weights.ts`.
22 */
23export class WeightedShardSequencer extends BaseSequencer {
24 override async shard(files: TestSpecification[]) {
25 const { index, count } = this.ctx.config.shard ?? { index: 1, count: 1 };
26 const weighted = files
27 .map((spec) => {
28 const key = NodePath.relative(this.ctx.config.root, spec.moduleId).replaceAll("\\", "/");
29 return { spec, key, seconds: (recordedSeconds[key] ?? 0) + FILE_OVERHEAD_SECONDS };
30 })
31 .toSorted((a, b) => b.seconds - a.seconds || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
32
33 const loads = Array.from({ length: count }, () => 0);
34 const picked: TestSpecification[] = [];
35 for (const file of weighted) {
36 const least = Math.min(...loads);
37 const lightest = loads.indexOf(least);
38 loads[lightest] = least + file.seconds;
39 if (lightest === index - 1) picked.push(file.spec);
40 }
41 return picked;
42 }
43}