/** * 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>, 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 { [index: number]: T; readonly length: number }", "interface ReadonlyArray { 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) | undefined): void;`; const normalized = normalizePluginSdkApiDeclarationText(repoRoot, declaration); expect(normalized).not.toContain(repoRoot); expect(normalized).toContain('import("", { 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(""); 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); } }); });