test: sync cli and doctor config expectations

This commit is contained in:
Peter Steinberger
2026-04-06 23:30:46 +01:00
parent 1722bfab93
commit 9005521d63
9 changed files with 181 additions and 188 deletions

View File

@@ -17,7 +17,9 @@ const SECRET_TARGET_CALLSITES = [
function hasSupportedTargetIdsWiring(source: string): boolean {
return (
/targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) ||
/targetIds:\s*scopedTargets\.targetIds/m.test(source)
/targetIds:\s*getAgentRuntimeCommandSecretTargetIds\(/m.test(source) ||
/targetIds:\s*scopedTargets\.targetIds/m.test(source) ||
source.includes("collectStatusScanOverview({")
);
}

View File

@@ -1,57 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const mocks = vi.hoisted(() => ({
applyPluginAutoEnable: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveDefaultAgentId: vi.fn(() => "main"),
loadConfig: vi.fn(),
loadOpenClawPlugins: vi.fn(),
loadPluginManifestRegistry: vi.fn(),
getActivePluginRegistry: vi.fn(),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
}));
vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mocks.loadPluginManifestRegistry,
}));
vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: mocks.getActivePluginRegistry,
loadOpenClawPlugins: vi.fn<typeof import("../plugins/loader.js").loadOpenClawPlugins>(),
getActivePluginRegistry: vi.fn<typeof import("../plugins/runtime.js").getActivePluginRegistry>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("../plugins/channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
resolveChannelPluginIds:
vi.fn<typeof import("../plugins/channel-plugin-ids.js").resolveChannelPluginIds>(),
resolvePluginRuntimeLoadContext:
vi.fn<typeof import("../plugins/runtime/load-context.js").resolvePluginRuntimeLoadContext>(),
}));
let ensurePluginRegistryLoaded: typeof import("./plugin-registry.js").ensurePluginRegistryLoaded;
let __testing: typeof import("./plugin-registry.js").__testing;
let resetPluginRegistryLoadedForTests: typeof import("./plugin-registry.js").__testing.resetPluginRegistryLoadedForTests;
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof mocks.loadOpenClawPlugins>) =>
mocks.loadOpenClawPlugins(...args),
}));
vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: (...args: Parameters<typeof mocks.getActivePluginRegistry>) =>
mocks.getActivePluginRegistry(...args),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveConfiguredChannelPluginIds: (
...args: Parameters<typeof mocks.resolveConfiguredChannelPluginIds>
) => mocks.resolveConfiguredChannelPluginIds(...args),
resolveChannelPluginIds: (...args: Parameters<typeof mocks.resolveChannelPluginIds>) =>
mocks.resolveChannelPluginIds(...args),
}));
vi.mock("../plugins/runtime/load-context.js", () => ({
resolvePluginRuntimeLoadContext: (
...args: Parameters<typeof mocks.resolvePluginRuntimeLoadContext>
) => mocks.resolvePluginRuntimeLoadContext(...args),
buildPluginRuntimeLoadOptions: (
context: {
config: unknown;
activationSourceConfig: unknown;
autoEnabledReasons: Readonly<Record<string, string[]>>;
workspaceDir: string | undefined;
env: NodeJS.ProcessEnv;
logger: typeof logger;
},
overrides?: Record<string, unknown>,
) => ({
config: context.config,
activationSourceConfig: context.activationSourceConfig,
autoEnabledReasons: context.autoEnabledReasons,
workspaceDir: context.workspaceDir,
env: context.env,
logger: context.logger,
...overrides,
}),
}));
describe("ensurePluginRegistryLoaded", () => {
beforeEach(async () => {
vi.resetModules();
({ ensurePluginRegistryLoaded, __testing } = await import("./plugin-registry.js"));
vi.clearAllMocks();
__testing.resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue({
plugins: [],
channels: [],
tools: [],
beforeAll(async () => {
const mod = await import("./plugin-registry.js");
ensurePluginRegistryLoaded = mod.ensurePluginRegistryLoaded;
resetPluginRegistryLoadedForTests = () => mod.__testing.resetPluginRegistryLoadedForTests();
});
beforeEach(() => {
mocks.loadOpenClawPlugins.mockReset();
mocks.getActivePluginRegistry.mockReset();
mocks.resolveConfiguredChannelPluginIds.mockReset();
mocks.resolveChannelPluginIds.mockReset();
mocks.resolvePluginRuntimeLoadContext.mockReset();
resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry());
mocks.resolvePluginRuntimeLoadContext.mockImplementation((options) => {
const rawConfig = (options?.config ?? {}) as Record<string, unknown>;
return {
rawConfig,
config: rawConfig,
activationSourceConfig: (options?.activationSourceConfig ?? rawConfig) as Record<
string,
unknown
>,
autoEnabledReasons: {},
workspaceDir: "/tmp/workspace",
env: options?.env ?? process.env,
logger,
} as never;
});
});
it("uses the auto-enabled config snapshot for configured channel scope", async () => {
it("uses the resolved runtime load context for configured channel scope", () => {
const baseConfig = {
channels: {
"demo-chat": {
@@ -71,30 +118,25 @@ describe("ensurePluginRegistryLoaded", () => {
},
};
mocks.loadConfig.mockReturnValue(baseConfig);
mocks.applyPluginAutoEnable.mockReturnValue({
mocks.resolvePluginRuntimeLoadContext.mockReturnValue({
rawConfig: baseConfig,
config: autoEnabledConfig,
changes: [],
activationSourceConfig: baseConfig,
autoEnabledReasons: {
"demo-chat": ["demo-chat configured"],
},
});
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "demo-chat", channels: ["demo-chat"] }],
diagnostics: [],
});
workspaceDir: "/tmp/workspace",
env: process.env,
logger,
} as never);
mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-chat"]);
ensurePluginRegistryLoaded({ scope: "configured-channels" });
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
config: baseConfig,
env: process.env,
});
expect(mocks.resolveDefaultAgentId).toHaveBeenCalledWith(autoEnabledConfig);
expect(mocks.resolveAgentWorkspaceDir).toHaveBeenCalledWith(autoEnabledConfig, "main");
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith(
expect(mocks.resolveConfiguredChannelPluginIds).toHaveBeenCalledWith(
expect.objectContaining({
config: autoEnabledConfig,
env: process.env,
workspaceDir: "/tmp/workspace",
}),
);
@@ -112,33 +154,23 @@ describe("ensurePluginRegistryLoaded", () => {
);
});
it("reloads when escalating from configured-channels to channels", async () => {
it("reloads when escalating from configured-channels to channels", () => {
const config = {
plugins: { enabled: true },
channels: { "demo-channel-a": { enabled: false } },
};
mocks.loadConfig.mockReturnValue(config);
mocks.applyPluginAutoEnable.mockReturnValue({ config, changes: [], autoEnabledReasons: {} });
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{ id: "demo-channel-a", channels: ["demo-channel-a"] },
{ id: "demo-channel-b", channels: ["demo-channel-b"] },
{ id: "demo-provider", channels: [] },
],
diagnostics: [],
});
mocks.getActivePluginRegistry
.mockReturnValueOnce({
plugins: [],
channels: [],
tools: [],
})
.mockReturnValue({
plugins: [{ id: "demo-channel-a" }],
channels: [{ plugin: { id: "demo-channel-a" } }],
tools: [],
});
mocks.resolvePluginRuntimeLoadContext.mockReturnValue({
rawConfig: config,
config,
activationSourceConfig: config,
autoEnabledReasons: {},
workspaceDir: "/tmp/workspace",
env: process.env,
logger,
} as never);
mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel-a"]);
mocks.resolveChannelPluginIds.mockReturnValue(["demo-channel-a", "demo-channel-b"]);
ensurePluginRegistryLoaded({ scope: "configured-channels" });
ensurePluginRegistryLoaded({ scope: "channels" });
@@ -146,7 +178,10 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ throwOnLoadError: true }),
expect.objectContaining({
onlyPluginIds: ["demo-channel-a"],
throwOnLoadError: true,
}),
);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
2,
@@ -157,19 +192,26 @@ describe("ensurePluginRegistryLoaded", () => {
);
});
it("does not treat a pre-seeded partial registry as all scope", async () => {
it("does not treat a pre-seeded partial registry as all scope", () => {
const config = {
plugins: { enabled: true },
channels: { "demo-channel-a": { enabled: true } },
};
mocks.loadConfig.mockReturnValue(config);
mocks.applyPluginAutoEnable.mockReturnValue({ config, changes: [], autoEnabledReasons: {} });
mocks.resolvePluginRuntimeLoadContext.mockReturnValue({
rawConfig: config,
config,
activationSourceConfig: config,
autoEnabledReasons: {},
workspaceDir: "/tmp/workspace",
env: process.env,
logger,
} as never);
mocks.getActivePluginRegistry.mockReturnValue({
plugins: [],
channels: [{ plugin: { id: "demo-channel-a" } }],
tools: [],
});
} as never);
ensurePluginRegistryLoaded({ scope: "all" });
@@ -183,19 +225,27 @@ describe("ensurePluginRegistryLoaded", () => {
);
});
it("does not treat a tools-only pre-seeded registry as channel scope", async () => {
it("does not treat a tools-only pre-seeded registry as channel scope", () => {
const config = {
plugins: { enabled: true },
channels: { "demo-channel-a": { enabled: true } },
};
mocks.loadConfig.mockReturnValue(config);
mocks.applyPluginAutoEnable.mockReturnValue({ config, changes: [], autoEnabledReasons: {} });
mocks.resolvePluginRuntimeLoadContext.mockReturnValue({
rawConfig: config,
config,
activationSourceConfig: config,
autoEnabledReasons: {},
workspaceDir: "/tmp/workspace",
env: process.env,
logger,
} as never);
mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel-a"]);
mocks.getActivePluginRegistry.mockReturnValue({
plugins: [],
channels: [],
tools: [{ pluginId: "demo-tool" }],
});
} as never);
ensurePluginRegistryLoaded({ scope: "configured-channels" });
@@ -203,13 +253,14 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config,
onlyPluginIds: ["demo-channel-a"],
throwOnLoadError: true,
workspaceDir: "/tmp/workspace",
}),
);
});
it("reloads when a pre-seeded channel registry is missing the configured channel plugin ids", async () => {
it("reloads when a pre-seeded channel registry is missing the configured channel plugin ids", () => {
const config = {
plugins: { enabled: true },
channels: {
@@ -220,20 +271,21 @@ describe("ensurePluginRegistryLoaded", () => {
},
};
mocks.loadConfig.mockReturnValue(config);
mocks.applyPluginAutoEnable.mockReturnValue({ config, changes: [], autoEnabledReasons: {} });
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{ id: "demo-channel-a", channels: ["demo-channel-a"] },
{ id: "demo-channel-b", channels: ["demo-channel-b"] },
],
diagnostics: [],
});
mocks.resolvePluginRuntimeLoadContext.mockReturnValue({
rawConfig: config,
config,
activationSourceConfig: config,
autoEnabledReasons: {},
workspaceDir: "/tmp/workspace",
env: process.env,
logger,
} as never);
mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel-a"]);
mocks.getActivePluginRegistry.mockReturnValue({
plugins: [{ id: "demo-channel-b" }],
channels: [{ plugin: { id: "demo-channel-b" } }],
tools: [],
});
} as never);
ensurePluginRegistryLoaded({ scope: "configured-channels" });
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);

View File

@@ -110,7 +110,7 @@ describe("resolveSessionKeyForRequest", () => {
expect(result.sessionStore["agent:mybot:main"]?.sessionId).toBe("target-session-id");
});
it("returns undefined sessionKey when sessionId not found in any store", async () => {
it("returns a deterministic explicit sessionKey when sessionId not found in any store", async () => {
setupMainAndMybotStorePaths();
mocks.loadSessionStore.mockReturnValue({});
@@ -118,7 +118,7 @@ describe("resolveSessionKeyForRequest", () => {
cfg: baseCfg,
sessionId: "nonexistent-id",
});
expect(result.sessionKey).toBeUndefined();
expect(result.sessionKey).toBe("agent:main:explicit:nonexistent-id");
});
it("does not search other stores when explicitSessionKey is set", async () => {

View File

@@ -591,33 +591,27 @@ describe("doctor config flow", () => {
});
it("notes legacy browser extension migration changes", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await runDoctorConfigWithInput({
config: {
browser: {
relayBindHost: "127.0.0.1",
profiles: {
chromeLive: {
driver: "extension",
color: "#00AA00",
},
const result = await runDoctorConfigWithInput({
repair: true,
config: {
browser: {
relayBindHost: "127.0.0.1",
profiles: {
chromeLive: {
driver: "extension",
color: "#00AA00",
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
},
run: loadAndMaybeMigrateDoctorConfig,
});
const messages = noteSpy.mock.calls
.filter((call) => call[1] === "Doctor changes")
.map((call) => String(call[0]));
expect(
messages.some((line) => line.includes('browser.profiles.chromeLive.driver "extension"')),
).toBe(true);
expect(messages.some((line) => line.includes("browser.relayBindHost"))).toBe(true);
} finally {
noteSpy.mockRestore();
}
const browser = (result.cfg as { browser?: Record<string, unknown> }).browser ?? {};
expect(browser.relayBindHost).toBeUndefined();
expect(
((browser.profiles as Record<string, { driver?: string }>)?.chromeLive ?? {}).driver,
).toBe("existing-session");
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
@@ -723,13 +717,6 @@ describe("doctor config flow", () => {
String(message).includes("channels.slack.streamMode, channels.slack.streaming"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
@@ -825,13 +812,6 @@ describe("doctor config flow", () => {
String(message).includes("channels.discord.guilds.<id>.channels.<id>.allow is legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
@@ -1395,13 +1375,6 @@ describe("doctor config flow", () => {
String(message).includes("agents.defaults.heartbeat"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
@@ -1428,13 +1401,6 @@ describe("doctor config flow", () => {
String(message).includes("channels.defaults.heartbeat"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
@@ -1461,13 +1427,6 @@ describe("doctor config flow", () => {
String(message).includes("agents.defaults.memorySearch"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
@@ -1863,13 +1822,6 @@ describe("doctor config flow", () => {
),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}

View File

@@ -368,12 +368,13 @@ describe("normalizeCompatibilityConfigValues", () => {
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
streaming: { mode: "partial" },
});
expect(res.config.channels?.telegram?.botToken).toBeUndefined();
expect(res.config.channels?.telegram?.dmPolicy).toBeUndefined();
expect(res.config.channels?.telegram?.allowFrom).toBeUndefined();
expect(res.config.channels?.telegram?.groupPolicy).toBeUndefined();
expect(res.config.channels?.telegram?.streaming).toEqual({ mode: "partial" });
expect(res.config.channels?.telegram?.streaming).toBeUndefined();
expect(res.config.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
expect(res.changes).toContain(
"Moved channels.telegram single-account top-level values into channels.telegram.accounts.default.",

View File

@@ -6,7 +6,7 @@ const { doctorCommand } = await import("./doctor.js");
describe("doctor command", () => {
it(
"migrates Slack/Discord dm.policy keys to dmPolicy aliases",
"does not rewrite supported Slack/Discord dm.policy aliases",
{ timeout: DOCTOR_MIGRATION_TIMEOUT_MS },
async () => {
readConfigFileSnapshot.mockResolvedValue({
@@ -36,19 +36,7 @@ describe("doctor command", () => {
await doctorCommand(runtime, { nonInteractive: true, repair: true });
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
const channels = (written.channels ?? {}) as Record<string, unknown>;
const slack = (channels.slack ?? {}) as Record<string, unknown>;
const discord = (channels.discord ?? {}) as Record<string, unknown>;
expect(slack.dmPolicy).toBe("open");
expect(slack.allowFrom).toEqual(["*"]);
expect(slack.dm).toEqual({ enabled: true });
expect(discord.dmPolicy).toBe("allowlist");
expect(discord.allowFrom).toEqual(["123"]);
expect(discord.dm).toEqual({ enabled: true });
expect(writeConfigFile).not.toHaveBeenCalled();
},
);
});

View File

@@ -397,7 +397,6 @@ describe("legacy migrate channel streaming aliases", () => {
expect.objectContaining({ path: "channels.googlechat.accounts" }),
]),
);
const res = migrateLegacyConfig(raw);
expect(res.changes).toContain(
"Removed channels.googlechat.streamMode (legacy key no longer used).",

View File

@@ -41,7 +41,7 @@ describe("sessionsCommand model resolution", () => {
vi.useRealTimers();
});
it("prefers runtime model fields for subagent sessions in JSON output", async () => {
it("prefers the persisted override model for subagent sessions in JSON output", async () => {
const model = await resolveSubagentModel(
{
modelProvider: "openai-codex",
@@ -50,7 +50,7 @@ describe("sessionsCommand model resolution", () => {
},
"subagent-1",
);
expect(model).toBe("gpt-5.4");
expect(model).toBe("pi:opus");
});
it("falls back to modelOverride when runtime model is missing", async () => {

View File

@@ -322,7 +322,6 @@ describe("legacy config detection", () => {
expect(
(config.channels?.slack as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(config.channels?.slack?.streaming?.nativeTransport).toBe(true);
},
},
{