mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { publishStagedDeclarations } from "../../scripts/lib/declaration-stage.mts";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const roots = useAutoCleanupTempDirTracker(afterEach);
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
function fixture() {
|
|
const root = fs.realpathSync(roots.make("declaration-stage-"));
|
|
const staging = path.join(root, "staging");
|
|
const dist = path.join(root, "dist");
|
|
function write(base: string, file: string, text: string) {
|
|
const target = path.join(base, file);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, text);
|
|
}
|
|
write(dist, "plugin-sdk/obsolete.d.ts", "old");
|
|
write(dist, "core.d.ts", "core");
|
|
write(dist, "shared-old.d.ts", "unattributed");
|
|
write(dist, "runtime.js", "runtime");
|
|
const invocation = (files: Record<string, string>, exitCode = 0, output = staging) => ({
|
|
command: process.execPath,
|
|
args: [
|
|
"-e",
|
|
`const fs=require('node:fs'),path=require('node:path'); for(const [file,bytes] of Object.entries(${JSON.stringify(files)})){const p=path.join(${JSON.stringify(output)},file);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,bytes);} process.exitCode=${exitCode};`,
|
|
],
|
|
options: { stdio: ["ignore", "pipe", "pipe"], shell: false, env: process.env },
|
|
});
|
|
return { staging, dist, invocation };
|
|
}
|
|
|
|
describe("canonical declaration stage", () => {
|
|
it("retains a sibling failure and stops queued work when another compiler cannot start", async () => {
|
|
const { staging, dist, invocation } = fixture();
|
|
const missing = invocation({});
|
|
missing.command = path.join(staging, "missing-compiler");
|
|
const failed = invocation({}, 17);
|
|
const seal = vi.fn();
|
|
const result = await publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [missing, failed, invocation({ "queued.d.ts": "export {};" })],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/core.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
seal,
|
|
2,
|
|
).catch((error: unknown) => error);
|
|
expect(result).toBeInstanceOf(AggregateError);
|
|
expect(result).toMatchObject({
|
|
errors: expect.arrayContaining([
|
|
expect.objectContaining({ code: "ENOENT" }),
|
|
expect.objectContaining({ exitCode: 17 }),
|
|
]),
|
|
});
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
expect(fs.existsSync(path.join(staging, "queued.d.ts"))).toBe(false);
|
|
expect(seal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["success", "exit", "dual-exit", "diagnostic"])(
|
|
"joins every private compiler stage before %s publication",
|
|
async (outcome) => {
|
|
const { staging, dist, invocation } = fixture();
|
|
const root = path.dirname(staging);
|
|
const names = outcome === "success" ? ["public", "private", "queued"] : ["public", "private"];
|
|
const sources = names.map((name) => ({
|
|
output: path.join(root, name),
|
|
required: [`plugin-sdk/${name}.d.ts`],
|
|
}));
|
|
const invocations = sources.map((source, index) => {
|
|
const child = invocation({ [source.required[0]!]: "export {};" }, 0, source.output);
|
|
if (index === 2) {
|
|
return child;
|
|
}
|
|
child.args[1] = `
|
|
const fs = require('node:fs'), path = require('node:path');
|
|
const root = ${JSON.stringify(root)}, index = ${index};
|
|
fs.writeFileSync(path.join(root, index + '.started'), 'started');
|
|
const deadline = Date.now() + 3000;
|
|
const wait = setInterval(() => {
|
|
if (!fs.existsSync(path.join(root, (1 - index) + '.started'))) {
|
|
if (Date.now() > deadline) { clearInterval(wait); process.exitCode = 73; }
|
|
return;
|
|
}
|
|
clearInterval(wait);
|
|
{ ${child.args[1]} }
|
|
if (index === 0) {
|
|
if (['exit', 'dual-exit'].includes(${JSON.stringify(outcome)})) process.exitCode = 17;
|
|
if (${JSON.stringify(outcome)} === 'diagnostic') console.error('[INEFFECTIVE_DYNAMIC_IMPORT]');
|
|
fs.writeFileSync(path.join(root, 'first.finished'), 'finished');
|
|
} else {
|
|
const drain = setInterval(() => {
|
|
if (!fs.existsSync(path.join(root, 'first.finished'))) {
|
|
if (Date.now() > deadline) { clearInterval(drain); process.exitCode = 74; }
|
|
return;
|
|
}
|
|
clearInterval(drain);
|
|
fs.writeFileSync(path.join(root, 'second.finished'), 'finished');
|
|
if (${JSON.stringify(outcome)} === 'dual-exit') process.exitCode = 23;
|
|
}, 10);
|
|
}
|
|
}, 10);
|
|
`;
|
|
return child;
|
|
});
|
|
const seal = vi.fn(() => {
|
|
expect(fs.existsSync(path.join(root, "second.finished"))).toBe(true);
|
|
for (const source of sources) {
|
|
expect(fs.existsSync(path.join(source.output, source.required[0]!))).toBe(true);
|
|
}
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
});
|
|
const publication = publishStagedDeclarations(
|
|
{ env: process.env, maxOldSpaceMb: 8192, heapShortfall: null, invocations },
|
|
sources,
|
|
staging,
|
|
dist,
|
|
sources.flatMap((source) => source.required),
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
seal,
|
|
2,
|
|
);
|
|
if (outcome === "success") {
|
|
await publication;
|
|
expect(seal).toHaveBeenCalledOnce();
|
|
for (const source of sources) {
|
|
expect(fs.readFileSync(path.join(dist, source.required[0]!), "utf8")).toBe("export {};");
|
|
}
|
|
} else {
|
|
if (outcome === "dual-exit") {
|
|
const result = await publication.catch((error: unknown) => error);
|
|
expect(result).toBeInstanceOf(AggregateError);
|
|
expect(result).toMatchObject({
|
|
errors: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message: "tsdown invocation 1 failed with exit 17",
|
|
exitCode: 17,
|
|
}),
|
|
expect.objectContaining({
|
|
message: "tsdown invocation 2 failed with exit 23",
|
|
exitCode: 23,
|
|
}),
|
|
]),
|
|
});
|
|
} else {
|
|
await expect(publication).rejects.toThrow(
|
|
`Declaration build failed with exit ${outcome === "exit" ? 17 : 1}`,
|
|
);
|
|
}
|
|
expect(seal).not.toHaveBeenCalled();
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
for (const source of sources) {
|
|
expect(fs.existsSync(path.join(dist, source.required[0]!))).toBe(false);
|
|
}
|
|
}
|
|
expect(fs.readFileSync(path.join(root, "second.finished"), "utf8")).toBe("finished");
|
|
},
|
|
);
|
|
|
|
it.each([".d.ts", ".d.mts", ".d.cts"])(
|
|
"strips undeclared __exportAll from staged %s declaration exports",
|
|
async (extension) => {
|
|
const { staging, dist, invocation } = fixture();
|
|
await publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [
|
|
invocation({
|
|
[`plugin-sdk/core${extension}`]:
|
|
"export declare const keep: number;\nexport { keep as k, __exportAll as ud };\n",
|
|
}),
|
|
],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
[`plugin-sdk/core${extension}`],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
);
|
|
const published = fs.readFileSync(path.join(dist, `plugin-sdk/core${extension}`), "utf8");
|
|
expect(published).toContain("keep as k");
|
|
expect(published).not.toContain("__exportAll");
|
|
},
|
|
);
|
|
|
|
it.each([".d.ts", ".d.mts", ".d.cts"])(
|
|
"also strips undeclared __exportAll left in live dist outside staging (%s)",
|
|
async (extension) => {
|
|
const { staging, dist, invocation } = fixture();
|
|
fs.writeFileSync(
|
|
path.join(dist, `leftover-chunk${extension}`),
|
|
"export declare const keep: number;\nexport { keep as k, __exportAll as ud };\n",
|
|
);
|
|
await publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [
|
|
invocation({
|
|
"plugin-sdk/core.d.ts": "export declare const ok: true;\n",
|
|
}),
|
|
],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/core.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
);
|
|
const leftover = fs.readFileSync(path.join(dist, `leftover-chunk${extension}`), "utf8");
|
|
expect(leftover).toContain("keep as k");
|
|
expect(leftover).not.toContain("__exportAll");
|
|
},
|
|
);
|
|
|
|
it("rejects absolute reference paths even when a staged relative namesake exists", async () => {
|
|
const { staging, dist, invocation } = fixture();
|
|
await expect(
|
|
publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [
|
|
invocation({
|
|
"plugin-sdk/core.d.ts": '/// <reference path="/shared.d.ts" />\nexport {};',
|
|
"plugin-sdk/shared.d.ts": "export {};",
|
|
}),
|
|
],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/core.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
),
|
|
).rejects.toThrow("Incomplete declaration closure");
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
});
|
|
it.each([
|
|
'/// <reference path="./missing.d.ts" />\nexport {};',
|
|
'import Type = require("./missing.cjs"); export { Type };',
|
|
'export * as Types from "./missing.js";',
|
|
'export type Type = import("./missing.js").Type;',
|
|
])("rejects a missing relative dependency in %s", async (declaration) => {
|
|
const { staging, dist, invocation } = fixture();
|
|
await expect(
|
|
publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [invocation({ "plugin-sdk/core.d.ts": declaration })],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/core.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
),
|
|
).rejects.toThrow("Incomplete declaration closure");
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
});
|
|
|
|
it("ignores imports in comments while preserving an actual reference directive", async () => {
|
|
const { staging, dist, invocation } = fixture();
|
|
const files = {
|
|
"plugin-sdk/core.d.ts":
|
|
'/// <reference path="../shared.d.ts" />\n// import type {Absent} from "./not-an-import.js";\nexport {};',
|
|
"shared.d.ts": "export {};",
|
|
};
|
|
await publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [invocation(files)],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/core.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
);
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/core.d.ts"), "utf8")).toBe(
|
|
files["plugin-sdk/core.d.ts"],
|
|
);
|
|
});
|
|
it.skipIf(process.platform === "win32")(
|
|
"does not mask a timeout when the child exits zero on SIGTERM",
|
|
async () => {
|
|
const { staging, dist, invocation } = fixture();
|
|
vi.stubEnv("OPENCLAW_TSDOWN_TIMEOUT_MS", "1500");
|
|
vi.stubEnv("OPENCLAW_TSDOWN_HEARTBEAT_MS", "0");
|
|
const first = invocation({});
|
|
first.args = [
|
|
"-e",
|
|
`const fs=require('node:fs');process.on('SIGTERM',()=>process.exit(0));fs.mkdirSync(${JSON.stringify(staging)},{recursive:true});fs.writeFileSync(${JSON.stringify(path.join(staging, "ready"))},'ready');setInterval(()=>{},1000);`,
|
|
];
|
|
await expect(
|
|
publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [first, invocation({ "later.d.ts": "export {};" })],
|
|
},
|
|
[],
|
|
staging,
|
|
dist,
|
|
[],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
),
|
|
).rejects.toMatchObject({ exitCode: 124 });
|
|
expect(fs.existsSync(path.join(staging, "ready"))).toBe(true);
|
|
expect(fs.existsSync(path.join(staging, "later.d.ts"))).toBe(false);
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
},
|
|
);
|
|
it.each(["child failure", "missing entry", "missing nested chunk"])(
|
|
"does not publish on %s",
|
|
async (failure) => {
|
|
const { staging, dist, invocation } = fixture();
|
|
const plan = {
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [
|
|
invocation({ "plugin-sdk/public.d.ts": 'export type { Shared } from "../shared.js";' }),
|
|
invocation(
|
|
failure === "missing nested chunk"
|
|
? { "plugin-sdk/private.d.ts": "export {};" }
|
|
: { "shared.d.ts": "export type Shared = string;" },
|
|
failure === "child failure" ? 2 : 0,
|
|
),
|
|
],
|
|
};
|
|
const before = fs.readdirSync(dist, { recursive: true }).map(String).toSorted();
|
|
await expect(
|
|
publishStagedDeclarations(
|
|
plan,
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/public.d.ts", "plugin-sdk/private.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
),
|
|
).rejects.toThrow();
|
|
expect(fs.readdirSync(dist, { recursive: true }).map(String).toSorted()).toEqual(before);
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
},
|
|
);
|
|
|
|
it("publishes both groups with root chunks, preserves unrelated files, and prunes only owned entries", async () => {
|
|
const { staging, dist, invocation } = fixture();
|
|
const first = path.join(staging, "..", "first");
|
|
const second = path.join(staging, "..", "second");
|
|
const publicFile = {
|
|
"plugin-sdk/public.d.ts": 'export type { Shared } from "../shared.js";',
|
|
"shared.d.ts": 'export type Shared = import("./z-leaf.js").Leaf;',
|
|
"z-leaf.d.ts": "export type Leaf = string;",
|
|
};
|
|
const plan = {
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [
|
|
invocation(publicFile, 0, first),
|
|
invocation(
|
|
{
|
|
"plugin-sdk/private.d.ts": 'export type { Shared } from "../shared.js";',
|
|
"shared.d.ts": publicFile["shared.d.ts"],
|
|
},
|
|
0,
|
|
second,
|
|
),
|
|
],
|
|
};
|
|
const rename = fs.renameSync;
|
|
const observed: string[] = [];
|
|
const publication = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
|
|
if (
|
|
target === path.join(dist, "plugin-sdk/public.d.ts") ||
|
|
target === path.join(dist, "plugin-sdk/private.d.ts")
|
|
) {
|
|
observed.push(
|
|
fs.existsSync(path.join(dist, "shared.d.ts")) ? "dependency ready" : "missing dependency",
|
|
);
|
|
}
|
|
if (target === path.join(dist, "shared.d.ts")) {
|
|
observed.push(
|
|
fs.existsSync(path.join(dist, "z-leaf.d.ts")) ? "leaf ready" : "missing leaf",
|
|
);
|
|
}
|
|
rename(source, target);
|
|
});
|
|
try {
|
|
await publishStagedDeclarations(
|
|
plan,
|
|
[
|
|
{ output: first, required: ["plugin-sdk/public.d.ts"] },
|
|
{ output: second, required: ["plugin-sdk/private.d.ts"] },
|
|
],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/public.d.ts", "plugin-sdk/private.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
);
|
|
} finally {
|
|
publication.mockRestore();
|
|
}
|
|
expect(observed).toEqual(["leaf ready", "dependency ready", "dependency ready"]);
|
|
expect(fs.existsSync(path.join(dist, "plugin-sdk/obsolete.d.ts"))).toBe(false);
|
|
for (const [file, bytes] of Object.entries({
|
|
...publicFile,
|
|
"core.d.ts": "core",
|
|
"shared-old.d.ts": "unattributed",
|
|
"runtime.js": "runtime",
|
|
})) {
|
|
expect(fs.readFileSync(path.join(dist, file), "utf8")).toBe(bytes);
|
|
}
|
|
const unchanged = fs.statSync(path.join(dist, "plugin-sdk/public.d.ts")).mtimeMs;
|
|
fs.rmSync(staging, { recursive: true });
|
|
await publishStagedDeclarations(
|
|
{ ...plan, invocations: [invocation(publicFile)] },
|
|
[],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/public.d.ts"],
|
|
["plugin-sdk/public.d.ts", "plugin-sdk/private.d.ts"],
|
|
);
|
|
expect(fs.existsSync(path.join(dist, "plugin-sdk/private.d.ts"))).toBe(false);
|
|
expect(fs.statSync(path.join(dist, "plugin-sdk/public.d.ts")).mtimeMs).toBe(unchanged);
|
|
});
|
|
|
|
it.each(["conflicting shared bytes", "misassigned entry"])(
|
|
"rejects canonical group ownership with %s before publishing",
|
|
async (failure) => {
|
|
const { staging, dist, invocation } = fixture();
|
|
const first = path.join(staging, "..", "first");
|
|
const second = path.join(staging, "..", "second");
|
|
const publicFile = { "plugin-sdk/public.d.ts": "export {};" };
|
|
await expect(
|
|
publishStagedDeclarations(
|
|
{
|
|
env: process.env,
|
|
maxOldSpaceMb: 8192,
|
|
heapShortfall: null,
|
|
invocations: [
|
|
invocation(
|
|
{
|
|
...(failure === "misassigned entry" ? {} : publicFile),
|
|
"shared.d.ts": "export type Shared = string;",
|
|
},
|
|
0,
|
|
first,
|
|
),
|
|
invocation(
|
|
{
|
|
...(failure === "misassigned entry" ? publicFile : {}),
|
|
"plugin-sdk/private.d.ts": "export {};",
|
|
"shared.d.ts":
|
|
failure === "conflicting shared bytes"
|
|
? "export type Shared = number;"
|
|
: "export type Shared = string;",
|
|
},
|
|
0,
|
|
second,
|
|
),
|
|
],
|
|
},
|
|
[
|
|
{ output: first, required: ["plugin-sdk/public.d.ts"] },
|
|
{ output: second, required: ["plugin-sdk/private.d.ts"] },
|
|
],
|
|
staging,
|
|
dist,
|
|
["plugin-sdk/public.d.ts", "plugin-sdk/private.d.ts"],
|
|
["plugin-sdk/obsolete.d.ts"],
|
|
),
|
|
).rejects.toThrow(
|
|
failure === "conflicting shared bytes"
|
|
? "Conflicting canonical declaration owners"
|
|
: "Missing canonical declaration",
|
|
);
|
|
expect(fs.readFileSync(path.join(dist, "plugin-sdk/obsolete.d.ts"), "utf8")).toBe("old");
|
|
expect(fs.existsSync(path.join(dist, "plugin-sdk/public.d.ts"))).toBe(false);
|
|
expect(fs.existsSync(path.join(dist, "plugin-sdk/private.d.ts"))).toBe(false);
|
|
},
|
|
);
|
|
});
|