Skip to content

Commit 40b37ae

Browse files
committed
ci: balance test shards and reuse package download caches
1 parent e8528ce commit 40b37ae

5 files changed

Lines changed: 335 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ jobs:
118118
name: rea-test-runtime
119119
path: .
120120
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
121+
env:
122+
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
121123
- uses: actions/upload-artifact@v4
122124
if: ${{ !cancelled() }}
123125
with:
@@ -133,6 +135,9 @@ jobs:
133135
needs: changes
134136
if: needs.changes.outputs.code == 'true'
135137
runs-on: ubuntu-latest
138+
permissions:
139+
contents: read
140+
actions: read
136141
steps:
137142
- uses: actions/checkout@v4
138143
- uses: actions/setup-node@v4
@@ -141,13 +146,36 @@ jobs:
141146
cache: npm
142147
- run: npm ci
143148
- run: npm run test:prepare
149+
- name: Find successful default-branch timing history
150+
id: history
151+
continue-on-error: true
152+
env:
153+
GH_TOKEN: ${{ github.token }}
154+
CI_REPOSITORY: ${{ github.repository }}
155+
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
156+
run: |
157+
run_id="$(gh api --method GET \
158+
"repos/${CI_REPOSITORY}/actions/workflows/ci.yml/runs" \
159+
-f branch="${CI_DEFAULT_BRANCH}" -f event=push -f status=success -f per_page=1 \
160+
--jq '.workflow_runs[0].id // empty')"
161+
echo "run-id=${run_id}" >> "${GITHUB_OUTPUT}"
162+
- name: Download previous timing report
163+
if: steps.history.outputs.run-id != ''
164+
continue-on-error: true
165+
uses: actions/download-artifact@v4
166+
with:
167+
name: test-results
168+
run-id: ${{ steps.history.outputs.run-id }}
169+
github-token: ${{ github.token }}
170+
path: .cache/ci-test-history
144171
- uses: actions/upload-artifact@v4
145172
with:
146173
name: rea-test-runtime
147174
path: |
148175
dist/
149176
skills/
150177
.cache/mcp-tool-catalog.json
178+
.cache/ci-test-history/test-results.json
151179
docs/public/product-catalog.json
152180
docs/verification/managed-conformance-*.json
153181
include-hidden-files: true
@@ -305,7 +333,14 @@ jobs:
305333
name: windows-native-controls
306334
path: native/windows/build/
307335
- run: npm run verify:windows-native:artifact
336+
- name: Resolve npm cache before verifier HOME isolation
337+
id: npm-cache
338+
run: |
339+
cache_path="$(npm config get cache)"
340+
echo "path=${cache_path}" >> "${GITHUB_OUTPUT}"
308341
- run: npm run verify:package
342+
env:
343+
npm_config_cache: ${{ steps.npm-cache.outputs.path }}
309344
- name: Verify real Node Inspector discovery and observation
310345
timeout-minutes: 10
311346
run: npm run verify:inspector

‎docs/testing.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,12 @@ target-free analysis, configuration backups/recovery, Evidence and owned
835835
lifecycle; Inspector checks execute source-owned loopback targets and special
836836
filename cases.
837837

838+
POSIX package verification resolves the runner's configured npm cache before
839+
creating disposable client homes and passes that location to its child installs.
840+
This reuses the download cache restored by `setup-node` while keeping client
841+
configuration isolated. Installs retain their existing registry and integrity
842+
checks.
843+
838844
The Apple artifact CLI/MCP lane uses one `macos-15` job to avoid adding demand
839845
for scarce macOS runner capacity. It runs the artifact-format and metadata
840846
verifiers, followed by one consolidated focused invocation containing all
@@ -847,6 +853,15 @@ catalog, and managed-conformance metadata. Each shard still installs the locked
847853
dependencies and runs its assigned coverage-enabled tests; it no longer
848854
regenerates the same metadata independently.
849855

