mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
Preserve plugin capture conditions, observed missing inputs, and lazy dependency acquisition under Bun. Target portable worker and recovery bundles at Node independently of the build runtime, and migrate runtime fixtures to owned cleanup channels. Validation: paired Node24/Bun17c9 covering suites; 54 Node plugin cases and 46 Bun cases with eight exact-pin skips; full build, changed-file checks, and import-cycle checks; green exact-head CI. The published 2026.9.7 updater installed the verified candidate, replaced the managed service, completed its durable update run, and passed readiness and build-identity checks.
1351 lines
55 KiB
TypeScript
1351 lines
55 KiB
TypeScript
// Tsdown config tests protect package artifact build contracts.
|
|
import { execFile } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { createRequire, isBuiltin } from "node:module";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { build } from "tsdown";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
collectRootPackageExcludedExtensionDirs,
|
|
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
|
|
} from "../../scripts/lib/bundled-plugin-build-entries.mjs";
|
|
import {
|
|
collectPackageDistImportErrors,
|
|
collectPackageDistImports,
|
|
} from "../../scripts/lib/package-dist-imports.mjs";
|
|
import { publicPluginSdkEntrypoints } from "../../scripts/lib/plugin-sdk-entries.mts";
|
|
import {
|
|
TSDOWN_PACKAGE_CONFIG_GROUP,
|
|
TSDOWN_UNIFIED_CONFIG_GROUP,
|
|
TSDOWN_UNIFIED_DTS_CONFIG_GROUPS,
|
|
} from "../../scripts/lib/tsdown-config-groups.mts";
|
|
import { WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID } from "../../scripts/lib/worker-deploy-build-plugin.mts";
|
|
import { importFreshModule } from "../../src/plugin-sdk/test-helpers/import-fresh.js";
|
|
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
|
import buildConfigs from "../../tsdown.config.ts";
|
|
import { copyFsSafePackageFixture } from "./fs-safe-package.test-support.js";
|
|
import { materializeNativeCompiler } from "./native-boundary-fixture.js";
|
|
import { createScriptTestHarness } from "./test-helpers.js";
|
|
|
|
const configs = Array.isArray(buildConfigs) ? buildConfigs : [buildConfigs];
|
|
const { createTempDir } = createScriptTestHarness();
|
|
const testNodeExecPath = resolveTestNodeExecPath();
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
type TsdownConfig = (typeof configs)[number];
|
|
type OutExtensions = NonNullable<TsdownConfig["outExtensions"]>;
|
|
|
|
function hasWorkerEntry(config: TsdownConfig, name: string, source: string): boolean {
|
|
const entry = config.entry;
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
return false;
|
|
}
|
|
return (entry as Record<string, unknown>)[name] === source;
|
|
}
|
|
|
|
const isWorkerDeployConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(config, "worker/worker", "src/worker/worker-deploy-entry.ts");
|
|
const isWorkerFileToolPlanningConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(
|
|
config,
|
|
"worker/file-tool-planning.worker",
|
|
"src/worker/worker-deploy-file-tool-planning.ts",
|
|
);
|
|
const isWorkerImageProcessorConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(
|
|
config,
|
|
"worker/image-processor.worker",
|
|
"src/worker/worker-deploy-image-processor.ts",
|
|
);
|
|
const isWorkerSqliteStoreConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(config, "worker/sqlite-store.worker", "src/worker/worker-deploy-sqlite-store.ts");
|
|
const isWorkerRsyncReceiverConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(
|
|
config,
|
|
"worker/workspace-rsync-receiver",
|
|
"src/worker/workspace-rsync-receiver.ts",
|
|
);
|
|
const isWorkerGitHubExecLauncherConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(config, "worker/github-exec-launcher", "src/agents/github-exec-launcher.ts");
|
|
const isWorkerServiceChildRelayConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(
|
|
config,
|
|
"worker/service-child-relay",
|
|
"src/process/supervisor/service-child-relay.ts",
|
|
);
|
|
const isWorkerServiceChildGroupAnchorConfig = (config: TsdownConfig) =>
|
|
hasWorkerEntry(
|
|
config,
|
|
"worker/service-child-group-anchor",
|
|
"src/process/supervisor/service-child-group-anchor.ts",
|
|
);
|
|
const workerBuildTargets = [
|
|
["worker", isWorkerDeployConfig],
|
|
["file-tool-planning", isWorkerFileToolPlanningConfig],
|
|
["image-processor", isWorkerImageProcessorConfig],
|
|
["sqlite-store", isWorkerSqliteStoreConfig],
|
|
["receiver", isWorkerRsyncReceiverConfig],
|
|
["github-launcher", isWorkerGitHubExecLauncherConfig],
|
|
["service-relay", isWorkerServiceChildRelayConfig],
|
|
["service-group-anchor", isWorkerServiceChildGroupAnchorConfig],
|
|
] as const;
|
|
const isWorkerBuildConfig = (config: TsdownConfig) =>
|
|
workerBuildTargets.some(([, matches]) => matches(config));
|
|
|
|
const FS_SAFE_CALLER_PROBE = `
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import { createRequire, isBuiltin, registerHooks } from "node:module";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
const [entry, observer, rootDir, mode, outcome, sealed] = process.argv.slice(1);
|
|
if (sealed) registerHooks({ resolve(specifier, context, next) {
|
|
if (!isBuiltin(specifier) && specifier !== pathToFileURL(entry).href)
|
|
throw new Error("sealed dependency escaped: " + specifier);
|
|
return next(specifier, context);
|
|
}});
|
|
const { root, parseJsonWithJson5Fallback, resolvePreferredOpenClawTmpDir, resolveRuntimeProcessEntrypointUrl } = await import(pathToFileURL(entry).href);
|
|
if (sealed) {
|
|
assert.deepEqual(parseJsonWithJson5Fallback("{value:'bundled',}"), {value:"bundled"});
|
|
assert.equal(resolvePreferredOpenClawTmpDir({preferredDir:rootDir, tmpdir:()=>rootDir, platform:"linux"}), rootDir);
|
|
assert.equal(resolveRuntimeProcessEntrypointUrl("githubExec").href, new URL("./github-exec-launcher.mjs", pathToFileURL(entry)).href);
|
|
assert.equal(resolveRuntimeProcessEntrypointUrl("serviceChildRelay").href, new URL("./service-child-relay.mjs", pathToFileURL(entry)).href);
|
|
}
|
|
const { configureFsSafeNative, getFsSafeNativeConfig, FsSafeError } = await import(pathToFileURL(observer).href);
|
|
assert.equal(getFsSafeNativeConfig().mode, mode === "configured" ? "off" : mode);
|
|
if (mode === "configured") configureFsSafeNative({ mode: "require" });
|
|
const scoped = await root(rootDir);
|
|
if (outcome === "missing") {
|
|
await assert.rejects(scoped.write("proof.txt", "native proof"), (error) => {
|
|
assert(error instanceof FsSafeError);
|
|
assert.equal(error.code, "helper-unavailable");
|
|
assert.equal(error.cause?.code, "MODULE_NOT_FOUND");
|
|
return true;
|
|
});
|
|
assert.deepEqual(fs.readdirSync(rootDir), []);
|
|
} else {
|
|
await scoped.write("proof.txt", "native proof");
|
|
await scoped.create("created.txt", "create proof");
|
|
assert.equal(fs.readFileSync(path.join(rootDir, "proof.txt"), "utf8"), "native proof");
|
|
assert.equal(fs.readFileSync(path.join(rootDir, "created.txt"), "utf8"), "create proof");
|
|
}
|
|
const loaded = Object.keys(createRequire(import.meta.url).cache).filter((file) => file.endsWith("fs-safe-native.node"));
|
|
assert.equal(loaded.length, outcome === "native" ? 1 : 0);
|
|
if (loaded.length) assert(loaded[0].startsWith(path.dirname(rootDir) + path.sep));
|
|
`;
|
|
|
|
describe("tsdown config", () => {
|
|
it("emits every private Telegram QA harness entry only in private QA builds", async () => {
|
|
const expectedEntries = {
|
|
"plugin-sdk/qa-channel-protocol": "src/plugin-sdk/qa-channel-protocol.ts",
|
|
"plugin-sdk/qa-lab": "src/plugin-sdk/qa-lab.ts",
|
|
"plugin-sdk/qa-runtime": "src/plugin-sdk/qa-runtime.ts",
|
|
};
|
|
const defaultUnified = configs.find((config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP);
|
|
expect(defaultUnified?.entry).not.toMatchObject(expectedEntries);
|
|
|
|
vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", "1");
|
|
const { default: privateQaBuildConfigs } = await importFreshModule<
|
|
typeof import("../../tsdown.config.ts")
|
|
>(import.meta.url, "../../tsdown.config.ts?private-qa-entries");
|
|
const privateQaUnified = privateQaBuildConfigs.find(
|
|
(config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP,
|
|
);
|
|
expect(privateQaUnified?.entry).toMatchObject(expectedEntries);
|
|
});
|
|
|
|
it.each([
|
|
"extensions/openai/setup-api",
|
|
"extensions/openai/capability-catalog",
|
|
"extensions/anthropic/provider-discovery",
|
|
])(
|
|
"keeps %s inventory chunks local and shares the host SDK across lazy imports",
|
|
async (entryName) => {
|
|
const selected = configs.find((config) =>
|
|
hasWorkerEntry(config, entryName, `${entryName}.ts`),
|
|
);
|
|
expect(selected).toBeDefined();
|
|
const root = fs.realpathSync(createTempDir("openclaw-tsdown-setup-"));
|
|
const sdkSpecifier = "openclaw/plugin-sdk/ssrf-runtime-internal";
|
|
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}');
|
|
for (const [name, exports, source] of [
|
|
[
|
|
"openclaw",
|
|
{ "./plugin-sdk/ssrf-runtime-internal": "./index.js" },
|
|
"export const identity = Symbol();",
|
|
],
|
|
[
|
|
"setup-private-dependency",
|
|
{ ".": "./index.js" },
|
|
'export const value = "bundled plugin dependency";',
|
|
],
|
|
] as const) {
|
|
const packageRoot = path.join(root, "node_modules", name);
|
|
fs.mkdirSync(packageRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name, type: "module", exports }),
|
|
);
|
|
fs.writeFileSync(path.join(packageRoot, "index.js"), source);
|
|
}
|
|
const entry = path.join(root, "setup-api.ts");
|
|
fs.writeFileSync(
|
|
entry,
|
|
[
|
|
`import { identity } from ${JSON.stringify(sdkSpecifier)};`,
|
|
'export async function probe() { const lazy = await import("./provider.ts"); return [identity, lazy.identity, lazy.value]; }',
|
|
].join("\n"),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(root, "provider.ts"),
|
|
[
|
|
`export { identity } from ${JSON.stringify(sdkSpecifier)};`,
|
|
'export { value } from "setup-private-dependency";',
|
|
].join("\n"),
|
|
);
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
cwd: root,
|
|
entry: { [entryName]: entry },
|
|
outDir: path.join(root, "dist"),
|
|
tsconfig: false,
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const chunks = bundles.flatMap((bundle) =>
|
|
bundle.chunks.filter((chunk) => chunk.type === "chunk"),
|
|
);
|
|
expect(chunks.flatMap((chunk) => chunk.imports)).toContain(sdkSpecifier);
|
|
const privateChunks = chunks.filter((chunk) => !chunk.isEntry);
|
|
expect(privateChunks.length).toBeGreaterThan(0);
|
|
expect(
|
|
privateChunks.every((chunk) =>
|
|
chunk.fileName.startsWith(`${path.dirname(entryName)}/.setup/`),
|
|
),
|
|
).toBe(true);
|
|
fs.rmSync(path.join(root, "node_modules/setup-private-dependency"), { recursive: true });
|
|
const script = `
|
|
import assert from "node:assert/strict";
|
|
import { identity } from ${JSON.stringify(sdkSpecifier)};
|
|
import { probe } from "./dist/${entryName}.js";
|
|
const [direct, lazy, value] = await probe();
|
|
assert.equal(direct, identity);
|
|
assert.equal(lazy, identity);
|
|
assert.equal(value, "bundled plugin dependency");
|
|
`;
|
|
const result = await new Promise<{ error: Error | null; stderr: string }>((resolve) => {
|
|
execFile(
|
|
testNodeExecPath,
|
|
["--input-type=module", "-e", script],
|
|
{ cwd: root, timeout: 30_000 },
|
|
(error, _stdout, stderr) => resolve({ error, stderr }),
|
|
);
|
|
});
|
|
expect(result.error, result.stderr).toBeNull();
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"runs the Docker-selected memory store with only production dependencies (verbose=%s)",
|
|
async (verbose) => {
|
|
vi.stubEnv("OPENCLAW_BUILD_VERBOSE", verbose ? "1" : "0");
|
|
const entryName = "extensions/memory-lancedb/lancedb-store";
|
|
const runtimeEntryName = "extensions/memory-lancedb/lancedb-runtime";
|
|
const defaultConfig = configs.find((config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP);
|
|
expect(defaultConfig?.entry).not.toHaveProperty(entryName);
|
|
vi.stubEnv(DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV, "memory-lancedb");
|
|
// Selection is captured during config evaluation; keep the default graph untouched.
|
|
const { default: selectedConfigs } = await importFreshModule<
|
|
typeof import("../../tsdown.config.ts")
|
|
>(import.meta.url, `../../tsdown.config.ts?docker-memory-lancedb=${verbose}`);
|
|
const selected = selectedConfigs.find(
|
|
(config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP,
|
|
);
|
|
const source = (selected?.entry as Record<string, string> | undefined)?.[entryName];
|
|
const runtimeSource = (selected?.entry as Record<string, string> | undefined)?.[
|
|
runtimeEntryName
|
|
];
|
|
expect(source).toBeDefined();
|
|
expect(runtimeSource).toBeDefined();
|
|
const root = fs.realpathSync(createTempDir("openclaw-tsdown-memory-"));
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync("extensions/memory-lancedb/package.json", "utf8"),
|
|
) as {
|
|
dependencies: Record<string, string>;
|
|
optionalDependencies: Record<string, string>;
|
|
};
|
|
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}');
|
|
for (const name of Object.keys({
|
|
...manifest.dependencies,
|
|
...manifest.optionalDependencies,
|
|
})) {
|
|
const installed = path.resolve("extensions/memory-lancedb/node_modules", name);
|
|
if (!fs.existsSync(installed)) {
|
|
continue;
|
|
}
|
|
const destination = path.join(root, "node_modules", name);
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
fs.symlinkSync(fs.realpathSync(installed), destination, "dir");
|
|
}
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
entry: { [entryName]: source!, [runtimeEntryName]: runtimeSource! },
|
|
outDir: path.join(root, "dist"),
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const script = `
|
|
import assert from "node:assert/strict";
|
|
import { registerHooks } from "node:module";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
const [root, entry, runtimeEntry, bindingsJson] = process.argv.slice(1);
|
|
const bindings = new Set(JSON.parse(bindingsJson));
|
|
const loadedBindings = new Set();
|
|
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
assert(!["@lancedb/lancedb", "@huggingface/transformers", "sharp"].includes(specifier),
|
|
"Unexpected runtime dependency: " + specifier);
|
|
if (bindings.has(specifier)) loadedBindings.add(specifier);
|
|
return nextResolve(specifier, context);
|
|
} });
|
|
const { MemoryDB } = await import(pathToFileURL(entry).href);
|
|
const dbPath = path.join(root, "memory-db");
|
|
let db = new MemoryDB(dbPath, 2);
|
|
try {
|
|
const stored = await db.store("alpha", {
|
|
text: "Bundled memory proof", vector: [1, 0], importance: 0.8, category: "fact"
|
|
});
|
|
assert.equal((await db.search("alpha", [1, 0], 1, 0))[0].entry.id, stored.id);
|
|
assert.deepEqual(await db.search("beta", [1, 0], 1, 0), []);
|
|
db.close();
|
|
db = new MemoryDB(dbPath, 2);
|
|
assert.equal((await db.search("alpha", [1, 0], 1, 0))[0].entry.id, stored.id);
|
|
const { loadLanceDbModule } = await import(pathToFileURL(runtimeEntry).href);
|
|
const connection = await (await loadLanceDbModule()).connect(dbPath);
|
|
const table = await connection.openTable("memories");
|
|
try {
|
|
assert.equal(await table.countRows("agentId = 'alpha'"), 1);
|
|
assert.equal(await table.countRows("agentId = 'beta'"), 0);
|
|
} finally {
|
|
table.close();
|
|
connection.close();
|
|
}
|
|
assert(loadedBindings.size > 0, "Expected a declared native binding");
|
|
} finally {
|
|
db.close();
|
|
}
|
|
console.log("bundled memory store persists and recalls without image dependencies");
|
|
`;
|
|
const result = await new Promise<{ error: Error | null; stdout: string; stderr: string }>(
|
|
(resolve) => {
|
|
execFile(
|
|
testNodeExecPath,
|
|
[
|
|
"--input-type=module",
|
|
"-e",
|
|
script,
|
|
root,
|
|
path.join(root, "dist", `${entryName}.js`),
|
|
path.join(root, "dist", `${runtimeEntryName}.js`),
|
|
JSON.stringify(Object.keys(manifest.optionalDependencies)),
|
|
],
|
|
{ cwd: root, timeout: 30_000 },
|
|
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
|
);
|
|
},
|
|
);
|
|
expect(result.error, result.stderr).toBeNull();
|
|
expect(result.stdout.trim()).toBe(
|
|
"bundled memory store persists and recalls without image dependencies",
|
|
);
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it("routes HTML-only mail and advances the cursor through packaged imap-watch", async () => {
|
|
const entryName = "extensions/imap/index";
|
|
const selected = configs.find((config) =>
|
|
hasWorkerEntry(config, entryName, "extensions/imap/index.ts"),
|
|
);
|
|
if (!selected) {
|
|
throw new Error("Missing IMAP build config");
|
|
}
|
|
const root = fs.realpathSync(createTempDir("openclaw-tsdown-imap-"));
|
|
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}');
|
|
fs.symlinkSync(fs.realpathSync("node_modules"), path.join(root, "node_modules"), "dir");
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
entry: {
|
|
[entryName]: "extensions/imap/index.ts",
|
|
"plugin-sdk/plugin-state-store-runtime": "src/plugin-sdk/plugin-state-store-runtime.ts",
|
|
},
|
|
outDir: path.join(root, "dist"),
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const result = await new Promise<{ error: Error | null; stdout: string; stderr: string }>(
|
|
(resolve) => {
|
|
execFile(
|
|
testNodeExecPath,
|
|
[
|
|
fileURLToPath(new URL("./imap-packaged-service.test-support.mjs", import.meta.url)),
|
|
root,
|
|
],
|
|
{
|
|
cwd: root,
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") },
|
|
timeout: 30_000,
|
|
},
|
|
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
|
);
|
|
},
|
|
);
|
|
expect(result.error, result.stderr || result.stdout).toBeNull();
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps writable database and session lifecycle outside the archive worker bootstrap", async () => {
|
|
const workerEntry = "config/sessions/session-accessor.sqlite-archive.worker";
|
|
const selected = configs.find((config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP);
|
|
if (!selected) {
|
|
throw new Error("Missing session archive worker build config");
|
|
}
|
|
const entries = selected.entry as Record<string, string>;
|
|
// Include the parent store: shared chunks must not pull its lifecycle writes
|
|
// into the one-shot materialize/publish worker's static closure.
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
entry: Object.fromEntries(
|
|
[workerEntry, "plugin-sdk/session-store-runtime"].map((name) => [name, entries[name]!]),
|
|
),
|
|
outDir: fs.realpathSync(createTempDir("openclaw-archive-worker-imports-")),
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const chunks = new Map(
|
|
bundles.flatMap((bundle) =>
|
|
bundle.chunks
|
|
.filter((chunk) => chunk.type === "chunk")
|
|
.map((chunk) => [chunk.fileName, chunk] as const),
|
|
),
|
|
);
|
|
const queue = [`${workerEntry}.js`];
|
|
const visited = new Set<string>();
|
|
const modules = new Set<string>();
|
|
for (const name of queue) {
|
|
if (visited.has(name)) {
|
|
continue;
|
|
}
|
|
visited.add(name);
|
|
const chunk = chunks.get(name);
|
|
if (!chunk) {
|
|
throw new Error(`Missing archive worker chunk: ${name}`);
|
|
}
|
|
for (const [id, module] of Object.entries(chunk.modules)) {
|
|
if (module.renderedLength > 0) {
|
|
modules.add(id.replaceAll("\\", "/"));
|
|
}
|
|
}
|
|
for (const specifier of chunk.imports) {
|
|
const target = specifier.startsWith(".")
|
|
? path.posix.normalize(path.posix.join(path.posix.dirname(name), specifier))
|
|
: specifier;
|
|
if (chunks.has(target)) {
|
|
queue.push(target);
|
|
}
|
|
}
|
|
}
|
|
expect(
|
|
[...modules].filter(
|
|
(id) =>
|
|
id.endsWith("/src/state/openclaw-agent-db.ts") ||
|
|
/\/session-accessor\.sqlite-(?:reclamation|lifecycle-state|entry-store|archive)\.ts$/u.test(
|
|
id,
|
|
),
|
|
),
|
|
).toEqual([]);
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("builds retained config repairs without plugin runtime or state migration closures", async () => {
|
|
const selected = configs.find((config) => config.outDir === "dist/config-doctor");
|
|
expect(selected?.name).toBe(TSDOWN_UNIFIED_CONFIG_GROUP);
|
|
const entries = selected?.entry ?? {};
|
|
expect(Object.keys(entries)).toContain("discord");
|
|
const root = fs.realpathSync(createTempDir("openclaw-retained-config-doctors-"));
|
|
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ type: "module" }));
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
outDir: root,
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const chunks = new Map(
|
|
bundles.flatMap((bundle) =>
|
|
bundle.chunks
|
|
.filter((chunk) => chunk.type === "chunk")
|
|
.map((chunk) => [chunk.fileName, chunk] as const),
|
|
),
|
|
);
|
|
const queue = Object.keys(entries).map((entry) => `${entry}.js`);
|
|
const visited = new Set<string>();
|
|
const dependencies = JSON.parse(fs.readFileSync("package.json", "utf8")).dependencies;
|
|
while (queue.length) {
|
|
const name = queue.pop()!;
|
|
if (visited.has(name)) {
|
|
continue;
|
|
}
|
|
visited.add(name);
|
|
const chunk = chunks.get(name);
|
|
if (!chunk) {
|
|
throw new Error(`Missing retained config chunk: ${name}`);
|
|
}
|
|
for (const specifier of chunk.imports) {
|
|
const target = specifier.startsWith(".")
|
|
? path.posix.normalize(path.posix.join(path.posix.dirname(name), specifier))
|
|
: specifier;
|
|
if (chunks.has(target)) {
|
|
queue.push(target);
|
|
continue;
|
|
}
|
|
if (isBuiltin(specifier)) {
|
|
continue;
|
|
}
|
|
const packageName = specifier
|
|
.split("/")
|
|
.slice(0, specifier.startsWith("@") ? 2 : 1)
|
|
.join("/");
|
|
expect(Object.hasOwn(dependencies, packageName), specifier).toBe(true);
|
|
const destination = path.join(root, "node_modules", packageName);
|
|
if (!fs.existsSync(destination)) {
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
fs.symlinkSync(
|
|
fs.realpathSync(path.join("node_modules", packageName)),
|
|
destination,
|
|
"dir",
|
|
);
|
|
}
|
|
}
|
|
expect(chunk.code).not.toMatch(
|
|
/migrateLegacyState|openPluginStateKeyedStore|openChannelIngressQueue/u,
|
|
);
|
|
const renderedModules = Object.entries(chunk.modules)
|
|
.filter(([, module]) => module.renderedLength > 0)
|
|
.map(([module]) => module.replaceAll("\\", "/"));
|
|
expect(
|
|
renderedModules.filter((module) =>
|
|
/\/extensions\/[^/]+\/(?:doctor-contract-api|runtime|index|channel-entry|setup-entry)\.[cm]?[jt]s$/u.test(
|
|
module,
|
|
),
|
|
),
|
|
).toEqual([]);
|
|
}
|
|
const script = `
|
|
import assert from "node:assert/strict";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
const [root, entriesJson] = process.argv.slice(1);
|
|
const modules = {};
|
|
for (const name of JSON.parse(entriesJson)) {
|
|
const mod = await import(pathToFileURL(path.join(root, name + ".js")).href);
|
|
assert.deepEqual(Object.keys(mod).sort(), name === "clickclack"
|
|
? ["normalizeCompatibilityConfig"]
|
|
: ["legacyConfigRules", "normalizeCompatibilityConfig"]);
|
|
modules[name] = mod;
|
|
}
|
|
const cfg = { channels: { discord: { dm: { enabled: true, policy: "allowlist", allowFrom: ["123"] }, accounts: { work: { dm: { policy: "disabled", allowFrom: ["456"] } } } } }, plugins: { allow: [] } };
|
|
const before = structuredClone(cfg);
|
|
const migrated = modules.discord.normalizeCompatibilityConfig({ cfg }).config;
|
|
assert.deepEqual(cfg, before);
|
|
assert.deepEqual(migrated.channels.discord.dm, { enabled: true });
|
|
assert.equal(migrated.channels.discord.dmPolicy, "allowlist");
|
|
assert.deepEqual(migrated.channels.discord.allowFrom, ["123"]);
|
|
assert.equal(migrated.channels.discord.accounts.work.dmPolicy, "disabled");
|
|
assert.deepEqual(migrated.channels.discord.accounts.work.allowFrom, ["456"]);
|
|
assert.deepEqual(migrated.plugins, { allow: [] });
|
|
for (const name of ["imessage", "msteams"]) {
|
|
const result = modules[name].normalizeCompatibilityConfig({ cfg: { channels: { [name]: { blockStreaming: false } } } });
|
|
assert.equal(result.config.channels[name].streaming.block.enabled, false);
|
|
assert.equal(Object.hasOwn(result.config.channels[name], "blockStreaming"), false);
|
|
}
|
|
console.log("retained config APIs migrate without plugin installation or capability grants");
|
|
`;
|
|
const result = await new Promise<{ error: Error | null; stdout: string; stderr: string }>(
|
|
(resolve) => {
|
|
execFile(
|
|
testNodeExecPath,
|
|
["--input-type=module", "-e", script, root, JSON.stringify(Object.keys(entries))],
|
|
{ cwd: root, timeout: 30_000 },
|
|
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
|
);
|
|
},
|
|
);
|
|
expect(result.error, result.stderr).toBeNull();
|
|
expect(result.stdout.trim()).toBe(
|
|
"retained config APIs migrate without plugin installation or capability grants",
|
|
);
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ target: "runtime", entry: "parser" },
|
|
{ target: "nested", entry: "extensions/fixture/.setup/parser" },
|
|
{ target: "worker", entry: "worker/parser" },
|
|
])("loads the Bash grammar from the relocated $target package", async ({ target, entry }) => {
|
|
const root = fs.realpathSync(createTempDir("openclaw-bash-parser-"));
|
|
const worker = target === "worker";
|
|
const selected = configs.find(
|
|
worker ? isWorkerDeployConfig : (config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP,
|
|
);
|
|
expect(selected).toBeDefined();
|
|
const outDir = path.join(root, "build");
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
entry: { [entry]: path.resolve("src/infra/command-explainer/tree-sitter-runtime.ts") },
|
|
outDir,
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const installed = path.join(root, "installed package");
|
|
fs.mkdirSync(installed);
|
|
fs.writeFileSync(path.join(installed, "package.json"), JSON.stringify({ type: "module" }));
|
|
fs.renameSync(outDir, path.join(installed, "dist"));
|
|
const require = createRequire(import.meta.url);
|
|
if (worker) {
|
|
expect(bundles.flatMap((bundle) => bundle.chunks.map((chunk) => chunk.fileName))).toEqual([
|
|
`${entry}.mjs`,
|
|
]);
|
|
} else {
|
|
// Install only the engine package. The grammar must come from emitted assets,
|
|
// even when the entrypoint is nested and the whole package has moved.
|
|
const engineRoot = path.dirname(require.resolve("web-tree-sitter"));
|
|
fs.cpSync(engineRoot, path.join(installed, "node_modules/web-tree-sitter"), {
|
|
recursive: true,
|
|
});
|
|
const grammarRoot = path.dirname(require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"));
|
|
for (const [output, source] of [
|
|
["tree-sitter-bash.wasm", "tree-sitter-bash.wasm"],
|
|
["tree-sitter-bash.LICENSE", "LICENSE"],
|
|
] as const) {
|
|
expect(fs.readFileSync(path.join(installed, "dist", output))).toEqual(
|
|
fs.readFileSync(path.join(grammarRoot, source)),
|
|
);
|
|
}
|
|
}
|
|
const result = await new Promise<{ error: Error | null; stdout: string; stderr: string }>(
|
|
(resolve) => {
|
|
execFile(
|
|
testNodeExecPath,
|
|
[
|
|
"--input-type=module",
|
|
"--eval",
|
|
`
|
|
import assert from "node:assert/strict";
|
|
import { createRequire } from "node:module";
|
|
import { pathToFileURL } from "node:url";
|
|
const entry = pathToFileURL(process.argv[1]);
|
|
assert.throws(() => createRequire(entry).resolve("tree-sitter-bash"), { code: "MODULE_NOT_FOUND" });
|
|
const { parseBashForCommandExplanation } = await import(entry.href);
|
|
const tree = await parseBashForCommandExplanation('printf "%s" "$(whoami)" | cat');
|
|
try {
|
|
assert.equal(tree.rootNode.hasError, false);
|
|
assert.deepEqual(tree.rootNode.descendantsOfType("command").map(node => node.childForFieldName("name").text), ["printf", "whoami", "cat"]);
|
|
} finally {
|
|
tree.delete();
|
|
}
|
|
console.log("relocated Bash parser works without native grammar package");
|
|
`,
|
|
path.join(installed, "dist", `${entry}.${worker ? "mjs" : "js"}`),
|
|
],
|
|
{ cwd: installed, timeout: 30_000 },
|
|
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
|
);
|
|
},
|
|
);
|
|
expect(result.error, result.stderr).toBeNull();
|
|
expect(result.stdout.trim()).toBe(
|
|
"relocated Bash parser works without native grammar package",
|
|
);
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
});
|
|
|
|
it.each(["runtime", "worker"])(
|
|
"keeps service relay dependencies inside the emitted %s artifact closure",
|
|
async (target) => {
|
|
const root = fs.realpathSync(createTempDir("openclaw-tsdown-service-relay-"));
|
|
const worker = target === "worker";
|
|
const prefix = worker ? "worker" : "process/supervisor";
|
|
const extension = worker ? "mjs" : "js";
|
|
const relay = `${prefix}/service-child-relay`;
|
|
const anchor = `${prefix}/service-child-group-anchor`;
|
|
const selectedConfigs = worker
|
|
? [
|
|
configs.find(isWorkerServiceChildRelayConfig),
|
|
configs.find(isWorkerServiceChildGroupAnchorConfig),
|
|
]
|
|
: [relay, anchor].map((entry) =>
|
|
configs.find((config) =>
|
|
hasWorkerEntry(config, entry, path.resolve(`src/${entry}.ts`)),
|
|
),
|
|
);
|
|
const files: string[] = [];
|
|
for (const selected of selectedConfigs) {
|
|
if (!selected) {
|
|
throw new Error(`Missing ${target} service relay build config`);
|
|
}
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
entry: Object.fromEntries(
|
|
Object.entries(selected.entry ?? {}).filter(
|
|
([name]) => name === relay || name === anchor,
|
|
),
|
|
),
|
|
outDir: path.join(root, "dist"),
|
|
clean: false,
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
files.push(
|
|
...bundles.flatMap((bundle) => bundle.chunks.map((chunk) => `dist/${chunk.fileName}`)),
|
|
);
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
}
|
|
expect(files).toEqual(
|
|
expect.arrayContaining([`dist/${relay}.${extension}`, `dist/${anchor}.${extension}`]),
|
|
);
|
|
const imports = collectPackageDistImports({
|
|
files,
|
|
readText: (file) => fs.readFileSync(path.join(root, file), "utf8"),
|
|
});
|
|
expect(collectPackageDistImportErrors({ files, imports })).toEqual([]);
|
|
const sealedAnchorEdge = {
|
|
importerPath: `dist/${relay}.${extension}`,
|
|
importedPath: `dist/${anchor}.mjs`,
|
|
};
|
|
if (worker) {
|
|
expect(imports).toContainEqual(sealedAnchorEdge);
|
|
} else {
|
|
// Each launch boots both helpers; shared chunks pull unrelated runtime
|
|
// exports into startup before the durable worker gate can open.
|
|
expect(files.toSorted()).toEqual(
|
|
[`dist/${relay}.${extension}`, `dist/${anchor}.${extension}`].toSorted(),
|
|
);
|
|
expect(imports).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["runtime", "worker"])(
|
|
"preserves fs-safe package ownership and policy in relocated %s output",
|
|
async (target) => {
|
|
const temporaryRoot = fs.realpathSync(createTempDir("openclaw-tsdown-fs-safe-"));
|
|
const sourceRoot = path.join(temporaryRoot, "build");
|
|
const relocatedRoot = path.join(temporaryRoot, "relocated");
|
|
fs.mkdirSync(sourceRoot);
|
|
fs.writeFileSync(path.join(sourceRoot, "package.json"), '{"type":"module"}');
|
|
const worker = target === "worker";
|
|
const require = createRequire(import.meta.url);
|
|
const { nativePackages } = worker
|
|
? { nativePackages: [] }
|
|
: copyFsSafePackageFixture(sourceRoot);
|
|
if (!worker) {
|
|
expect(nativePackages.length).toBeGreaterThan(0);
|
|
}
|
|
const sdkSource = path.resolve("src/plugin-sdk/memory-core-host-engine-fs.ts");
|
|
const observerSource = path.join(sourceRoot, "observer.ts");
|
|
fs.writeFileSync(
|
|
observerSource,
|
|
[
|
|
...(worker
|
|
? [
|
|
`import ${JSON.stringify(path.resolve("src/worker/worker-deploy-runtime.ts"))};`,
|
|
`export { parseJsonWithJson5Fallback } from ${JSON.stringify(path.resolve("src/utils/parse-json-compat.ts"))};`,
|
|
`export { resolvePreferredOpenClawTmpDir } from ${JSON.stringify(path.resolve("src/infra/tmp-openclaw-dir.ts"))};`,
|
|
`export { resolveRuntimeProcessEntrypointUrl } from ${JSON.stringify(path.resolve("src/infra/runtime-process-url.ts"))};`,
|
|
]
|
|
: []),
|
|
`export { root } from ${JSON.stringify(sdkSource)};`,
|
|
`export { configureFsSafeNative, getFsSafeNativeConfig } from ${JSON.stringify(worker ? require.resolve("@openclaw/fs-safe/config") : "@openclaw/fs-safe/config")};`,
|
|
`export { FsSafeError } from ${JSON.stringify(worker ? require.resolve("@openclaw/fs-safe/errors") : "@openclaw/fs-safe/errors")};`,
|
|
].join("\n"),
|
|
);
|
|
const selected = configs.find(
|
|
worker ? isWorkerDeployConfig : (config) => config.name === TSDOWN_UNIFIED_CONFIG_GROUP,
|
|
);
|
|
expect(selected).toBeDefined();
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
entry: worker
|
|
? { "worker/worker": observerSource }
|
|
: { "plugin-sdk/memory-core-host-engine-fs": sdkSource, observer: observerSource },
|
|
outDir: path.join(sourceRoot, "output"),
|
|
dts: false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
fs.renameSync(sourceRoot, relocatedRoot);
|
|
const entry = path.join(
|
|
relocatedRoot,
|
|
worker ? "output/worker/worker.mjs" : "output/plugin-sdk/memory-core-host-engine-fs.js",
|
|
);
|
|
const observer = worker ? entry : path.join(relocatedRoot, "output/observer.js");
|
|
const probe = async (
|
|
name: string,
|
|
mode: string,
|
|
outcome: string,
|
|
override: NodeJS.ProcessEnv = {},
|
|
) => {
|
|
const rootDir = path.join(relocatedRoot, name);
|
|
fs.mkdirSync(rootDir);
|
|
const result = await new Promise<{ error: Error | null; stdout: string; stderr: string }>(
|
|
(resolve) => {
|
|
execFile(
|
|
testNodeExecPath,
|
|
[
|
|
"--input-type=module",
|
|
"--eval",
|
|
FS_SAFE_CALLER_PROBE,
|
|
entry,
|
|
observer,
|
|
rootDir,
|
|
mode,
|
|
outcome,
|
|
worker ? "sealed" : "",
|
|
],
|
|
{
|
|
cwd: relocatedRoot,
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
SystemRoot: process.env.SystemRoot,
|
|
WINDIR: process.env.WINDIR,
|
|
HOME: temporaryRoot,
|
|
USERPROFILE: temporaryRoot,
|
|
TMPDIR: temporaryRoot,
|
|
TMP: temporaryRoot,
|
|
TEMP: temporaryRoot,
|
|
...override,
|
|
},
|
|
},
|
|
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
|
);
|
|
},
|
|
);
|
|
expect(result.error, `${name}\n${result.stdout}\n${result.stderr}`).toBeNull();
|
|
};
|
|
// Join every caller before removing its dependency tree, even after a failure.
|
|
const join = async (probes: Promise<void>[]) => {
|
|
const results = await Promise.allSettled(probes);
|
|
const errors = results.flatMap((result) =>
|
|
result.status === "rejected" ? [result.reason] : [],
|
|
);
|
|
if (errors.length) {
|
|
throw new AggregateError(errors, "fs-safe package probes failed");
|
|
}
|
|
};
|
|
if (worker) {
|
|
await join([
|
|
probe("default", "off", "fallback"),
|
|
...["FS_SAFE_NATIVE_MODE", "OPENCLAW_FS_SAFE_NATIVE_MODE"].map((key) =>
|
|
probe(key, "off", "fallback", { [key]: "require" }),
|
|
),
|
|
]);
|
|
} else {
|
|
await join([
|
|
...["FS_SAFE_NATIVE_MODE", "OPENCLAW_FS_SAFE_NATIVE_MODE"].map((key) =>
|
|
probe(key, "require", "native", { [key]: "require" }),
|
|
),
|
|
probe("shared-config", "configured", "native", { FS_SAFE_NATIVE_MODE: "off" }),
|
|
probe("default", "auto", "native"),
|
|
]);
|
|
for (const nativePackage of nativePackages) {
|
|
fs.rmSync(path.join(relocatedRoot, path.relative(sourceRoot, nativePackage.root)), {
|
|
recursive: true,
|
|
});
|
|
}
|
|
await join([
|
|
probe("missing", "require", "missing", { FS_SAFE_NATIVE_MODE: "require" }),
|
|
...["off", "auto"].map((mode) =>
|
|
probe(mode, mode, "fallback", { FS_SAFE_NATIVE_MODE: mode }),
|
|
),
|
|
]);
|
|
}
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
["runtime", "declarations", ...workerBuildTargets.map(([target]) => target)].flatMap((target) =>
|
|
[false, true].map((verbose) => ({ target, verbose })),
|
|
),
|
|
)(
|
|
"preserves dependency package boundaries for $target (verbose=$verbose)",
|
|
async ({ target, verbose }) => {
|
|
vi.stubEnv("OPENCLAW_BUILD_VERBOSE", verbose ? "1" : "0");
|
|
const root = fs.realpathSync(createTempDir("openclaw-tsdown-dependencies-"));
|
|
const declarations = target === "declarations";
|
|
const workerConfigMatcher = workerBuildTargets.find(([name]) => name === target)?.[1];
|
|
const bundleAll = workerConfigMatcher !== undefined;
|
|
const selected = configs.find(
|
|
workerConfigMatcher ??
|
|
((entry) =>
|
|
entry.name ===
|
|
(declarations ? TSDOWN_UNIFIED_DTS_CONFIG_GROUPS[0] : TSDOWN_UNIFIED_CONFIG_GROUP)),
|
|
);
|
|
expect(selected).toBeDefined();
|
|
const packages = [
|
|
"@anthropic-ai/vertex-sdk",
|
|
"@slack/bolt",
|
|
"@slack/web-api",
|
|
"@discordjs/voice",
|
|
"@lancedb/lancedb",
|
|
"@larksuiteoapi/node-sdk",
|
|
"@matrix-org/matrix-sdk-crypto-nodejs",
|
|
"@openclaw/ai",
|
|
"@openclaw/crabline",
|
|
"@openclaw/fs-safe",
|
|
"@vitest/expect",
|
|
"jimp",
|
|
"matrix-js-sdk",
|
|
"prism-media",
|
|
"typescript",
|
|
"vitest",
|
|
"zod",
|
|
...Object.keys(
|
|
JSON.parse(fs.readFileSync("extensions/memory-lancedb/package.json", "utf8"))
|
|
.optionalDependencies,
|
|
),
|
|
];
|
|
// No manifest dependencies: only phantom/transitive copies are resolvable.
|
|
// Automatic manifest externalization must not hide a missing build boundary.
|
|
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ type: "module" }));
|
|
const specifiers: string[] = [];
|
|
const expectedImports: string[] = [];
|
|
for (const name of packages) {
|
|
for (const packageName of [name, `${name}-extra`]) {
|
|
const packageRoot = path.join(root, "node_modules", packageName);
|
|
fs.mkdirSync(packageRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({
|
|
name: packageName,
|
|
version: "1.0.0",
|
|
type: "module",
|
|
exports: {
|
|
".": { types: "./index.d.ts", default: "./index.js" },
|
|
"./subpath": { types: "./index.d.ts", default: "./index.js" },
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "index.js"),
|
|
"export const identity = import.meta.url;\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "index.d.ts"),
|
|
"export interface Identity { value: string }\n",
|
|
);
|
|
const imports = [packageName, `${packageName}/subpath`];
|
|
specifiers.push(...imports);
|
|
if (
|
|
!bundleAll &&
|
|
packageName === name &&
|
|
name !== "@lancedb/lancedb" &&
|
|
name !== "@openclaw/crabline" &&
|
|
(name !== "zod" || declarations)
|
|
) {
|
|
expectedImports.push(...imports);
|
|
}
|
|
}
|
|
}
|
|
const entry = path.join(root, declarations ? "entry.d.ts" : "entry.ts");
|
|
fs.writeFileSync(
|
|
entry,
|
|
specifiers
|
|
.map(
|
|
(specifier, index) =>
|
|
`export { ${declarations ? "type Identity" : "identity"} as value${index} } from ${JSON.stringify(specifier)};`,
|
|
)
|
|
.join("\n"),
|
|
);
|
|
if (declarations) {
|
|
materializeNativeCompiler(root);
|
|
fs.writeFileSync(
|
|
path.join(root, "tsconfig.json"),
|
|
JSON.stringify({
|
|
compilerOptions: {
|
|
module: "NodeNext",
|
|
target: "ES2023",
|
|
types: [],
|
|
// The fixture checks bundling of authored declarations, not their type surface.
|
|
skipLibCheck: true,
|
|
},
|
|
files: ["entry.d.ts"],
|
|
}),
|
|
);
|
|
}
|
|
const { bundles } = await build({
|
|
...selected,
|
|
config: false,
|
|
cwd: root,
|
|
entry: [entry],
|
|
outDir: path.join(root, "dist"),
|
|
tsconfig: declarations ? path.join(root, "tsconfig.json") : false,
|
|
dts: declarations ? { emitDtsOnly: true } : false,
|
|
logLevel: "silent",
|
|
});
|
|
try {
|
|
const imports = bundles.flatMap((bundle) =>
|
|
bundle.chunks.flatMap((chunk) => (chunk.type === "chunk" ? chunk.imports : [])),
|
|
);
|
|
expect(imports.toSorted()).toEqual(expectedImports.toSorted());
|
|
} finally {
|
|
for (const bundle of bundles) {
|
|
await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["tsdown.config.ts", "tsdown.ai.config.ts"])(
|
|
"keeps %s free of runtime imports from tsdown",
|
|
(configPath) => {
|
|
const source = fs.readFileSync(configPath, "utf8");
|
|
expect(source).not.toMatch(/^import(?!\s+type\b).*from ["']tsdown["'];?$/mu);
|
|
},
|
|
);
|
|
|
|
it("gives every standalone declaration caller one canonical package config", () => {
|
|
for (const packageName of [
|
|
"gateway-client",
|
|
"gateway-protocol",
|
|
"sdk",
|
|
"retry",
|
|
"normalization-core",
|
|
"net-policy",
|
|
"media-understanding-common",
|
|
"media-generation-core",
|
|
"media-core",
|
|
"acp-core",
|
|
]) {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(`packages/${packageName}/package.json`, "utf8"),
|
|
) as { scripts: { build: string }; exports: Record<string, { import: string }> };
|
|
expect(manifest.scripts.build).toContain(`build-workspace-package.mts ${packageName}`);
|
|
const selected = configs.filter((config) => config.outDir === `packages/${packageName}/dist`);
|
|
expect(selected, packageName).toHaveLength(1);
|
|
const sources = Object.values(selected[0]!.entry ?? {});
|
|
for (const entry of Object.values(manifest.exports)) {
|
|
expect(sources).toContain(
|
|
entry.import.replace("./dist/", `packages/${packageName}/src/`).replace(/\.mjs$/u, ".ts"),
|
|
);
|
|
}
|
|
if (packageName === "acp-core") {
|
|
expect(sources).toContain("packages/acp-core/src/error-format.ts");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("isolates runtime output from bounded declaration-only graphs", () => {
|
|
const packageConfigs = configs.filter((entry) => entry.name === TSDOWN_PACKAGE_CONFIG_GROUP);
|
|
const unifiedRuntimeConfig = configs.find(
|
|
(entry) => entry.name === TSDOWN_UNIFIED_CONFIG_GROUP,
|
|
);
|
|
const standaloneRuntimeConfig = configs.find(
|
|
(entry) =>
|
|
entry.name === TSDOWN_UNIFIED_CONFIG_GROUP &&
|
|
entry.dts === false &&
|
|
hasWorkerEntry(
|
|
entry,
|
|
"infra/sqlite-readonly-location.worker",
|
|
path.resolve("src/infra/sqlite-readonly-location.worker.ts"),
|
|
),
|
|
);
|
|
const unifiedDeclarationConfigs = TSDOWN_UNIFIED_DTS_CONFIG_GROUPS.map((name) =>
|
|
configs.find((entry) => entry.name === name),
|
|
);
|
|
|
|
expect(packageConfigs).not.toHaveLength(0);
|
|
expect(packageConfigs.map((entry) => entry.dts)).toEqual(packageConfigs.map(() => true));
|
|
expect(unifiedRuntimeConfig?.dts).toBe(false);
|
|
expect(standaloneRuntimeConfig?.dts).toBe(false);
|
|
expect(unifiedDeclarationConfigs.every(Boolean)).toBe(true);
|
|
const runtimeEntries = configs
|
|
.filter(
|
|
(entry) =>
|
|
entry.name === TSDOWN_UNIFIED_CONFIG_GROUP &&
|
|
entry !== standaloneRuntimeConfig &&
|
|
!isWorkerBuildConfig(entry) &&
|
|
!entry.outDir,
|
|
)
|
|
.flatMap((entry) => Object.entries(entry.entry ?? {}));
|
|
const runtimeEntryNames = runtimeEntries.map(([name]) => name);
|
|
const runtimeOnlyEntryNames = ["native-hook-relay/entry", "node-host-launcher-bootstrap"];
|
|
expect(runtimeEntryNames).toEqual(expect.arrayContaining(runtimeOnlyEntryNames));
|
|
const declarationEntries = runtimeEntries.filter(
|
|
([name]) => !runtimeOnlyEntryNames.includes(name),
|
|
);
|
|
const declarationEntryNames = declarationEntries.map(([name]) => name);
|
|
const standaloneEntries = Object.entries(standaloneRuntimeConfig?.entry ?? {});
|
|
const standaloneNames = new Set(standaloneEntries.map(([name]) => name));
|
|
const declarationInputs = Object.fromEntries([...declarationEntries, ...standaloneEntries]);
|
|
for (const declarationConfig of unifiedDeclarationConfigs) {
|
|
expect(declarationConfig?.dts).toMatchObject({ emitDtsOnly: true });
|
|
// Runtime and inventory graphs retain every alias in the declaration input map.
|
|
expect(declarationConfig?.entry).toEqual(declarationInputs);
|
|
expect(
|
|
Object.keys(declarationConfig?.entry ?? {})
|
|
.filter((name) => !standaloneNames.has(name))
|
|
.toSorted(),
|
|
).toEqual(declarationEntryNames.toSorted());
|
|
}
|
|
});
|
|
|
|
it("keeps excluded plugins out of the unified graph without dropping host helpers", () => {
|
|
const runtime = configs.find((entry) => entry.name === TSDOWN_UNIFIED_CONFIG_GROUP);
|
|
const entries = runtime?.entry as Record<string, string>;
|
|
const excluded = collectRootPackageExcludedExtensionDirs();
|
|
expect(
|
|
Object.keys(entries).filter(
|
|
(name) => name.startsWith("extensions/") && excluded.has(name.split("/")[1]!),
|
|
),
|
|
).toEqual([]);
|
|
expect(entries["plugin-sdk/codex-mcp-projection"]).toBe(
|
|
"src/plugin-sdk/codex-mcp-projection.ts",
|
|
);
|
|
expect(entries["plugin-sdk/codex-session-transcript-runtime"]).toBe(
|
|
"src/plugin-sdk/codex-session-transcript-runtime.ts",
|
|
);
|
|
expect(entries["plugins/public-surface-runtime"]).toBe("src/plugins/public-surface-runtime.ts");
|
|
expect(Object.values(entries)).toEqual(
|
|
expect.arrayContaining([
|
|
"extensions/vault/vault-secret-id.js",
|
|
"extensions/vault/vault-secret-ref-resolver.js",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("emits bounded public declarations without private runtime roots", () => {
|
|
const declarationSources = TSDOWN_UNIFIED_DTS_CONFIG_GROUPS.flatMap((name) => {
|
|
const declarationConfig = configs.find((entry) => entry.name === name);
|
|
const dts = declarationConfig?.dts;
|
|
if (!dts || typeof dts !== "object" || !Array.isArray(dts.entry)) {
|
|
return [];
|
|
}
|
|
expect(dts.entry.length).toBeLessThanOrEqual(200);
|
|
return dts.entry;
|
|
});
|
|
|
|
expect(declarationSources).toEqual(
|
|
expect.arrayContaining([
|
|
"src/index.ts",
|
|
...publicPluginSdkEntrypoints.map((entry) => `src/plugin-sdk/${entry}.ts`),
|
|
"extensions/anthropic/api.ts",
|
|
"extensions/anthropic/contract-api.ts",
|
|
"extensions/memory-core/api.ts",
|
|
"extensions/memory-core/runtime-api.ts",
|
|
]),
|
|
);
|
|
expect(
|
|
declarationSources.filter(
|
|
(source) => source.startsWith("src/") && !source.startsWith("src/plugin-sdk/"),
|
|
),
|
|
).toEqual(["src/index.ts"]);
|
|
expect(
|
|
declarationSources.filter((source) => source.startsWith("extensions/anthropic/")).toSorted(),
|
|
).toEqual(["extensions/anthropic/api.ts", "extensions/anthropic/contract-api.ts"]);
|
|
expect(declarationSources.every((source) => /\.[cm]?tsx?$/u.test(source))).toBe(true);
|
|
expect(new Set(declarationSources).size).toBe(declarationSources.length);
|
|
});
|
|
|
|
it("keeps public SDK types canonical without emitting private runtime declarations", () => {
|
|
const [publicDeclarationSources = [], privateDeclarationSources = []] =
|
|
TSDOWN_UNIFIED_DTS_CONFIG_GROUPS.filter((name) =>
|
|
name.startsWith("openclaw-dts-plugin-sdk-"),
|
|
).map((name) => {
|
|
const dts = configs.find((entry) => entry.name === name)?.dts;
|
|
return dts && typeof dts === "object" && Array.isArray(dts.entry) ? dts.entry : [];
|
|
});
|
|
const publicSources = publicPluginSdkEntrypoints.map((entry) => `src/plugin-sdk/${entry}.ts`);
|
|
expect(publicDeclarationSources.toSorted()).toEqual(publicSources.toSorted());
|
|
expect(privateDeclarationSources).toEqual([]);
|
|
const runtime = configs.find((entry) => entry.name === TSDOWN_UNIFIED_CONFIG_GROUP);
|
|
expect(runtime?.entry).toHaveProperty(
|
|
"plugin-sdk/tts-runtime",
|
|
"src/plugin-sdk/tts-runtime.ts",
|
|
);
|
|
});
|
|
|
|
it("builds self-contained worker deploy executables with every dependency bundled", () => {
|
|
const workerConfig = configs.find(isWorkerDeployConfig);
|
|
const fileToolPlanningConfig = configs.find(isWorkerFileToolPlanningConfig);
|
|
const imageProcessorConfig = configs.find(isWorkerImageProcessorConfig);
|
|
const sqliteStoreConfig = configs.find(isWorkerSqliteStoreConfig);
|
|
const receiverConfig = configs.find(isWorkerRsyncReceiverConfig);
|
|
const launcherConfig = configs.find(isWorkerGitHubExecLauncherConfig);
|
|
const relayConfig = configs.find(isWorkerServiceChildRelayConfig);
|
|
const anchorConfig = configs.find(isWorkerServiceChildGroupAnchorConfig);
|
|
expect(workerConfig?.entry).toEqual({
|
|
"worker/worker": "src/worker/worker-deploy-entry.ts",
|
|
});
|
|
expect(fileToolPlanningConfig?.entry).toEqual({
|
|
"worker/file-tool-planning.worker": "src/worker/worker-deploy-file-tool-planning.ts",
|
|
});
|
|
expect(imageProcessorConfig?.entry).toEqual({
|
|
"worker/image-processor.worker": "src/worker/worker-deploy-image-processor.ts",
|
|
});
|
|
expect(sqliteStoreConfig?.entry).toEqual({
|
|
"worker/sqlite-store.worker": "src/worker/worker-deploy-sqlite-store.ts",
|
|
});
|
|
expect(receiverConfig?.entry).toEqual({
|
|
"worker/workspace-rsync-receiver": "src/worker/workspace-rsync-receiver.ts",
|
|
});
|
|
expect(launcherConfig?.entry).toEqual({
|
|
"worker/github-exec-launcher": "src/agents/github-exec-launcher.ts",
|
|
});
|
|
expect(relayConfig?.entry).toEqual({
|
|
"worker/service-child-relay": "src/process/supervisor/service-child-relay.ts",
|
|
});
|
|
expect(anchorConfig?.entry).toEqual({
|
|
"worker/service-child-group-anchor": "src/process/supervisor/service-child-group-anchor.ts",
|
|
});
|
|
const packageVersion = (
|
|
JSON.parse(fs.readFileSync("package.json", "utf8")) as {
|
|
version: string;
|
|
}
|
|
).version;
|
|
expect(workerConfig?.define).toEqual({
|
|
WORKER_DEPLOY_BUILD: "true",
|
|
SEALED_RUNTIME_BUILD: "true",
|
|
WORKER_DEPLOY_VERSION: JSON.stringify(packageVersion),
|
|
});
|
|
expect(workerConfig?.alias).toMatchObject({
|
|
bufferutil: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
"chromium-bidi/lib/cjs/bidiMapper/BidiMapper": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
"chromium-bidi/lib/cjs/cdp/CdpConnection": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
"electron/index.js": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
fsevents: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
kerberos: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
"utf-8-validate": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
|
|
});
|
|
expect(workerConfig?.outDir).toBe("dist");
|
|
expect(workerConfig?.shims).toBe(true);
|
|
expect(workerConfig?.plugins).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ name: "openclaw:worker-deploy" })]),
|
|
);
|
|
expect(workerConfig?.outputOptions).toMatchObject({
|
|
codeSplitting: false,
|
|
assetFileNames: "worker/[name][extname]",
|
|
});
|
|
for (const config of [receiverConfig, launcherConfig]) {
|
|
expect(config?.define).toBeUndefined();
|
|
}
|
|
for (const config of [relayConfig, anchorConfig]) {
|
|
expect(config?.define).toEqual({
|
|
WORKER_DEPLOY_BUILD: "true",
|
|
SEALED_RUNTIME_BUILD: "true",
|
|
});
|
|
}
|
|
for (const config of [receiverConfig, launcherConfig, relayConfig, anchorConfig]) {
|
|
expect(config?.alias).toBeUndefined();
|
|
expect(config?.plugins).toBeUndefined();
|
|
expect(config?.outputOptions).toEqual({ codeSplitting: false });
|
|
}
|
|
|
|
const context = {
|
|
format: "es",
|
|
options: {},
|
|
pkgType: "module",
|
|
} as Parameters<OutExtensions>[0];
|
|
for (const config of [
|
|
workerConfig,
|
|
fileToolPlanningConfig,
|
|
imageProcessorConfig,
|
|
sqliteStoreConfig,
|
|
receiverConfig,
|
|
launcherConfig,
|
|
relayConfig,
|
|
anchorConfig,
|
|
]) {
|
|
expect(config?.dts).toBe(false);
|
|
expect(config?.outDir).toBe("dist");
|
|
expect(config?.shims).toBe(true);
|
|
expect(config?.deps?.onlyBundle).toBe(false);
|
|
expect(config?.deps?.alwaysBundle).toBeTypeOf("function");
|
|
const alwaysBundle = config?.deps?.alwaysBundle;
|
|
if (typeof alwaysBundle !== "function") {
|
|
throw new Error("worker deploy config must define dependency bundling");
|
|
}
|
|
expect(alwaysBundle("json5", undefined)).toBe(true);
|
|
expect(config?.platform).toBe("node");
|
|
expect(config?.outExtensions?.(context)).toEqual({ js: ".mjs", dts: ".d.ts" });
|
|
}
|
|
});
|
|
|
|
it("keeps node package artifacts on the declared js and dts extensions", () => {
|
|
const nodePackageConfigs = configs.filter(
|
|
(entry) => entry.fixedExtension === false && !isWorkerBuildConfig(entry),
|
|
);
|
|
expect(nodePackageConfigs).not.toHaveLength(0);
|
|
|
|
const context = {
|
|
format: "es",
|
|
options: {},
|
|
pkgType: "module",
|
|
} as Parameters<OutExtensions>[0];
|
|
|
|
for (const entry of nodePackageConfigs) {
|
|
expect(entry.outExtensions?.(context)).toEqual({ js: ".js", dts: ".d.ts" });
|
|
}
|
|
});
|
|
});
|