Files
rea/tests/boundary/filesystem/ciTestSequencer.test.ts
T
morluto c0cbbf8d50 perf(ci): keep fast built shards under ten minutes (#5652)
* test(acceptance): split competing loader ownership across files

The loader matrix was one 578-second file, which set the floor for any
shard that received it. Three files keep every CLI and MCP case and let
workers overlap them.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* perf(ci): run eight fast built shards under a ten-minute budget

Charge each Vitest project by its resolved worker width so serial
process-boundary files spread as tail time. Fast feedback uses eight
built shards. The npm download cache stays; node_modules is too large
to ship as a shared artifact.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(ci): keep each scheduled file on one worker lane

Dividing a file by maxWorkers dropped its own wall-time floor and could
pack a parallel file onto a serial tail. Place each file whole on a lane,
overlap peers only inside a sequence group, and add later groups after.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* docs(testing): separate the shard budget from workflow time

The ten-minute figure is the built shard job. Classification, build, and
the portable-test merge are measured separately on run 39451548945.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* docs(testing): match fast shard count to lane scheduling

Main still described five shards and half-duration weights. Fast
qualification uses eight shards, and each file keeps its full duration
on one worker.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(ci): seed split loader durations from pre-split history

Timings published before the file split still name one 578-second path.
Until a new file appears in that artifact, share the recorded duration
by the pre-split case counts.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 03:55:40 +08:00

275 lines
8.0 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fc, it } from "@fast-check/vitest";
import { describe, expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
allocateByDuration,
parseTestDurations,
readTestDurations,
scheduleWall,
seedSplitLoaderDurations,
} from "../../ciTestSequencer.js";
describe("CI test history", () => {
it("uses only successful in-root file durations", () => {
const root = resolve("/workspace/rea");
const durations = parseTestDurations(
JSON.stringify({
version: 1,
success: true,
files: [
{
name: join(root, "tests/slow.test.ts"),
durationMs: 100,
},
{
name: join(root, "tests/slow.test.ts"),
durationMs: 60,
},
{
name: resolve(root, "../outside.test.ts"),
durationMs: 990,
},
{ name: "tests/negative.test.ts", durationMs: -1 },
{ name: "tests/zero.test.ts", durationMs: 0 },
{ name: "tests/invalid.test.ts", durationMs: "100" },
null,
],
}),
root,
);
expect(durations).toEqual(new Map([["tests/slow.test.ts", 100]]));
expect(
parseTestDurations('{"version": 1, "success": false, "files": []}', root),
).toBeUndefined();
// Legacy test-body spans must not be mixed with complete module costs.
expect(
parseTestDurations(
JSON.stringify({
success: true,
numFailedTests: 0,
testResults: [
{ name: "tests/legacy.test.ts", startTime: 0, endTime: 100 },
],
}),
root,
),
).toBeUndefined();
expect(parseTestDurations("not json", root)).toBeUndefined();
expect(parseTestDurations("null", root)).toBeUndefined();
});
it("falls back for missing history while preserving real filesystem errors", async () => {
const temporaryRoot = await createTestTempDirectory("rea-ci-sequencer-");
const missing = await readTestDurations(
join(temporaryRoot, "missing.json"),
temporaryRoot,
);
expect(missing).toBeUndefined();
const directory = join(temporaryRoot, "directory");
await mkdir(directory);
const reportPath = join(temporaryRoot, "report.json");
await writeFile(reportPath, "{");
expect(await readTestDurations(reportPath, temporaryRoot)).toBeUndefined();
await expect(
readTestDurations(directory, temporaryRoot),
).rejects.toMatchObject({
code: "EISDIR",
});
});
});
describe("split loader history", () => {
const source =
"tests/acceptance/applications/javascriptLiteralPercentPaths.test.ts";
const parts = [
"tests/acceptance/applications/javascriptLiteralLoaderOwnershipEarly.test.ts",
"tests/acceptance/applications/javascriptLiteralLoaderOwnershipMiddle.test.ts",
"tests/acceptance/applications/javascriptLiteralLoaderOwnershipLate.test.ts",
];
it("divides a pre-split duration across the four current files", () => {
const seeded = seedSplitLoaderDurations(
new Map([
[source, 192_000],
["tests/other.test.ts", 10],
]),
);
expect(seeded.get(source)).toBe(54_000);
for (const path of parts) expect(seeded.get(path)).toBe(46_000);
expect(seeded.get("tests/other.test.ts")).toBe(10);
expect(
[...seeded.values()].reduce((sum, duration) => sum + duration, 0),
).toBe(192_000 + 10);
});
it("keeps published durations once any split file is in history", () => {
const recorded = new Map([
[source, 20_000],
[parts[0]!, 30_000],
]);
expect(seedSplitLoaderDurations(recorded)).toEqual(recorded);
expect(
seedSplitLoaderDurations(new Map([["tests/other.test.ts", 10]])),
).toEqual(new Map([["tests/other.test.ts", 10]]));
});
});
describe("duration shard allocation", () => {
it.prop([
fc.uniqueArray(fc.integer({ min: 0, max: 10_000 }), {
minLength: 1,
maxLength: 80,
}),
fc.integer({ min: 3, max: 8 }),
])(
"is complete, disjoint, reference-preserving, and input-order independent",
(ids, shardCount) => {
const items = ids.map((id, index) => ({
id,
path: `tests/${id}.test.ts`,
project: `project-${id % 3}`,
pool: id % 2 === 0 ? "forks" : "threads",
duration: index > 0 && id % 5 === 0 ? undefined : (id % 17) + 1,
}));
const specifications = items.map((item) => ({
item,
key: `${item.path}\0${item.project}\0${item.pool}`,
duration: item.duration,
}));
const shards = allocateByDuration(specifications, shardCount);
const reversedShards = allocateByDuration(
[...specifications].reverse(),
shardCount,
);
expect(shards).toBeDefined();
expect(reversedShards).toEqual(shards);
const assigned = shards!.flat();
expect(assigned).toHaveLength(items.length);
expect(new Set(assigned).size).toBe(items.length);
expect(assigned.every((item) => items.includes(item))).toBe(true);
},
);
it("keeps tied path, project, pool, and new-file entries deterministic", () => {
const items = [
{
id: "a",
path: "tests/shared.test.ts",
project: "alpha",
pool: "forks",
duration: 10,
},
{
id: "b",
path: "tests/shared.test.ts",
project: "beta",
pool: "forks",
duration: 10,
},
{
id: "c",
path: "tests/shared.test.ts",
project: "beta",
pool: "threads",
duration: 10,
},
{
id: "new",
path: "tests/new.test.ts",
project: "alpha",
pool: "forks",
duration: undefined,
},
];
const specifications = items.map((item) => ({
item,
key: `${item.path}\0${item.project}\0${item.pool}`,
duration: item.duration,
}));
const shards = allocateByDuration(specifications, 3);
const reversedShards = allocateByDuration([...specifications].reverse(), 3);
expect(shards).toBeDefined();
expect(reversedShards).toEqual(shards);
expect(shards!.flat()).toHaveLength(items.length);
expect(new Set(shards!.flat()).size).toBe(items.length);
});
it("keeps each file whole and runs the serial group after the parallel group", () => {
const parallel = (id: string, duration: number) => ({
item: id,
key: id,
duration,
laneWidth: 2,
groupOrder: 0,
});
const serial = (id: string, duration: number) => ({
item: id,
key: id,
duration,
laneWidth: 1,
groupOrder: 1,
});
const paired = [
parallel("wide", 300),
parallel("peer", 300),
parallel("third", 300),
];
expect(scheduleWall(paired.slice(0, 1), 1)).toBe(300);
expect(scheduleWall(paired.slice(0, 2), 1)).toBe(300);
expect(scheduleWall(paired, 1)).toBe(600);
expect(
scheduleWall([parallel("ordinary", 100), serial("tail", 60)], 1),
).toBe(160);
expect(() =>
scheduleWall(
[parallel("wide", 10), { ...parallel("wide", 10), laneWidth: 1 }],
1,
),
).toThrow(RangeError);
const packed = [
parallel("ordinary", 100),
serial("tail-a", 60),
serial("tail-b", 60),
];
expect(allocateByDuration(packed, 2)).toEqual([
["ordinary"],
["tail-a", "tail-b"],
]);
const spread = [
parallel("wide", 300),
serial("tail-a", 100),
serial("tail-b", 100),
parallel("small", 40),
];
const shards = allocateByDuration(spread, 2);
expect(shards).toEqual([["wide"], ["tail-a", "tail-b", "small"]]);
expect(
scheduleWall(
spread.filter((entry) => entry.item === "wide"),
1,
),
).toBe(300);
expect(
Math.max(
...shards!.map((ids) =>
scheduleWall(
spread.filter((entry) => ids.includes(entry.item)),
1,
),
),
),
).toBe(300);
});
});