2026-03-16 16:43:23 +00:00
|
|
|
import fs from "node:fs";
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
import path from "node:path";
|
2026-03-16 20:58:58 -04:00
|
|
|
import { pathToFileURL } from "node:url";
|
2026-03-16 16:43:23 +00:00
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
|
import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs";
|
2026-03-16 20:58:58 -04:00
|
|
|
import { discoverOpenClawPlugins } from "./discovery.js";
|
|
|
|
|
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
2026-03-16 16:43:23 +00:00
|
|
|
|
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
|
|
|
|
|
|
function makeRepoRoot(prefix: string): string {
|
|
|
|
|
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
|
|
|
tempDirs.push(repoRoot);
|
|
|
|
|
return repoRoot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
for (const dir of tempDirs.splice(0, tempDirs.length)) {
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("stageBundledPluginRuntime", () => {
|
2026-03-16 20:58:58 -04:00
|
|
|
it("stages bundled dist plugins as runtime wrappers and links plugin-local node_modules", () => {
|
2026-03-16 16:43:23 +00:00
|
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-");
|
|
|
|
|
const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs");
|
|
|
|
|
fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
|
|
|
|
|
const sourcePluginNodeModulesDir = path.join(repoRoot, "extensions", "diffs", "node_modules");
|
|
|
|
|
fs.mkdirSync(distPluginDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(path.join(sourcePluginNodeModulesDir, "@pierre", "diffs"), {
|
|
|
|
|
recursive: true,
|
|
|
|
|
});
|
|
|
|
|
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8");
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(sourcePluginNodeModulesDir, "@pierre", "diffs", "index.js"),
|
|
|
|
|
"export default {}\n",
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
|
|
|
|
|
|
const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "diffs");
|
|
|
|
|
expect(fs.existsSync(path.join(runtimePluginDir, "index.js"))).toBe(true);
|
2026-03-16 20:58:58 -04:00
|
|
|
expect(fs.readFileSync(path.join(runtimePluginDir, "index.js"), "utf8")).toContain(
|
|
|
|
|
"../../../dist/extensions/diffs/index.js",
|
|
|
|
|
);
|
2026-03-16 16:43:23 +00:00
|
|
|
expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true);
|
|
|
|
|
expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe(
|
|
|
|
|
fs.realpathSync(sourcePluginNodeModulesDir),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 20:58:58 -04:00
|
|
|
it("writes wrappers that forward plugin entry imports into canonical dist files", async () => {
|
2026-03-16 16:43:23 +00:00
|
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-chunks-");
|
|
|
|
|
fs.mkdirSync(path.join(repoRoot, "dist", "extensions", "diffs"), { recursive: true });
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(repoRoot, "dist", "chunk-abc.js"),
|
|
|
|
|
"export const value = 1;\n",
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(repoRoot, "dist", "extensions", "diffs", "index.js"),
|
|
|
|
|
"export { value } from '../../chunk-abc.js';\n",
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
|
|
2026-03-16 20:58:58 -04:00
|
|
|
const runtimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "diffs", "index.js");
|
|
|
|
|
expect(fs.readFileSync(runtimeEntryPath, "utf8")).toContain(
|
|
|
|
|
"../../../dist/extensions/diffs/index.js",
|
|
|
|
|
);
|
|
|
|
|
expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "chunk-abc.js"))).toBe(false);
|
|
|
|
|
|
|
|
|
|
const runtimeModule = await import(`${pathToFileURL(runtimeEntryPath).href}?t=${Date.now()}`);
|
|
|
|
|
expect(runtimeModule.value).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("copies package metadata files but symlinks other non-js plugin artifacts into the runtime overlay", () => {
|
|
|
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-");
|
|
|
|
|
const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs");
|
|
|
|
|
fs.mkdirSync(path.join(distPluginDir, "assets"), { recursive: true });
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(distPluginDir, "package.json"),
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
{ name: "@openclaw/diffs", openclaw: { extensions: ["./index.js"] } },
|
|
|
|
|
null,
|
|
|
|
|
2,
|
|
|
|
|
),
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
fs.writeFileSync(path.join(distPluginDir, "openclaw.plugin.json"), "{}\n", "utf8");
|
|
|
|
|
fs.writeFileSync(path.join(distPluginDir, "assets", "info.txt"), "ok\n", "utf8");
|
|
|
|
|
|
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
|
|
|
|
|
|
const runtimePackagePath = path.join(
|
|
|
|
|
repoRoot,
|
|
|
|
|
"dist-runtime",
|
|
|
|
|
"extensions",
|
|
|
|
|
"diffs",
|
|
|
|
|
"package.json",
|
|
|
|
|
);
|
|
|
|
|
const runtimeManifestPath = path.join(
|
|
|
|
|
repoRoot,
|
|
|
|
|
"dist-runtime",
|
|
|
|
|
"extensions",
|
|
|
|
|
"diffs",
|
|
|
|
|
"openclaw.plugin.json",
|
|
|
|
|
);
|
|
|
|
|
const runtimeAssetPath = path.join(
|
|
|
|
|
repoRoot,
|
|
|
|
|
"dist-runtime",
|
|
|
|
|
"extensions",
|
|
|
|
|
"diffs",
|
|
|
|
|
"assets",
|
|
|
|
|
"info.txt",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(fs.lstatSync(runtimePackagePath).isSymbolicLink()).toBe(false);
|
|
|
|
|
expect(fs.readFileSync(runtimePackagePath, "utf8")).toContain('"extensions": [');
|
|
|
|
|
expect(fs.lstatSync(runtimeManifestPath).isSymbolicLink()).toBe(false);
|
|
|
|
|
expect(fs.readFileSync(runtimeManifestPath, "utf8")).toBe("{}\n");
|
|
|
|
|
expect(fs.lstatSync(runtimeAssetPath).isSymbolicLink()).toBe(true);
|
|
|
|
|
expect(fs.readFileSync(runtimeAssetPath, "utf8")).toBe("ok\n");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves package metadata needed for bundled plugin discovery from dist-runtime", () => {
|
|
|
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-discovery-");
|
|
|
|
|
const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo");
|
|
|
|
|
const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions");
|
|
|
|
|
fs.mkdirSync(distPluginDir, { recursive: true });
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(distPluginDir, "package.json"),
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
{
|
|
|
|
|
name: "@openclaw/demo",
|
|
|
|
|
openclaw: {
|
|
|
|
|
extensions: ["./main.js"],
|
|
|
|
|
setupEntry: "./setup.js",
|
|
|
|
|
startup: {
|
|
|
|
|
deferConfiguredChannelFullLoadUntilAfterListen: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
null,
|
|
|
|
|
2,
|
|
|
|
|
),
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(distPluginDir, "openclaw.plugin.json"),
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
{
|
|
|
|
|
id: "demo",
|
|
|
|
|
channels: ["demo"],
|
|
|
|
|
configSchema: { type: "object" },
|
|
|
|
|
},
|
|
|
|
|
null,
|
|
|
|
|
2,
|
2026-03-16 16:43:23 +00:00
|
|
|
),
|
2026-03-16 20:58:58 -04:00
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
fs.writeFileSync(path.join(distPluginDir, "main.js"), "export default {};\n", "utf8");
|
|
|
|
|
fs.writeFileSync(path.join(distPluginDir, "setup.js"), "export default {};\n", "utf8");
|
|
|
|
|
|
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
|
|
|
|
|
|
const env = {
|
|
|
|
|
...process.env,
|
|
|
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: runtimeExtensionsDir,
|
|
|
|
|
};
|
|
|
|
|
const discovery = discoverOpenClawPlugins({
|
|
|
|
|
env,
|
|
|
|
|
cache: false,
|
|
|
|
|
});
|
|
|
|
|
const manifestRegistry = loadPluginManifestRegistry({
|
|
|
|
|
env,
|
|
|
|
|
cache: false,
|
|
|
|
|
candidates: discovery.candidates,
|
|
|
|
|
diagnostics: discovery.diagnostics,
|
|
|
|
|
});
|
|
|
|
|
const expectedRuntimeMainPath = fs.realpathSync(
|
|
|
|
|
path.join(runtimeExtensionsDir, "demo", "main.js"),
|
|
|
|
|
);
|
|
|
|
|
const expectedRuntimeSetupPath = fs.realpathSync(
|
|
|
|
|
path.join(runtimeExtensionsDir, "demo", "setup.js"),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(discovery.candidates).toHaveLength(1);
|
|
|
|
|
expect(fs.realpathSync(discovery.candidates[0]?.source ?? "")).toBe(expectedRuntimeMainPath);
|
|
|
|
|
expect(fs.realpathSync(discovery.candidates[0]?.setupSource ?? "")).toBe(
|
|
|
|
|
expectedRuntimeSetupPath,
|
|
|
|
|
);
|
|
|
|
|
expect(fs.realpathSync(manifestRegistry.plugins[0]?.setupSource ?? "")).toBe(
|
|
|
|
|
expectedRuntimeSetupPath,
|
|
|
|
|
);
|
|
|
|
|
expect(manifestRegistry.plugins[0]?.startupDeferConfiguredChannelFullLoadUntilAfterListen).toBe(
|
|
|
|
|
true,
|
|
|
|
|
);
|
2026-03-16 16:43:23 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes stale runtime plugin directories that are no longer in dist", () => {
|
|
|
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-stale-");
|
|
|
|
|
const staleRuntimeDir = path.join(repoRoot, "dist-runtime", "extensions", "stale");
|
|
|
|
|
fs.mkdirSync(staleRuntimeDir, { recursive: true });
|
|
|
|
|
fs.writeFileSync(path.join(staleRuntimeDir, "index.js"), "stale\n", "utf8");
|
|
|
|
|
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
|
|
|
|
|
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
|
|
|
|
|
|
expect(fs.existsSync(staleRuntimeDir)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes dist-runtime when the built bundled plugin tree is absent", () => {
|
|
|
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-missing-");
|
|
|
|
|
const runtimeRoot = path.join(repoRoot, "dist-runtime", "extensions", "diffs");
|
|
|
|
|
fs.mkdirSync(runtimeRoot, { recursive: true });
|
|
|
|
|
|
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
|
|
|
|
|
|
expect(fs.existsSync(path.join(repoRoot, "dist-runtime"))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|