Files
openclaw/test/scripts/check-extension-package-tsc-boundary.test.ts
2026-04-08 08:52:51 +01:00

370 lines
12 KiB
TypeScript

import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireBoundaryCheckLock,
cleanupCanaryArtifactsForExtensions,
formatBoundaryCheckSuccessSummary,
formatSlowCompileSummary,
formatSkippedCompileProgress,
formatStepFailure,
installCanaryArtifactCleanup,
isBoundaryCompileFresh,
resolveBoundaryCheckLockPath,
resolveCanaryArtifactPaths,
runNodeStepAsync,
runNodeStepsWithConcurrency,
} from "../../scripts/check-extension-package-tsc-boundary.mjs";
const tempRoots = new Set<string>();
function createTempExtensionRoot(extensionId = "demo") {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-canary-"));
tempRoots.add(rootDir);
const extensionRoot = path.join(rootDir, "extensions", extensionId);
fs.mkdirSync(extensionRoot, { recursive: true });
return { rootDir, extensionRoot };
}
function writeCanaryArtifacts(rootDir: string, extensionId = "demo") {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId, rootDir);
fs.writeFileSync(canaryPath, "export {};\n", "utf8");
fs.writeFileSync(tsconfigPath, '{ "extends": "./tsconfig.json" }\n', "utf8");
return { canaryPath, tsconfigPath };
}
afterEach(() => {
for (const rootDir of tempRoots) {
fs.rmSync(rootDir, { force: true, recursive: true });
}
tempRoots.clear();
});
describe("check-extension-package-tsc-boundary", () => {
it("removes stale canary artifacts across extensions", () => {
const { rootDir } = createTempExtensionRoot();
const { canaryPath, tsconfigPath } = writeCanaryArtifacts(rootDir);
cleanupCanaryArtifactsForExtensions(["demo"], rootDir);
expect(fs.existsSync(canaryPath)).toBe(false);
expect(fs.existsSync(tsconfigPath)).toBe(false);
});
it("cleans canary artifacts again on process exit", () => {
const { rootDir } = createTempExtensionRoot();
const { canaryPath, tsconfigPath } = writeCanaryArtifacts(rootDir);
const processObject = new EventEmitter();
const teardown = installCanaryArtifactCleanup(["demo"], { processObject, rootDir });
processObject.emit("exit");
teardown();
expect(fs.existsSync(canaryPath)).toBe(false);
expect(fs.existsSync(tsconfigPath)).toBe(false);
});
it("cleans stale artifacts for every extension id passed to the cleanup hook", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-canary-"));
tempRoots.add(rootDir);
fs.mkdirSync(path.join(rootDir, "extensions", "demo-a"), { recursive: true });
fs.mkdirSync(path.join(rootDir, "extensions", "demo-b"), { recursive: true });
const demoA = writeCanaryArtifacts(rootDir, "demo-a");
const demoB = writeCanaryArtifacts(rootDir, "demo-b");
const processObject = new EventEmitter();
const teardown = installCanaryArtifactCleanup(["demo-a", "demo-b"], {
processObject,
rootDir,
});
processObject.emit("exit");
teardown();
expect(fs.existsSync(demoA.canaryPath)).toBe(false);
expect(fs.existsSync(demoA.tsconfigPath)).toBe(false);
expect(fs.existsSync(demoB.canaryPath)).toBe(false);
expect(fs.existsSync(demoB.tsconfigPath)).toBe(false);
});
it("blocks concurrent boundary checks in the same checkout", () => {
const { rootDir } = createTempExtensionRoot();
const processObject = new EventEmitter();
const release = acquireBoundaryCheckLock({ processObject, rootDir });
let thrownError = null;
try {
acquireBoundaryCheckLock({ rootDir });
} catch (error) {
thrownError = error;
}
expect(thrownError).toMatchObject({
message: expect.stringContaining("kind: lock-contention"),
fullOutput: expect.stringContaining(
"another extension package boundary check is already running",
),
kind: "lock-contention",
});
release();
const lockPath = resolveBoundaryCheckLockPath(rootDir);
expect(fs.existsSync(lockPath)).toBe(false);
});
it("summarizes long failure output with the useful tail", () => {
const stdout = Array.from({ length: 45 }, (_, index) => `stdout ${index + 1}`).join("\n");
const stderr = Array.from({ length: 3 }, (_, index) => `stderr ${index + 1}`).join("\n");
const message = formatStepFailure("demo-plugin", {
stdout,
stderr,
kind: "timeout",
elapsedMs: 4_321,
note: "demo-plugin timed out after 5000ms",
});
const messageLines = message.split("\n");
expect(message).toContain("demo-plugin");
expect(message).toContain("[... 5 earlier lines omitted ...]");
expect(message).toContain("kind: timeout");
expect(message).toContain("elapsed: 4321ms");
expect(message).toContain("stdout 45");
expect(messageLines).not.toContain("stdout 1");
expect(message).toContain("stderr:\nstderr 1\nstderr 2\nstderr 3");
expect(message).toContain("demo-plugin timed out after 5000ms");
});
it("formats a success summary with counts and elapsed time", () => {
expect(
formatBoundaryCheckSuccessSummary({
mode: "all",
compileCount: 84,
skippedCompileCount: 13,
canaryCount: 12,
prepElapsedMs: 12_345,
compileElapsedMs: 54_321,
canaryElapsedMs: 6_789,
elapsedMs: 54_321,
}),
).toBe(
[
"extension package boundary check passed",
"mode: all",
"compiled plugins: 84",
"skipped plugins: 13",
"canary plugins: 12",
"prep elapsed: 12345ms",
"compile elapsed: 54321ms",
"canary elapsed: 6789ms",
"elapsed: 54321ms",
"",
].join("\n"),
);
});
it("omits phase timings that never ran", () => {
expect(
formatBoundaryCheckSuccessSummary({
mode: "compile",
compileCount: 97,
skippedCompileCount: 0,
canaryCount: 0,
prepElapsedMs: 12_345,
compileElapsedMs: 54_321,
canaryElapsedMs: 0,
elapsedMs: 66_666,
}),
).toBe(
[
"extension package boundary check passed",
"mode: compile",
"compiled plugins: 97",
"canary plugins: 0",
"prep elapsed: 12345ms",
"compile elapsed: 54321ms",
"elapsed: 66666ms",
"",
].join("\n"),
);
});
it("formats skipped compile progress concisely", () => {
expect(
formatSkippedCompileProgress({
skippedCount: 13,
totalCount: 97,
}),
).toBe("skipped 13 fresh plugin compiles before running 84 stale plugin checks\n");
expect(
formatSkippedCompileProgress({
skippedCount: 97,
totalCount: 97,
}),
).toBe("skipped 97 fresh plugin compiles\n");
});
it("formats the slowest plugin compiles in descending order", () => {
expect(
formatSlowCompileSummary({
compileTimings: [
{ extensionId: "quick", elapsedMs: 40 },
{ extensionId: "slow", elapsedMs: 900 },
{ extensionId: "medium", elapsedMs: 250 },
],
limit: 2,
}),
).toBe(["slowest plugin compiles:", "- slow: 900ms", "- medium: 250ms", ""].join("\n"));
});
it("treats a plugin compile as fresh only when its outputs are newer than plugin and shared sdk inputs", () => {
const { rootDir, extensionRoot } = createTempExtensionRoot();
const extensionSourcePath = path.join(extensionRoot, "index.ts");
const extensionTsconfigPath = path.join(extensionRoot, "tsconfig.json");
const stampPath = path.join(extensionRoot, "dist", ".boundary-tsc.stamp");
const rootSdkTypePath = path.join(rootDir, "dist", "plugin-sdk", "core.d.ts");
const packageSdkTypePath = path.join(
rootDir,
"packages",
"plugin-sdk",
"dist",
"src",
"plugin-sdk",
"core.d.ts",
);
fs.mkdirSync(path.dirname(extensionSourcePath), { recursive: true });
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
fs.mkdirSync(path.dirname(rootSdkTypePath), { recursive: true });
fs.mkdirSync(path.dirname(packageSdkTypePath), { recursive: true });
fs.writeFileSync(extensionSourcePath, "export const demo = 1;\n", "utf8");
fs.writeFileSync(
extensionTsconfigPath,
'{ "extends": "../tsconfig.package-boundary.base.json" }\n',
"utf8",
);
fs.writeFileSync(stampPath, "ok\n", "utf8");
fs.writeFileSync(rootSdkTypePath, "export {};\n", "utf8");
fs.writeFileSync(packageSdkTypePath, "export {};\n", "utf8");
fs.utimesSync(extensionSourcePath, new Date(1_000), new Date(1_000));
fs.utimesSync(extensionTsconfigPath, new Date(1_000), new Date(1_000));
fs.utimesSync(rootSdkTypePath, new Date(500), new Date(500));
fs.utimesSync(packageSdkTypePath, new Date(2_000), new Date(2_000));
fs.utimesSync(stampPath, new Date(3_000), new Date(3_000));
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(true);
fs.utimesSync(rootSdkTypePath, new Date(500), new Date(500));
fs.utimesSync(packageSdkTypePath, new Date(500), new Date(500));
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(true);
fs.utimesSync(rootSdkTypePath, new Date(4_000), new Date(4_000));
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(false);
});
it("accepts cached input mtimes for freshness checks", () => {
const { rootDir, extensionRoot } = createTempExtensionRoot();
const extensionSourcePath = path.join(extensionRoot, "index.ts");
const stampPath = path.join(extensionRoot, "dist", ".boundary-tsc.stamp");
fs.mkdirSync(path.dirname(extensionSourcePath), { recursive: true });
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
fs.writeFileSync(extensionSourcePath, "export const demo = 1;\n", "utf8");
fs.writeFileSync(stampPath, "ok\n", "utf8");
fs.utimesSync(extensionSourcePath, new Date(1_000), new Date(1_000));
fs.utimesSync(stampPath, new Date(3_000), new Date(3_000));
expect(
isBoundaryCompileFresh("demo", {
rootDir,
extensionNewestInputMtimeMs: 1_000,
sharedNewestInputMtimeMs: 2_000,
}),
).toBe(true);
expect(
isBoundaryCompileFresh("demo", {
rootDir,
extensionNewestInputMtimeMs: 1_000,
sharedNewestInputMtimeMs: 4_000,
}),
).toBe(false);
});
it("keeps full failure output on the thrown error for canary detection", async () => {
await expect(
runNodeStepAsync(
"demo-plugin",
[
"--eval",
[
"console.log('src/plugins/contracts/rootdir-boundary-canary.ts');",
"for (let index = 1; index <= 45; index += 1) console.log(`stdout ${index}`);",
"console.error('TS6059');",
"process.exit(2);",
].join(" "),
],
5_000,
),
).rejects.toMatchObject({
message: expect.stringContaining("[... 6 earlier lines omitted ...]"),
fullOutput: expect.stringContaining("src/plugins/contracts/rootdir-boundary-canary.ts"),
kind: "nonzero-exit",
elapsedMs: expect.any(Number),
});
});
it("aborts concurrent sibling steps after the first failure", async () => {
const startedAt = Date.now();
await expect(
runNodeStepsWithConcurrency(
[
{
label: "fail-fast",
args: ["--eval", "setTimeout(() => process.exit(2), 10)"],
timeoutMs: 5_000,
},
{
label: "slow-step",
args: ["--eval", "setTimeout(() => {}, 10_000)"],
timeoutMs: 5_000,
},
],
2,
),
).rejects.toThrow("fail-fast");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("passes successful step timing metadata to onSuccess handlers", async () => {
const elapsedTimes: number[] = [];
await runNodeStepsWithConcurrency(
[
{
label: "demo-step",
args: ["--eval", "setTimeout(() => process.exit(0), 10)"],
timeoutMs: 5_000,
onSuccess(result: { elapsedMs: number }) {
elapsedTimes.push(result.elapsedMs);
},
},
],
1,
);
expect(elapsedTimes).toHaveLength(1);
expect(elapsedTimes[0]).toBeGreaterThanOrEqual(0);
});
});