285 lines
8.9 KiB
TypeScript
285 lines
8.9 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createShardPlan,
|
|
discoverTestCatalog,
|
|
validateShardManifest,
|
|
type CatalogUnit,
|
|
} from "./plan-shards";
|
|
import type { TimingProfile } from "./e2e-timings";
|
|
|
|
const profile: TimingProfile = {
|
|
version: 1,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
source: { branch: "main", sha: "main-sha", runId: "12" },
|
|
entries: [
|
|
{
|
|
key: "chromium::tests/a.spec.ts::a",
|
|
project: "chromium",
|
|
file: "tests/a.spec.ts",
|
|
title: "a",
|
|
fileHash: "hash-a",
|
|
samples: [],
|
|
p50Seconds: 8,
|
|
p75Seconds: 10,
|
|
lastSeenSha: "main-sha",
|
|
lastSeenRunId: "12",
|
|
},
|
|
{
|
|
key: "chromium::tests/b.spec.ts::b",
|
|
project: "chromium",
|
|
file: "tests/b.spec.ts",
|
|
title: "b",
|
|
fileHash: "hash-old-b",
|
|
samples: [],
|
|
p50Seconds: 20,
|
|
p75Seconds: 20,
|
|
lastSeenSha: "main-sha",
|
|
lastSeenRunId: "12",
|
|
},
|
|
],
|
|
};
|
|
|
|
const units: CatalogUnit[] = [
|
|
{ project: "chromium", file: "tests/a.spec.ts", fileHash: "hash-a", testCount: 1 },
|
|
{ project: "chromium", file: "tests/b.spec.ts", fileHash: "hash-new-b", testCount: 1 },
|
|
{ project: "chromium", file: "tests/c.spec.ts", fileHash: "hash-c", testCount: 3 },
|
|
{ project: "mobile-chrome", file: "tests/mobile-c.spec.ts", fileHash: "hash-m", testCount: 2 },
|
|
];
|
|
|
|
describe("duration-aware shard planning", () => {
|
|
it("discovers specs below the web e2e test root and hashes their source", () => {
|
|
const webRoot = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-planner-"));
|
|
const testDir = path.join(webRoot, "e2e", "tests", "chat");
|
|
fs.mkdirSync(testDir, { recursive: true });
|
|
fs.symlinkSync(path.resolve("node_modules"), path.join(webRoot, "node_modules"), "dir");
|
|
fs.writeFileSync(
|
|
path.join(webRoot, "e2e", "playwright.config.ts"),
|
|
[
|
|
'import { defineConfig } from "@playwright/test";',
|
|
"export default defineConfig({",
|
|
' testDir: "./tests",',
|
|
" projects: [",
|
|
' { name: "chromium", testIgnore: [/mobile-.*\\.spec\\.ts$/, /tests\\/(docker|ssh)\\//] },',
|
|
' { name: "mobile-chrome", testMatch: /mobile-.*\\.spec\\.ts$/ },',
|
|
' { name: "containers", testMatch: /tests\\/(docker|ssh)\\/.*\\.spec\\.ts$/ },',
|
|
" ],",
|
|
"});",
|
|
].join("\n"),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(testDir, "example.spec.ts"),
|
|
[
|
|
'import { test } from "@playwright/test";',
|
|
'test.describe("suite", () => {',
|
|
" test.beforeEach(async () => {});",
|
|
' test("works", async () => {});',
|
|
' test.skip("skipped", async () => {});',
|
|
' for (const name of ["generated one", "generated two"]) {',
|
|
" test(name, async () => {});",
|
|
" }",
|
|
"});",
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(discoverTestCatalog(webRoot, "normal")).toEqual([
|
|
expect.objectContaining({
|
|
project: "chromium",
|
|
file: "tests/chat/example.spec.ts",
|
|
testCount: 4,
|
|
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("assigns weighted units with stable longest-processing-time packing", () => {
|
|
const first = createShardPlan(units, {
|
|
cohort: "normal",
|
|
shardCount: 2,
|
|
profile,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
const second = createShardPlan(units, {
|
|
cohort: "normal",
|
|
shardCount: 2,
|
|
profile,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
|
|
expect(first).toEqual(second);
|
|
expect(first.shards).toHaveLength(2);
|
|
expect(first.shards.flatMap((shard) => shard.units)).toHaveLength(units.length);
|
|
expect(first.summary.warmUnits).toBe(1);
|
|
expect(first.summary.unknownUnits).toBe(2);
|
|
expect(first.shards[0]?.predictedSeconds).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("validates exact coverage and rejects duplicate assignments", () => {
|
|
const plan = createShardPlan(units, {
|
|
cohort: "containers",
|
|
shardCount: 2,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
expect(() => validateShardManifest(plan, units)).not.toThrow();
|
|
|
|
const duplicate = structuredClone(plan);
|
|
duplicate.shards[1]?.units.push(plan.shards[0]!.units[0]!);
|
|
expect(() => validateShardManifest(duplicate, units)).toThrow(/duplicate/i);
|
|
});
|
|
|
|
it("rejects units whose immutable catalog fields were changed", () => {
|
|
const plan = createShardPlan(units, {
|
|
cohort: "normal",
|
|
shardCount: 2,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
const tampered = structuredClone(plan);
|
|
const unit = tampered.shards.find((shard) => shard.units.length > 0)?.units[0];
|
|
expect(unit).toBeDefined();
|
|
unit!.fileHash = "tampered";
|
|
|
|
expect(() => validateShardManifest(tampered, units)).toThrow(/catalog fields/i);
|
|
});
|
|
|
|
it("rejects duplicate and out-of-range shard indexes", () => {
|
|
const plan = createShardPlan(units, {
|
|
cohort: "normal",
|
|
shardCount: 2,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
const duplicate = structuredClone(plan);
|
|
duplicate.shards[1]!.index = duplicate.shards[0]!.index;
|
|
expect(() => validateShardManifest(duplicate, units)).toThrow(/shard index/i);
|
|
|
|
const outOfRange = structuredClone(plan);
|
|
outOfRange.shards[0]!.index = plan.shardCount + 1;
|
|
expect(() => validateShardManifest(outOfRange, units)).toThrow(/shard index/i);
|
|
});
|
|
|
|
it("uses count-based estimates when no profile exists", () => {
|
|
const plan = createShardPlan(units.slice(0, 2), {
|
|
cohort: "containers",
|
|
shardCount: 1,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
|
|
expect(plan.profile.mode).toBe("count-fallback");
|
|
expect(plan.summary.unknownUnits).toBe(2);
|
|
expect(plan.shards[0]?.predictedSeconds).toBe(40);
|
|
});
|
|
|
|
it("budgets unknown Kubernetes specs for real cluster lifecycle time", () => {
|
|
const plan = createShardPlan(
|
|
[
|
|
{
|
|
project: "containers",
|
|
file: "tests/kubernetes/kubernetes-executor.spec.ts",
|
|
fileHash: "hash-kubernetes",
|
|
testCount: 2,
|
|
},
|
|
],
|
|
{
|
|
cohort: "containers",
|
|
shardCount: 1,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
},
|
|
);
|
|
|
|
expect(plan.shards[0]?.predictedSeconds).toBe(360);
|
|
});
|
|
|
|
it("uses the Kubernetes fallback for tests missing from a timing profile", () => {
|
|
const kubernetesUnit: CatalogUnit = {
|
|
project: "containers",
|
|
file: "tests/kubernetes/kubernetes-executor.spec.ts",
|
|
fileHash: "hash-kubernetes",
|
|
testCount: 2,
|
|
};
|
|
const kubernetesProfile: TimingProfile = {
|
|
...profile,
|
|
entries: [
|
|
{
|
|
...profile.entries[0]!,
|
|
key: "containers::tests/kubernetes/kubernetes-executor.spec.ts::launches",
|
|
project: "containers",
|
|
file: kubernetesUnit.file,
|
|
fileHash: kubernetesUnit.fileHash,
|
|
p50Seconds: 120,
|
|
p75Seconds: 140,
|
|
},
|
|
],
|
|
};
|
|
|
|
const plan = createShardPlan([kubernetesUnit], {
|
|
cohort: "containers",
|
|
shardCount: 1,
|
|
profile: kubernetesProfile,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
|
|
expect(plan.shards[0]?.predictedSeconds).toBe(320);
|
|
expect(plan.shards[0]?.units[0]?.classification).toBe("unknown");
|
|
});
|
|
|
|
it("adds a fallback estimate for new tests in a profiled file", () => {
|
|
const plan = createShardPlan(
|
|
[{ project: "chromium", file: "tests/a.spec.ts", fileHash: "hash-a", testCount: 2 }],
|
|
{
|
|
cohort: "normal",
|
|
shardCount: 1,
|
|
profile,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
},
|
|
);
|
|
|
|
expect(plan.summary.unknownUnits).toBe(1);
|
|
expect(plan.shards[0]?.units[0]?.classification).toBe("unknown");
|
|
expect(plan.shards[0]?.predictedSeconds).toBe(20);
|
|
});
|
|
|
|
it("reports a file-level unit that dominates its shard", () => {
|
|
const plan = createShardPlan(
|
|
[
|
|
{ project: "chromium", file: "tests/heavy.spec.ts", fileHash: "heavy", testCount: 100 },
|
|
{ project: "chromium", file: "tests/light.spec.ts", fileHash: "light", testCount: 1 },
|
|
],
|
|
{
|
|
cohort: "normal",
|
|
shardCount: 2,
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
},
|
|
);
|
|
|
|
expect(plan.summary.dominantUnits).toEqual([
|
|
expect.objectContaining({
|
|
id: "chromium::tests/heavy.spec.ts",
|
|
shard: 1,
|
|
shareOfShard: 1,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("reports profiled files that are no longer in the catalog", () => {
|
|
const plan = createShardPlan(units.slice(0, 1), {
|
|
cohort: "normal",
|
|
shardCount: 1,
|
|
profile: {
|
|
...profile,
|
|
entries: [
|
|
...profile.entries,
|
|
{
|
|
...profile.entries[0]!,
|
|
key: "chromium::tests/removed.spec.ts::removed",
|
|
file: "tests/removed.spec.ts",
|
|
},
|
|
],
|
|
},
|
|
generatedAt: "2026-08-10T00:00:00.000Z",
|
|
});
|
|
|
|
expect(plan.summary.staleUnits).toBe(2);
|
|
});
|
|
});
|