Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { describe, expect, it } from "vitest";
import {
collectChangedPaths,
formatConfigValidationFailure,
restoreEnvRefsFromMap,
resolvePersistCandidateForWrite,
resolveWriteEnvSnapshotForPath,
unsetPathForWrite,
} from "./io.write-prepare.js";
import type { OpenClawConfig } from "./types.js";
describe("config io write prepare", () => {
it("persists caller changes onto resolved config without leaking runtime defaults", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
gateway: { port: 18789 },
agents: { defaults: { cliBackend: "codex" } },
messages: { ackReaction: "eyes" },
sessions: { persistence: true },
},
sourceConfig: {
gateway: { port: 18789 },
},
nextConfig: {
gateway: {
port: 18789,
auth: { mode: "token" },
},
},
}) as Record<string, unknown>;
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
expect(persisted).not.toHaveProperty("agents.defaults");
expect(persisted).not.toHaveProperty("messages.ackReaction");
expect(persisted).not.toHaveProperty("sessions.persistence");
});
it("preserves authored source-only nested fields during partial writes", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
plugins: {
entries: {},
},
},
sourceConfig: {
plugins: {
entries: {},
installs: {
"openclaw-web-search": {
source: "npm",
spec: "@ollama/openclaw-web-search",
installPath: "/tmp/openclaw-web-search",
resolvedName: "@ollama/openclaw-web-search",
resolvedVersion: "0.2.2",
},
},
},
},
nextConfig: {
plugins: {
entries: {},
installs: {
"openclaw-web-search": {
source: "npm",
spec: "@ollama/openclaw-web-search@0.2.2",
installPath: "/tmp/openclaw-web-search",
resolvedName: "@ollama/openclaw-web-search",
resolvedVersion: "0.2.2",
},
},
},
},
}) as {
plugins?: {
installs?: Record<string, Record<string, unknown>>;
};
};
expect(persisted.plugins?.installs?.["openclaw-web-search"]).toEqual({
source: "npm",
spec: "@ollama/openclaw-web-search@0.2.2",
installPath: "/tmp/openclaw-web-search",
resolvedName: "@ollama/openclaw-web-search",
resolvedVersion: "0.2.2",
});
});
it("preserves untouched include-owned subtrees during unrelated writes", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
gateway: { mode: "local" },
},
sourceConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
gateway: { mode: "local" },
},
rootAuthoredConfig: {
agents: { $include: "./config/agents.json" },
gateway: { mode: "local" },
},
nextConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
gateway: { mode: "local", port: 18789 },
},
}) as Record<string, unknown>;
expect(persisted.agents).toEqual({ $include: "./config/agents.json" });
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
it("rejects writes that would flatten include-owned subtrees", () => {
expect(() =>
resolvePersistCandidateForWrite({
runtimeConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
},
sourceConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
},
rootAuthoredConfig: {
agents: { $include: "./config/agents.json" },
},
nextConfig: {
agents: {
defaults: { model: "anthropic/sonnet-4.5" },
},
},
}),
).toThrow("Config write would flatten $include-owned config at agents");
});
it('formats actionable guidance for dmPolicy="open" without wildcard allowFrom', () => {
const message = formatConfigValidationFailure(
"channels.telegram.allowFrom",
'channels.telegram.dmPolicy = "open" requires channels.telegram.allowFrom to include "* "',
);
expect(message).toContain("openclaw config set channels.telegram.allowFrom '[\"*\"]'");
expect(message).toContain('openclaw config set channels.telegram.dmPolicy "pairing"');
});
it("unsets explicit paths when runtime defaults would otherwise reappear", () => {
const next = unsetPathForWrite(
{
gateway: { auth: { mode: "none" } },
commands: { ownerDisplay: "hash" },
},
["commands", "ownerDisplay"],
);
expect(next.changed).toBe(true);
expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay");
});
it("does not mutate caller config when unsetting existing config objects", () => {
const input: OpenClawConfig = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
} satisfies OpenClawConfig;
const next = unsetPathForWrite(input, ["commands", "ownerDisplay"]);
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay");
});
it("keeps caller arrays immutable when unsetting array entries", () => {
const input: OpenClawConfig = {
gateway: { mode: "local" },
tools: { alsoAllow: ["exec", "fetch", "read"] },
} satisfies OpenClawConfig;
const next = unsetPathForWrite(input, ["tools", "alsoAllow", "1"]);
expect(input.tools!.alsoAllow).toEqual(["exec", "fetch", "read"]);
expect((next.next.tools as { alsoAllow?: string[] } | undefined)?.alsoAllow).toEqual([
"exec",
"read",
]);
});
it("treats missing unset paths as no-op without mutating caller config", () => {
const input: OpenClawConfig = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
} satisfies OpenClawConfig;
const next = unsetPathForWrite(input, ["commands", "missingKey"]);
expect(next.changed).toBe(false);
expect(next.next).toBe(input);
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
});
it("ignores blocked prototype-key unset path segments", () => {
const input: OpenClawConfig = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
} satisfies OpenClawConfig;
const blocked = [
["commands", "__proto__"],
["commands", "constructor"],
["commands", "prototype"],
].map((segments) => unsetPathForWrite(input, segments));
for (const result of blocked) {
expect(result.changed).toBe(false);
expect(result.next).toBe(input);
}
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
});
it("preserves env refs on unchanged paths while keeping changed paths resolved", () => {
const changedPaths = new Set<string>();
collectChangedPaths(
{
agents: {
defaults: {
cliBackends: {
codex: {
env: { OPENAI_API_KEY: "sk-secret" },
},
},
},
},
gateway: { port: 18789 },
},
{
agents: {
defaults: {
cliBackends: {
codex: {
env: { OPENAI_API_KEY: "sk-secret" },
},
},
},
},
gateway: {
port: 18789,
auth: { mode: "token" },
},
},
"",
changedPaths,
);
const restored = restoreEnvRefsFromMap(
{
agents: {
defaults: {
cliBackends: {
codex: {
env: { OPENAI_API_KEY: "sk-secret" },
},
},
},
},
gateway: {
port: 18789,
auth: { mode: "token" },
},
},
"",
new Map([["agents.defaults.cliBackends.codex.env.OPENAI_API_KEY", "${OPENAI_API_KEY}"]]),
changedPaths,
) as {
agents: { defaults: { cliBackends: { codex: { env: { OPENAI_API_KEY: string } } } } };
gateway: { port: number; auth: { mode: string } };
};
expect(restored.agents.defaults.cliBackends.codex.env.OPENAI_API_KEY).toBe("${OPENAI_API_KEY}");
expect(restored.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
});
it("preserves env refs in arrays while keeping appended entries resolved", () => {
const changedPaths = new Set<string>();
collectChangedPaths(
{
agents: {
defaults: {
cliBackends: {
codex: {
args: ["${DISCORD_USER_ID}", "123"],
},
},
},
},
},
{
agents: {
defaults: {
cliBackends: {
codex: {
args: ["${DISCORD_USER_ID}", "123", "456"],
},
},
},
},
},
"",
changedPaths,
);
const restored = restoreEnvRefsFromMap(
{
agents: {
defaults: {
cliBackends: {
codex: {
args: ["999", "123", "456"],
},
},
},
},
},
"",
new Map([["agents.defaults.cliBackends.codex.args[0]", "${DISCORD_USER_ID}"]]),
changedPaths,
) as {
agents: { defaults: { cliBackends: { codex: { args: string[] } } } };
};
expect(restored.agents.defaults.cliBackends.codex.args).toEqual([
"${DISCORD_USER_ID}",
"123",
"456",
]);
});
it("keeps the read-time env snapshot when writing the same config path", () => {
const snapshot = { OPENAI_API_KEY: "sk-secret" };
expect(
resolveWriteEnvSnapshotForPath({
actualConfigPath: "/tmp/openclaw.json",
expectedConfigPath: "/tmp/openclaw.json",
envSnapshotForRestore: snapshot,
}),
).toBe(snapshot);
});
it("drops the read-time env snapshot when writing a different config path", () => {
expect(
resolveWriteEnvSnapshotForPath({
actualConfigPath: "/tmp/openclaw.json",
expectedConfigPath: "/tmp/other.json",
envSnapshotForRestore: { OPENAI_API_KEY: "sk-secret" },
}),
).toBeUndefined();
});
it("keeps plugin AJV defaults out of the persisted candidate", () => {
const sourceConfig = {
gateway: { port: 18789 },
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
} satisfies OpenClawConfig;
const runtimeConfig: OpenClawConfig = {
gateway: { port: 18789 },
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
enrichGroupParticipantsFromContacts: true,
},
},
} satisfies OpenClawConfig;
const nextConfig: OpenClawConfig = structuredClone(runtimeConfig);
nextConfig.gateway = {
...nextConfig.gateway,
auth: { mode: "token" },
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig,
sourceConfig,
nextConfig,
}) as Record<string, unknown>;
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
const channels = persisted.channels as Record<string, Record<string, unknown>> | undefined;
expect(channels?.bluebubbles).toBeDefined();
expect(channels?.bluebubbles).not.toHaveProperty("enrichGroupParticipantsFromContacts");
expect(channels?.bluebubbles?.serverUrl).toBe("http://localhost:1234");
expect(channels?.bluebubbles?.password).toBe("test-password");
});
it("does not reintroduce legacy nested dm.policy defaults in the persisted candidate", () => {
const sourceConfig: OpenClawConfig = {
channels: {
discord: {
dmPolicy: "pairing",
dm: { enabled: true, policy: "pairing" },
},
slack: {
dmPolicy: "pairing",
dm: { enabled: true, policy: "pairing" },
},
},
gateway: { port: 18789 },
} satisfies OpenClawConfig;
const nextConfig = structuredClone(sourceConfig);
delete (nextConfig.channels?.discord?.dm as { enabled?: boolean; policy?: string } | undefined)
?.policy;
delete (nextConfig.channels?.slack?.dm as { enabled?: boolean; policy?: string } | undefined)
?.policy;
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig,
}) as {
channels?: {
discord?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
slack?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
};
};
expect(persisted.channels?.discord?.dmPolicy).toBe("pairing");
expect(persisted.channels?.discord?.dm).toEqual({ enabled: true });
expect(persisted.channels?.slack?.dmPolicy).toBe("pairing");
expect(persisted.channels?.slack?.dm).toEqual({ enabled: true });
});
it("preserves normalized nested channel enabled keys during unrelated writes", () => {
const sourceConfig = {
channels: {
slack: {
channels: {
ops: {
enabled: false,
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
enabled: true,
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
enabled: false,
},
},
},
},
},
},
} satisfies OpenClawConfig;
const nextConfig: OpenClawConfig = {
...structuredClone(sourceConfig),
gateway: {
auth: { mode: "token" },
},
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig,
}) as {
channels?: {
slack?: { channels?: Record<string, Record<string, unknown>> };
googlechat?: { groups?: Record<string, Record<string, unknown>> };
discord?: {
guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
};
};
gateway?: Record<string, unknown>;
};
expect(persisted.gateway).toEqual({
auth: { mode: "token" },
});
expect(persisted.channels?.slack?.channels?.ops).toEqual({ enabled: false });
expect(persisted.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ enabled: true });
expect(persisted.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
});
it("preserves root $schema during unrelated partial writes", () => {
const sourceConfig: OpenClawConfig = {
$schema: "https://openclaw.ai/config.json",
gateway: { mode: "local" },
} satisfies OpenClawConfig;
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig: {
gateway: { mode: "local", port: 18789 },
} satisfies OpenClawConfig,
}) as OpenClawConfig;
expect(persisted.$schema).toBe("https://openclaw.ai/config.json");
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
it("rejects writes that would flatten a root include", () => {
const sourceConfig = {
$schema: "https://openclaw.ai/config-from-include.json",
gateway: { mode: "local" },
};
expect(() =>
resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
rootAuthoredConfig: {
$include: "./extra.json5",
gateway: { mode: "local" },
},
nextConfig: {
gateway: { mode: "local", port: 18789 },
},
}),
).toThrow("Config write would flatten $include-owned config at <root>");
});
it("does not restore root $schema when the next config explicitly clears it", () => {
const sourceConfig = {
$schema: "https://openclaw.ai/config.json",
gateway: { mode: "local" },
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig: {
$schema: null,
gateway: { mode: "local", port: 18789 },
},
}) as Record<string, unknown>;
expect(persisted).not.toHaveProperty("$schema");
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
it("does not restore root $schema when the next config sets an invalid value", () => {
const sourceConfig = {
$schema: "https://openclaw.ai/config.json",
gateway: { mode: "local" },
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig: {
$schema: 123,
gateway: { mode: "local", port: 18789 },
},
}) as Record<string, unknown>;
expect(persisted.$schema).toBe(123);
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
});
¤ Dauer der Verarbeitung: 0.16 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|