Files
openclaw/test/scripts/write-plugin-sdk-entry-dts.test.ts
Peter Steinberger 53cc262cc6 perf(test): run declaration commands concurrently (#157669)
Use the existing command lifetime for independent real compiler cases, cap active cases at two, and retain physical compiler inputs and every assertion. Paired Linux medians reduce the three-file wall sum from 96.013s to 61.070s; thirty endurance runs and helper consumers pass.
2026-09-25 02:34:45 +00:00

373 lines
16 KiB
TypeScript

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, expect } from "vitest";
import {
pluginSdkEntrypoints,
publicPluginSdkEntrypoints,
} from "../../scripts/lib/plugin-sdk-entries.mts";
import { materializeNativeCompiler } from "./native-boundary-fixture.js";
import {
createDeclarationFixture as createFixture,
createDeclarationTest,
declarationCacheRecords,
declarationInputs,
expectOutputs,
expectStagingClean,
loader,
runFixture,
runWriter,
treeHashes,
} from "./tsdown-declaration-fixture.js";
const compiler = path.resolve("scripts/run-tsgo.mjs");
// Repeated end-to-end writer runs exceed the default timeout on Windows.
const WRITER_TEST_TIMEOUT_MS = process.platform === "win32" ? 360_000 : 120_000;
const it = createDeclarationTest();
describe("write-plugin-sdk-entry-dts", { timeout: WRITER_TEST_TIMEOUT_MS }, () => {
it.concurrent("retains private stages and both compiler errors when a child is unjoined", ({
command,
}) =>
command.lifetime.run(async () => {
const { root, write } = createFixture(command);
write("dist/plugin-sdk/core.d.ts", "previous generation");
const before = treeHashes(path.join(root, "dist"));
// Inject the executor's terminal error contract, without leaving a real process behind.
write(
"scripts/lib/declaration-stage.mts",
`export async function publishStagedDeclarations() {
throw new AggregateError([
new Error("first compiler failed"),
Object.assign(new Error("second compiler unjoined"), { processTreeState: "live" }),
], "compiler batch failed");
}`,
);
const result = await runWriter(command, root, true);
expect(result.status).not.toBe(0);
expect(result.stdout + result.stderr).toContain("first compiler failed");
expect(result.stdout + result.stderr).toContain("second compiler unjoined");
expect(treeHashes(path.join(root, "dist"))).toEqual(before);
expect(
fs
.readdirSync(path.join(root, ".artifacts"))
.filter((name) => name.startsWith("plugin-sdk-staging-")),
).toHaveLength(3);
expect(fs.existsSync(path.join(root, ".artifacts/dist-artifacts.lock/unjoined"))).toBe(true);
}));
it.concurrent("preserves repository input metadata during direct declaration builds", ({
command,
}) =>
command.lifetime.run(async () => {
const { root, write, declarations, production } = createFixture(command);
for (const [name, roots] of Object.entries(declarations)) {
write(
`compiler-inputs/${name}.json`,
JSON.stringify({ roots, sentinel: "repository input" }),
);
}
const before = treeHashes(path.join(root, "compiler-inputs"));
const direct = await runFixture(command, root, [
"--import",
loader,
path.resolve("scripts/tsdown-build.mts"),
"--config",
"tsdown.config.ts",
...Object.keys(declarations).flatMap((name) => ["--filter", name]),
]);
expect(direct.status, direct.stdout + direct.stderr).toBe(0);
expect(
(direct.stdout + direct.stderr).match(/\[tsdown-build\] invocation \d\/2 finished/gu),
).toHaveLength(2);
expect(treeHashes(path.join(root, "compiler-inputs"))).toEqual(before);
expectOutputs(root, production, Object.keys(treeHashes(path.join(root, "dist"))));
expectStagingClean(root);
}));
it.concurrent.for<{
name: string;
badPlugin: string;
before: NodeJS.ProcessEnv;
after: NodeJS.ProcessEnv;
}>([
{
name: "bounded plugins",
badPlugin: "broken",
before: { OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: "plain" },
after: {},
},
{
name: "optional plugins",
badPlugin: "acpx",
before: { OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "0" },
after: {},
},
{
name: "Docker plugins",
badPlugin: "external",
before: {},
after: { OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS: "external" },
},
])(
"rejects newly selected $name instead of restoring their previous SDK cache",
({ badPlugin, before, after }, { command }) =>
command.lifetime.run(async () => {
const { root, write } = createFixture(command);
for (const id of ["plain", badPlugin]) {
write(`extensions/${id}/openclaw.plugin.json`, JSON.stringify({ id }));
write(
`extensions/${id}/package.json`,
JSON.stringify({
name: `@openclaw/${id}`,
openclaw: { build: { bundledDist: id !== "external" } },
}),
);
if (id !== badPlugin) {
write(`extensions/${id}/index.ts`, "export {};\n");
}
}
const initial = await runWriter(command, root, false, before);
expect(initial.status, initial.stdout + initial.stderr).toBe(0);
const published = treeHashes(path.join(root, "dist"));
const selected = await runWriter(command, root, false, after);
expect(selected.status, selected.stdout + selected.stderr).toBeGreaterThan(0);
expect(selected.stdout + selected.stderr).toContain(`extensions/${badPlugin}/index.ts`);
expect(treeHashes(path.join(root, "dist"))).toEqual(published);
expectStagingClean(root);
}),
);
it.concurrent("publishes fresh canonical partitions with stable bytes and public nominal identity", ({
command,
}) =>
command.lifetime.run(async () => {
const { root, write, writeDeclarations, production, qa } = createFixture(command);
materializeNativeCompiler(root);
expect(production.toSorted()).toEqual(
publicPluginSdkEntrypoints.map((entry) => `plugin-sdk/${entry}`).toSorted(),
);
expect(qa).toEqual(
expect.arrayContaining(pluginSdkEntrypoints.map((entry) => `plugin-sdk/${entry}`)),
);
const preserved = {
"dist/plugin-sdk/core.js": "runtime stays intact",
"dist/plugin-sdk/.tsbuildinfo": "boundary compiler state stays intact",
"dist/plugin-sdk/src/retained.d.ts": "source-shaped output stays intact",
"dist/unrelated.d.ts": "unrelated root declaration stays intact",
"packages/plugin-sdk/dist/src/plugin-sdk/core.d.ts":
"local native declaration stays intact",
"packages/plugin-sdk/dist/.tsbuildinfo": "local native compiler state stays intact",
".artifacts/extension-package-boundary/plugins/qa-channel/api.d.ts":
"local plugin declaration stays intact",
};
for (const [relative, content] of Object.entries(preserved)) {
write(relative, content);
}
const initial = await runWriter(command, root);
expect(initial.status, initial.stdout + initial.stderr).toBe(0);
expect(
(initial.stdout + initial.stderr).match(/\[tsdown-build\] invocation \d\/2 finished/gu),
).toHaveLength(2);
const before = treeHashes(path.join(root, "dist"));
expectOutputs(root, production, Object.keys(before));
expectStagingClean(root);
const records = declarationCacheRecords(root);
expect(records).toHaveLength(2);
const inputs = records.flatMap((record) => record.inputs ?? []);
expect(inputs).toEqual(
expect.arrayContaining([
...declarationInputs.map(({ file }) => file),
"src/shared.ts",
"src/schema.d.ts",
"contracts/before.ts",
]),
);
expect(inputs.some((file) => file.endsWith("/lib.es2023.d.ts"))).toBe(true);
expect(inputs).not.toContain("test/unrelated.test.ts");
expect(inputs).not.toContain("ui/unrelated.ts");
for (const entry of qa.filter((candidate) => !production.includes(candidate))) {
expect(fs.existsSync(path.join(root, `dist/${entry}.d.ts`)), entry).toBe(false);
}
// Identical sources with a different QA selection must emit the extra canonical entries.
const privateQa = await runWriter(command, root, true);
expect(privateQa.status, privateQa.stdout + privateQa.stderr).toBe(0);
expect(
(privateQa.stdout + privateQa.stderr).match(/\[tsdown-build\] invocation \d\/2 finished/gu),
).toHaveLength(2);
const priorOutputs = treeHashes(path.join(root, "dist"));
expectOutputs(root, qa, Object.keys(priorOutputs));
expectStagingClean(root);
writeDeclarations("after");
fs.rmSync(path.join(root, "contracts/before.ts"));
write("dist/plugin-sdk/obsolete.d.ts", "obsolete flat declaration");
const changed = await runWriter(command, root, true);
expect(changed.status, changed.stdout + changed.stderr).toBe(0);
expect(
(changed.stdout + changed.stderr).match(/\[tsdown-build\] invocation \d\/2 finished/gu),
).toHaveLength(2);
const first = treeHashes(path.join(root, "dist"));
const cachedDistFiles = new Set(
declarationCacheRecords(root).flatMap((record) =>
Object.keys(record.outputs)
.filter((file) => file.startsWith("dist/"))
.map((file) => file.slice("dist/".length)),
),
);
expectOutputs(root, qa, Object.keys(first));
expectStagingClean(root);
expect(first).not.toEqual(before);
expect(fs.existsSync(path.join(root, "dist/plugin-sdk/obsolete.d.ts"))).toBe(false);
for (const [relative, content] of Object.entries(preserved)) {
expect(fs.readFileSync(path.join(root, relative), "utf8")).toBe(content);
}
expect(fs.readFileSync(path.join(root, "src/schema.sql"), "utf8")).toBe(
"CREATE TABLE fixture (value TEXT NOT NULL);",
);
// One restore proves portable, byte-stable reuse despite unrelated edits.
// Copy only the cache: copying Windows junctions can change the source topology.
const {
root: relocated,
write: writeRelocated,
writeDeclarations: writeRelocatedDeclarations,
} = createFixture(command);
materializeNativeCompiler(relocated);
writeRelocatedDeclarations("after");
fs.rmSync(path.join(relocated, "contracts/before.ts"));
writeRelocated("test/unrelated.test.ts", "export const test = 2;\n");
writeRelocated("ui/unrelated.ts", "export const view = 2;\n");
writeRelocated(".github/workflows/unrelated.yml", "name: unrelated after\n");
for (const [relative, content] of Object.entries(preserved)) {
writeRelocated(relative, content);
}
// The QA build can add shared chunks after the production snapshot. Seed
// only unowned history; current cache outputs must come from the restore.
for (const file of Object.keys(priorOutputs).filter(
(entry) => !entry.startsWith("plugin-sdk/") && !cachedDistFiles.has(entry),
)) {
expect(first[file]).toBe(priorOutputs[file]);
writeRelocated(`dist/${file}`, fs.readFileSync(path.join(root, "dist", file), "utf8"));
}
writeRelocated("dist/plugin-sdk/obsolete.d.ts", "obsolete restored declaration");
fs.cpSync(
path.join(root, ".artifacts/build-all-cache"),
path.join(relocated, ".artifacts/build-all-cache"),
{ recursive: true },
);
const restored = await runWriter(command, relocated, true);
expect(restored.status, restored.stdout + restored.stderr).toBe(0);
expect(restored.stdout + restored.stderr).not.toContain("[tsdown-build] invocation");
const restoredFiles = treeHashes(path.join(relocated, "dist"));
// Include shared root chunks, not just flat SDK entries, in filename/byte determinism.
expect(restoredFiles).toEqual(first);
expectOutputs(relocated, qa, Object.keys(restoredFiles));
expectStagingClean(relocated);
for (const [relative, content] of Object.entries(preserved)) {
expect(fs.readFileSync(path.join(relocated, relative), "utf8")).toBe(content);
}
write(
"consumer.ts",
[
'import type { Shared } from "./dist/plugin-sdk/core.js";',
'import type { Shared as ChannelShared } from "./dist/plugin-sdk/channel-core.js";',
`import type { TransitiveAlias, ${declarationInputs.map(({ name }) => name).join(", ")} } from "./dist/plugin-sdk/test-fixtures.js";`,
"declare const shared: Shared; const canonical: ChannelShared = shared;",
"declare const channelShared: ChannelShared; const publicShared: Shared = channelShared;",
"// @ts-expect-error An empty object cannot satisfy the nominal SDK class.",
"const impostor: Shared = {}; void impostor;",
"// @ts-expect-error The second public subpath must retain the nominal class too.",
"const channelImpostor: ChannelShared = {}; void channelImpostor;",
...["TransitiveAlias", ...declarationInputs.map(({ name }) => name)].map(
(name) => `const current${name}: ${name} = { value: "after" }; void current${name};`,
),
"void canonical; void publicShared;",
].join("\n"),
);
write(
"consumer.json",
JSON.stringify({
compilerOptions: {
module: "NodeNext",
moduleResolution: "NodeNext",
strict: true,
types: [],
},
files: ["consumer.ts"],
}),
);
const consumer = spawnSync(
process.execPath,
[compiler, "-p", path.join(root, "consumer.json"), "--noEmit"],
{ cwd: root, encoding: "utf8" },
);
expect(consumer.status, consumer.stdout + consumer.stderr).toBe(0);
}));
it.concurrent.for([
{
source: "source declaration export",
diagnostics: ["TS1110", "src/contract.d.ts", "Type expected"],
},
{
source: "transitive declaration export",
diagnostics: ["TS1109", "contracts/before.ts", "Expression expected"],
},
{ source: "missing entry", diagnostics: ["core.ts"] },
{ source: "invalid config", diagnostics: ["missing-config.json"] },
{ source: "missing declaration", diagnostics: ["contract"] },
{ source: "input mutation after emit", diagnostics: ["changed during compilation"] },
])(
"rejects $source before replacing published or local declarations",
({ source, diagnostics }, { command }) =>
command.lifetime.run(async () => {
const { root, write } = createFixture(command);
write("dist/plugin-sdk/core.d.ts", "previous declaration");
write("dist/shared.d.ts", "previous shared declaration");
write("packages/plugin-sdk/dist/src/plugin-sdk/core.d.ts", "previous local declaration");
const published = treeHashes(path.join(root, "dist"));
const local = treeHashes(path.join(root, "packages/plugin-sdk/dist"));
if (source === "source declaration export") {
write("src/contract.d.ts", "export type SourceOnly = ;");
} else if (source === "transitive declaration export") {
write("contracts/before.ts", "export const broken = ;");
} else if (source === "missing entry") {
fs.rmSync(path.join(root, "src/plugin-sdk/core.ts"));
} else if (source === "invalid config") {
write("tsconfig.json", '{"extends":"./missing-config.json"}');
} else if (source === "missing declaration") {
fs.rmSync(path.join(root, "src/contract.d.ts"));
} else {
write(
"tsdown.config.ts",
`${fs.readFileSync(path.join(root, "tsdown.config.ts"), "utf8")}
for (const config of configs) {
if (!config.dts?.emitDtsOnly) continue;
const register = config.hooks;
config.hooks = async hooks => {
await register(hooks);
hooks.hook("build:done", () => fs.appendFileSync("src/shared.ts", "\\n"));
};
}
`,
);
}
const failed = await runWriter(command, root, true);
expect(failed.error).toBeUndefined();
expect(failed.signal).toBeNull();
expect(failed.status, failed.stdout + failed.stderr).toBeGreaterThan(0);
expect(treeHashes(path.join(root, "dist"))).toEqual(published);
expect(treeHashes(path.join(root, "packages/plugin-sdk/dist"))).toEqual(local);
expectStagingClean(root);
for (const diagnostic of diagnostics) {
expect(failed.stdout + failed.stderr).toContain(diagnostic);
}
}),
);
});