mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 11:11:09 +00:00
370 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|