Files
openclaw/src/plugin-sdk/api-baseline.test.ts
T
2026-08-11 20:19:59 -07:00

631 lines
24 KiB
TypeScript

/**
* Tests the plugin SDK public API baseline.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
import { afterEach, describe, expect, it } from "vitest";
import { publicPluginSdkEntrypoints } from "../../scripts/lib/plugin-sdk-entries.mts";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { formatPluginSdkApiTypeAlias } from "./api-baseline-declaration-print.js";
import {
listPluginSdkApiBaselineEntrypoints,
normalizePluginSdkApiDeclarationText,
normalizePluginSdkApiSourcePath,
renderPluginSdkApiBaseline,
renderPluginSdkApiBaselineModules,
writeRenderedPluginSdkApiBaselineArtifacts,
type PluginSdkApiModule,
type PluginSdkApiBaselineRender,
} from "./api-baseline.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const SERIALIZATION_MODULES = ["fixture-a", "fixture-b"].map(
(entrypoint, index): PluginSdkApiModule => ({
category: null,
entrypoint,
exports: [
{
closureHash: String(index).repeat(64),
declaration: `export type Fixture${index} = string;`,
exportName: `Fixture${index}`,
kind: "type",
source: { path: `src/plugin-sdk/${entrypoint}.ts` },
},
],
importSpecifier: `openclaw/plugin-sdk/${entrypoint}`,
source: { path: `src/plugin-sdk/${entrypoint}.ts` },
}),
);
const rendered = renderPluginSdkApiBaselineModules(SERIALIZATION_MODULES);
function contractContents(result: PluginSdkApiBaselineRender): Record<string, string> {
return Object.fromEntries(result.contractFiles.map((file) => [file.fileName, file.content]));
}
function writeContractFiles(directory: string, result: PluginSdkApiBaselineRender): void {
fs.mkdirSync(directory, { recursive: true });
for (const file of result.contractFiles) {
fs.writeFileSync(path.join(directory, file.fileName), file.content);
}
}
function git(repoRoot: string, args: string[]): string {
const result = spawnSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_EMAIL: "plugin-sdk-test@openclaw.invalid",
GIT_AUTHOR_NAME: "Plugin SDK Test",
GIT_COMMITTER_EMAIL: "plugin-sdk-test@openclaw.invalid",
GIT_COMMITTER_NAME: "Plugin SDK Test",
},
});
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
return result.stdout.trim();
}
function stablePatchId(repoRoot: string, revision: string): string {
const patch = spawnSync("git", ["show", "--pretty=format:", revision], {
cwd: repoRoot,
encoding: "utf8",
});
expect(patch.status, patch.stderr).toBe(0);
const result = spawnSync("git", ["patch-id", "--stable"], {
cwd: repoRoot,
encoding: "utf8",
input: patch.stdout,
});
expect(result.status, result.stderr).toBe(0);
return result.stdout.split(" ")[0] ?? "";
}
async function renderSourceFixture(
files: Readonly<Record<string, string>>,
entrypoints: readonly string[] = ["fixture"],
) {
const repoRoot = tempDirs.make("openclaw-plugin-sdk-api-");
const sourceDir = path.join(repoRoot, "src", "plugin-sdk");
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "tsconfig.json"),
`${JSON.stringify({
compilerOptions: {
module: "NodeNext",
moduleResolution: "NodeNext",
target: "ESNext",
},
})}\n`,
);
for (const [relativePath, content] of Object.entries(files)) {
const filePath = path.join(sourceDir, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
return renderPluginSdkApiBaseline({ repoRoot, entrypoints });
}
async function renderPrivateDeclarationFixture(params?: {
optionalOption?: boolean;
optionalResult?: boolean;
}) {
const repoRoot = tempDirs.make("openclaw-plugin-sdk-api-");
const sourceDir = path.join(repoRoot, "src", "plugin-sdk");
const externalDir = path.join(repoRoot, "node_modules", "fixture-external");
fs.mkdirSync(sourceDir, { recursive: true });
fs.mkdirSync(externalDir, { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "tsconfig.json"),
`${JSON.stringify({
compilerOptions: {
module: "NodeNext",
moduleResolution: "NodeNext",
target: "ESNext",
},
})}\n`,
);
fs.writeFileSync(
path.join(sourceDir, "fixture.ts"),
[
'import type { FixtureOptionLeaf } from "./fixture-option.js";',
'import type { FixtureResultLeaf } from "./fixture-result.js";',
"type FixtureOptions = { nested: FixtureOptionLeaf };",
"type FixtureResult = { nested: FixtureResultLeaf };",
"export declare function createFixture(options: FixtureOptions): FixtureResult;",
"export class FixtureError extends Error {",
" readonly status: number;",
' constructor(status: number) { super("fixture"); this.status = status; }',
" getStatus() { return this.status; }",
"}",
].join("\n"),
);
fs.writeFileSync(
path.join(sourceDir, "fixture-option.ts"),
[
'import type { FixtureResultLeaf } from "./fixture-result.js";',
'import type { FixtureExternal } from "fixture-external";',
`export type FixtureOptionLeaf = { required${params?.optionalOption ? "?" : ""}: string; result?: FixtureResultLeaf; external?: FixtureExternal };`,
].join("\n"),
);
fs.writeFileSync(
path.join(sourceDir, "fixture-result.ts"),
'export type { FixtureResultLeaf } from "./fixture-result-shape.js";\n',
);
fs.writeFileSync(
path.join(sourceDir, "fixture-result-shape.ts"),
[
'import type { FixtureOptionLeaf } from "./fixture-option.js";',
`export type FixtureResultLeaf = { value${params?.optionalResult ? "?" : ""}: string; option?: FixtureOptionLeaf };`,
].join("\n"),
);
fs.writeFileSync(
path.join(externalDir, "package.json"),
`${JSON.stringify({ name: "fixture-external", types: "index.d.ts" })}\n`,
);
fs.writeFileSync(
path.join(externalDir, "index.d.ts"),
"export type FixtureExternal = { externalOnly: string };\n",
);
return renderPluginSdkApiBaseline({ repoRoot, entrypoints: ["fixture"] });
}
function createTupleAliasFixture(tuple: string, warmup: string, prewarm: boolean) {
const fileName = "/plugin-sdk-tuple-fixture.ts";
const source = [
"interface Array<T> { [index: number]: T; readonly length: number }",
"interface ReadonlyArray<T> { readonly [index: number]: T; readonly length: number }",
`type Warmup = ${warmup};`,
`const VALUES = ${tuple};`,
"type Value = (typeof VALUES)[number];",
].join("\n");
const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, true);
const options = { noLib: true, target: ts.ScriptTarget.ESNext };
const host = ts.createCompilerHost(options);
host.fileExists = (candidate) => candidate === fileName;
host.getSourceFile = (candidate) => (candidate === fileName ? sourceFile : undefined);
const checker = ts.createProgram([fileName], options, host).getTypeChecker();
const [warmupAlias, declaration] = sourceFile.statements.filter(ts.isTypeAliasDeclaration);
if (!warmupAlias || !declaration) {
throw new Error("Missing tuple fixture type aliases");
}
if (prewarm) {
checker.getTypeAtLocation(warmupAlias);
}
return { checker, declaration };
}
describe("Plugin SDK API baseline", () => {
it("normalizes declaration import paths to repo-relative paths", () => {
const repoRoot = process.cwd();
const modelCatalogPath = path.join(repoRoot, "src", "agents", "agent-model-discovery");
const declaration = `export function setModelCatalogImportForTest(loader?: (() => Promise<typeof import("${modelCatalogPath}", { with: { "resolution-mode": "import" } })>) | undefined): void;`;
const normalized = normalizePluginSdkApiDeclarationText(repoRoot, declaration);
expect(normalized).not.toContain(repoRoot);
expect(normalized).toContain('import("<repo>", { with: { "resolution-mode": "import" } })');
expect(
normalizePluginSdkApiDeclarationText(
repoRoot,
'type Owned = import("src/x").Foo; type External = import("node_modules/pkg/x").Foo; type Namespace = typeof import("src/x"); type ExternalNamespace = typeof import("node_modules/pkg/x");',
),
).toBe(
'type Owned = Foo; type External = import("node_modules/pkg/x").Foo; type Namespace = typeof import("<repo>"); type ExternalNamespace = typeof import("node_modules/pkg/x");',
);
});
it("normalizes dependency source paths to stable node_modules paths", () => {
const repoRoot = path.join(path.sep, "workspace", "openclaw-worktree");
const linkedDependencyPath = path.join(
path.sep,
"workspace",
"openclaw",
"node_modules",
"@openclaw",
"fs-safe",
"dist",
"secret-file.d.ts",
);
const pnpmDependencyPath = path.join(
repoRoot,
"node_modules",
".pnpm",
"@openclaw+fs-safe@1.0.0",
"node_modules",
"@openclaw",
"fs-safe",
"dist",
"secret-file.d.ts",
);
expect(normalizePluginSdkApiSourcePath(repoRoot, linkedDependencyPath)).toBe(
"node_modules/@openclaw/fs-safe/dist/secret-file.d.ts",
);
expect(normalizePluginSdkApiSourcePath(repoRoot, pnpmDependencyPath)).toBe(
"node_modules/@openclaw/fs-safe/dist/secret-file.d.ts",
);
});
it("keeps repo source paths relative when a parent directory is named node_modules", () => {
const repoRoot = path.join(path.sep, "workspace", "node_modules", "openclaw");
const sourcePath = path.join(repoRoot, "src", "plugin-sdk", "core.ts");
expect(normalizePluginSdkApiSourcePath(repoRoot, sourcePath)).toBe("src/plugin-sdk/core.ts");
});
it.each([
{
tuple: '["first", "middle", "last", "first"] as const',
warmup: '"last"',
expected: '"first" | "middle" | "last"',
},
{
tuple: "[3, 1, 2] as const",
warmup: "1",
expected: "3 | 1 | 2",
},
])("keeps tuple-derived unions stable across unrelated type discovery", (fixture) => {
const baseline = createTupleAliasFixture(fixture.tuple, fixture.warmup, false);
const prewarmed = createTupleAliasFixture(fixture.tuple, fixture.warmup, true);
const unstable = prewarmed.checker.typeToString(
prewarmed.checker.getTypeAtLocation(prewarmed.declaration),
prewarmed.declaration,
ts.TypeFormatFlags.NoTruncation,
);
expect(unstable).not.toBe(fixture.expected);
expect(formatPluginSdkApiTypeAlias(baseline.checker, baseline.declaration)).toBe(
fixture.expected,
);
expect(formatPluginSdkApiTypeAlias(prewarmed.checker, prewarmed.declaration)).toBe(
fixture.expected,
);
});
it("uses the canonical public entrypoint inventory", () => {
expect(listPluginSdkApiBaselineEntrypoints()).toEqual(publicPluginSdkEntrypoints);
});
it("serializes modules independently of entrypoint discovery order", () => {
const reverse = renderPluginSdkApiBaselineModules(rendered.baseline.modules.toReversed());
expect(reverse.json).toBe(rendered.json);
expect(reverse.contractFiles).toEqual(rendered.contractFiles);
});
it("keeps unrelated module hashes byte-identical when one export changes", () => {
const target = rendered.baseline.modules[0];
expect(target?.exports.length).toBeGreaterThan(0);
const changed = renderPluginSdkApiBaselineModules(
rendered.baseline.modules.map((moduleSurface) =>
moduleSurface === target
? {
...moduleSurface,
exports: moduleSurface.exports.map((exportSurface, index) =>
index === 0
? { ...exportSurface, declaration: `${exportSurface.declaration ?? ""} changed` }
: exportSurface,
),
}
: moduleSurface,
),
);
const before = contractContents(rendered);
const after = contractContents(changed);
const changedFileNames = Object.keys(before).filter(
(fileName) => before[fileName] !== after[fileName],
);
expect(changedFileNames).toEqual([`${target?.entrypoint}.json`]);
});
it("writes one file per module and keeps adjacent edits merge- and patch-stable", () => {
const modules = rendered.baseline.modules;
const left = modules[0];
const right = modules[1];
expect(left?.exports.length).toBeGreaterThan(0);
expect(right?.exports.length).toBeGreaterThan(0);
expect(left?.entrypoint).not.toBe(right?.entrypoint);
const editModule = (target: typeof left, suffix: string) =>
renderPluginSdkApiBaselineModules(
modules.map((moduleSurface) =>
moduleSurface === target
? {
...moduleSurface,
exports: moduleSurface.exports.map((exportSurface, index) =>
index === 0
? {
...exportSurface,
declaration: `${exportSurface.declaration ?? ""} ${suffix}`,
}
: exportSurface,
),
}
: moduleSurface,
),
);
const ours = editModule(left, "left edit");
const theirs = editModule(right, "right edit");
const records = rendered.contractFiles.map((file) => JSON.parse(file.content));
expect(rendered.contractFiles).toHaveLength(modules.length);
expect(rendered.contractFiles.map((file) => file.fileName)).toEqual(
modules.map((moduleSurface) => `${moduleSurface.entrypoint}.json`),
);
expect(records.map((record) => record.importSpecifier)).toEqual(
modules.map((moduleSurface) => moduleSurface.importSpecifier),
);
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({ contentHash: expect.stringMatching(/^[a-f0-9]{64}$/u) }),
]),
);
const contract = rendered.contractFiles.map((file) => file.content).join("");
expect(rendered.json).toContain('"source": {');
expect(contract).not.toContain('"sourceLine":');
expect(contract).not.toContain('"sourcePath":');
expect(contract).not.toContain('"closureHash":');
const mergeDir = tempDirs.make("openclaw-plugin-sdk-api-merge-");
const contractDirectory = path.join(mergeDir, "plugin-sdk-api-baseline");
git(mergeDir, ["init", "-q", "--initial-branch=main"]);
writeContractFiles(contractDirectory, rendered);
git(mergeDir, ["add", "."]);
git(mergeDir, ["commit", "-qm", "base"]);
const baseRevision = git(mergeDir, ["rev-parse", "HEAD"]);
git(mergeDir, ["switch", "-qc", "feature"]);
fs.writeFileSync(
path.join(contractDirectory, `${left?.entrypoint}.json`),
contractContents(ours)[`${left?.entrypoint}.json`] ?? "",
);
git(mergeDir, ["add", "."]);
git(mergeDir, ["commit", "-qm", "feature"]);
const featureRevision = git(mergeDir, ["rev-parse", "HEAD"]);
const featurePatchId = stablePatchId(mergeDir, featureRevision);
git(mergeDir, ["switch", "-qc", "main-update", baseRevision]);
fs.writeFileSync(
path.join(contractDirectory, `${right?.entrypoint}.json`),
contractContents(theirs)[`${right?.entrypoint}.json`] ?? "",
);
git(mergeDir, ["add", "."]);
git(mergeDir, ["commit", "-qm", "main update"]);
git(mergeDir, ["switch", "-qc", "merge-check", featureRevision]);
git(mergeDir, ["merge", "-q", "--no-edit", "main-update"]);
git(mergeDir, ["switch", "-qc", "rebase-check", featureRevision]);
git(mergeDir, ["rebase", "main-update"]);
expect(stablePatchId(mergeDir, "HEAD")).toBe(featurePatchId);
expect(fs.readFileSync(path.join(contractDirectory, `${left?.entrypoint}.json`), "utf8")).toBe(
contractContents(ours)[`${left?.entrypoint}.json`],
);
expect(fs.readFileSync(path.join(contractDirectory, `${right?.entrypoint}.json`), "utf8")).toBe(
contractContents(theirs)[`${right?.entrypoint}.json`],
);
});
it("renders byte-identical contract files deterministically", async () => {
const firstRender = await renderPrivateDeclarationFixture();
const secondRender = await renderPrivateDeclarationFixture();
const fixtureError = firstRender.baseline.modules[0]?.exports.find(
(exportSurface) => exportSurface.exportName === "FixtureError",
)?.declaration;
expect(secondRender.contractFiles).toEqual(firstRender.contractFiles);
expect(fixtureError).toContain("constructor(status: number);");
expect(fixtureError).toContain("getStatus(): number;");
expect(fixtureError).not.toContain("super(");
expect(fixtureError).not.toContain("return this.status");
});
it("checks and repairs modified, missing, and stale contract records", async () => {
const outputDir = tempDirs.make("openclaw-plugin-sdk-api-output-");
const contractDirectory = path.join(outputDir, "plugin-sdk-api-baseline");
const jsonPath = path.join(outputDir, "plugin-sdk-api-baseline.json");
const options = {
contractDirectory,
jsonPath,
rendered,
} as const;
await writeRenderedPluginSdkApiBaselineArtifacts(options);
const [modified, missing] = rendered.contractFiles;
if (!modified || !missing) {
throw new Error("Expected at least two rendered contract files");
}
fs.writeFileSync(path.join(contractDirectory, modified.fileName), "modified\n");
fs.unlinkSync(path.join(contractDirectory, missing.fileName));
fs.writeFileSync(path.join(contractDirectory, "stale.json"), "{}\n");
const drifted = await writeRenderedPluginSdkApiBaselineArtifacts({
...options,
check: true,
});
expect(drifted).toEqual(
expect.objectContaining({
changed: true,
contractDiff: {
modified: [modified.fileName],
missing: [missing.fileName],
stale: ["stale.json"],
},
wrote: false,
}),
);
const repaired = await writeRenderedPluginSdkApiBaselineArtifacts(options);
expect(repaired.removedStaleCount).toBe(1);
const current = await writeRenderedPluginSdkApiBaselineArtifacts({
...options,
check: true,
});
expect(current).toEqual(expect.objectContaining({ changed: false, wrote: false }));
expect(fs.existsSync(path.join(contractDirectory, "stale.json"))).toBe(false);
expect(fs.readFileSync(path.join(contractDirectory, modified.fileName), "utf8")).toBe(
modified.content,
);
expect(fs.readFileSync(path.join(contractDirectory, missing.fileName), "utf8")).toBe(
missing.content,
);
expect(fs.readFileSync(jsonPath, "utf8")).toContain(
'"generatedBy": "scripts/generate-plugin-sdk-api-baseline.ts"',
);
});
it("keeps hashes stable when reachable declarations move", async () => {
const baseline = await renderSourceFixture({
"fixture.ts": [
'import type { Leaf } from "./dep/leaf.js";',
"export declare function createFixture(value: Leaf): Leaf;",
].join("\n"),
"dep/leaf.ts": "export type Leaf = { value: string };\n",
});
const moved = await renderSourceFixture({
"fixture.ts": [
'import type { Leaf } from "./moved/leaf.js";',
"export declare function createFixture(value: Leaf): Leaf;",
].join("\n"),
"moved/leaf.ts": "export type Leaf = { value: string };\n",
});
expect(moved.contractFiles).toEqual(baseline.contractFiles);
});
it("includes globals from side-effect imports in closure hashes", async () => {
const render = (optionalValue: boolean) =>
renderSourceFixture({
"fixture.ts": [
'import "./ambient.js";',
"export declare function createFixture(value: OpenClawBaselineFixtureGlobal): void;",
].join("\n"),
"ambient.ts": [
"declare global {",
` interface OpenClawBaselineFixtureGlobal { value${optionalValue ? "?" : ""}: string }`,
"}",
"export {};",
].join("\n"),
});
const baseline = await render(false);
const changed = await render(true);
expect(changed.baseline.modules[0]?.exports[0]?.closureHash).not.toBe(
baseline.baseline.modules[0]?.exports[0]?.closureHash,
);
});
it("keeps hashes stable when unqualified repo import types move", async () => {
const baseline = await renderSourceFixture({
"fixture.ts": 'export declare const fixture: typeof import("./dep/mod.js");\n',
"dep/mod.ts": "export const value = 1;\n",
});
const moved = await renderSourceFixture({
"fixture.ts": 'export declare const fixture: typeof import("./moved/mod.js");\n',
"moved/mod.ts": "export const value = 1;\n",
});
expect(moved.contractFiles).toEqual(baseline.contractFiles);
});
it("ignores unreachable transitive declaration changes", async () => {
const render = (extra = "") =>
renderSourceFixture({
"fixture.ts": [
'import type { Bridge } from "./bridge.js";',
"export declare function createFixture(value: Bridge): Bridge;",
].join("\n"),
"bridge.ts": [
'import type { Shared } from "./shared.js";',
"export type Bridge = { shared: Shared };",
].join("\n"),
"shared.ts": `export type Shared = { value: string };\n${extra}`,
});
const baseline = await render();
const unrelated = await render("export type TelegramProbe = { ignored: boolean };\n");
expect(unrelated.contractFiles).toEqual(baseline.contractFiles);
});
it("keeps cycle members complete across cached export walks", async () => {
const render = (optionalMarker: boolean) =>
renderSourceFixture(
{
"cycle-a.ts": [
'import type { A } from "./a.js";',
"export declare function first(value: A): A;",
].join("\n"),
"cycle-b.ts": [
'import type { B } from "./b.js";',
"export declare function second(value: B): B;",
].join("\n"),
"a.ts": [
'import type { B } from "./b.js";',
`export type A = { marker${optionalMarker ? "?" : ""}: string; b?: B };`,
].join("\n"),
"b.ts": [
'import type { A } from "./a.js";',
"export type B = { value: string; a?: A };",
].join("\n"),
},
["cycle-a", "cycle-b"],
);
const baseline = await render(false);
const changed = await render(true);
const closureHash = (result: PluginSdkApiBaselineRender) =>
result.baseline.modules.find((moduleSurface) => moduleSurface.entrypoint === "cycle-b")
?.exports[0]?.closureHash;
expect(closureHash(changed)).not.toBe(closureHash(baseline));
});
it("ignores unrelated declarations beside an aliased re-export", async () => {
const render = (extra = "") =>
renderSourceFixture({
"fixture.ts": 'export { internalFixture as publicFixture } from "./dep.js";\n',
"dep.ts": `export function internalFixture(value: string): string { return value; }\n${extra}`,
});
const baseline = await render();
const unrelated = await render("export type Unrelated = { ignored: boolean };\n");
const declaration = baseline.baseline.modules[0]?.exports[0]?.declaration;
expect(unrelated.contractFiles).toEqual(baseline.contractFiles);
expect(declaration).toContain("function publicFixture(");
expect(declaration).not.toContain("internalFixture");
});
it("captures transitive private declaration changes deterministically", async () => {
const baseline = await renderPrivateDeclarationFixture();
const optionChanged = await renderPrivateDeclarationFixture({ optionalOption: true });
const resultChanged = await renderPrivateDeclarationFixture({ optionalResult: true });
const declaration = baseline.baseline.modules[0]?.exports[0];
expect(declaration).toEqual(
expect.objectContaining({
exportName: "createFixture",
kind: "function",
source: { path: "src/plugin-sdk/fixture.ts" },
}),
);
expect(declaration?.closureHash).toMatch(/^[a-f0-9]{64}$/u);
expect(declaration?.declaration).toContain("FixtureOptions");
expect(declaration?.declaration).toContain("FixtureResult");
expect(declaration?.declaration).not.toContain("required: string;");
expect(declaration?.declaration).not.toContain("value: string;");
expect(declaration?.declaration).not.toContain("externalOnly: string;");
for (const changed of [optionChanged, resultChanged]) {
expect(changed.baseline.modules[0]?.exports[0]?.declaration).toBe(declaration?.declaration);
expect(changed.baseline.modules[0]?.exports[0]?.closureHash).not.toBe(
declaration?.closureHash,
);
expect(changed.contractFiles).not.toEqual(baseline.contractFiles);
}
});
});