openclaw/test/scripts/ci-changed-type-graphs.test.ts
Peter Steinberger 192b90aaae
ci: plan extension types without serial core discovery
Retain complete core graph validation in the existing required parallel boundary row for canonical extension-only changes. Discover all four noncore compiler consumers while preserving full fallback for aliases, missing inputs, mixed changes and incomplete inventories.
2026-09-30 06:45:23 -07:00

255 lines
10 KiB
TypeScript

import { mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreTsgoGraph } from "../../scripts/check-tsgo-core-boundary.mts";
import {
selectChangedCiTsgoGraphs,
resolveCiTsgoGraphs,
TSGO_CI_GRAPHS,
TSGO_CI_ADDITIONAL_GRAPHS,
} from "../../scripts/lib/tsgo-core-test-shards.mts";
import { createChangedCiTypeCheckPlan } from "../../scripts/run-tsgo-core-test-shards.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const inspectGraphs = vi.hoisted(() => vi.fn<() => Promise<CoreTsgoGraph[]>>());
vi.mock("../../scripts/check-tsgo-core-boundary.mts", () => ({
inspectCiTsgoCheckGraphs: inspectGraphs,
}));
afterEach(() => inspectGraphs.mockReset());
describe("changed CI compiler graph selection", () => {
const sharedType = "packages/example/src/types.ts";
const graphs = () =>
TSGO_CI_GRAPHS.map(({ name, config }) => ({ name, config, files: new Array<string>() }));
it("admits only canonical graphs while preserving their execution owner's order", () => {
expect(resolveCiTsgoGraphs(["test-root", "core-test-agents-tools", "ui"])).toEqual([
{ name: "test-root", config: "test/tsconfig/tsconfig.test.root.json" },
{
name: "core-test-agents-tools",
config: "test/tsconfig/tsconfig.core.test.agents-tools.json",
},
{ name: "ui", config: "tsconfig.ui.json" },
]);
for (const names of [[], ["core", "core"], ["unknown"], ["core", "../../unowned.json"]]) {
expect(() => resolveCiTsgoGraphs(names)).toThrow("canonical");
}
});
it("keeps type-only consumers in every compiler family", () => {
const inventory = graphs();
const consumers = [
"core",
"ui",
"core-test-agents-tools",
"extensions",
"extensions-test",
"scripts",
"test-root",
];
for (const graph of inventory) {
if (consumers.includes(graph.name)) {
graph.files.push(sharedType);
}
}
expect(selectChangedCiTsgoGraphs([sharedType], inventory)?.map((graph) => graph.name)).toEqual(
consumers,
);
});
it.for([["docs/plugins/sdk-subpaths.md", "docs/example.mdx"], ["ui/src/styles/chat.css"]])(
"keeps a mixed source change scoped to its compiler consumers alongside %j",
(nonCompilerPaths) => {
const inventory = graphs();
inventory.find(({ name }) => name === "ui")!.files.push(sharedType);
expect(
selectChangedCiTsgoGraphs([sharedType, ...nonCompilerPaths], inventory)?.map(
(graph) => graph.name,
),
).toEqual(["ui"]);
},
);
it.for([
[],
["src/missing.ts"],
["src/types/runtime.d.ts"],
["test/tsconfig/tsconfig.test.root.json"],
["package.json"],
["unclassified/module.ts"],
["docs/plugins/sdk-subpaths.md", "ui/src/styles/chat.css"],
[sharedType, "src/config/catalog.json"],
["extensions/example/value.ts", "extensions/example/types.d.ts"],
["extensions/example/value.ts", "extensions/example/tsconfig.json"],
])("retains all compilers without discovery for uncertain input %j", async (paths) => {
expect(selectChangedCiTsgoGraphs(paths, graphs())).toBeUndefined();
inspectGraphs.mockRejectedValue(new Error("Full plans must not enumerate compiler inputs"));
expect(await createChangedCiTypeCheckPlan(paths)).toEqual({
mode: "full",
graphs: TSGO_CI_GRAPHS,
});
expect(inspectGraphs).not.toHaveBeenCalled();
});
it("retains full planning for deleted paths alongside existing source", async () => {
const cwd = tempDirs.make("ci-type-deleted-");
mkdirSync(join(cwd, "src"));
writeFileSync(join(cwd, "src/value.ts"), "export type Value = number;\n");
inspectGraphs.mockRejectedValue(new Error("Deleted inputs must not enumerate compilers"));
for (const deleted of ["src/deleted.ts", "docs/deleted.md", "ui/deleted.css"]) {
expect(await createChangedCiTypeCheckPlan(["src/value.ts", deleted], { cwd })).toEqual({
mode: "full",
graphs: TSGO_CI_GRAPHS,
});
}
expect(inspectGraphs).not.toHaveBeenCalled();
});
it.each([undefined, "additional-checks"] as const)(
"still discovers core consumers when the parallel boundary owner is %s",
async (coreBoundaryOwner) => {
const cwd = tempDirs.make("ci-type-source-");
mkdirSync(join(cwd, "src"));
writeFileSync(join(cwd, "src/value.ts"), "export type Value = number;\n");
inspectGraphs.mockResolvedValue(
graphs().map(({ name, config }) => ({
name,
config,
roots: [],
files: name === "ui" ? ["src/value.ts"] : [],
})),
);
expect(
await createChangedCiTypeCheckPlan(["src/value.ts"], { cwd, coreBoundaryOwner }),
).toEqual({ mode: "changed", graphs: [{ name: "ui", config: "tsconfig.ui.json" }] });
expect(inspectGraphs).toHaveBeenCalledOnce();
},
);
it("keeps every noncore consumer when a parallel owner checks the core boundary", async () => {
const cwd = tempDirs.make("ci-type-extension-");
const paths = ["extensions/example/value.ts", "docs/value.md", "ui/styles/value.css"];
for (const file of paths) {
mkdirSync(dirname(join(cwd, file)), { recursive: true });
writeFileSync(join(cwd, file), "export type Value = number;\n");
}
inspectGraphs.mockResolvedValue(
TSGO_CI_ADDITIONAL_GRAPHS.map((graph) => ({
...graph,
roots: [],
files: [paths[0]!],
})),
);
expect(
await createChangedCiTypeCheckPlan(paths, { cwd, coreBoundaryOwner: "additional-checks" }),
).toEqual({ mode: "changed", graphs: TSGO_CI_ADDITIONAL_GRAPHS });
// Without a separate owner, a partial inventory cannot authorize narrowing.
expect(await createChangedCiTypeCheckPlan(paths, { cwd })).toEqual({
mode: "full",
graphs: TSGO_CI_GRAPHS,
});
});
it.each(["leaf", "directory", "extension-alias", "broken", "traversal", "directory-file"])(
"retains every compiler for a nonphysical extension input (%s)",
async (kind) => {
const cwd = tempDirs.make("ci-type-extension-alias-");
mkdirSync(join(cwd, "src"));
mkdirSync(join(cwd, "extensions", "example"), { recursive: true });
writeFileSync(join(cwd, "src/value.ts"), "export type Value = number;\n");
writeFileSync(join(cwd, "extensions/example/other.ts"), "export type Value = number;\n");
const file =
kind === "traversal"
? "extensions/example/../../src/value.ts"
: kind === "directory"
? "extensions/alias/value.ts"
: "extensions/example/value.ts";
if (kind === "directory") {
symlinkSync(join(cwd, "src"), join(cwd, "extensions/alias"), "dir");
} else if (kind === "directory-file") {
mkdirSync(join(cwd, file));
} else if (kind !== "traversal") {
symlinkSync(
join(
cwd,
kind === "broken"
? "src/missing.ts"
: kind === "extension-alias"
? "extensions/example/other.ts"
: "src/value.ts",
),
join(cwd, file),
"file",
);
}
inspectGraphs.mockRejectedValue(
new Error("Aliases must retain all graphs without discovery"),
);
expect(
await createChangedCiTypeCheckPlan([file], { cwd, coreBoundaryOwner: "additional-checks" }),
).toEqual({ mode: "full", graphs: TSGO_CI_GRAPHS });
expect(inspectGraphs).not.toHaveBeenCalled();
},
);
it.each(["deleted", "symlink"])(
"retains every compiler when an extension input becomes %s during discovery",
async (change) => {
const cwd = tempDirs.make("ci-type-extension-replaced-");
const file = "extensions/example/value.ts";
mkdirSync(join(cwd, "extensions/example"), { recursive: true });
writeFileSync(join(cwd, file), "export type Value = number;\n");
const target = join(cwd, "extensions/example/other.ts");
writeFileSync(target, "export type Value = number;\n");
inspectGraphs.mockImplementation(async () => {
unlinkSync(join(cwd, file));
if (change === "symlink") {
symlinkSync(target, join(cwd, file), "file");
}
return TSGO_CI_ADDITIONAL_GRAPHS.map((graph) => ({ ...graph, roots: [], files: [file] }));
});
expect(
await createChangedCiTypeCheckPlan([file], { cwd, coreBoundaryOwner: "additional-checks" }),
).toEqual({ mode: "full", graphs: TSGO_CI_GRAPHS });
},
);
it.each(["missing", "duplicate", "unexpected", "unmatched", "mixed", "ambient", "config"])(
"refuses incomplete or inapplicable noncore discovery (%s)",
(kind) => {
const file = "extensions/example/value.ts";
const inventory: { config: string; files: string[] }[] = TSGO_CI_ADDITIONAL_GRAPHS.map(
({ config }) => ({ config, files: [file] }),
);
const paths = [file];
if (kind === "missing") {
inventory.pop();
} else if (kind === "duplicate") {
inventory[inventory.length - 1] = inventory[0]!;
} else if (kind === "unexpected") {
inventory[0] = { config: "tsconfig.core.json", files: [file] };
} else if (kind === "unmatched") {
paths.push("extensions/example/other.ts");
} else if (kind === "mixed") {
paths.push("src/value.ts");
} else if (kind === "ambient") {
paths.push("extensions/example/types.d.ts");
} else if (kind === "config") {
paths.push("extensions/example/package.json");
}
expect(selectChangedCiTsgoGraphs(paths, inventory, { scope: "noncore" })).toBeUndefined();
},
);
it.each(["missing", "duplicate"])("refuses an incomplete %s compiler inventory", (kind) => {
const inventory = graphs();
inventory[0]!.files.push(sharedType);
if (kind === "missing") {
inventory.pop();
} else {
inventory[inventory.length - 1] = inventory[0]!;
}
expect(selectChangedCiTsgoGraphs([sharedType], inventory)).toBeUndefined();
});
});