import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { copyBundledPluginMetadata, rewritePackageExtensions, } from "../../scripts/copy-bundled-plugin-metadata.mjs"; import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js"; const tempDirs: string[] = []; const excludeOptionalEnv = { OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "0" } as const; const copyBundledPluginMetadataWithEnv = copyBundledPluginMetadata as (params?: { repoRoot?: string; env?: NodeJS.ProcessEnv; }) => void; function makeRepoRoot(prefix: string): string { return makeTempRepoRoot(tempDirs, prefix); } function writeJson(filePath: string, value: unknown): void { writeJsonFile(filePath, value); } function createPlugin( repoRoot: string, params: { id: string; packageName: string; manifest?: Record; packageOpenClaw?: Record; }, ) { const pluginDir = path.join(repoRoot, "extensions", params.id); fs.mkdirSync(pluginDir, { recursive: true }); writeJson(path.join(pluginDir, "openclaw.plugin.json"), { id: params.id, configSchema: { type: "object" }, ...params.manifest, }); writeJson(path.join(pluginDir, "package.json"), { name: params.packageName, ...(params.packageOpenClaw ? { openclaw: params.packageOpenClaw } : {}), }); return pluginDir; } function readBundledManifest(repoRoot: string, pluginId: string) { return JSON.parse( fs.readFileSync( path.join(repoRoot, "dist", "extensions", pluginId, "openclaw.plugin.json"), "utf8", ), ) as { skills?: string[] }; } function readBundledPackageJson(repoRoot: string, pluginId: string) { return JSON.parse( fs.readFileSync(path.join(repoRoot, "dist", "extensions", pluginId, "package.json"), "utf8"), ) as { openclaw?: { extensions?: string[] } }; } function bundledPluginDir(repoRoot: string, pluginId: string) { return path.join(repoRoot, "dist", "extensions", pluginId); } function bundledSkillPath(repoRoot: string, pluginId: string, ...relativePath: string[]) { return path.join(bundledPluginDir(repoRoot, pluginId), ...relativePath); } function expectBundledSkills(repoRoot: string, pluginId: string, skills: string[]) { expect(readBundledManifest(repoRoot, pluginId).skills).toEqual(skills); } function createTlonSkillPlugin(repoRoot: string, skillPath = "node_modules/@tloncorp/tlon-skill") { return createPlugin(repoRoot, { id: "tlon", packageName: "@openclaw/tlon", manifest: { skills: [skillPath] }, packageOpenClaw: { extensions: ["./index.ts"] }, }); } afterEach(() => { cleanupTempDirs(tempDirs); }); describe("rewritePackageExtensions", () => { it("rewrites TypeScript extension entries to built JS paths", () => { expect(rewritePackageExtensions(["./index.ts", "./nested/entry.mts"])).toEqual([ "./index.js", "./nested/entry.js", ]); }); }); describe("copyBundledPluginMetadata", () => { it("copies plugin manifests, package metadata, and local skill directories", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-"); const pluginDir = createPlugin(repoRoot, { id: "acpx", packageName: "@openclaw/acpx", manifest: { skills: ["./skills"] }, packageOpenClaw: { extensions: ["./index.ts"] }, }); fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true }); fs.writeFileSync( path.join(pluginDir, "skills", "acp-router", "SKILL.md"), "# ACP Router\n", "utf8", ); copyBundledPluginMetadata({ repoRoot }); expect( fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")), ).toBe(true); expect( fs.readFileSync( path.join(repoRoot, "dist", "extensions", "acpx", "skills", "acp-router", "SKILL.md"), "utf8", ), ).toContain("ACP Router"); expectBundledSkills(repoRoot, "acpx", ["./skills"]); const packageJson = readBundledPackageJson(repoRoot, "acpx"); expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]); }); it("relocates node_modules-backed skill paths into bundled-skills and rewrites the manifest", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-"); const pluginDir = createTlonSkillPlugin(repoRoot); const storeSkillDir = path.join( repoRoot, "node_modules", ".pnpm", "@tloncorp+tlon-skill@0.2.2", "node_modules", "@tloncorp", "tlon-skill", ); fs.mkdirSync(storeSkillDir, { recursive: true }); fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8"); fs.mkdirSync(path.join(storeSkillDir, "node_modules", ".bin"), { recursive: true }); fs.writeFileSync( path.join(storeSkillDir, "node_modules", ".bin", "tlon"), "#!/bin/sh\n", "utf8", ); fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true }); fs.symlinkSync( storeSkillDir, path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"), process.platform === "win32" ? "junction" : "dir", ); const staleNodeModulesSkillDir = path.join( bundledPluginDir(repoRoot, "tlon"), "node_modules", "@tloncorp", "tlon-skill", ); fs.mkdirSync(staleNodeModulesSkillDir, { recursive: true }); fs.writeFileSync(path.join(staleNodeModulesSkillDir, "stale.txt"), "stale\n", "utf8"); copyBundledPluginMetadata({ repoRoot }); const copiedSkillDir = path.join( bundledPluginDir(repoRoot, "tlon"), "bundled-skills", "@tloncorp", "tlon-skill", ); expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true); expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false); expect(fs.existsSync(path.join(copiedSkillDir, "node_modules"))).toBe(false); expect(fs.existsSync(path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"))).toBe( false, ); expectBundledSkills(repoRoot, "tlon", ["./bundled-skills/@tloncorp/tlon-skill"]); }); it("falls back to repo-root hoisted node_modules skill paths", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-hoisted-skill-"); const pluginDir = createTlonSkillPlugin(repoRoot); const hoistedSkillDir = path.join(repoRoot, "node_modules", "@tloncorp", "tlon-skill"); fs.mkdirSync(hoistedSkillDir, { recursive: true }); fs.writeFileSync(path.join(hoistedSkillDir, "SKILL.md"), "# Hoisted Tlon Skill\n", "utf8"); fs.mkdirSync(pluginDir, { recursive: true }); copyBundledPluginMetadata({ repoRoot }); expect( fs.readFileSync( bundledSkillPath(repoRoot, "tlon", "bundled-skills", "@tloncorp", "tlon-skill", "SKILL.md"), "utf8", ), ).toContain("Hoisted Tlon Skill"); expectBundledSkills(repoRoot, "tlon", ["./bundled-skills/@tloncorp/tlon-skill"]); }); it("omits missing declared skill paths and removes stale generated outputs", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-"); createTlonSkillPlugin(repoRoot); const staleBundledSkillDir = path.join( bundledPluginDir(repoRoot, "tlon"), "bundled-skills", "@tloncorp", "tlon-skill", ); fs.mkdirSync(staleBundledSkillDir, { recursive: true }); fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8"); const staleNodeModulesDir = path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"); fs.mkdirSync(staleNodeModulesDir, { recursive: true }); copyBundledPluginMetadata({ repoRoot }); expectBundledSkills(repoRoot, "tlon", []); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe( false, ); expect(fs.existsSync(staleNodeModulesDir)).toBe(false); }); it("retries transient skill copy races from concurrent runtime postbuilds", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-retry-"); const pluginDir = createPlugin(repoRoot, { id: "diffs", packageName: "@openclaw/diffs", manifest: { skills: ["./skills"] }, packageOpenClaw: { extensions: ["./index.ts"] }, }); fs.mkdirSync(path.join(pluginDir, "skills", "diffs"), { recursive: true }); fs.writeFileSync(path.join(pluginDir, "skills", "diffs", "SKILL.md"), "# Diffs\n", "utf8"); const realCpSync = fs.cpSync.bind(fs); let attempts = 0; const cpSyncSpy = vi.spyOn(fs, "cpSync").mockImplementation((...args) => { attempts += 1; if (attempts === 1) { const error = Object.assign(new Error("race"), { code: "EEXIST" }); throw error; } return realCpSync(...args); }); try { copyBundledPluginMetadata({ repoRoot }); } finally { cpSyncSpy.mockRestore(); } expect(attempts).toBe(2); expect( fs.readFileSync( path.join(repoRoot, "dist", "extensions", "diffs", "skills", "diffs", "SKILL.md"), "utf8", ), ).toContain("Diffs"); }); it("removes generated outputs for plugins no longer present in source", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-"); const staleBundledSkillDir = path.join( repoRoot, "dist", "extensions", "removed-plugin", "bundled-skills", "@scope", "skill", ); fs.mkdirSync(staleBundledSkillDir, { recursive: true }); fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8"); const staleNodeModulesDir = path.join( repoRoot, "dist", "extensions", "removed-plugin", "node_modules", ); fs.mkdirSync(staleNodeModulesDir, { recursive: true }); fs.writeFileSync( path.join(repoRoot, "dist", "extensions", "removed-plugin", "index.js"), "export default {}\n", "utf8", ); writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), { id: "removed-plugin", configSchema: { type: "object" }, skills: ["./bundled-skills/@scope/skill"], }); writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json"), { name: "@openclaw/removed-plugin", }); fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); copyBundledPluginMetadata({ repoRoot }); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin"))).toBe(false); }); it("removes stale dist outputs when a source extension directory no longer has a manifest", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-manifestless-source-"); const sourcePluginDir = path.join(repoRoot, "extensions", "google-gemini-cli-auth"); fs.mkdirSync(path.join(sourcePluginDir, "node_modules"), { recursive: true }); const staleDistDir = path.join(repoRoot, "dist", "extensions", "google-gemini-cli-auth"); fs.mkdirSync(staleDistDir, { recursive: true }); fs.writeFileSync(path.join(staleDistDir, "index.js"), "export default {}\n", "utf8"); writeJson(path.join(staleDistDir, "openclaw.plugin.json"), { id: "google-gemini-cli-auth", configSchema: { type: "object" }, }); writeJson(path.join(staleDistDir, "package.json"), { name: "@openclaw/google-gemini-cli-auth", }); copyBundledPluginMetadata({ repoRoot }); expect(fs.existsSync(staleDistDir)).toBe(false); }); it.each([ { name: "skips metadata for optional bundled clusters only when explicitly disabled", pluginId: "acpx", packageName: "@openclaw/acpx-plugin", packageOpenClaw: { extensions: ["./index.ts"] }, env: excludeOptionalEnv, expectedExists: false, }, { name: "still bundles previously released optional plugins without the opt-in env", pluginId: "whatsapp", packageName: "@openclaw/whatsapp", packageOpenClaw: { extensions: ["./index.ts"], install: { npmSpec: "@openclaw/whatsapp" }, }, env: {}, expectedExists: true, }, ] as const)("$name", ({ pluginId, packageName, packageOpenClaw, env, expectedExists }) => { const repoRoot = makeRepoRoot(`openclaw-bundled-plugin-${pluginId}-`); createPlugin(repoRoot, { id: pluginId, packageName, packageOpenClaw, }); copyBundledPluginMetadataWithEnv({ repoRoot, env }); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", pluginId))).toBe(expectedExists); }); it("preserves manifest-less runtime support package outputs and copies package metadata", () => { const repoRoot = makeRepoRoot("openclaw-bundled-runtime-support-"); const pluginDir = path.join(repoRoot, "extensions", "image-generation-core"); fs.mkdirSync(pluginDir, { recursive: true }); writeJson(path.join(pluginDir, "package.json"), { name: "@openclaw/image-generation-core", version: "0.0.1", private: true, type: "module", }); fs.writeFileSync(path.join(pluginDir, "runtime-api.ts"), "export {};\n", "utf8"); fs.mkdirSync(path.join(repoRoot, "dist", "extensions", "image-generation-core"), { recursive: true, }); fs.writeFileSync( path.join(repoRoot, "dist", "extensions", "image-generation-core", "runtime-api.js"), "export {};\n", "utf8", ); copyBundledPluginMetadata({ repoRoot }); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "image-generation-core"))).toBe( true, ); expect( fs.existsSync( path.join(repoRoot, "dist", "extensions", "image-generation-core", "runtime-api.js"), ), ).toBe(true); expect( fs.existsSync( path.join(repoRoot, "dist", "extensions", "image-generation-core", "openclaw.plugin.json"), ), ).toBe(false); expect( JSON.parse( fs.readFileSync( path.join(repoRoot, "dist", "extensions", "image-generation-core", "package.json"), "utf8", ), ), ).toMatchObject({ name: "@openclaw/image-generation-core", type: "module", }); }); });