mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
159 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|