Files
gbrain/test/scripts/mine-shard-weights.test.ts
T
3a2605e9a0 v0.41.6.0 feat(ci): CI test speedup — 23min → ~9min via matrix 4→6 + weight-aware sharding + auto SHA cache + parallel verify (#1444)
* feat(ci): scripts/run-verify-parallel.sh — parallel verify dispatcher

Fans out the 21 pre-test grep guards via & + wait, captures per-check
exit codes in a tempdir, aggregates failures with named check + log
tail to stderr on miss. Wallclock 27s sequential → 13s parallel
locally (2x). Bigger CI win is shard 1 deload (workflow restructure
in a later commit).

Pinned by test/scripts/run-verify-parallel.test.ts (6 cases: CLI
contract + synthetic dispatcher failure-surfacing).

* feat(ci): weight-aware LPT bin-packer + auto SHA cache hash

scripts/sharding.ts (NEW) — pure TypeScript LPT bin-packer. Sort
weights desc, assign each file to the shard with current minimum total.
Worst-case makespan within 4/3 of optimal, O(n log n). Missing weights
fall back to corpus median (not 0). New test file → ships immediately
without regenerating weights. Pinned by test/scripts/sharding.test.ts
(23 cases).

scripts/mine-shard-weights.ts (NEW) — scrapes per-file timing from
gh run view --log via timestamp delta between ##[group]test/foo.test.ts:
headers within a shard. Three input modes: --run <ID>, --from-file
<PATH>, stdin. Stable JSON output (sorted keys). Initial weights mined
from run 26398061007. Pinned by test/scripts/mine-shard-weights.test.ts
(15 cases).

scripts/ci-cache-hash.sh (NEW) — deterministic 16-char sha256 over
git ls-files -s minus deny-list (CHANGELOG/TODOS/README/LICENSE/
docs/**/*.md). CLAUDE.md, AGENTS.md, skills/**/* deliberately
INCLUDED (8+ test files read them; deny-listing would create
false-pass holes). ~40ms on 1891 files. Pinned by
test/scripts/ci-cache-hash.test.ts (24 cases: 8 CRITICAL false-pass
guards + 7 SAFE deny-list invariants + 9 edge cases).

scripts/test-weights.json (NEW) — 712 weights. Total 3306s observed
runtime; median 30ms; max 6 min outlier.

* chore: bump version and changelog (v0.41.6.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:15:11 -07:00

159 lines
5.8 KiB
TypeScript

// mine-shard-weights.test.ts — pure-function tests for the log parser
// and weight extraction. The full pipeline (gh run view → write JSON)
// is integration-tested by actually running it once during T4.
import { describe, expect, it } from "bun:test";
import {
computeWeights,
parseLog,
serializeWeights,
} from "../../scripts/mine-shard-weights.ts";
const SAMPLE = `test (1)\tUNKNOWN STEP\t2026-05-25T11:26:40.000000Z ##[group]test/alpha.test.ts:
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:41.000000Z (pass) some test [1.00ms]
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:43.500000Z ##[group]test/beta.test.ts:
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:45.000000Z (pass) another test
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:48.000000Z ##[group]test/gamma.test.ts:
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:50.000000Z ##[group]test/delta.test.ts:
test (2)\tUNKNOWN STEP\t2026-05-25T11:26:42.000000Z ##[group]test/epsilon.test.ts:
test (2)\tUNKNOWN STEP\t2026-05-25T11:26:55.000000Z ##[group]test/zeta.test.ts:
`;
describe("parseLog", () => {
it("extracts file-start events with timestamps and jobs", () => {
const events = parseLog(SAMPLE);
expect(events.length).toBe(6);
expect(events[0]).toMatchObject({
job: "test (1)",
file: "test/alpha.test.ts",
});
expect(events[5]).toMatchObject({
job: "test (2)",
file: "test/zeta.test.ts",
});
});
it("preserves stream order", () => {
const events = parseLog(SAMPLE);
const files = events.map((e) => e.file);
expect(files).toEqual([
"test/alpha.test.ts",
"test/beta.test.ts",
"test/gamma.test.ts",
"test/delta.test.ts",
"test/epsilon.test.ts",
"test/zeta.test.ts",
]);
});
it("ignores non-group lines", () => {
// The sample has (pass) lines mixed in. They should not appear in the
// event list; parseLog only emits ##[group]test/X.test.ts: matches.
const events = parseLog(SAMPLE);
expect(events.every((e) => e.file.startsWith("test/"))).toBe(true);
expect(events.every((e) => e.file.endsWith(".test.ts"))).toBe(true);
});
it("handles empty input", () => {
expect(parseLog("")).toEqual([]);
});
it("ignores malformed timestamps", () => {
const bad = "test (1)\tUNKNOWN\tBAD-TS ##[group]test/x.test.ts:\n";
expect(parseLog(bad)).toEqual([]);
});
it("rejects non-test-file groups (e.g. setup-bun action groups)", () => {
const noise =
"test (1)\tUNKNOWN\t2026-05-25T11:26:40.000Z ##[group]Run actions/checkout\n";
expect(parseLog(noise)).toEqual([]);
});
});
describe("computeWeights", () => {
it("computes delta between consecutive file headers within a job", () => {
const events = parseLog(SAMPLE);
const weights = computeWeights(events);
// job test (1): alpha → 3500ms (40s → 43.5s), beta → 4500ms (43.5s → 48s),
// gamma → 2000ms (48s → 50s), delta dropped (no successor).
expect(weights.get("test/alpha.test.ts")).toBe(3500);
expect(weights.get("test/beta.test.ts")).toBe(4500);
expect(weights.get("test/gamma.test.ts")).toBe(2000);
expect(weights.get("test/delta.test.ts")).toBeUndefined();
// job test (2): epsilon → 13000ms (42s → 55s), zeta dropped.
expect(weights.get("test/epsilon.test.ts")).toBe(13000);
expect(weights.get("test/zeta.test.ts")).toBeUndefined();
});
it("does not cross job boundaries", () => {
// If we naively diffed across jobs we'd get bogus values when
// alpha (test 1) was followed by epsilon (test 2) by clock skew.
const events = parseLog(SAMPLE);
const weights = computeWeights(events);
// alpha → beta within same job: 3500ms. Not 2000ms (which would be
// alpha's stamp to epsilon's stamp across jobs).
expect(weights.get("test/alpha.test.ts")).toBe(3500);
});
it("takes the max when a file appears with multiple deltas", () => {
const events = [
{ job: "test (1)", timestampMs: 1000, file: "test/x.test.ts" },
{ job: "test (1)", timestampMs: 2000, file: "test/y.test.ts" },
{ job: "test (2)", timestampMs: 3000, file: "test/x.test.ts" },
{ job: "test (2)", timestampMs: 8000, file: "test/y.test.ts" },
];
const w = computeWeights(events);
// x: 1000→2000 (1000ms in job 1), 3000→8000 (5000ms in job 2). Max = 5000.
expect(w.get("test/x.test.ts")).toBe(5000);
});
it("skips out-of-order events defensively (negative delta)", () => {
const events = [
{ job: "test (1)", timestampMs: 5000, file: "test/x.test.ts" },
{ job: "test (1)", timestampMs: 3000, file: "test/y.test.ts" },
];
const w = computeWeights(events);
// x → negative delta → skipped. y has no successor → not recorded.
expect(w.size).toBe(0);
});
it("empty input → empty map", () => {
expect(computeWeights([]).size).toBe(0);
});
});
describe("serializeWeights", () => {
it("sorts keys alphabetically for stable diffs", () => {
const w = new Map([
["test/z.test.ts", 100],
["test/a.test.ts", 200],
["test/m.test.ts", 50],
]);
const json = serializeWeights(w);
const lines = json.split("\n").filter((l) => l.includes('"test/'));
expect(lines[0]).toContain("a.test.ts");
expect(lines[1]).toContain("m.test.ts");
expect(lines[2]).toContain("z.test.ts");
});
it("ends with a trailing newline (POSIX-friendly diff)", () => {
const w = new Map([["test/x.test.ts", 1]]);
expect(serializeWeights(w).endsWith("\n")).toBe(true);
});
it("empty map → empty object JSON", () => {
expect(serializeWeights(new Map())).toBe("{}\n");
});
it("round-trips: parse our own output", () => {
const w = new Map([
["test/alpha.test.ts", 100],
["test/beta.test.ts", 250],
]);
const json = serializeWeights(w);
const parsed = JSON.parse(json);
expect(parsed["test/alpha.test.ts"]).toBe(100);
expect(parsed["test/beta.test.ts"]).toBe(250);
});
});