test: dedupe plugin contract and loader suites

This commit is contained in:
Peter Steinberger 2026-03-28 01:17:05 +00:00
parent 1adf08a19d
commit c8c669537f
5 changed files with 556 additions and 643 deletions

View File

@ -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();
}
});
});
});
});

View File

@ -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();
}
});
});

View File

@ -740,13 +740,14 @@ describe("installPluginFromDir", () => {
result: Awaited<ReturnType<typeof installPluginFromDir>>,
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");

View File

@ -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<T>(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<T>(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<string, unknown>) {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) =>
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<typeof loadOpenClawPlugins>) =>
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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<typeof loadOpenClawPlugins>) => {
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([

View File

@ -30,6 +30,19 @@ function createRegistry(services: OpenClawPluginService[]) {
return registry;
}
function expectServiceContext(
ctx: OpenClawPluginServiceContext,
config: Parameters<typeof startPluginServices>[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);
}
});