mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
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.
373 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}),
|
|
);
|
|
});
|