diff --git a/src/plugins/contracts/tts.contract.test.ts b/src/plugins/contracts/tts.contract.test.ts index 84dd39b3655..24ea7af237f 100644 --- a/src/plugins/contracts/tts.contract.test.ts +++ b/src/plugins/contracts/tts.contract.test.ts @@ -330,33 +330,30 @@ describe("tts", () => { messages: { tts: {} }, }; - it("uses default edge output format unless overridden", () => { - const cases = [ - { - name: "default", - cfg: baseCfg, - expected: "audio-24khz-48kbitrate-mono-mp3", - }, - { - name: "override", - cfg: { - ...baseCfg, - messages: { - tts: { - edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" }, - }, + it.each([ + { + name: "default", + cfg: baseCfg, + expected: "audio-24khz-48kbitrate-mono-mp3", + }, + { + name: "override", + cfg: { + ...baseCfg, + messages: { + tts: { + edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" }, }, - } as OpenClawConfig, - expected: "audio-24khz-96kbitrate-mono-mp3", - }, - ] as const; - for (const testCase of cases) { - const config = resolveTtsConfig(testCase.cfg); - const providerConfig = getResolvedSpeechProviderConfig(config, "microsoft") as { - outputFormat?: string; - }; - expect(providerConfig.outputFormat, testCase.name).toBe(testCase.expected); - } + }, + } as OpenClawConfig, + expected: "audio-24khz-96kbitrate-mono-mp3", + }, + ] as const)("$name", ({ cfg, expected, name }) => { + const config = resolveTtsConfig(cfg); + const providerConfig = getResolvedSpeechProviderConfig(config, "microsoft") as { + outputFormat?: string; + }; + expect(providerConfig.outputFormat, name).toBe(expected); }); }); @@ -566,19 +563,52 @@ describe("tts", () => { expect(ensureCustomApiRegisteredMock).not.toHaveBeenCalled(); }); - it("validates targetLength bounds", async () => { + it.each([ + { targetLength: 99, shouldThrow: true }, + { targetLength: 100, shouldThrow: false }, + { targetLength: 10000, shouldThrow: false }, + { targetLength: 10001, shouldThrow: true }, + ] as const)("validates targetLength bounds: $targetLength", async (testCase) => { const baseConfig = resolveTtsConfig(baseCfg); - const cases = [ - { targetLength: 99, shouldThrow: true }, - { targetLength: 100, shouldThrow: false }, - { targetLength: 10000, shouldThrow: false }, - { targetLength: 10001, shouldThrow: true }, - ] as const; - for (const testCase of cases) { - const call = summarizeText( + const call = summarizeText( + { + text: "text", + targetLength: testCase.targetLength, + cfg: baseCfg, + config: baseConfig, + timeoutMs: 30_000, + }, + { + completeSimple, + getApiKeyForModel: getApiKeyForModelMock, + prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock, + requireApiKey: requireApiKeyMock, + resolveModelAsync: resolveModelAsyncMock, + }, + ); + if (testCase.shouldThrow) { + await expect(call, String(testCase.targetLength)).rejects.toThrow( + `Invalid targetLength: ${testCase.targetLength}`, + ); + } else { + await expect(call, String(testCase.targetLength)).resolves.toBeDefined(); + } + }); + + it.each([ + { name: "no summary blocks", message: mockAssistantMessage([]) }, + { + name: "empty summary content", + message: mockAssistantMessage([{ type: "text", text: " " }]), + }, + ] as const)("throws when summary output is missing or empty: $name", async (testCase) => { + const baseConfig = resolveTtsConfig(baseCfg); + vi.mocked(completeSimple).mockResolvedValue(testCase.message); + await expect( + summarizeText( { text: "text", - targetLength: testCase.targetLength, + targetLength: 500, cfg: baseCfg, config: baseConfig, timeoutMs: 30_000, @@ -590,48 +620,9 @@ describe("tts", () => { requireApiKey: requireApiKeyMock, resolveModelAsync: resolveModelAsyncMock, }, - ); - if (testCase.shouldThrow) { - await expect(call, String(testCase.targetLength)).rejects.toThrow( - `Invalid targetLength: ${testCase.targetLength}`, - ); - } else { - await expect(call, String(testCase.targetLength)).resolves.toBeDefined(); - } - } - }); - - it("throws when summary output is missing or empty", async () => { - const baseConfig = resolveTtsConfig(baseCfg); - const cases = [ - { name: "no summary blocks", message: mockAssistantMessage([]) }, - { - name: "empty summary content", - message: mockAssistantMessage([{ type: "text", text: " " }]), - }, - ] as const; - for (const testCase of cases) { - vi.mocked(completeSimple).mockResolvedValue(testCase.message); - await expect( - summarizeText( - { - text: "text", - targetLength: 500, - cfg: baseCfg, - config: baseConfig, - timeoutMs: 30_000, - }, - { - completeSimple, - getApiKeyForModel: getApiKeyForModelMock, - prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock, - requireApiKey: requireApiKeyMock, - resolveModelAsync: resolveModelAsyncMock, - }, - ), - testCase.name, - ).rejects.toThrow("No summary returned"); - } + ), + testCase.name, + ).rejects.toThrow("No summary returned"); }); }); @@ -641,44 +632,43 @@ describe("tts", () => { messages: { tts: {} }, }; - it("selects provider based on available API keys", () => { - const cases = [ - { - env: { - OPENAI_API_KEY: "test-openai-key", - ELEVENLABS_API_KEY: undefined, - XI_API_KEY: undefined, - }, - prefsPath: "/tmp/tts-prefs-openai.json", - expected: "openai", + it.each([ + { + name: "openai key available", + env: { + OPENAI_API_KEY: "test-openai-key", + ELEVENLABS_API_KEY: undefined, + XI_API_KEY: undefined, }, - { - env: { - OPENAI_API_KEY: undefined, - ELEVENLABS_API_KEY: "test-elevenlabs-key", - XI_API_KEY: undefined, - }, - prefsPath: "/tmp/tts-prefs-elevenlabs.json", - expected: "elevenlabs", + prefsPath: "/tmp/tts-prefs-openai.json", + expected: "openai", + }, + { + name: "elevenlabs key available", + env: { + OPENAI_API_KEY: undefined, + ELEVENLABS_API_KEY: "test-elevenlabs-key", + XI_API_KEY: undefined, }, - { - env: { - OPENAI_API_KEY: undefined, - ELEVENLABS_API_KEY: undefined, - XI_API_KEY: undefined, - }, - prefsPath: "/tmp/tts-prefs-microsoft.json", - expected: "microsoft", + prefsPath: "/tmp/tts-prefs-elevenlabs.json", + expected: "elevenlabs", + }, + { + name: "falls back to microsoft", + env: { + OPENAI_API_KEY: undefined, + ELEVENLABS_API_KEY: undefined, + XI_API_KEY: undefined, }, - ] as const; - - for (const testCase of cases) { - withEnv(testCase.env, () => { - const config = resolveTtsConfig(baseCfg); - const provider = getTtsProvider(config, testCase.prefsPath); - expect(provider).toBe(testCase.expected); - }); - } + prefsPath: "/tmp/tts-prefs-microsoft.json", + expected: "microsoft", + }, + ] as const)("selects provider based on available API keys: $name", (testCase) => { + withEnv(testCase.env, () => { + const config = resolveTtsConfig(baseCfg); + const provider = getTtsProvider(config, testCase.prefsPath); + expect(provider).toBe(testCase.expected); + }); }); }); @@ -707,49 +697,50 @@ describe("tts", () => { messages: { tts: {} }, }; - it("resolves openai.baseUrl from config/env with config precedence and slash trimming", () => { - for (const testCase of [ - { - name: "default endpoint", - cfg: baseCfg, - env: { OPENAI_TTS_BASE_URL: undefined }, - expected: "https://api.openai.com/v1", - }, - { - name: "env override", - cfg: baseCfg, - env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, - expected: "http://localhost:8880/v1", - }, - { - name: "config wins over env", - cfg: { - ...baseCfg, - messages: { - tts: { openai: { baseUrl: "http://my-server:9000/v1" } }, - }, - } as OpenClawConfig, - env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, - expected: "http://my-server:9000/v1", - }, - { - name: "config slash trimming", - cfg: { - ...baseCfg, - messages: { - tts: { openai: { baseUrl: "http://my-server:9000/v1///" } }, - }, - } as OpenClawConfig, - env: { OPENAI_TTS_BASE_URL: undefined }, - expected: "http://my-server:9000/v1", - }, - { - name: "env slash trimming", - cfg: baseCfg, - env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1/" }, - expected: "http://localhost:8880/v1", - }, - ] as const) { + it.each([ + { + name: "default endpoint", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: undefined }, + expected: "https://api.openai.com/v1", + }, + { + name: "env override", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, + expected: "http://localhost:8880/v1", + }, + { + name: "config wins over env", + cfg: { + ...baseCfg, + messages: { + tts: { openai: { baseUrl: "http://my-server:9000/v1" } }, + }, + } as OpenClawConfig, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, + expected: "http://my-server:9000/v1", + }, + { + name: "config slash trimming", + cfg: { + ...baseCfg, + messages: { + tts: { openai: { baseUrl: "http://my-server:9000/v1///" } }, + }, + } as OpenClawConfig, + env: { OPENAI_TTS_BASE_URL: undefined }, + expected: "http://my-server:9000/v1", + }, + { + name: "env slash trimming", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1/" }, + expected: "http://localhost:8880/v1", + }, + ] as const)( + "resolves openai.baseUrl from config/env with config precedence and slash trimming: $name", + (testCase) => { withEnv(testCase.env, () => { const config = resolveTtsConfig(testCase.cfg); const openaiConfig = getResolvedSpeechProviderConfig(config, "openai") as { @@ -757,8 +748,8 @@ describe("tts", () => { }; expect(openaiConfig.baseUrl, testCase.name).toBe(testCase.expected); }); - } - }); + }, + ); }); describe("textToSpeechTelephony – openai instructions", () => { @@ -797,14 +788,19 @@ describe("tts", () => { }); } - it("only includes instructions for supported telephony models", async () => { - for (const testCase of [ - { model: "tts-1", expectedInstructions: undefined }, - { model: "gpt-4o-mini-tts", expectedInstructions: "Speak warmly" }, - ] as const) { + it.each([ + { name: "tts-1 omits instructions", model: "tts-1", expectedInstructions: undefined }, + { + name: "gpt-4o-mini-tts keeps instructions", + model: "gpt-4o-mini-tts", + expectedInstructions: "Speak warmly", + }, + ] as const)( + "only includes instructions for supported telephony models: $name", + async (testCase) => { await expectTelephonyInstructions(testCase.model, testCase.expectedInstructions); - } - }); + }, + ); }); describe("maybeApplyTtsToPayload", () => { @@ -846,32 +842,31 @@ describe("tts", () => { }, }; - it("applies inbound auto-TTS gating by audio status and cleaned text length", async () => { - const cases = [ - { - name: "inbound gating blocks non-audio", - payload: { text: "Hello world" }, - inboundAudio: false, - expectedFetchCalls: 0, - expectSamePayload: true, - }, - { - name: "inbound gating blocks too-short cleaned text", - payload: { text: "### **bold**" }, - inboundAudio: true, - expectedFetchCalls: 0, - expectSamePayload: true, - }, - { - name: "inbound gating allows audio with real text", - payload: { text: "Hello world" }, - inboundAudio: true, - expectedFetchCalls: 1, - expectSamePayload: false, - }, - ] as const; - - for (const testCase of cases) { + it.each([ + { + name: "inbound gating blocks non-audio", + payload: { text: "Hello world" }, + inboundAudio: false, + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "inbound gating blocks too-short cleaned text", + payload: { text: "### **bold**" }, + inboundAudio: true, + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "inbound gating allows audio with real text", + payload: { text: "Hello world" }, + inboundAudio: true, + expectedFetchCalls: 1, + expectSamePayload: false, + }, + ] as const)( + "applies inbound auto-TTS gating by audio status and cleaned text length: $name", + async (testCase) => { await withMockedAutoTtsFetch(async (fetchMock) => { const result = await maybeApplyTtsToPayload({ payload: testCase.payload, @@ -886,39 +881,37 @@ describe("tts", () => { expect(result.mediaUrl, testCase.name).toBeDefined(); } }); - } - }); + }, + ); - it("respects tagged-mode auto-TTS gating", async () => { - for (const testCase of [ - { - name: "plain text is skipped", - payload: { text: "Hello world" }, - expectedFetchCalls: 0, - expectSamePayload: true, - }, - { - name: "tagged text is synthesized", - payload: { text: "[[tts:text]]Hello world[[/tts:text]]" }, - expectedFetchCalls: 1, - expectSamePayload: false, - }, - ] as const) { - await withMockedAutoTtsFetch(async (fetchMock) => { - const result = await maybeApplyTtsToPayload({ - payload: testCase.payload, - cfg: taggedCfg, - kind: "final", - }); - - expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls); - if (testCase.expectSamePayload) { - expect(result, testCase.name).toBe(testCase.payload); - } else { - expect(result.mediaUrl, testCase.name).toBeDefined(); - } + it.each([ + { + name: "plain text is skipped", + payload: { text: "Hello world" }, + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "tagged text is synthesized", + payload: { text: "[[tts:text]]Hello world[[/tts:text]]" }, + expectedFetchCalls: 1, + expectSamePayload: false, + }, + ] as const)("respects tagged-mode auto-TTS gating: $name", async (testCase) => { + await withMockedAutoTtsFetch(async (fetchMock) => { + const result = await maybeApplyTtsToPayload({ + payload: testCase.payload, + cfg: taggedCfg, + kind: "final", }); - } + + expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls); + if (testCase.expectSamePayload) { + expect(result, testCase.name).toBe(testCase.payload); + } else { + expect(result.mediaUrl, testCase.name).toBeDefined(); + } + }); }); }); }); diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 76b068ea515..4bf3c7c8f58 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -191,14 +191,25 @@ describe("provider wizard contract", () => { }); it("round-trips every shared wizard choice back to its provider and auth method", () => { - for (const option of resolveProviderWizardOptions({ config: {}, env: process.env })) { + const options = resolveProviderWizardOptions({ config: {}, env: process.env }); + + expect(options).toEqual( + expect.arrayContaining( + options.map((option) => + expect.objectContaining({ + value: option.value, + }), + ), + ), + ); + for (const option of options) { const resolved = resolveProviderPluginChoice({ providers: TEST_PROVIDERS, choice: option.value, }); - expect(resolved).not.toBeNull(); - expect(resolved?.provider.id).toBeTruthy(); - expect(resolved?.method.id).toBeTruthy(); + expect(resolved, option.value).not.toBeNull(); + expect(resolved?.provider.id, option.value).toBeTruthy(); + expect(resolved?.method.id, option.value).toBeTruthy(); } }); @@ -213,7 +224,7 @@ describe("provider wizard contract", () => { providers: TEST_PROVIDERS, choice: entry.value, }); - expect(resolved).not.toBeNull(); + expect(resolved, entry.value).not.toBeNull(); } }); }); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 64be6346e10..0ebe3001c9e 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -740,13 +740,14 @@ describe("installPluginFromDir", () => { result: Awaited>, extensionsDir: string, pluginId: string, + name?: string, ) { - expect(result.ok).toBe(true); + expect(result.ok, name).toBe(true); if (!result.ok) { return; } - expect(result.pluginId).toBe(pluginId); - expect(result.targetDir).toBe(resolvePluginInstallDir(pluginId, extensionsDir)); + expect(result.pluginId, name).toBe(pluginId); + expect(result.targetDir, name).toBe(resolvePluginInstallDir(pluginId, extensionsDir)); } it("uses --ignore-scripts for dependency install", async () => { @@ -910,54 +911,59 @@ describe("installPluginFromDir", () => { ).toBe(true); }); - it("keeps scoped install ids aligned across manifest and package-name cases", async () => { - const scenarios = [ - { - setup: () => setupManifestInstallFixture({ manifestId: "@team/memory-cognee" }), - expectedPluginId: "@team/memory-cognee", - install: (pluginDir: string, extensionsDir: string) => - installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - expectedPluginId: "@team/memory-cognee", - logger: { info: () => {}, warn: () => {} }, - }), - }, - { - setup: () => setupInstallPluginFromDirFixture(), - expectedPluginId: "@openclaw/test-plugin", - install: (pluginDir: string, extensionsDir: string) => - installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - }), - }, - { - setup: () => setupInstallPluginFromDirFixture(), - expectedPluginId: "@openclaw/test-plugin", - install: (pluginDir: string, extensionsDir: string) => - installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - expectedPluginId: "test-plugin", - }), - }, - ] as const; - - for (const scenario of scenarios) { + it.each([ + { + name: "manifest id wins for scoped plugin ids", + setup: () => setupManifestInstallFixture({ manifestId: "@team/memory-cognee" }), + expectedPluginId: "@team/memory-cognee", + install: (pluginDir: string, extensionsDir: string) => + installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + expectedPluginId: "@team/memory-cognee", + logger: { info: () => {}, warn: () => {} }, + }), + }, + { + name: "package name keeps scoped plugin id by default", + setup: () => setupInstallPluginFromDirFixture(), + expectedPluginId: "@openclaw/test-plugin", + install: (pluginDir: string, extensionsDir: string) => + installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }), + }, + { + name: "unscoped expectedPluginId resolves to scoped install id", + setup: () => setupInstallPluginFromDirFixture(), + expectedPluginId: "@openclaw/test-plugin", + install: (pluginDir: string, extensionsDir: string) => + installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + expectedPluginId: "test-plugin", + }), + }, + ] as const)( + "keeps scoped install ids aligned across manifest and package-name cases: $name", + async (scenario) => { const { pluginDir, extensionsDir } = scenario.setup(); const res = await scenario.install(pluginDir, extensionsDir); - expectInstalledWithPluginId(res, extensionsDir, scenario.expectedPluginId); - } - }); + expectInstalledWithPluginId(res, extensionsDir, scenario.expectedPluginId, scenario.name); + }, + ); - it("keeps scoped install-dir validation aligned", () => { - for (const invalidId of ["@", "@/name", "team/name"]) { - expect(() => resolvePluginInstallDir(invalidId)).toThrow( + it.each(["@", "@/name", "team/name"] as const)( + "keeps scoped install-dir validation aligned: %s", + (invalidId) => { + expect(() => resolvePluginInstallDir(invalidId), invalidId).toThrow( "invalid plugin name: scoped ids must use @scope/name format", ); - } + }, + ); + it("keeps scoped install-dir validation aligned for real scoped ids", () => { const extensionsDir = path.join(makeTempDir(), "extensions"); const scopedTarget = resolvePluginInstallDir("@scope/name", extensionsDir); const hashedFlatId = safePathSegmentHashed("@scope/name"); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 21cd821d3b9..939c1726f38 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -114,6 +114,54 @@ function writePlugin(params: { return { dir, file, id: params.id }; } +function simplePluginBody(id: string) { + return `module.exports = { id: ${JSON.stringify(id)}, register() {} };`; +} + +function memoryPluginBody(id: string) { + return `module.exports = { id: ${JSON.stringify(id)}, kind: "memory", register() {} };`; +} + +function writeBundledPlugin(params: { + id: string; + body?: string; + filename?: string; + bundledDir?: string; +}) { + const bundledDir = params.bundledDir ?? makeTempDir(); + const plugin = writePlugin({ + id: params.id, + dir: bundledDir, + filename: params.filename ?? "index.cjs", + body: params.body ?? simplePluginBody(params.id), + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + return { bundledDir, plugin }; +} + +function writeWorkspacePlugin(params: { + id: string; + body?: string; + filename?: string; + workspaceDir?: string; +}) { + const workspaceDir = params.workspaceDir ?? makeTempDir(); + const workspacePluginDir = path.join(workspaceDir, ".openclaw", "extensions", params.id); + mkdirSafe(workspacePluginDir); + const plugin = writePlugin({ + id: params.id, + dir: workspacePluginDir, + filename: params.filename ?? "index.cjs", + body: params.body ?? simplePluginBody(params.id), + }); + return { workspaceDir, workspacePluginDir, plugin }; +} + +function withStateDir(run: (stateDir: string) => T) { + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => run(stateDir)); +} + function loadBundledMemoryPluginRegistry(options?: { packageMeta?: { name: string; version: string; description?: string }; pluginBody?: string; @@ -251,6 +299,32 @@ function runRegistryScenarios< } } +function runScenarioCases(scenarios: readonly T[], run: (scenario: T) => void) { + for (const scenario of scenarios) { + run(scenario); + } +} + +function runSinglePluginRegistryScenarios< + T extends { + pluginId: string; + body: string; + assert: (registry: PluginRegistry, scenario: T) => void; + }, +>(scenarios: readonly T[], resolvePluginConfig?: (scenario: T) => Record) { + runRegistryScenarios(scenarios, (scenario) => { + const plugin = writePlugin({ + id: scenario.pluginId, + filename: `${scenario.pluginId}.cjs`, + body: scenario.body, + }); + return loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: resolvePluginConfig?.(scenario) ?? { allow: [scenario.pluginId] }, + }); + }); +} + function loadRegistryFromScenarioPlugins(plugins: readonly TempPlugin[]) { return plugins.length === 1 ? loadRegistryFromSinglePlugin({ @@ -301,6 +375,99 @@ function expectLoadedPluginProvenance(params: { ).toBe(params.expectWarning); } +function expectRegisteredHttpRoute( + registry: PluginRegistry, + scenario: { + pluginId: string; + expectedPath: string; + expectedAuth: string; + expectedMatch: string; + label: string; + }, +) { + const route = registry.httpRoutes.find((entry) => entry.pluginId === scenario.pluginId); + expect(route, scenario.label).toBeDefined(); + expect(route?.path, scenario.label).toBe(scenario.expectedPath); + expect(route?.auth, scenario.label).toBe(scenario.expectedAuth); + expect(route?.match, scenario.label).toBe(scenario.expectedMatch); + const httpPlugin = registry.plugins.find((entry) => entry.id === scenario.pluginId); + expect(httpPlugin?.httpRoutes, scenario.label).toBe(1); +} + +function expectDuplicateRegistrationResult( + registry: PluginRegistry, + scenario: { + selectCount: (registry: PluginRegistry) => number; + ownerB: string; + duplicateMessage: string; + label: string; + assertPrimaryOwner?: (registry: PluginRegistry) => void; + }, +) { + expect(scenario.selectCount(registry), scenario.label).toBe(1); + scenario.assertPrimaryOwner?.(registry); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === scenario.ownerB && + diag.message === scenario.duplicateMessage, + ), + scenario.label, + ).toBe(true); +} + +function expectPluginSourcePrecedence( + registry: PluginRegistry, + scenario: { + pluginId: string; + expectedLoadedOrigin: string; + expectedDisabledOrigin: string; + label: string; + expectedDisabledError?: string; + }, +) { + const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId); + const loaded = entries.find((entry) => entry.status === "loaded"); + const overridden = entries.find((entry) => entry.status === "disabled"); + expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); + expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); + if (scenario.expectedDisabledError) { + expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); + } +} + +function expectPluginOriginAndStatus(params: { + registry: PluginRegistry; + pluginId: string; + origin: string; + status: string; + label: string; + errorIncludes?: string; +}) { + const plugin = params.registry.plugins.find((entry) => entry.id === params.pluginId); + expect(plugin?.origin, params.label).toBe(params.origin); + expect(plugin?.status, params.label).toBe(params.status); + if (params.errorIncludes) { + expect(plugin?.error, params.label).toContain(params.errorIncludes); + } +} + +function expectRegistryErrorDiagnostic(params: { + registry: PluginRegistry; + pluginId: string; + message: string; +}) { + expect( + params.registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === params.pluginId && + diag.message === params.message, + ), + ).toBe(true); +} + function createWarningLogger(warnings: string[]) { return { info: () => {}, @@ -1834,14 +2001,11 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip } };`, assert: (registry: ReturnType) => { expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); - expect( - registry.diagnostics.some( - (entry) => - entry.level === "error" && - entry.pluginId === "channel-dup" && - entry.message === "channel already registered: demo (channel-dup)", - ), - ).toBe(true); + expectRegistryErrorDiagnostic({ + registry, + pluginId: "channel-dup", + message: "channel already registered: demo (channel-dup)", + }); }, }, { @@ -1851,14 +2015,11 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip api.registerContextEngine("legacy", () => ({})); } };`, assert: (registry: ReturnType) => { - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "context-engine-core-collision" && - diag.message === "context engine id reserved by core: legacy", - ), - ).toBe(true); + expectRegistryErrorDiagnostic({ + registry, + pluginId: "context-engine-core-collision", + message: "context engine id reserved by core: legacy", + }); }, }, { @@ -1869,14 +2030,11 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip } };`, assert: (registry: ReturnType) => { expect(registry.cliRegistrars).toHaveLength(0); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "cli-missing-metadata" && - diag.message === "cli registration missing explicit commands metadata", - ), - ).toBe(true); + expectRegistryErrorDiagnostic({ + registry, + pluginId: "cli-missing-metadata", + message: "cli registration missing explicit commands metadata", + }); }, }, { @@ -1887,31 +2045,16 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip } };`, assert: (registry: ReturnType) => { expect(registry.cliBackends).toHaveLength(0); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "cli-backend-missing-id" && - diag.message === "cli backend registration missing id", - ), - ).toBe(true); + expectRegistryErrorDiagnostic({ + registry, + pluginId: "cli-backend-missing-id", + message: "cli backend registration missing id", + }); }, }, ] as const; - runRegistryScenarios(scenarios, (scenario) => { - const plugin = writePlugin({ - id: scenario.pluginId, - filename: `${scenario.pluginId}.cjs`, - body: scenario.body, - }); - return loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: [scenario.pluginId], - }, - }); - }); + runSinglePluginRegistryScenarios(scenarios); }); it("registers plugin http routes", () => { @@ -1925,24 +2068,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip expectedPath: "/demo", expectedAuth: "gateway", expectedMatch: "exact", - assert: ( - registry: PluginRegistry, - scenario: { - pluginId: string; - expectedPath: string; - expectedAuth: string; - expectedMatch: string; - label: string; - }, - ) => { - const route = registry.httpRoutes.find((entry) => entry.pluginId === scenario.pluginId); - expect(route, scenario.label).toBeDefined(); - expect(route?.path, scenario.label).toBe(scenario.expectedPath); - expect(route?.auth, scenario.label).toBe(scenario.expectedAuth); - expect(route?.match, scenario.label).toBe(scenario.expectedMatch); - const httpPlugin = registry.plugins.find((entry) => entry.id === scenario.pluginId); - expect(httpPlugin?.httpRoutes, scenario.label).toBe(1); - }, + assert: expectRegisteredHttpRoute, }, { label: "keeps explicit auth and match options", @@ -1952,42 +2078,18 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip expectedPath: "/webhook", expectedAuth: "plugin", expectedMatch: "prefix", - assert: ( - registry: PluginRegistry, - scenario: { - pluginId: string; - expectedPath: string; - expectedAuth: string; - expectedMatch: string; - label: string; - }, - ) => { - const route = registry.httpRoutes.find((entry) => entry.pluginId === scenario.pluginId); - expect(route, scenario.label).toBeDefined(); - expect(route?.path, scenario.label).toBe(scenario.expectedPath); - expect(route?.auth, scenario.label).toBe(scenario.expectedAuth); - expect(route?.match, scenario.label).toBe(scenario.expectedMatch); - const httpPlugin = registry.plugins.find((entry) => entry.id === scenario.pluginId); - expect(httpPlugin?.httpRoutes, scenario.label).toBe(1); - }, + assert: expectRegisteredHttpRoute, }, ] as const; - runRegistryScenarios(scenarios, (scenario) => { - const plugin = writePlugin({ - id: scenario.pluginId, - filename: `${scenario.pluginId}.cjs`, + runSinglePluginRegistryScenarios( + scenarios.map((scenario) => ({ + ...scenario, body: `module.exports = { id: "${scenario.pluginId}", register(api) { api.registerHttpRoute(${scenario.routeOptions}); } };`, - }); - return loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: [scenario.pluginId], - }, - }); - }); + })), + ); }); it("rejects duplicate plugin registrations", () => { @@ -2003,26 +2105,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip selectCount: (registry: ReturnType) => registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook").length, duplicateMessage: "hook already registered: shared-hook (hook-owner-a)", - assert: ( - registry: PluginRegistry, - scenario: { - selectCount: (registry: PluginRegistry) => number; - ownerB: string; - duplicateMessage: string; - label: string; - }, - ) => { - expect(scenario.selectCount(registry), scenario.label).toBe(1); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === scenario.ownerB && - diag.message === scenario.duplicateMessage, - ), - scenario.label, - ).toBe(true); - }, + assert: expectDuplicateRegistrationResult, }, { label: "plugin service ids", @@ -2034,26 +2117,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip selectCount: (registry: ReturnType) => registry.services.filter((entry) => entry.service.id === "shared-service").length, duplicateMessage: "service already registered: shared-service (service-owner-a)", - assert: ( - registry: PluginRegistry, - scenario: { - selectCount: (registry: PluginRegistry) => number; - ownerB: string; - duplicateMessage: string; - label: string; - }, - ) => { - expect(scenario.selectCount(registry), scenario.label).toBe(1); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === scenario.ownerB && - diag.message === scenario.duplicateMessage, - ), - scenario.label, - ).toBe(true); - }, + assert: expectDuplicateRegistrationResult, }, { label: "plugin context engine ids", @@ -2065,26 +2129,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip selectCount: () => 1, duplicateMessage: "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", - assert: ( - registry: PluginRegistry, - scenario: { - selectCount: (registry: PluginRegistry) => number; - ownerB: string; - duplicateMessage: string; - label: string; - }, - ) => { - expect(scenario.selectCount(registry), scenario.label).toBe(1); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === scenario.ownerB && - diag.message === scenario.duplicateMessage, - ), - scenario.label, - ).toBe(true); - }, + assert: expectDuplicateRegistrationResult, }, { label: "plugin CLI command roots", @@ -2099,28 +2144,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip assertPrimaryOwner: (registry: ReturnType) => { expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a"); }, - assert: ( - registry: PluginRegistry, - scenario: { - selectCount: (registry: PluginRegistry) => number; - ownerB: string; - duplicateMessage: string; - label: string; - assertPrimaryOwner?: (registry: PluginRegistry) => void; - }, - ) => { - expect(scenario.selectCount(registry), scenario.label).toBe(1); - scenario.assertPrimaryOwner?.(registry); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === scenario.ownerB && - diag.message === scenario.duplicateMessage, - ), - scenario.label, - ).toBe(true); - }, + assert: expectDuplicateRegistrationResult, }, { label: "plugin cli backend ids", @@ -2136,28 +2160,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip assertPrimaryOwner: (registry: ReturnType) => { expect(registry.cliBackends?.[0]?.pluginId).toBe("cli-backend-owner-a"); }, - assert: ( - registry: PluginRegistry, - scenario: { - selectCount: (registry: PluginRegistry) => number; - ownerB: string; - duplicateMessage: string; - label: string; - assertPrimaryOwner?: (registry: PluginRegistry) => void; - }, - ) => { - expect(scenario.selectCount(registry), scenario.label).toBe(1); - scenario.assertPrimaryOwner?.(registry); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === scenario.ownerB && - diag.message === scenario.duplicateMessage, - ), - scenario.label, - ).toBe(true); - }, + assert: expectDuplicateRegistrationResult, }, ] as const; @@ -2711,11 +2714,11 @@ module.exports = { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memoryA = writePlugin({ id: "memory-a", - body: `module.exports = { id: "memory-a", kind: "memory", register() {} };`, + body: memoryPluginBody("memory-a"), }); const memoryB = writePlugin({ id: "memory-b", - body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + body: memoryPluginBody("memory-b"), }); return loadOpenClawPlugins({ @@ -2753,7 +2756,7 @@ module.exports = { id: "memory-b", dir: memoryBDir, filename: "index.cjs", - body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + body: memoryPluginBody("memory-b"), }); fs.writeFileSync( path.join(memoryADir, "openclaw.plugin.json"), @@ -2811,7 +2814,7 @@ module.exports = { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memory = writePlugin({ id: "memory-off", - body: `module.exports = { id: "memory-off", kind: "memory", register() {} };`, + body: memoryPluginBody("memory-off"), }); return loadOpenClawPlugins({ @@ -2841,18 +2844,15 @@ module.exports = { pluginId: "shadow", bundledFilename: "shadow.cjs", loadRegistry: () => { - const bundledDir = makeTempDir(); - writePlugin({ + writeBundledPlugin({ id: "shadow", - body: `module.exports = { id: "shadow", register() {} };`, - dir: bundledDir, + body: simplePluginBody("shadow"), filename: "shadow.cjs", }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; const override = writePlugin({ id: "shadow", - body: `module.exports = { id: "shadow", register() {} };`, + body: simplePluginBody("shadow"), }); return loadOpenClawPlugins({ @@ -2869,47 +2869,23 @@ module.exports = { }, expectedLoadedOrigin: "config", expectedDisabledOrigin: "bundled", - assert: ( - registry: PluginRegistry, - scenario: { - pluginId: string; - expectedLoadedOrigin: string; - expectedDisabledOrigin: string; - label: string; - expectedDisabledError?: string; - }, - ) => { - const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); - expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); - if (scenario.expectedDisabledError) { - expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); - } - }, + assert: expectPluginSourcePrecedence, }, { label: "bundled beats auto-discovered global duplicate", pluginId: "demo-bundled-duplicate", bundledFilename: "index.cjs", loadRegistry: () => { - const bundledDir = makeTempDir(); - writePlugin({ + writeBundledPlugin({ id: "demo-bundled-duplicate", - body: `module.exports = { id: "demo-bundled-duplicate", register() {} };`, - dir: bundledDir, - filename: "index.cjs", + body: simplePluginBody("demo-bundled-duplicate"), }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const stateDir = makeTempDir(); - return withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => { + return withStateDir((stateDir) => { const globalDir = path.join(stateDir, "extensions", "demo-bundled-duplicate"); mkdirSafe(globalDir); writePlugin({ id: "demo-bundled-duplicate", - body: `module.exports = { id: "demo-bundled-duplicate", register() {} };`, + body: simplePluginBody("demo-bundled-duplicate"), dir: globalDir, filename: "index.cjs", }); @@ -2930,47 +2906,23 @@ module.exports = { expectedLoadedOrigin: "bundled", expectedDisabledOrigin: "global", expectedDisabledError: "overridden by bundled plugin", - assert: ( - registry: PluginRegistry, - scenario: { - pluginId: string; - expectedLoadedOrigin: string; - expectedDisabledOrigin: string; - label: string; - expectedDisabledError?: string; - }, - ) => { - const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); - expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); - if (scenario.expectedDisabledError) { - expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); - } - }, + assert: expectPluginSourcePrecedence, }, { label: "installed global beats bundled duplicate", pluginId: "demo-installed-duplicate", bundledFilename: "index.cjs", loadRegistry: () => { - const bundledDir = makeTempDir(); - writePlugin({ + writeBundledPlugin({ id: "demo-installed-duplicate", - body: `module.exports = { id: "demo-installed-duplicate", register() {} };`, - dir: bundledDir, - filename: "index.cjs", + body: simplePluginBody("demo-installed-duplicate"), }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const stateDir = makeTempDir(); - return withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => { + return withStateDir((stateDir) => { const globalDir = path.join(stateDir, "extensions", "demo-installed-duplicate"); mkdirSafe(globalDir); writePlugin({ id: "demo-installed-duplicate", - body: `module.exports = { id: "demo-installed-duplicate", register() {} };`, + body: simplePluginBody("demo-installed-duplicate"), dir: globalDir, filename: "index.cjs", }); @@ -2997,25 +2949,7 @@ module.exports = { expectedLoadedOrigin: "global", expectedDisabledOrigin: "bundled", expectedDisabledError: "overridden by global plugin", - assert: ( - registry: PluginRegistry, - scenario: { - pluginId: string; - expectedLoadedOrigin: string; - expectedDisabledOrigin: string; - label: string; - expectedDisabledError?: string; - }, - ) => { - const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); - expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); - if (scenario.expectedDisabledError) { - expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); - } - }, + assert: expectPluginSourcePrecedence, }, ] as const; @@ -3034,7 +2968,7 @@ module.exports = { loadRegistry: (warnings: string[]) => { const plugin = writePlugin({ id: "warn-open-allow-config", - body: `module.exports = { id: "warn-open-allow-config", register() {} };`, + body: simplePluginBody("warn-open-allow-config"), }); return loadOpenClawPlugins({ cache: false, @@ -3053,19 +2987,8 @@ module.exports = { loads: 2, expectedWarnings: 1, loadRegistry: (() => { - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join( - workspaceDir, - ".openclaw", - "extensions", - "warn-open-allow-workspace", - ); - mkdirSafe(workspaceExtDir); - writePlugin({ + const { workspaceDir } = writeWorkspacePlugin({ id: "warn-open-allow-workspace", - body: `module.exports = { id: "warn-open-allow-workspace", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", }); return (warnings: string[]) => loadOpenClawPlugins({ @@ -3082,7 +3005,7 @@ module.exports = { }, ] as const; - for (const scenario of scenarios) { + runScenarioCases(scenarios, (scenario) => { const warnings: string[] = []; for (let index = 0; index < scenario.loads; index += 1) { @@ -3095,7 +3018,7 @@ module.exports = { expectedWarnings: scenario.expectedWarnings, label: scenario.label, }); - } + }); }); it("handles workspace-discovered plugins according to trust and precedence", () => { @@ -3105,19 +3028,8 @@ module.exports = { label: "untrusted workspace plugins stay disabled", pluginId: "workspace-helper", loadRegistry: () => { - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join( - workspaceDir, - ".openclaw", - "extensions", - "workspace-helper", - ); - mkdirSafe(workspaceExtDir); - writePlugin({ + const { workspaceDir } = writeWorkspacePlugin({ id: "workspace-helper", - body: `module.exports = { id: "workspace-helper", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", }); return loadOpenClawPlugins({ @@ -3131,29 +3043,22 @@ module.exports = { }); }, assert: (registry: ReturnType) => { - const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); - expect(workspacePlugin?.origin).toBe("workspace"); - expect(workspacePlugin?.status).toBe("disabled"); - expect(workspacePlugin?.error).toContain("workspace plugin (disabled by default)"); + expectPluginOriginAndStatus({ + registry, + pluginId: "workspace-helper", + origin: "workspace", + status: "disabled", + label: "untrusted workspace plugins stay disabled", + errorIncludes: "workspace plugin (disabled by default)", + }); }, }, { label: "trusted workspace plugins load", pluginId: "workspace-helper", loadRegistry: () => { - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join( - workspaceDir, - ".openclaw", - "extensions", - "workspace-helper", - ); - mkdirSafe(workspaceExtDir); - writePlugin({ + const { workspaceDir } = writeWorkspacePlugin({ id: "workspace-helper", - body: `module.exports = { id: "workspace-helper", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", }); return loadOpenClawPlugins({ @@ -3168,32 +3073,27 @@ module.exports = { }); }, assert: (registry: ReturnType) => { - const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); - expect(workspacePlugin?.origin).toBe("workspace"); - expect(workspacePlugin?.status).toBe("loaded"); + expectPluginOriginAndStatus({ + registry, + pluginId: "workspace-helper", + origin: "workspace", + status: "loaded", + label: "trusted workspace plugins load", + }); }, }, { label: "bundled plugins stay ahead of trusted workspace duplicates", pluginId: "shadowed", + expectedLoadedOrigin: "bundled", + expectedDisabledOrigin: "workspace", + expectedDisabledError: "overridden by bundled plugin", loadRegistry: () => { - const bundledDir = makeTempDir(); - writePlugin({ + writeBundledPlugin({ id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, - dir: bundledDir, - filename: "index.cjs", }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "shadowed"); - mkdirSafe(workspaceExtDir); - writePlugin({ + const { workspaceDir } = writeWorkspacePlugin({ id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", }); return loadOpenClawPlugins({ @@ -3210,13 +3110,14 @@ module.exports = { }, }); }, - assert: (registry: ReturnType) => { - const entries = registry.plugins.filter((entry) => entry.id === "shadowed"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("bundled"); - expect(overridden?.origin).toBe("workspace"); - expect(overridden?.error).toContain("overridden by bundled plugin"); + assert: (registry: PluginRegistry) => { + expectPluginSourcePrecedence(registry, { + pluginId: "shadowed", + expectedLoadedOrigin: "bundled", + expectedDisabledOrigin: "workspace", + expectedDisabledError: "overridden by bundled plugin", + label: "bundled plugins stay ahead of trusted workspace duplicates", + }); }, }, ] as const; @@ -3225,12 +3126,9 @@ module.exports = { }); it("loads bundled plugins when manifest metadata opts into default enablement", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ + const { bundledDir, plugin } = writeBundledPlugin({ id: "profile-aware", - body: `module.exports = { id: "profile-aware", register() {} };`, - dir: bundledDir, - filename: "index.cjs", + body: simplePluginBody("profile-aware"), }); fs.writeFileSync( path.join(plugin.dir, "openclaw.plugin.json"), @@ -3245,7 +3143,6 @@ module.exports = { ), "utf-8", ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; const registry = loadOpenClawPlugins({ cache: false, @@ -3266,12 +3163,12 @@ module.exports = { useNoBundledPlugins(); const scoped = writePlugin({ id: "@team/shadowed", - body: `module.exports = { id: "@team/shadowed", register() {} };`, + body: simplePluginBody("@team/shadowed"), filename: "scoped.cjs", }); const unscoped = writePlugin({ id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, + body: simplePluginBody("shadowed"), filename: "unscoped.cjs", }); @@ -3298,13 +3195,12 @@ module.exports = { { label: "warns when loaded non-bundled plugin has no install/load-path provenance", loadRegistry: () => { - const stateDir = makeTempDir(); - return withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => { + return withStateDir((stateDir) => { const globalDir = path.join(stateDir, "extensions", "rogue"); mkdirSafe(globalDir); writePlugin({ id: "rogue", - body: `module.exports = { id: "rogue", register() {} };`, + body: simplePluginBody("rogue"), dir: globalDir, filename: "index.cjs", }); @@ -3385,7 +3281,7 @@ module.exports = { }, ] as const; - for (const scenario of scenarios) { + runScenarioCases(scenarios, (scenario) => { const loadedScenario = scenario.loadRegistry(); const expectedSource = "expectedSource" in loadedScenario && typeof loadedScenario.expectedSource === "string" @@ -3396,7 +3292,7 @@ module.exports = { ...loadedScenario, expectedSource, }); - } + }); }); it.each([ diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index aa13ee88b6f..9ae4d50f181 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -30,6 +30,19 @@ function createRegistry(services: OpenClawPluginService[]) { return registry; } +function expectServiceContext( + ctx: OpenClawPluginServiceContext, + config: Parameters[0]["config"], +) { + expect(ctx.config).toBe(config); + expect(ctx.workspaceDir).toBe("/tmp/workspace"); + expect(ctx.stateDir).toBe(STATE_DIR); + expect(ctx.logger).toBeDefined(); + expect(typeof ctx.logger.info).toBe("function"); + expect(typeof ctx.logger.warn).toBe("function"); + expect(typeof ctx.logger.error).toBe("function"); +} + describe("startPluginServices", () => { beforeEach(() => { vi.clearAllMocks(); @@ -80,13 +93,7 @@ describe("startPluginServices", () => { expect(stops).toEqual(["c", "a"]); expect(contexts).toHaveLength(3); for (const ctx of contexts) { - expect(ctx.config).toBe(config); - expect(ctx.workspaceDir).toBe("/tmp/workspace"); - expect(ctx.stateDir).toBe(STATE_DIR); - expect(ctx.logger).toBeDefined(); - expect(typeof ctx.logger.info).toBe("function"); - expect(typeof ctx.logger.warn).toBe("function"); - expect(typeof ctx.logger.error).toBe("function"); + expectServiceContext(ctx, config); } });