test: clear config cli broad matchers

This commit is contained in:
Peter Steinberger
2026-05-10 14:57:21 +01:00
parent cf1bc41a9b
commit c6ae3232e8

View File

@@ -143,6 +143,41 @@ async function runValidateJsonAndGetPayload() {
};
}
function requireWriteOptions(): { unsetPaths?: string[][]; explicitSetPaths?: string[][] } {
const options = mockWriteConfigFile.mock.calls[0]?.[1];
if (!options) {
throw new Error("expected write options");
}
return options;
}
function expectLogIncludes(text: string) {
expect(mockLog.mock.calls.some((call) => String(call[0]).includes(text))).toBe(true);
}
function expectLogExcludes(text: string) {
expect(mockLog.mock.calls.some((call) => String(call[0]).includes(text))).toBe(false);
}
function expectErrorIncludes(text: string) {
expect(mockError.mock.calls.some((call) => String(call[0]).includes(text))).toBe(true);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function requireResolveSecretRefCall(index: number): [unknown, unknown] {
const call = mockResolveSecretRefValue.mock.calls[index];
if (!call) {
throw new Error(`expected SecretRef resolver call ${index}`);
}
return call as [unknown, unknown];
}
let registerConfigCli: typeof import("./config-cli.js").registerConfigCli;
let sharedProgram: Command;
@@ -247,9 +282,9 @@ describe("config cli", () => {
await runConfigCommand(["config", "set", "channels.telegram.dmPolicy", "pairing"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toMatchObject({
explicitSetPaths: [["channels", "telegram", "dmPolicy"]],
});
expect(requireWriteOptions().explicitSetPaths).toEqual([
["channels", "telegram", "dmPolicy"],
]);
});
it("marks object set paths explicit so nested default-equal writes persist", async () => {
@@ -280,9 +315,7 @@ describe("config cli", () => {
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toMatchObject({
explicitSetPaths: [["channels", "telegram"]],
});
expect(requireWriteOptions().explicitSetPaths).toEqual([["channels", "telegram"]]);
});
it("does not inject runtime defaults into the written config", async () => {
@@ -404,11 +437,9 @@ describe("config cli", () => {
expect(written.agents?.defaults?.models).toEqual({
"google/gemini-3.1-pro-preview": { alias: "gemini" },
});
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toMatchObject({
explicitSetPaths: [
["agents", "defaults", "models", "google/gemini-3.1-pro-preview", "alias"],
],
});
expect(requireWriteOptions().explicitSetPaths).toEqual([
["agents", "defaults", "models", "google/gemini-3.1-pro-preview", "alias"],
]);
});
it("rejects plugin install record config updates", async () => {
@@ -424,12 +455,8 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("openclaw plugins install <spec>"),
);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("openclaw plugins update <plugin-id>"),
);
expectErrorIncludes("openclaw plugins install <spec>");
expectErrorIncludes("openclaw plugins update <plugin-id>");
});
it("rejects protected model map replacement unless explicitly requested", async () => {
@@ -456,9 +483,7 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Refusing to replace agents.defaults.models"),
);
expectErrorIncludes("Refusing to replace agents.defaults.models");
});
it("merges protected model map values with --merge", async () => {
@@ -546,11 +571,7 @@ describe("config cli", () => {
token: "token-keep",
allowTailscale: true,
});
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining(
"Removed inactive gateway.auth.password for gateway.auth.mode=token",
),
);
expectLogIncludes("Removed inactive gateway.auth.password for gateway.auth.mode=token");
});
it("drops gateway.auth.token when switching mode to password", async () => {
@@ -573,11 +594,7 @@ describe("config cli", () => {
mode: "password",
password: "password-keep", // pragma: allowlist secret
});
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining(
"Removed inactive gateway.auth.token for gateway.auth.mode=password",
),
);
expectLogIncludes("Removed inactive gateway.auth.token for gateway.auth.mode=password");
});
it("applies mode-based credential cleanup using the final batch result", async () => {
@@ -605,11 +622,7 @@ describe("config cli", () => {
mode: "token",
token: "token-keep",
});
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining(
"Removed inactive gateway.auth.password for gateway.auth.mode=token",
),
);
expectLogIncludes("Removed inactive gateway.auth.password for gateway.auth.mode=token");
});
});
@@ -641,7 +654,7 @@ describe("config cli", () => {
expect(mockExit).not.toHaveBeenCalled();
expect(mockError).not.toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Config valid:"));
expectLogIncludes("Config valid:");
});
it("prints issues and exits 1 when config is invalid", async () => {
@@ -658,10 +671,8 @@ describe("config cli", () => {
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("config is invalid"));
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("agents.defaults.suppressToolErrorWarnings"),
);
expectErrorIncludes("config is invalid");
expectErrorIncludes("agents.defaults.suppressToolErrorWarnings");
expect(mockLog).not.toHaveBeenCalled();
});
@@ -723,7 +734,7 @@ describe("config cli", () => {
});
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config file not found:"));
expectErrorIncludes("Config file not found:");
expect(mockLog).not.toHaveBeenCalled();
});
});
@@ -754,24 +765,19 @@ describe("config cli", () => {
| { title?: string; description?: string }
| undefined;
expect(payload.properties?.$schema).toEqual({ type: "string" });
expect(gatewayPort).toMatchObject({
title: "Gateway Port",
description: expect.stringContaining("TCP port used by the gateway listener"),
});
expect(payload.properties?.channels).toMatchObject({
title: "Channels",
properties: {},
additionalProperties: true,
});
expect(payload.properties?.plugins).toMatchObject({
title: "Plugins",
description: expect.stringContaining("Plugin system controls"),
properties: {
entries: {
title: "Plugin Entries",
},
},
});
expect(gatewayPort?.title).toBe("Gateway Port");
expect(gatewayPort?.description).toContain("TCP port used by the gateway listener");
const channels = requireRecord(payload.properties?.channels, "schema channels");
expect(channels.title).toBe("Channels");
expect(channels.properties).toEqual({});
expect(channels.additionalProperties).toBe(true);
const plugins = requireRecord(payload.properties?.plugins, "schema plugins");
expect(plugins.title).toBe("Plugins");
expect(plugins.description).toContain("Plugin system controls");
const pluginProperties = requireRecord(plugins.properties, "schema plugin properties");
expect(requireRecord(pluginProperties.entries, "schema plugin entries").title).toBe(
"Plugin Entries",
);
});
it("falls back cleanly when best-effort schema loading returns channel-only data", async () => {
@@ -802,10 +808,9 @@ describe("config cli", () => {
properties?: Record<string, unknown>;
};
expect(payload.properties?.$schema).toEqual({ type: "string" });
expect(payload.properties?.channels).toMatchObject({
type: "object",
properties: { telegram: { type: "object" } },
});
const channels = requireRecord(payload.properties?.channels, "schema channels");
expect(channels.type).toBe("object");
expect(channels.properties).toEqual({ telegram: { type: "object" } });
expect(payload.properties?.plugins).toBeUndefined();
expect(mockError).not.toHaveBeenCalled();
});
@@ -830,12 +835,8 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining('Could not parse "{bad" as JSON for --strict-json.'),
);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("For plain strings, omit --strict-json."),
);
expectErrorIncludes('Could not parse "{bad" as JSON for --strict-json.');
expectErrorIncludes("For plain strings, omit --strict-json.");
});
it("keeps --json as a strict parsing alias", async () => {
@@ -954,10 +955,8 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Config policy validation failed: unsupported SecretRef usage"),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
expectErrorIncludes("Config policy validation failed: unsupported SecretRef usage");
expectErrorIncludes("hooks.token");
});
it("fails early when parent-object writes include unsupported SecretRef objects", async () => {
@@ -977,10 +976,8 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Config policy validation failed: unsupported SecretRef usage"),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
expectErrorIncludes("Config policy validation failed: unsupported SecretRef usage");
expectErrorIncludes("hooks.token");
});
it("supports provider builder mode under secrets.providers.<alias>", async () => {
@@ -1038,16 +1035,13 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
{
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
expect.objectContaining({
env: expect.any(Object),
}),
);
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
expect(secretRef).toEqual({
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
});
expect(requireRecord(resolveOptions, "resolve options").env).toBeTypeOf("object");
});
it("requires schema validation in JSON dry-run mode", async () => {
@@ -1068,9 +1062,7 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: config schema validation failed."),
);
expectErrorIncludes("Dry run failed: config schema validation failed.");
});
it("fails dry-run when unsupported mutable paths receive SecretRef objects in value/json mode", async () => {
@@ -1096,10 +1088,8 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: config schema validation failed."),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
expectErrorIncludes("Dry run failed: config schema validation failed.");
expectErrorIncludes("hooks.token");
});
it("aggregates policy failures across batch entries", async () => {
@@ -1119,10 +1109,8 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("commands.ownerDisplaySecret"),
);
expectErrorIncludes("hooks.token");
expectErrorIncludes("commands.ownerDisplaySecret");
});
it("does not duplicate policy errors in --dry-run --json mode for parent-object writes", async () => {
@@ -1170,14 +1158,8 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining(
"Dry run note: value mode does not run schema/resolvability checks.",
),
);
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining("Dry run successful: 1 update(s) validated"),
);
expectLogIncludes("Dry run note: value mode does not run schema/resolvability checks.");
expectLogIncludes("Dry run successful: 1 update(s) validated");
});
it("supports batch mode for refs/providers in dry-run", async () => {
@@ -1233,10 +1215,8 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining(
"Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec",
),
expectLogIncludes(
"Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec",
);
});
@@ -1271,17 +1251,13 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
expect.objectContaining({
source: "exec",
provider: "runner",
id: "openai",
}),
expect.any(Object),
);
expect(mockLog).not.toHaveBeenCalledWith(
expect.stringContaining("Dry run note: skipped 1 exec SecretRef resolvability check(s)."),
);
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
const secretRefRecord = requireRecord(secretRef, "exec SecretRef");
expect(secretRefRecord.source).toBe("exec");
expect(secretRefRecord.provider).toBe("runner");
expect(secretRefRecord.id).toBe("openai");
expect(resolveOptions).toBeTypeOf("object");
expectLogExcludes("Dry run note: skipped 1 exec SecretRef resolvability check(s).");
});
it("rejects --allow-exec without --dry-run", async () => {
@@ -1295,9 +1271,7 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("config set mode error: --allow-exec requires --dry-run."),
);
expectErrorIncludes("config set mode error: --allow-exec requires --dry-run.");
});
it("fails dry-run when skipped exec refs use an unconfigured provider", async () => {
@@ -1325,9 +1299,7 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining('Secret provider "runner" is not configured'),
);
expectErrorIncludes('Secret provider "runner" is not configured');
});
it("fails dry-run when skipped exec refs use a provider with mismatched source", async () => {
@@ -1359,11 +1331,7 @@ describe("config cli", () => {
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining(
'Secret provider "runner" has source "env" but ref requests "exec".',
),
);
expectErrorIncludes('Secret provider "runner" has source "env" but ref requests "exec".');
});
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
@@ -1416,9 +1384,7 @@ describe("config cli", () => {
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("config set mode error: choose exactly one mode"),
);
expectErrorIncludes("config set mode error: choose exactly one mode");
});
it("rejects mixing batch mode with builder flags", async () => {
@@ -1437,10 +1403,8 @@ describe("config cli", () => {
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining(
"config set mode error: batch mode (--batch-json/--batch-file) cannot be combined",
),
expectErrorIncludes(
"config set mode error: batch mode (--batch-json/--batch-file) cannot be combined",
);
});
@@ -1529,9 +1493,7 @@ describe("config cli", () => {
fs.rmSync(pathname, { force: true });
}
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("--batch-file must be a JSON array."),
);
expectErrorIncludes("--batch-file must be a JSON array.");
});
it("patches config from one object in one write", async () => {
@@ -1710,10 +1672,9 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
{ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
expect.any(Object),
);
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
expect(secretRef).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
expect(resolveOptions).toBeTypeOf("object");
});
it("schema-validates SecretRef-only config patch operations", async () => {
@@ -1751,11 +1712,9 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: config schema validation failed."),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("gateway"));
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('"typo"'));
expectErrorIncludes("Dry run failed: config schema validation failed.");
expectErrorIncludes("gateway");
expectErrorIncludes('"typo"');
});
it("dry-runs nested SecretRefs inside config patch replacements", async () => {
@@ -1812,18 +1771,14 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(2);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: 2 SecretRef assignment(s) could not be resolved."),
);
expectErrorIncludes("Dry run failed: 2 SecretRef assignment(s) could not be resolved.");
});
it("rejects config patch --json without dry-run", async () => {
await expect(runConfigCommand(["config", "patch", "--stdin", "--json"])).rejects.toThrow(
"__exit__:1",
);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("config patch mode error: --json requires --dry-run."),
);
expectErrorIncludes("config patch mode error: --json requires --dry-run.");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
@@ -1893,9 +1848,7 @@ describe("config cli", () => {
.channels as Record<string, unknown>,
).toEqual({ maintainers: { enabled: true, requireMention: true } });
expect((written.channels as Record<string, unknown>).slack).not.toHaveProperty("appToken");
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toMatchObject({
unsetPaths: [["channels", "slack", "appToken"]],
});
expect(requireWriteOptions().unsetPaths).toEqual([["channels", "slack", "appToken"]]);
});
it("rejects unused config patch replace paths", async () => {
@@ -1931,10 +1884,8 @@ describe("config cli", () => {
fs.rmSync(pathname, { force: true });
}
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining(
"config patch mode error: --replace-path channels.discord.guilds did not match any value in the input patch.",
),
expectErrorIncludes(
"config patch mode error: --replace-path channels.discord.guilds did not match any value in the input patch.",
);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
@@ -1949,9 +1900,7 @@ describe("config cli", () => {
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("must include exactly one of: value, ref, provider"),
);
expectErrorIncludes("must include exactly one of: value, ref, provider");
});
it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => {
@@ -1981,9 +1930,7 @@ describe("config cli", () => {
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
);
expectErrorIncludes("Dry run failed: 1 SecretRef assignment(s) could not be resolved.");
});
it("emits structured JSON for --dry-run --json success", async () => {
@@ -2188,7 +2135,8 @@ describe("config cli", () => {
};
expect(payload.ok).toBe(false);
const errorKinds = (payload.errors ?? []).map((entry) => entry.kind);
expect(errorKinds).toEqual(expect.arrayContaining(["schema", "resolvability"]));
expect(errorKinds).toContain("schema");
expect(errorKinds).toContain("resolvability");
const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? "");
const discordTokenRefs = errorRefs.filter((ref) => ref.includes("default:DISCORD_BOT_TOKEN"));
expect(discordTokenRefs.length).toBeGreaterThan(0);
@@ -2231,10 +2179,8 @@ describe("config cli", () => {
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch"));
expectErrorIncludes("Dry run failed: 1 SecretRef assignment(s) could not be resolved.");
expectErrorIncludes("provider mismatch");
});
it("fails dry-run for nested provider edits that make existing refs unresolvable", async () => {
@@ -2274,17 +2220,13 @@ describe("config cli", () => {
]),
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
expect.objectContaining({
provider: "vaultfile",
id: "/providers/search/apiKey",
}),
expect.any(Object),
);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch"));
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
const secretRefRecord = requireRecord(secretRef, "existing SecretRef");
expect(secretRefRecord.provider).toBe("vaultfile");
expect(secretRefRecord.id).toBe("/providers/search/apiKey");
expect(resolveOptions).toBeTypeOf("object");
expectErrorIncludes("Dry run failed: 1 SecretRef assignment(s) could not be resolved.");
expectErrorIncludes("provider mismatch");
});
});