// Plugin npm runtime build tests validate plugin runtime package builds. import { existsSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync, } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildPluginNpmRuntime, listMissingPluginNpmRuntimeHostExports, listPublishablePluginPackageDirs, resolvePluginNpmRuntimeBuildPlan, } from "../scripts/lib/plugin-npm-runtime-build.mjs"; import { useAutoCleanupTempDirTracker } from "./helpers/temp-dir.js"; const repoRoot = path.resolve(import.meta.dirname, ".."); const tempDirs = useAutoCleanupTempDirTracker(afterEach); type PluginNpmRuntimeBuildPlan = NonNullable>; function expectDistRelativePaths(paths: string[]) { expect(paths.every((entry) => entry.startsWith("./dist/"))).toBe(true); } function expectPluginNpmRuntimeBuildPlan( plan: ReturnType, ): PluginNpmRuntimeBuildPlan { if (!plan) { throw new Error("expected plugin npm runtime build plan"); } return plan; } describe("plugin npm runtime build planning", () => { it("rejects a symlinked package dist root before building", async () => { const syntheticRepoRoot = tempDirs.make("openclaw-plugin-runtime-output-root-"); const packageDir = path.join(syntheticRepoRoot, "extensions", "demo"); mkdirSync(packageDir, { recursive: true }); writeFileSync( path.join(syntheticRepoRoot, "package.json"), JSON.stringify({ version: "1.0.0" }), ); writeFileSync( path.join(packageDir, "package.json"), JSON.stringify({ name: "@openclaw/demo", version: "1.0.0", openclaw: { compat: { pluginApi: "1.0.0" }, extensions: ["./index.ts"], release: { publishToNpm: true }, }, }), ); writeFileSync(path.join(packageDir, "index.ts"), "export default {};\n"); const targetDir = path.join(syntheticRepoRoot, "live-gateway-dist"); mkdirSync(targetDir); writeFileSync(path.join(targetDir, "sentinel.js"), "keep\n"); symlinkSync(targetDir, path.join(packageDir, "dist"), "dir"); await expect( buildPluginNpmRuntime({ repoRoot: syntheticRepoRoot, packageDir, logLevel: "silent", }), ).rejects.toThrow(/symbolic link/u); expect(readFileSync(path.join(targetDir, "sentinel.js"), "utf8")).toBe("keep\n"); expect(readlinkSync(path.join(packageDir, "dist"))).toBe(targetDir); }); it("plans package-local runtime entries for every publishable plugin package", () => { const packageDirs = listPublishablePluginPackageDirs({ repoRoot }); expect(packageDirs.length).toBeGreaterThan(0); const plans = packageDirs.map((packageDir) => resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir, }), ); const resolvedPlans = plans.map(expectPluginNpmRuntimeBuildPlan); expect(resolvedPlans.map((plan) => plan.pluginDir)).toEqual( packageDirs.map((packageDir) => path.basename(packageDir)), ); for (const plan of resolvedPlans) { expect(plan.outDir).toBe(path.join(plan.packageDir, "dist")); expectDistRelativePaths(plan.runtimeExtensions); expectDistRelativePaths(plan.runtimeBuildOutputs); expect(plan.packageFiles).toContain("dist/**"); expect(plan.packagePeerMetadata.peerDependencies.openclaw).toBe( plan.packageJson.openclaw.compat.pluginApi, ); expect(plan.packagePeerMetadata.peerDependenciesMeta.openclaw.optional).toBe(true); } }); it("includes top-level public runtime surfaces and root-build-excluded plugins", () => { const qqbotPlan = resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "qqbot"), }); const qqbotRuntimePlan = expectPluginNpmRuntimeBuildPlan(qqbotPlan); expect(qqbotRuntimePlan.entry).toEqual({ api: path.join(repoRoot, "extensions", "qqbot", "api.ts"), "channel-entry-api": path.join(repoRoot, "extensions", "qqbot", "channel-entry-api.ts"), "channel-plugin-api": path.join(repoRoot, "extensions", "qqbot", "channel-plugin-api.ts"), "doctor-contract-api": path.join(repoRoot, "extensions", "qqbot", "doctor-contract-api.ts"), index: path.join(repoRoot, "extensions", "qqbot", "index.ts"), "runtime-api": path.join(repoRoot, "extensions", "qqbot", "runtime-api.ts"), "secret-contract-api": path.join(repoRoot, "extensions", "qqbot", "secret-contract-api.ts"), "setup-entry": path.join(repoRoot, "extensions", "qqbot", "setup-entry.ts"), "setup-plugin-api": path.join(repoRoot, "extensions", "qqbot", "setup-plugin-api.ts"), "tools-api": path.join(repoRoot, "extensions", "qqbot", "tools-api.ts"), }); expect(qqbotRuntimePlan.runtimeExtensions).toEqual(["./dist/index.js"]); expect(qqbotRuntimePlan.runtimeSetupEntry).toBe("./dist/setup-entry.js"); const diffsPlan = resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "diffs"), }); const diffsRuntimePlan = expectPluginNpmRuntimeBuildPlan(diffsPlan); expect(diffsRuntimePlan.entry).toEqual({ api: path.join(repoRoot, "extensions", "diffs", "api.ts"), index: path.join(repoRoot, "extensions", "diffs", "index.ts"), "runtime-api": path.join(repoRoot, "extensions", "diffs", "runtime-api.ts"), }); expect(diffsRuntimePlan.packageFiles).toEqual([ "dist/**", "openclaw.plugin.json", "README.md", "skills/**", ]); }); it("builds doctor contract surfaces for publishable channel plugins", () => { for (const pluginDir of ["msteams", "nostr"]) { const plan = expectPluginNpmRuntimeBuildPlan( resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", pluginDir), }), ); expect(plan.entry["doctor-contract-api"]).toBe( path.join(repoRoot, "extensions", pluginDir, "doctor-contract-api.ts"), ); const extension = plan.runtimeFormat === "cjs" ? ".cjs" : ".js"; expect(plan.runtimeBuildOutputs).toContain(`./dist/doctor-contract-api${extension}`); expect(plan.packageFiles).toContain("dist/**"); } }); it("plans msteams startup runtime surfaces as native CommonJS entrypoints", () => { const plan = expectPluginNpmRuntimeBuildPlan( resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "msteams"), }), ); expect(plan.runtimeFormat).toBe("cjs"); expect(plan.runtimeExtensions).toEqual(["./dist/index.cjs"]); expect(plan.runtimeSetupEntry).toBe("./dist/setup-entry.cjs"); expect(plan.runtimeBuildOutputs).toEqual( expect.arrayContaining([ "./dist/channel-plugin-api.cjs", "./dist/doctor-contract-api.cjs", "./dist/index.cjs", "./dist/runtime-api.cjs", "./dist/secret-contract-api.cjs", "./dist/setup-entry.cjs", "./dist/setup-plugin-api.cjs", ]), ); }); it("builds msteams startup runtime surfaces as CommonJS files", async () => { const result = await buildPluginNpmRuntime({ repoRoot, packageDir: "extensions/msteams", logLevel: "silent", }); const plan = expectPluginNpmRuntimeBuildPlan(result); expect(plan.runtimeFormat).toBe("cjs"); expect(plan.runtimeExtensions).toEqual(["./dist/index.cjs"]); expect(plan.runtimeSetupEntry).toBe("./dist/setup-entry.cjs"); const entrypoints = [ "dist/index.cjs", "dist/channel-plugin-api.cjs", "dist/runtime-api.cjs", "dist/setup-plugin-api.cjs", "dist/secret-contract-api.cjs", ]; const missing = entrypoints.filter( (relativePath) => !existsSync(path.join(repoRoot, "extensions/msteams", relativePath)), ); expect(missing).toEqual([]); for (const relativePath of entrypoints) { const text = readFileSync(path.join(repoRoot, "extensions/msteams", relativePath), "utf8"); expect(text).not.toMatch(/^import\s/u); expect(text).toMatch(/(?:require\(|exports\.)/u); } const indexText = readFileSync( path.join(repoRoot, "extensions/msteams/dist/index.cjs"), "utf8", ); expect(indexText).toContain('specifier: "./channel-plugin-api.cjs"'); expect(indexText).toContain('specifier: "./secret-contract-api.cjs"'); expect(indexText).toContain('specifier: "./runtime-api.cjs"'); const setupEntryText = readFileSync( path.join(repoRoot, "extensions/msteams/dist/setup-entry.cjs"), "utf8", ); expect(setupEntryText).toContain('specifier: "./setup-plugin-api.cjs"'); expect(setupEntryText).toContain('specifier: "./secret-contract-api.cjs"'); }); it("builds Tencent setup metadata for installed-package migrations", () => { const plan = expectPluginNpmRuntimeBuildPlan( resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "tencent"), }), ); expect(plan.entry["setup-api"]).toBe( path.join(repoRoot, "extensions", "tencent", "setup-api.ts"), ); expect(plan.runtimeSetupEntry).toBe("./dist/setup-api.js"); expect(plan.runtimeBuildOutputs).toContain("./dist/setup-api.js"); }); it("keeps published Codex runtime imports resolvable from the host package", async () => { const result = await buildPluginNpmRuntime({ repoRoot, packageDir: "extensions/codex", logLevel: "silent", }); const plan = expectPluginNpmRuntimeBuildPlan(result); expect(listMissingPluginNpmRuntimeHostExports(plan)).toEqual([]); }); it("detects unresolved side-effect host imports in built plugin runtimes", () => { const outDir = tempDirs.make("openclaw-plugin-runtime-host-import-"); writeFileSync( path.join(outDir, "index.js"), [ 'import "openclaw/plugin-sdk/not-exported";', 'const runtime = __require("openclaw/plugin-sdk/not-exported-from-require");', "void runtime;", "", ].join("\n"), ); const plan = expectPluginNpmRuntimeBuildPlan( resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "codex"), }), ); expect(listMissingPluginNpmRuntimeHostExports({ ...plan, outDir })).toEqual([ "openclaw/plugin-sdk/not-exported", "openclaw/plugin-sdk/not-exported-from-require", ]); }); it("does not require host metadata when the runtime has no host imports", () => { const syntheticRepoRoot = tempDirs.make("openclaw-plugin-runtime-synthetic-repo-"); const outDir = tempDirs.make("openclaw-plugin-runtime-no-host-import-"); writeFileSync(path.join(outDir, "index.js"), "export default {};\n"); const plan = expectPluginNpmRuntimeBuildPlan( resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "codex"), }), ); expect( listMissingPluginNpmRuntimeHostExports({ ...plan, repoRoot: syntheticRepoRoot, outDir }), ).toEqual([]); }); });