( files: readonly WeightedTestFile[], shardCount: number, )
| 161 | * or more shards than files. External CLI values must be parsed first. |
| 162 | */ |
| 163 | export const planCiShards = ( |
| 164 | files: readonly WeightedTestFile[], |
| 165 | shardCount: number, |
| 166 | ): readonly CiShardAssignment[] => { |
| 167 | if (!Number.isSafeInteger(shardCount) || shardCount <= 0) { |
| 168 | throw new Error("shard count must be a positive safe integer"); |
| 169 | } |
| 170 | if (files.length < shardCount) { |
| 171 | throw new Error("every configured shard must receive at least one test file"); |
| 172 | } |
| 173 | |
| 174 | const paths = files.map((file) => file.path); |
| 175 | if (new Set(paths).size !== paths.length) { |
| 176 | throw new Error("test file paths must be unique"); |
| 177 | } |
| 178 | for (const file of files) { |
| 179 | if (file.path.length === 0 || !Number.isFinite(file.durationMs) || file.durationMs <= 0) { |
| 180 | throw new Error("test files must have a path and positive finite duration"); |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | const bins = Array.from({ length: shardCount }, (_, index) => ({ |
| 185 | index: index + 1, |
| 186 | estimatedDurationMs: 0, |
| 187 | files: [] as string[], |
| 188 | isolated: false, |
| 189 | })); |
| 190 | const longestFirst = [...files].sort( |
| 191 | (left, right) => right.durationMs - left.durationMs || comparePaths(left.path, right.path), |
| 192 | ); |
| 193 | |
| 194 | for (const file of longestFirst) { |
| 195 | const mustIsolate = file.durationMs >= ISOLATED_TEST_DURATION_MS; |
| 196 | const candidates = bins.filter((bin) => (mustIsolate ? bin.files.length === 0 : !bin.isolated)); |
| 197 | const firstCandidate = candidates[0]; |
| 198 | if (firstCandidate === undefined) { |
| 199 | throw new Error("configured shard count cannot isolate every long-running test file"); |
| 200 | } |
| 201 | |
| 202 | let lightest = firstCandidate; |
| 203 | for (const candidate of candidates.slice(1)) { |
| 204 | if (candidate.estimatedDurationMs < lightest.estimatedDurationMs) { |
| 205 | lightest = candidate; |
| 206 | } |
| 207 | } |
| 208 | lightest.files.push(file.path); |
| 209 | lightest.estimatedDurationMs += file.durationMs; |
| 210 | lightest.isolated = mustIsolate; |
| 211 | } |
| 212 | |
| 213 | return bins.map((bin) => ({ |
| 214 | index: bin.index, |
| 215 | estimatedDurationMs: bin.estimatedDurationMs, |
| 216 | files: [...bin.files].sort(comparePaths), |
| 217 | })); |
| 218 | }; |
| 219 | |
| 220 | /** Discovers and plans all shards for one configured target. */ |
no test coverage detected