856+
The build job also retrieves the merged timing report from the last successful
857+
default-branch push and includes that same report in each shard's snapshot.
858+
CI assigns discovered files by historical duration, with a deterministic
859+
fallback weight for new files, rather than dividing only by file count. Every
860+
discovered file still belongs to exactly one shard; worker limits, project
861+
ordering, coverage instrumentation, and aggregate thresholds remain unchanged.
862+
Missing or unusable history falls back to Vitest's default assignment. Only
863+
timing data is reused; persistent compile caches remain disabled.
864+
850865
These native baseline checks complement the Linux source-test shards and the
851866
separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
852867
browser and managed-tool claims require their corresponding verification lanes.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { mkdir, writeFile } from "node:fs/promises";
2+
import { join, resolve } from "node:path";
3+
4+
import { fc, it } from "@fast-check/vitest";
5+
import { describe, expect } from "vitest";
6+
7+
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
8+
9+
import {
10+
allocateByDuration,
11+
parseTestDurations,
12+
readTestDurations,
13+
} from "../../ciTestSequencer.js";
14+
15+
describe("CI test history", () => {
16+
it("uses only successful in-root file durations", () => {
17+
const root = resolve("/workspace/rea");
18+
const durations = parseTestDurations(
19+
JSON.stringify({
20+
success: true,
21+
numFailedTests: 0,
22+
testResults: [
23+
{
24+
name: join(root, "tests/slow.test.ts"),
25+
startTime: 10,
26+
endTime: 110,
27+
},
28+
{
29+
name: join(root, "tests/slow.test.ts"),
30+
startTime: 20,
31+
endTime: 80,
32+
},
33+
{
34+
name: resolve(root, "../outside.test.ts"),
35+
startTime: 10,
36+
endTime: 1000,
37+
},
38+
null,
39+
],
40+
}),
41+
root,
42+
);
43+
44+
expect(durations).toEqual(new Map([["tests/slow.test.ts", 100]]));
45+
expect(
46+
parseTestDurations('{"success": false, "numFailedTests": 1}', root),
47+
).toBeUndefined();
48+
expect(parseTestDurations("not json", root)).toBeUndefined();
49+
expect(parseTestDurations("null", root)).toBeUndefined();
50+
});
51+
52+
it("falls back for missing history while preserving real filesystem errors", async () => {
53+
const temporaryRoot = await createTestTempDirectory("rea-ci-sequencer-");
54+
const missing = await readTestDurations(
55+
join(temporaryRoot, "missing.json"),
56+
temporaryRoot,
57+
);
58+
expect(missing).toBeUndefined();
59+
60+
const directory = join(temporaryRoot, "directory");
61+
await mkdir(directory);
62+
const reportPath = join(temporaryRoot, "report.json");
63+
await writeFile(reportPath, "{");
64+
expect(await readTestDurations(reportPath, temporaryRoot)).toBeUndefined();
65+
66+
await expect(
67+
readTestDurations(directory, temporaryRoot),
68+
).rejects.toMatchObject({
69+
code: "EISDIR",
70+
});
71+
});
72+
});
73+
74+
describe("duration shard allocation", () => {
75+
it.prop([
76+
fc.uniqueArray(fc.integer({ min: 0, max: 10_000 }), {
77+
minLength: 1,
78+
maxLength: 80,
79+
}),
80+
fc.integer({ min: 3, max: 8 }),
81+
])(
82+
"is complete, disjoint, reference-preserving, and input-order independent",
83+
(ids, shardCount) => {
84+
const items = ids.map((id, index) => ({
85+
id,
86+
path: `tests/${id}.test.ts`,
87+
project: `project-${id % 3}`,
88+
pool: id % 2 === 0 ? "forks" : "threads",
89+
duration: index > 0 && id % 5 === 0 ? undefined : (id % 17) + 1,
90+
}));
91+
const specifications = items.map((item) => ({
92+
item,
93+
key: `${item.path}\0${item.project}\0${item.pool}`,
94+
duration: item.duration,
95+
}));
96+
const shards = allocateByDuration(specifications, shardCount);
97+
const reversedShards = allocateByDuration(
98+
[...specifications].reverse(),
99+
shardCount,
100+
);
101+
102+
expect(shards).toBeDefined();
103+
expect(reversedShards).toEqual(shards);
104+
const assigned = shards!.flat();
105+
expect(assigned).toHaveLength(items.length);
106+
expect(new Set(assigned).size).toBe(items.length);
107+
expect(assigned.every((item) => items.includes(item))).toBe(true);
108+
},
109+
);
110+
111+
it("keeps tied path, project, pool, and new-file entries deterministic", () => {
112+
const items = [
113+
{
114+
id: "a",
115+
path: "tests/shared.test.ts",
116+
project: "alpha",
117+
pool: "forks",
118+
duration: 10,
119+
},
120+
{
121+
id: "b",
122+
path: "tests/shared.test.ts",
123+
project: "beta",
124+
pool: "forks",
125+
duration: 10,
126+
},
127+
{
128+
id: "c",
129+
path: "tests/shared.test.ts",
130+
project: "beta",
131+
pool: "threads",
132+
duration: 10,
133+
},
134+
{
135+
id: "new",
136+
path: "tests/new.test.ts",
137+
project: "alpha",
138+
pool: "forks",
139+
duration: undefined,
140+
},
141+
];
142+
const specifications = items.map((item) => ({
143+
item,
144+
key: `${item.path}\0${item.project}\0${item.pool}`,
145+
duration: item.duration,
146+
}));
147+
const shards = allocateByDuration(specifications, 3);
148+
const reversedShards = allocateByDuration([...specifications].reverse(), 3);
149+
150+
expect(shards).toBeDefined();
151+
expect(reversedShards).toEqual(shards);
152+
expect(shards!.flat()).toHaveLength(items.length);
153+
expect(new Set(shards!.flat()).size).toBe(items.length);
154+
});
155+
});

