openclaw / src /plugin-sdk /api-baseline.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5c2a829 verified
Raw
History Blame Contribute Delete
29.1 kB
/**
* 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,
type PluginSdkApiBaseline,
} from "./api-baseline.js";
import {
diffPluginSdkApi,
formatPluginSdkApiDiffReport,
hasPluginSdkApiChanges,
parsePluginSdkApiDiffSurface,
pluginSdkApiAcknowledgement,
readPluginSdkApiEntrypoints,
type PluginSdkApiDiff,
} from "./api-diff.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
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 });
}
function writePluginSdkInventory(repoRoot: string, entrypoints: readonly string[]): void {
const inventoryDir = path.join(repoRoot, "scripts", "lib");
fs.mkdirSync(inventoryDir, { recursive: true });
fs.writeFileSync(
path.join(inventoryDir, "plugin-sdk-entrypoints.json"),
`${JSON.stringify(entrypoints)}\n`,
);
fs.writeFileSync(
path.join(inventoryDir, "plugin-sdk-private-local-only-subpaths.json"),
'["private-fixture"]\n',
);
}
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"] });
}
async function renderDependencyDeclarationFixture(dependencyDeclaration: string) {
const repoRoot = tempDirs.make("openclaw-plugin-sdk-api-dependency-");
const sourceDir = path.join(repoRoot, "src", "plugin-sdk");
const dependencyDir = path.join(repoRoot, "node_modules", "fixture-dependency");
fs.mkdirSync(sourceDir, { recursive: true });
fs.mkdirSync(dependencyDir, { 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 { fixtureValue } from "fixture-dependency";\nexport const publicValue = fixtureValue;\n',
);
fs.writeFileSync(
path.join(dependencyDir, "package.json"),
'{"name":"fixture-dependency","type":"module","types":"index.d.ts"}\n',
);
fs.writeFileSync(path.join(dependencyDir, "index.d.ts"), dependencyDeclaration);
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("reports same-entrypoint closure changes without a committed merge unit", async () => {
const render = (optionsExtra: string, resultExtra: string) =>
renderSourceFixture({
"fixture.ts": [
`type SendOptions = { text: string${optionsExtra} };`,
`type SendResult = { ok: boolean${resultExtra} };`,
"export declare function send(options: SendOptions): SendResult;",
].join("\n"),
});
const baseline = await render("", "");
const optionsChanged = await render("; accountId: string", "");
const resultChanged = await render("", "; traceId: string");
const combined = await render("; accountId: string", "; traceId: string");
const conflictDir = tempDirs.make("openclaw-plugin-sdk-api-conflict-");
const basePath = path.join(conflictDir, "base.json");
const optionsPath = path.join(conflictDir, "options.json");
const resultPath = path.join(conflictDir, "result.json");
fs.writeFileSync(basePath, '{"contentHash":"base","entrypoint":"fixture"}\n');
fs.writeFileSync(optionsPath, '{"contentHash":"options","entrypoint":"fixture"}\n');
fs.writeFileSync(resultPath, '{"contentHash":"result","entrypoint":"fixture"}\n');
const oldRepresentationMerge = spawnSync(
"git",
["merge-file", "-p", optionsPath, basePath, resultPath],
{ encoding: "utf8" },
);
expect(oldRepresentationMerge.status).toBe(1);
expect(oldRepresentationMerge.stdout).toContain("<<<<<<<");
for (const changed of [optionsChanged, resultChanged]) {
const diff = diffPluginSdkApi(baseline, changed);
expect(diff.exports).toEqual([
expect.objectContaining({
change: "reachable",
entrypoint: "fixture",
exportName: "send",
}),
]);
}
const combinedDiff = diffPluginSdkApi(baseline, combined);
expect(
combinedDiff.exports.flatMap((change) =>
change.declarationChanges.map((declaration) => declaration.name),
),
).toEqual(expect.arrayContaining(["SendOptions", "SendResult"]));
expect(hasPluginSdkApiChanges(combinedDiff)).toBe(true);
expect(pluginSdkApiAcknowledgement(combinedDiff)).toMatch(/^[a-f0-9]{8}$/u);
expect(
formatPluginSdkApiDiffReport({
baseLabel: "base",
diff: combinedDiff,
headLabel: "head",
}),
).toContain("Reachable declarations changed");
});
it("reads added and removed entrypoints from each revision's own inventory", async () => {
const baseRoot = tempDirs.make("openclaw-plugin-sdk-api-base-");
const headRoot = tempDirs.make("openclaw-plugin-sdk-api-head-");
writePluginSdkInventory(baseRoot, ["fixture", "private-fixture"]);
writePluginSdkInventory(headRoot, ["fixture", "added", "private-fixture"]);
expect(await readPluginSdkApiEntrypoints(baseRoot)).toEqual(["fixture"]);
expect(await readPluginSdkApiEntrypoints(headRoot)).toEqual(["fixture", "added"]);
const before = await renderSourceFixture({
"fixture.ts": "export type Fixture = string;\n",
});
const renderAdded = (valueType: "number" | "string") =>
renderSourceFixture(
{
"added.ts": [
`type AddedShape = { value: ${valueType} };`,
"export declare function createAdded(input: AddedShape): AddedShape;",
].join("\n"),
"fixture.ts": "export type Fixture = string;\n",
},
["fixture", "added"],
);
const after = await renderAdded("number");
const sameNamesDifferentContents = await renderAdded("string");
const addedDiff = diffPluginSdkApi(before, after);
const removedDiff = diffPluginSdkApi(after, before);
expect(addedDiff.entrypointsAdded).toEqual([
{
entrypoint: "added",
exportNames: ["createAdded"],
importSpecifier: "openclaw/plugin-sdk/added",
},
]);
expect(addedDiff.exports).toEqual([
expect.objectContaining({
change: "added",
declarationChanges: expect.arrayContaining([
expect.objectContaining({ after: expect.stringContaining("value: number") }),
]),
entrypoint: "added",
exportName: "createAdded",
}),
]);
expect(removedDiff.exports[0]).toMatchObject({
change: "removed",
declarationChanges: expect.arrayContaining([
expect.objectContaining({ before: expect.stringContaining("value: number") }),
]),
});
expect(addedDiff.digest).not.toBe(diffPluginSdkApi(before, sameNamesDifferentContents).digest);
expect(
formatPluginSdkApiDiffReport({ baseLabel: "base", diff: addedDiff, headLabel: "head" }),
).toContain("value: number");
});
it("classifies exported signature changes separately from reachable-only changes", async () => {
const baseline = await renderSourceFixture({
"fixture.ts": [
"export interface SendOptions { text: string }",
"export declare function send(options: SendOptions): void;",
].join("\n"),
});
const changed = await renderSourceFixture({
"fixture.ts": [
"export interface SendOptions { text: string; accountId: string }",
"export declare function send(options: SendOptions): void;",
].join("\n"),
});
const diff = diffPluginSdkApi(baseline, changed);
expect(diff.exports).toEqual([
expect.objectContaining({ change: "signature", exportName: "SendOptions" }),
expect.objectContaining({ change: "reachable", exportName: "send" }),
]);
expect(diff.exports[0]?.declarationChanges.length).toBeGreaterThan(0);
expect(diff.exports[1]?.declarationChanges).toEqual([]);
});
it("stores shared declaration detail once while retaining every affected export", async () => {
const render = (field: string) =>
renderSourceFixture(
{
"fixture.ts": [
`type SharedOptions = { ${field}: string };`,
"export declare function preview(options: SharedOptions): void;",
"export declare function send(options: SharedOptions): void;",
].join("\n"),
"pool-a.ts": [
"type PoolOptions = { value: string };",
"export declare function fixed(options: PoolOptions): void;",
].join("\n"),
"pool-b.ts": [
"type PoolOptions = { value: number };",
"export declare function fixed(options: PoolOptions): void;",
].join("\n"),
},
["pool-b", "fixture", "pool-a"],
);
const baseline = await render("text");
const changed = await render("accountId");
for (const [surface, field] of [
[baseline, "text"],
[changed, "accountId"],
] as const) {
expect(surface.declarationSections.map(({ name, text }) => [name, text])).toEqual([
["PoolOptions", expect.stringContaining("value: number;")],
["PoolOptions", expect.stringContaining("value: string;")],
["SharedOptions", expect.stringContaining(`${field}: string;`)],
["fixed", expect.stringContaining("function fixed(options: PoolOptions): void;")],
["preview", expect.stringContaining("function preview(options: SharedOptions): void;")],
["send", expect.stringContaining("function send(options: SharedOptions): void;")],
]);
expect(
surface.modules.flatMap(({ entrypoint, exports }) =>
exports.map(({ exportName, closureSectionIds }) => [
entrypoint,
exportName,
closureSectionIds,
]),
),
).toEqual([
["fixture", "preview", [2, 4]],
["fixture", "send", [2, 5]],
["pool-a", "fixed", [1, 3]],
["pool-b", "fixed", [0, 3]],
]);
}
const diff = diffPluginSdkApi(baseline, changed);
expect(diff.exports.map(({ change, exportName }) => ({ change, exportName }))).toEqual([
{ change: "reachable", exportName: "preview" },
{ change: "reachable", exportName: "send" },
]);
expect(diff.exports[0]?.declarationChanges).toEqual([
expect.objectContaining({
after: expect.stringContaining("accountId: string"),
before: expect.stringContaining("text: string"),
name: expect.stringContaining("SharedOptions"),
}),
]);
expect(diff.exports[1]?.declarationChanges).toEqual([]);
expect(JSON.stringify(diff).match(/type SharedOptions/gu)).toHaveLength(2);
const report = formatPluginSdkApiDiffReport({ baseLabel: "base", diff, headLabel: "head" });
expect(report).toContain("Affected exports (2)");
expect(report).toContain("`openclaw/plugin-sdk/fixture` — `preview` (reachable)");
expect(report).toContain("`openclaw/plugin-sdk/fixture` — `send` (reachable)");
expect(report).not.toContain("affects 1 export");
});
it("validates renderer artifacts at the subprocess boundary", async () => {
const baseline = await renderSourceFixture({
"fixture.ts": "export type Fixture = { value: string };\n",
});
const parsed = parsePluginSdkApiDiffSurface(JSON.stringify(baseline));
expect(hasPluginSdkApiChanges(diffPluginSdkApi(baseline, parsed))).toBe(false);
expect(() =>
parsePluginSdkApiDiffSurface('{"declarationSections":[],"modules":[{"entrypoint":1}]}'),
).toThrow("invalid module");
});
it("reports public types resolved from each revision's dependency declarations", async () => {
const before = await renderDependencyDeclarationFixture(
"export declare const fixtureValue: { oldValue: string };\n",
);
const after = await renderDependencyDeclarationFixture(
"export declare const fixtureValue: { newValue: number };\n",
);
const diff = diffPluginSdkApi(before, after);
const report = formatPluginSdkApiDiffReport({ baseLabel: "base", diff, headLabel: "head" });
expect(diff.exports).toEqual([
expect.objectContaining({ change: "signature", exportName: "publicValue" }),
]);
expect(report).toContain("oldValue: string");
expect(report).toContain("newValue: number");
expect(report).toContain(`Acknowledgement digest: \`${pluginSdkApiAcknowledgement(diff)}\``);
});
it("bounds reports by UTF-8 bytes without splitting multibyte text", () => {
const diff: PluginSdkApiDiff = {
digest: "a".repeat(64),
entrypointsAdded: [],
entrypointsRemoved: [],
exports: [
{
after: {
closureHash: "b".repeat(64),
declaration: `type Wide = "${"界".repeat(70_000)}";`,
kind: "type",
},
before: null,
change: "added",
declarationChanges: [],
entrypoint: "fixture",
exportName: "Wide",
importSpecifier: "openclaw/plugin-sdk/fixture",
},
],
};
const report = formatPluginSdkApiDiffReport({ baseLabel: "base", diff, headLabel: "head" });
expect(Buffer.byteLength(report, "utf8")).toBeLessThanOrEqual(64 * 1024);
expect(report).toContain("summary truncated");
expect(report).not.toContain("�");
});
it("renders byte-identical surfaces deterministically", async () => {
const firstRender = await renderPrivateDeclarationFixture();
const secondRender = await renderPrivateDeclarationFixture();
const fixtureError = firstRender.modules[0]?.exports.find(
(exportSurface) => exportSurface.exportName === "FixtureError",
)?.declaration;
expect(secondRender).toEqual(firstRender);
expect(fixtureError).toContain("constructor(status: number);");
expect(fixtureError).toContain("getStatus(): number;");
expect(fixtureError).not.toContain("super(");
expect(fixtureError).not.toContain("return this.status");
});
it("fails when a declaration dependency cannot be resolved", async () => {
await expect(
renderSourceFixture({
"fixture.ts": [
'import type { Missing } from "missing-plugin-sdk-dependency";',
"export declare function createFixture(value: Missing): void;",
].join("\n"),
}),
).rejects.toThrow("missing-plugin-sdk-dependency");
});
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).toEqual(baseline);
});
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.modules[0]?.exports[0]?.closureHash).not.toBe(
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).toEqual(baseline);
});
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).toEqual(baseline);
});
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: PluginSdkApiBaseline) =>
result.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.modules[0]?.exports[0]?.declaration;
expect(unrelated).toEqual(baseline);
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.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?.closureSectionIds?.map((id) => baseline.declarationSections[id])).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "FixtureOptionLeaf",
text: expect.stringContaining("required: string"),
}),
expect.objectContaining({
name: "FixtureResult",
text: expect.stringContaining("FixtureResultLeaf"),
}),
]),
);
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.modules[0]?.exports[0]?.declaration).toBe(declaration?.declaration);
expect(changed.modules[0]?.exports[0]?.closureHash).not.toBe(declaration?.closureHash);
}
});
});