diff --git a/src/channels/plugins/contracts/plugins-core.catalog.contract.test.ts b/src/channels/plugins/contracts/plugins-core.catalog.contract.test.ts deleted file mode 100644 index 14fb8733038..00000000000 --- a/src/channels/plugins/contracts/plugins-core.catalog.contract.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { listChannelPluginCatalogEntries } from "../catalog.js"; - -describe("channel plugin catalog", () => { - function createCatalogEntry(params: { - packageName: string; - channelId: string; - label: string; - blurb: string; - order?: number; - }) { - return { - name: params.packageName, - openclaw: { - channel: { - id: params.channelId, - label: params.label, - selectionLabel: params.label, - docsPath: `/channels/${params.channelId}`, - blurb: params.blurb, - ...(params.order === undefined ? {} : { order: params.order }), - }, - install: { - npmSpec: params.packageName, - }, - }, - }; - } - - function writeCatalogFile(catalogPath: string, entry: Record) { - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [entry], - }), - ); - } - - function writeDiscoveredChannelPlugin(params: { - stateDir: string; - packageName: string; - channelLabel: string; - pluginId: string; - blurb: string; - }) { - const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: params.packageName, - openclaw: { - extensions: ["./index.js"], - channel: { - id: "demo-channel", - label: params.channelLabel, - selectionLabel: params.channelLabel, - docsPath: "/channels/demo-channel", - blurb: params.blurb, - }, - install: { - npmSpec: params.packageName, - }, - }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify({ - id: params.pluginId, - configSchema: {}, - }), - "utf8", - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8"); - return pluginDir; - } - - function expectCatalogIdsContain(params: { - expectedId: string; - catalogPaths?: string[]; - env?: NodeJS.ProcessEnv; - }) { - const ids = listChannelPluginCatalogEntries({ - ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), - ...(params.env ? { env: params.env } : {}), - }).map((entry) => entry.id); - expect(ids).toContain(params.expectedId); - } - - function findCatalogEntry(params: { - channelId: string; - catalogPaths?: string[]; - env?: NodeJS.ProcessEnv; - }) { - return listChannelPluginCatalogEntries({ - ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), - ...(params.env ? { env: params.env } : {}), - }).find((entry) => entry.id === params.channelId); - } - - function expectCatalogEntryMatch(params: { - channelId: string; - expected: Record; - catalogPaths?: string[]; - env?: NodeJS.ProcessEnv; - }) { - expect( - findCatalogEntry({ - channelId: params.channelId, - ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), - ...(params.env ? { env: params.env } : {}), - }), - ).toMatchObject(params.expected); - } - - it.each([ - { - name: "includes external catalog entries", - setup: () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); - const catalogPath = path.join(dir, "catalog.json"); - writeCatalogFile( - catalogPath, - createCatalogEntry({ - packageName: "@openclaw/demo-channel", - channelId: "demo-channel", - label: "Demo Channel", - blurb: "Demo entry", - order: 999, - }), - ); - return { - channelId: "demo-channel", - catalogPaths: [catalogPath], - expected: { id: "demo-channel" }, - }; - }, - }, - { - name: "preserves plugin ids when they differ from channel ids", - setup: () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-state-")); - writeDiscoveredChannelPlugin({ - stateDir, - packageName: "@vendor/demo-channel-plugin", - channelLabel: "Demo Channel", - pluginId: "@vendor/demo-runtime", - blurb: "Demo channel", - }); - return { - channelId: "demo-channel", - env: { - ...process.env, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - expected: { pluginId: "@vendor/demo-runtime" }, - }; - }, - }, - { - name: "keeps discovered plugins ahead of external catalog overrides", - setup: () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); - const catalogPath = path.join(stateDir, "catalog.json"); - writeDiscoveredChannelPlugin({ - stateDir, - packageName: "@vendor/demo-channel-plugin", - channelLabel: "Demo Channel Runtime", - pluginId: "@vendor/demo-channel-runtime", - blurb: "discovered plugin", - }); - writeCatalogFile( - catalogPath, - createCatalogEntry({ - packageName: "@vendor/demo-channel-catalog", - channelId: "demo-channel", - label: "Demo Channel Catalog", - blurb: "external catalog", - }), - ); - return { - channelId: "demo-channel", - catalogPaths: [catalogPath], - env: { - ...process.env, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - expected: { - install: { npmSpec: "@vendor/demo-channel-plugin" }, - meta: { label: "Demo Channel Runtime" }, - pluginId: "@vendor/demo-channel-runtime", - }, - }; - }, - }, - ] as const)("$name", ({ setup }) => { - const setupResult = setup(); - const { channelId, expected } = setupResult; - expectCatalogEntryMatch({ - channelId, - expected, - ...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}), - ...("env" in setupResult ? { env: setupResult.env } : {}), - }); - }); - - it.each([ - { - name: "uses the provided env for external catalog path resolution", - setup: () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-")); - const catalogPath = path.join(home, "catalog.json"); - writeCatalogFile( - catalogPath, - createCatalogEntry({ - packageName: "@openclaw/env-demo-channel", - channelId: "env-demo-channel", - label: "Env Demo Channel", - blurb: "Env demo entry", - order: 1000, - }), - ); - return { - env: { - ...process.env, - OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json", - OPENCLAW_HOME: home, - HOME: home, - }, - expectedId: "env-demo-channel", - }; - }, - }, - { - name: "uses the provided env for default catalog paths", - setup: () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); - const catalogPath = path.join(stateDir, "plugins", "catalog.json"); - fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); - writeCatalogFile( - catalogPath, - createCatalogEntry({ - packageName: "@openclaw/default-env-demo", - channelId: "default-env-demo", - label: "Default Env Demo", - blurb: "Default env demo entry", - }), - ); - return { - env: { - ...process.env, - OPENCLAW_STATE_DIR: stateDir, - }, - expectedId: "default-env-demo", - }; - }, - }, - ] as const)("$name", ({ setup }) => { - const { env, expectedId } = setup(); - expectCatalogIdsContain({ env, expectedId }); - }); -}); diff --git a/src/channels/plugins/contracts/plugins-core.catalog.entries.contract.test.ts b/src/channels/plugins/contracts/plugins-core.catalog.entries.contract.test.ts new file mode 100644 index 00000000000..2e12116dfbc --- /dev/null +++ b/src/channels/plugins/contracts/plugins-core.catalog.entries.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelPluginCatalogEntriesContract } from "../../../../test/helpers/channels/channel-plugin-catalog-contract-suites.js"; + +describeChannelPluginCatalogEntriesContract(); diff --git a/src/channels/plugins/contracts/plugins-core.catalog.paths.contract.test.ts b/src/channels/plugins/contracts/plugins-core.catalog.paths.contract.test.ts new file mode 100644 index 00000000000..ae341862881 --- /dev/null +++ b/src/channels/plugins/contracts/plugins-core.catalog.paths.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelPluginCatalogPathResolutionContract } from "../../../../test/helpers/channels/channel-plugin-catalog-contract-suites.js"; + +describeChannelPluginCatalogPathResolutionContract(); diff --git a/test/helpers/channels/channel-plugin-catalog-contract-suites.ts b/test/helpers/channels/channel-plugin-catalog-contract-suites.ts new file mode 100644 index 00000000000..4aad2edf3c3 --- /dev/null +++ b/test/helpers/channels/channel-plugin-catalog-contract-suites.ts @@ -0,0 +1,277 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { listChannelPluginCatalogEntries } from "../../../src/channels/plugins/catalog.js"; + +function createCatalogEntry(params: { + packageName: string; + channelId: string; + label: string; + blurb: string; + order?: number; +}) { + return { + name: params.packageName, + openclaw: { + channel: { + id: params.channelId, + label: params.label, + selectionLabel: params.label, + docsPath: `/channels/${params.channelId}`, + blurb: params.blurb, + ...(params.order === undefined ? {} : { order: params.order }), + }, + install: { + npmSpec: params.packageName, + }, + }, + }; +} + +function writeCatalogFile(catalogPath: string, entry: Record) { + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [entry], + }), + ); +} + +function writeDiscoveredChannelPlugin(params: { + stateDir: string; + packageName: string; + channelLabel: string; + pluginId: string; + blurb: string; +}) { + const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: params.packageName, + openclaw: { + extensions: ["./index.js"], + channel: { + id: "demo-channel", + label: params.channelLabel, + selectionLabel: params.channelLabel, + docsPath: "/channels/demo-channel", + blurb: params.blurb, + }, + install: { + npmSpec: params.packageName, + }, + }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: params.pluginId, + configSchema: {}, + }), + "utf8", + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8"); +} + +function expectCatalogIdsContain(params: { + expectedId: string; + catalogPaths?: string[]; + env?: NodeJS.ProcessEnv; +}) { + const ids = listChannelPluginCatalogEntries({ + ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), + ...(params.env ? { env: params.env } : {}), + }).map((entry) => entry.id); + expect(ids).toContain(params.expectedId); +} + +function findCatalogEntry(params: { + channelId: string; + catalogPaths?: string[]; + env?: NodeJS.ProcessEnv; +}) { + return listChannelPluginCatalogEntries({ + ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), + ...(params.env ? { env: params.env } : {}), + }).find((entry) => entry.id === params.channelId); +} + +function expectCatalogEntryMatch(params: { + channelId: string; + expected: Record; + catalogPaths?: string[]; + env?: NodeJS.ProcessEnv; +}) { + expect( + findCatalogEntry({ + channelId: params.channelId, + ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), + ...(params.env ? { env: params.env } : {}), + }), + ).toMatchObject(params.expected); +} + +export function describeChannelPluginCatalogEntriesContract() { + describe("channel plugin catalog entries contract", () => { + it.each([ + { + name: "includes external catalog entries", + setup: () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@openclaw/demo-channel", + channelId: "demo-channel", + label: "Demo Channel", + blurb: "Demo entry", + order: 999, + }), + ); + return { + channelId: "demo-channel", + catalogPaths: [catalogPath], + expected: { id: "demo-channel" }, + }; + }, + }, + { + name: "preserves plugin ids when they differ from channel ids", + setup: () => { + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-channel-catalog-state-"), + ); + writeDiscoveredChannelPlugin({ + stateDir, + packageName: "@vendor/demo-channel-plugin", + channelLabel: "Demo Channel", + pluginId: "@vendor/demo-runtime", + blurb: "Demo channel", + }); + return { + channelId: "demo-channel", + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + expected: { pluginId: "@vendor/demo-runtime" }, + }; + }, + }, + { + name: "keeps discovered plugins ahead of external catalog overrides", + setup: () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); + const catalogPath = path.join(stateDir, "catalog.json"); + writeDiscoveredChannelPlugin({ + stateDir, + packageName: "@vendor/demo-channel-plugin", + channelLabel: "Demo Channel Runtime", + pluginId: "@vendor/demo-channel-runtime", + blurb: "discovered plugin", + }); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@vendor/demo-channel-catalog", + channelId: "demo-channel", + label: "Demo Channel Catalog", + blurb: "external catalog", + }), + ); + return { + channelId: "demo-channel", + catalogPaths: [catalogPath], + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + expected: { + install: { npmSpec: "@vendor/demo-channel-plugin" }, + meta: { label: "Demo Channel Runtime" }, + pluginId: "@vendor/demo-channel-runtime", + }, + }; + }, + }, + ] as const)("$name", ({ setup }) => { + const setupResult = setup(); + const { channelId, expected } = setupResult; + expectCatalogEntryMatch({ + channelId, + expected, + ...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}), + ...("env" in setupResult ? { env: setupResult.env } : {}), + }); + }); + }); +} + +export function describeChannelPluginCatalogPathResolutionContract() { + describe("channel plugin catalog path resolution contract", () => { + it.each([ + { + name: "uses the provided env for external catalog path resolution", + setup: () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-")); + const catalogPath = path.join(home, "catalog.json"); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@openclaw/env-demo-channel", + channelId: "env-demo-channel", + label: "Env Demo Channel", + blurb: "Env demo entry", + order: 1000, + }), + ); + return { + env: { + ...process.env, + OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json", + OPENCLAW_HOME: home, + HOME: home, + }, + expectedId: "env-demo-channel", + }; + }, + }, + { + name: "uses the provided env for default catalog paths", + setup: () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); + const catalogPath = path.join(stateDir, "plugins", "catalog.json"); + fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@openclaw/default-env-demo", + channelId: "default-env-demo", + label: "Default Env Demo", + blurb: "Default env demo entry", + }), + ); + return { + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + }, + expectedId: "default-env-demo", + }; + }, + }, + ] as const)("$name", ({ setup }) => { + const { env, expectedId } = setup(); + expectCatalogIdsContain({ env, expectedId }); + }); + }); +}