‎tests/ciTestSequencer.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { readFile } from "node:fs/promises";
2+
import { isAbsolute, relative, resolve, sep } from "node:path";
3+
4+
import { BaseSequencer, type TestSpecification } from "vitest/node";
5+
import { z } from "zod";
6+
7+
const reportSchema = z.object({
8+
success: z.literal(true),
9+
numFailedTests: z.literal(0),
10+
testResults: z.array(z.unknown()),
11+
});
12+
const fileSchema = z.object({
13+
name: z.string(),
14+
startTime: z.number(),
15+
endTime: z.number(),
16+
});
17+
18+
export function parseTestDurations(
19+
contents: string,
20+
root: string,
21+
): Map<string, number> | undefined {
22+
let value: unknown;
23+
try {
24+
value = JSON.parse(contents);
25+
} catch (error) {
26+
if (error instanceof SyntaxError) return undefined;
27+
throw error;
28+
}
29+
const report = reportSchema.safeParse(value);
30+
if (!report.success) return undefined;
31+
32+
const durations = new Map<string, number>();
33+
for (const value of report.data.testResults) {
34+
const result = fileSchema.safeParse(value);
35+
if (!result.success) continue;
36+
const { name, startTime, endTime } = result.data;
37+
const duration = endTime - startTime;
38+
const path = relative(root, resolve(root, name));
39+
if (
40+
!Number.isFinite(duration) ||
41+
duration <= 0 ||
42+
path === "" ||
43+
path === ".." ||
44+
path.startsWith(".." + sep) ||
45+
isAbsolute(path)
46+
)
47+
continue;
48+
const key = path.split(sep).join("/");
49+
durations.set(key, Math.max(durations.get(key) ?? 0, duration));
50+
}
51+
return durations.size ? durations : undefined;
52+
}
53+
54+
export async function readTestDurations(
55+
historyPath: string,
56+
root: string,
57+
): Promise<Map<string, number> | undefined> {
58+
try {
59+
return parseTestDurations(await readFile(historyPath, "utf8"), root);
60+
} catch (error) {
61+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
62+
return undefined;
63+
throw error;
64+
}
65+
}
66+
67+
type TimedItem<T> = { item: T; key: string; duration: number | undefined };
68+
69+
/** Distribute the longest files first, retaining every discovered specification. */
70+
export function allocateByDuration<T>(
71+
items: readonly TimedItem<T>[],
72+
shardCount: number,
73+
): T[][] | undefined {
74+
if (!Number.isInteger(shardCount) || shardCount < 1)
75+
throw new RangeError("shardCount must be a positive integer");
76+
const known = items
77+
.flatMap(({ duration }) => (duration === undefined ? [] : [duration]))
78+
.sort((left, right) => left - right);
79+
if (!known.length) return undefined;
80+
const lower = known[Math.floor((known.length - 1) / 2)]!;
81+
const median = lower + (known[Math.floor(known.length / 2)]! - lower) / 2;
82+
const ordered = items.toSorted(
83+
(left, right) =>
84+
(right.duration ?? median) - (left.duration ?? median) ||
85+
(left.key < right.key ? -1 : left.key > right.key ? 1 : 0),
86+
);
87+
const shards = Array.from({ length: shardCount }, () => ({
88+
duration: 0,
89+
items: [] as T[],
90+
}));
91+
for (const entry of ordered) {
92+
const lightest = shards.reduce((left, right) =>
93+
right.duration < left.duration ? right : left,
94+
);
95+
lightest.items.push(entry.item);
96+
lightest.duration += entry.duration ?? median;
97+
}
98+
return shards.map(({ items }) => items);
99+
}
100+
101+
export class CiTestSequencer extends BaseSequencer {
102+
override async shard(
103+
files: TestSpecification[],
104+
): Promise<TestSpecification[]> {
105+
const historyPath = process.env.REA_CI_TEST_HISTORY;
106+
const shard = this.ctx.config.shard;
107+
if (!historyPath || !shard) return super.shard(files);
108+
const root = this.ctx.config.root;
109+
const durations = await readTestDurations(historyPath, root);
110+
if (!durations) return super.shard(files);
111+
112+
const weighted = files.map((item) => {
113+
const path = relative(root, resolve(root, item.moduleId))
114+
.split(sep)
115+
.join("/");
116+
return {
117+
item,
118+
key: path + "\0" + item.project.name + "\0" + item.pool,
119+
duration: durations.get(path),
120+
};
121+
});
122+
const shards = allocateByDuration(weighted, shard.count);
123+
return shards ? shards[shard.index - 1]! : super.shard(files);
124+
}
125+
}

0 commit comments

Comments
 (0)