Skip to content

Commit a0f2db0

Browse files
committed
test(contracts): prove the shard split drops no test (partition assert + CI count check)
Add two trust guards that the sharding/balancing took every test into account. assignBalancedShards now asserts a disjoint, complete partition at runtime, backed by a unit test (synthetic inputs via an injected weight, plus the real planShard partition over the integration tree). CI gains a verify-test-count job that runs the integration suite once (test:integration) and a merge-time reconciliation that the sum of the coverage shards' passing counts equals it (2672), failing on mismatch. The oracle job runs in parallel with the coverage matrix, so it adds no critical-path time. Signed-off-by: Miguel_LZPF <miguel.carpena@io.builders>
1 parent e7ca88e commit a0f2db0

4 files changed

Lines changed: 216 additions & 6 deletions

File tree

.github/workflows/100-flow-ats-test.yaml

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,23 +130,92 @@ jobs:
130130
run: npm ci --ignore-scripts
131131

132132
- name: Run contracts coverage shard
133-
run: npm run test:coverage:shard --workspace=packages/ats/contracts
133+
run: |
134+
npm run test:coverage:shard --workspace=packages/ats/contracts 2>&1 | tee shard.log
135+
# Record this shard's passing-test count (ANSI-stripped) for the merge-time reconciliation
136+
# that proves the sharded runs executed every test — see the merge-coverage job.
137+
sed -E 's/\x1b\[[0-9;]*m//g' shard.log | grep -oE '[0-9]+ passing' | head -1 | grep -oE '^[0-9]+' \
138+
> packages/ats/contracts/coverage/shard-count.txt
139+
echo "shard ${SHARD_INDEX} passing: $(cat packages/ats/contracts/coverage/shard-count.txt)"
134140
135141
- name: Upload coverage shard artifact
136142
if: ${{ !cancelled() }}
137143
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
138144
with:
139145
name: coverage-shard-${{ matrix.shard }}
140-
path: packages/ats/contracts/coverage/lcov.info
146+
path: |
147+
packages/ats/contracts/coverage/lcov.info
148+
packages/ats/contracts/coverage/shard-count.txt
149+
if-no-files-found: error
150+
retention-days: 1
151+
152+
# Sequential oracle for the trust check: run the integration suite in ONE pass and record its
153+
# passing-test count. The merge job compares this against the sum of the coverage shards' counts to
154+
# prove the sharding/balancing dropped no test. Integration-only (NOT the scripts suite, which the
155+
# coverage shards don't run), and excludes the local-parallel `ats.shard.*` files. Runs in parallel
156+
# with the coverage matrix, so it adds no critical-path time.
157+
verify-test-count:
158+
name: verify test count
159+
runs-on: token-studio-linux-large
160+
timeout-minutes: 45
161+
env:
162+
CONTRACT_SIZER_RUN_ON_COMPILE: "false"
163+
REPORT_GAS: "false"
164+
165+
steps:
166+
- name: Harden Runner
167+
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
168+
with:
169+
egress-policy: audit
170+
171+
- name: Checkout repository
172+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
173+
174+
- name: Setup NodeJS Environment
175+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
176+
with:
177+
node-version-file: .nvmrc
178+
cache: "npm"
179+
180+
- name: Restore dependencies from cache
181+
id: deps-cache
182+
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
183+
with:
184+
path: |
185+
node_modules
186+
*/*/node_modules
187+
*/*/*/node_modules
188+
~/.npm
189+
key: ${{ runner.os }}-deps-${{ hashFiles('package-lock.json') }}
190+
191+
- name: Install dependencies
192+
if: steps.deps-cache.outputs.cache-hit != 'true'
193+
run: npm ci --ignore-scripts
194+
195+
- name: Run the integration suite sequentially (oracle)
196+
working-directory: packages/ats/contracts
197+
run: |
198+
npm run test:integration 2>&1 | tee oracle.log
199+
sed -E 's/\x1b\[[0-9;]*m//g' oracle.log | grep -oE '[0-9]+ passing' | head -1 | grep -oE '^[0-9]+' \
200+
> oracle-count.txt
201+
echo "sequential integration passing: $(cat oracle-count.txt)"
202+
203+
- name: Upload oracle count
204+
if: ${{ !cancelled() }}
205+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
206+
with:
207+
name: oracle-count
208+
path: packages/ats/contracts/oracle-count.txt
141209
if-no-files-found: error
142210
retention-days: 1
143211

144212
# Merge the per-shard lcov reports into a single report and upload it once to Codecov — matching
145213
# the local `npm run test:coverage` single report, instead of uploading partial per-shard pieces.
146-
# Runs only when every shard succeeded, so the merged report is complete.
214+
# Also reconciles the sharded test count against the sequential oracle. Runs only when every shard
215+
# and the oracle succeeded, so the merged report is complete.
147216
merge-coverage:
148217
name: merge coverage + upload
149-
needs: coverage-ats
218+
needs: [coverage-ats, verify-test-count]
150219
runs-on: token-studio-linux-large
151220
timeout-minutes: 15
152221
env:
@@ -188,6 +257,26 @@ jobs:
188257
pattern: coverage-shard-*
189258
path: coverage-shards
190259

260+
- name: Download sequential oracle count
261+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
262+
with:
263+
name: oracle-count
264+
path: oracle
265+
266+
# Trust check: the sum of the coverage shards' passing-test counts must equal the sequential
267+
# single-pass count, proving the sharding/balancing ran every test (none dropped or doubled).
268+
- name: Reconcile sharded vs sequential test count
269+
run: |
270+
oracle=$(cat oracle/oracle-count.txt)
271+
sharded=$(find coverage-shards -name shard-count.txt -exec cat {} + | awk '{s += $1} END {print s + 0}')
272+
echo "sequential integration : ${oracle} passing"
273+
echo "Σ coverage shards : ${sharded} passing"
274+
if [[ "${oracle}" != "${sharded}" ]]; then
275+
echo "::error::Test-count mismatch — sequential ${oracle} vs sharded ${sharded}. A suite may be dropped or double-run by the sharding."
276+
exit 1
277+
fi
278+
echo "✅ all tests accounted for (${oracle})"
279+
191280
# Sum line/function/branch hits across the disjoint shards into one lcov, written to the same
192281
# path a local single-run coverage produces, so the Codecov upload is identical to local. Uses
193282
# the in-repo merger (preserves function coverage, which lcov-result-merger drops; no

packages/ats/contracts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"test": "bash -c 'npx hardhat test $(find test/contracts/integration test/scripts -name \"*.test.ts\" -not -name \"ats.shard.*.test.ts\")'",
131131
"test:parallel": "bash -c 'ATS_TEST_MODE=true NODE_OPTIONS=\"--import tsx\" npx hardhat test --parallel $(find test/contracts/integration test/scripts -name \"*.test.ts\" -not -name \"ats.test.ts\")'",
132132
"test:parallel:ats": "bash -c 'ATS_TEST_MODE=true NODE_OPTIONS=\"--import tsx\" npx hardhat test --parallel $(find test/contracts/integration -name \"ats.shard.*.test.ts\")'",
133+
"test:integration": "bash -c 'npx hardhat test $(find test/contracts/integration -name \"*.test.ts\" -not -name \"ats.shard.*.test.ts\")'",
133134
"test:contracts": "npx hardhat test",
134135
"test:contracts:parallel": "NODE_OPTIONS='--import tsx' npx hardhat test --parallel",
135136
"test:scripts": "bash -c 'npx hardhat test --no-compile $(find test/scripts -name \"*.test.ts\")'",

packages/ats/contracts/test/contracts/integration/suiteDiscovery.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,36 @@ export function suiteWeight(path: string): number {
5252
* round-robin's "whichever shard drew the heavy suites". Deterministic — files are sorted by weight
5353
* (desc) then path, and ties pick the lowest-index shard — so a given `shardCount` yields the same
5454
* partition wherever it is computed (the test entry and the coverage planner must agree).
55+
*
56+
* `weightOf` defaults to `suiteWeight` (reads the file); it is a parameter only so the partition
57+
* invariant can be unit-tested with synthetic inputs that never touch the filesystem.
5558
*/
56-
export function assignBalancedShards(files: string[], shardCount: number): string[][] {
59+
export function assignBalancedShards(
60+
files: string[],
61+
shardCount: number,
62+
weightOf: (path: string) => number = suiteWeight,
63+
): string[][] {
5764
const bins: string[][] = Array.from({ length: shardCount }, () => []);
5865
const loads = new Array<number>(shardCount).fill(0);
5966
const ordered = files
60-
.map((path) => ({ path, weight: suiteWeight(path) }))
67+
.map((path) => ({ path, weight: weightOf(path) }))
6168
.sort((a, b) => b.weight - a.weight || (a.path < b.path ? -1 : 1));
6269
for (const { path, weight } of ordered) {
6370
let lightest = 0;
6471
for (let i = 1; i < shardCount; i++) if (loads[i] < loads[lightest]) lightest = i;
6572
bins[lightest].push(path);
6673
loads[lightest] += weight;
6774
}
75+
76+
// Trust invariant — the shards MUST be a disjoint, complete partition of the input: every suite
77+
// runs in exactly one shard, none dropped or doubled. This is the guarantee the parallel/coverage
78+
// split rests on, so assert it here rather than hope a future edit preserves it (cheap: O(files)).
79+
const assigned = bins.flat();
80+
if (assigned.length !== files.length || new Set(assigned).size !== new Set(files).size) {
81+
throw new Error(
82+
`assignBalancedShards: not a disjoint+complete partition — assigned ${assigned.length} ` +
83+
`(${new Set(assigned).size} distinct) of ${files.length} input files`,
84+
);
85+
}
6886
return bins;
6987
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* Unit tests for the shard partitioner — the "no suite is silently dropped" guarantee.
5+
*
6+
* The whole parallel/coverage scheme trusts that splitting the suites into shards is a DISJOINT,
7+
* COMPLETE partition: every suite runs in exactly one shard, none dropped or doubled. These tests
8+
* assert that property of `assignBalancedShards` directly (with synthetic inputs + an injected
9+
* weight, so no filesystem), and check the real coverage `planShard` partition end to end. They go
10+
* red if a future edit to the balancing or planning logic ever drops, duplicates, or misroutes a
11+
* suite — which a coverage % alone would not reveal.
12+
*
13+
* @module test/scripts/unit/tools/shardPartition.test
14+
*/
15+
16+
import { expect } from "chai";
17+
import {
18+
assignBalancedShards,
19+
isMegaSuiteFile,
20+
isShardEntryFile,
21+
walkTestFiles,
22+
} from "@test/contracts/integration/suiteDiscovery";
23+
import { join } from "path";
24+
// planShard reads the integration tree and imports test/ helpers, so it can't live in the @scripts
25+
// barrel (that would pull test/ into the scripts build) — import it directly.
26+
import { planShard } from "../../../../scripts/tools/coverage-shard/planShard";
27+
28+
/** Assert `shards` is a disjoint, complete partition of `input` (order-independent). */
29+
function expectPartition(shards: string[][], input: string[]): void {
30+
const flat = shards.flat();
31+
expect(flat.length, "no suite dropped or doubled").to.equal(input.length);
32+
expect(new Set(flat).size, "no suite appears twice").to.equal(flat.length);
33+
expect([...flat].sort()).to.deep.equal([...input].sort());
34+
}
35+
36+
describe("assignBalancedShards — partition invariant", () => {
37+
// A synthetic weight so the test never touches the filesystem (real suiteWeight reads each file).
38+
const weight = (p: string) => p.length;
39+
const files = ["aaaa", "bbb", "cc", "d", "eeeee", "ff", "ggggggg", "h", "iii", "jjjj"];
40+
41+
for (const n of [1, 2, 3, 4, 8]) {
42+
it(`is disjoint + complete for ${n} shard(s)`, () => {
43+
const shards = assignBalancedShards(files, n, weight);
44+
expect(shards.length).to.equal(n);
45+
expectPartition(shards, files);
46+
});
47+
}
48+
49+
it("handles more shards than files (some shards empty, still complete)", () => {
50+
const few = ["x", "yy", "zzz"];
51+
const shards = assignBalancedShards(few, 8, weight);
52+
expect(shards.length).to.equal(8);
53+
expect(shards.filter((s) => s.length === 0).length).to.equal(5);
54+
expectPartition(shards, few);
55+
});
56+
57+
it("is deterministic — same input yields the same partition", () => {
58+
expect(assignBalancedShards(files, 4, weight)).to.deep.equal(assignBalancedShards(files, 4, weight));
59+
});
60+
61+
it("balances weight: the heaviest shard exceeds the lightest by at most one suite's weight", () => {
62+
const shards = assignBalancedShards(files, 4, weight);
63+
const loads = shards.map((s) => s.reduce((sum, p) => sum + weight(p), 0));
64+
const maxSingle = Math.max(...files.map(weight));
65+
// Greedy LPT guarantees the spread is bounded by the largest single item.
66+
expect(Math.max(...loads) - Math.min(...loads)).to.be.at.most(maxSingle);
67+
});
68+
});
69+
70+
describe("planShard — the real coverage shards cover every integration suite", () => {
71+
const N = 4;
72+
const shards = Array.from({ length: N }, (_, i) => planShard(i, N).map((p) => p.split("/integration/")[1] ?? p));
73+
const integrationDir = join(process.cwd(), "test", "contracts", "integration");
74+
75+
it("includes the mega entry (ats.test.ts) in EVERY shard", () => {
76+
for (let i = 0; i < N; i++) expect(shards[i], `shard ${i}`).to.include("ats.test.ts");
77+
});
78+
79+
it("never lists a local parallel shard file or a discovered mega suite", () => {
80+
for (const shard of shards) {
81+
for (const f of shard) {
82+
expect(f.startsWith("ats.shard."), `${f} is a local-parallel file`).to.be.false;
83+
}
84+
}
85+
});
86+
87+
it("partitions the standalone suites disjointly and completely", () => {
88+
const expectedStandalone = walkTestFiles(integrationDir)
89+
.filter((p) => {
90+
const base = p.split("/").pop() ?? "";
91+
return !isShardEntryFile(base) && !isMegaSuiteFile(p);
92+
})
93+
.map((p) => p.split("/integration/")[1])
94+
.sort();
95+
96+
const standaloneAcrossShards = shards.flat().filter((f) => f !== "ats.test.ts");
97+
expect(new Set(standaloneAcrossShards).size, "no standalone suite duplicated").to.equal(
98+
standaloneAcrossShards.length,
99+
);
100+
expect([...standaloneAcrossShards].sort()).to.deep.equal(expectedStandalone);
101+
});
102+
});

0 commit comments

Comments
 (0)