mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 15:00:21 +00:00
* feat(config): add canonical streaming config helpers * refactor(runtime): prefer canonical streaming accessors * feat(config): normalize preview channel streaming shape * test(config): lock streaming normalization followups * fix(config): polish streaming migration edges * chore(config): refresh streaming baseline hash
994 lines
28 KiB
TypeScript
994 lines
28 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
validateConfigObjectRawWithPlugins,
|
|
validateConfigObjectWithPlugins,
|
|
} from "../../../config/validation.js";
|
|
import {
|
|
applyLegacyDoctorMigrations,
|
|
migrateLegacyConfig,
|
|
} from "./legacy-config-migrate.js";
|
|
|
|
describe("legacy migrate audio transcription", () => {
|
|
it("does not rewrite removed routing.transcribeAudio migrations", () => {
|
|
const res = migrateLegacyConfig({
|
|
routing: {
|
|
transcribeAudio: {
|
|
command: ["whisper", "--model", "base"],
|
|
timeoutSeconds: 2,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toEqual([]);
|
|
expect(res.config).toBeNull();
|
|
});
|
|
|
|
it("does not rewrite removed routing.transcribeAudio migrations when new config exists", () => {
|
|
const res = migrateLegacyConfig({
|
|
routing: {
|
|
transcribeAudio: {
|
|
command: ["whisper", "--model", "tiny"],
|
|
},
|
|
},
|
|
tools: {
|
|
media: {
|
|
audio: {
|
|
models: [{ command: "existing", type: "cli" }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toEqual([]);
|
|
expect(res.config).toBeNull();
|
|
});
|
|
|
|
it("drops invalid audio.transcription payloads", () => {
|
|
const res = migrateLegacyConfig({
|
|
audio: {
|
|
transcription: {
|
|
command: [{}],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain("Removed audio.transcription (invalid or empty command).");
|
|
expect(res.config?.audio).toBeUndefined();
|
|
expect(res.config?.tools?.media?.audio).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate mention routing", () => {
|
|
it("does not rewrite removed routing.groupChat.requireMention migrations", () => {
|
|
const res = migrateLegacyConfig({
|
|
routing: {
|
|
groupChat: {
|
|
requireMention: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toEqual([]);
|
|
expect(res.config).toBeNull();
|
|
});
|
|
|
|
it("does not rewrite removed channels.telegram.requireMention migrations", () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
telegram: {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toEqual([]);
|
|
expect(res.config).toBeNull();
|
|
});
|
|
|
|
it("moves channels.telegram.groupMentionsOnly into groups.*.requireMention", () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
telegram: {
|
|
groupMentionsOnly: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.',
|
|
);
|
|
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(true);
|
|
expect(
|
|
(res.config?.channels?.telegram as { groupMentionsOnly?: unknown } | undefined)
|
|
?.groupMentionsOnly,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('keeps explicit channels.telegram.groups."*".requireMention when migrating groupMentionsOnly', () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
telegram: {
|
|
groupMentionsOnly: true,
|
|
groups: {
|
|
"*": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
'Removed channels.telegram.groupMentionsOnly (channels.telegram.groups."*" already set).',
|
|
);
|
|
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
|
expect(
|
|
(res.config?.channels?.telegram as { groupMentionsOnly?: unknown } | undefined)
|
|
?.groupMentionsOnly,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not overwrite invalid channels.telegram.groups when migrating groupMentionsOnly", () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
telegram: {
|
|
groupMentionsOnly: true,
|
|
groups: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.config).toBeNull();
|
|
expect(res.changes).toEqual([]);
|
|
});
|
|
|
|
it('does not overwrite invalid channels.telegram.groups."*" when migrating groupMentionsOnly', () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
telegram: {
|
|
groupMentionsOnly: true,
|
|
groups: {
|
|
"*": false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.config).toBeNull();
|
|
expect(res.changes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate sandbox scope aliases", () => {
|
|
it("moves agents.defaults.sandbox.perSession into scope", () => {
|
|
const res = migrateLegacyConfig({
|
|
agents: {
|
|
defaults: {
|
|
sandbox: {
|
|
perSession: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Moved agents.defaults.sandbox.perSession → agents.defaults.sandbox.scope (session).",
|
|
);
|
|
expect(res.config?.agents?.defaults?.sandbox).toEqual({
|
|
scope: "session",
|
|
});
|
|
});
|
|
|
|
it("moves agents.list[].sandbox.perSession into scope", () => {
|
|
const res = migrateLegacyConfig({
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "pi",
|
|
sandbox: {
|
|
perSession: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Moved agents.list.0.sandbox.perSession → agents.list.0.sandbox.scope (shared).",
|
|
);
|
|
expect(res.config?.agents?.list?.[0]?.sandbox).toEqual({
|
|
scope: "shared",
|
|
});
|
|
});
|
|
|
|
it("drops legacy sandbox perSession when scope is already set", () => {
|
|
const res = migrateLegacyConfig({
|
|
agents: {
|
|
defaults: {
|
|
sandbox: {
|
|
scope: "agent",
|
|
perSession: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Removed agents.defaults.sandbox.perSession (agents.defaults.sandbox.scope already set).",
|
|
);
|
|
expect(res.config?.agents?.defaults?.sandbox).toEqual({
|
|
scope: "agent",
|
|
});
|
|
});
|
|
|
|
it("does not migrate invalid sandbox perSession values", () => {
|
|
const raw = {
|
|
agents: {
|
|
defaults: {
|
|
sandbox: {
|
|
perSession: "yes",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const res = migrateLegacyConfig(raw);
|
|
|
|
expect(res.changes).toEqual([]);
|
|
expect(res.config).toBeNull();
|
|
expect(validateConfigObjectWithPlugins(raw).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate channel streaming aliases", () => {
|
|
it("migrates preview-channel legacy streaming fields into the nested streaming shape", () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
telegram: {
|
|
streamMode: "block",
|
|
chunkMode: "newline",
|
|
blockStreaming: true,
|
|
draftChunk: {
|
|
minChars: 120,
|
|
},
|
|
blockStreamingCoalesce: {
|
|
idleMs: 250,
|
|
},
|
|
},
|
|
discord: {
|
|
streaming: false,
|
|
chunkMode: "newline",
|
|
blockStreaming: true,
|
|
draftChunk: {
|
|
maxChars: 900,
|
|
},
|
|
},
|
|
slack: {
|
|
streamMode: "status_final",
|
|
blockStreaming: true,
|
|
blockStreamingCoalesce: {
|
|
minChars: 100,
|
|
},
|
|
nativeStreaming: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.telegram.chunkMode → channels.telegram.streaming.chunkMode.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.telegram.blockStreaming → channels.telegram.streaming.block.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.telegram.draftChunk → channels.telegram.streaming.preview.chunk.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.telegram.blockStreamingCoalesce → channels.telegram.streaming.block.coalesce.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.slack.nativeStreaming → channels.slack.streaming.nativeTransport.",
|
|
);
|
|
expect(res.config?.channels?.telegram).toMatchObject({
|
|
streaming: {
|
|
mode: "block",
|
|
chunkMode: "newline",
|
|
block: {
|
|
enabled: true,
|
|
coalesce: {
|
|
idleMs: 250,
|
|
},
|
|
},
|
|
preview: {
|
|
chunk: {
|
|
minChars: 120,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(res.config?.channels?.discord).toMatchObject({
|
|
streaming: {
|
|
mode: "off",
|
|
chunkMode: "newline",
|
|
block: {
|
|
enabled: true,
|
|
},
|
|
preview: {
|
|
chunk: {
|
|
maxChars: 900,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(res.config?.channels?.slack).toMatchObject({
|
|
streaming: {
|
|
mode: "progress",
|
|
block: {
|
|
enabled: true,
|
|
coalesce: {
|
|
minChars: 100,
|
|
},
|
|
},
|
|
nativeTransport: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves slack streaming=false when deriving nativeTransport during migration", () => {
|
|
const raw = {
|
|
channels: {
|
|
slack: {
|
|
botToken: "xoxb-test",
|
|
streaming: false,
|
|
},
|
|
},
|
|
};
|
|
const res = migrateLegacyConfig(raw);
|
|
const migrated = applyLegacyDoctorMigrations(raw);
|
|
|
|
expect(res.changes).toContain(
|
|
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
|
|
);
|
|
expect((migrated.next as { channels?: { slack?: unknown } }).channels?.slack).toMatchObject({
|
|
streaming: {
|
|
mode: "off",
|
|
nativeTransport: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("removes legacy googlechat streamMode aliases", () => {
|
|
const raw = {
|
|
channels: {
|
|
googlechat: {
|
|
streamMode: "append",
|
|
accounts: {
|
|
work: {
|
|
streamMode: "replace",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const validated = validateConfigObjectWithPlugins(raw);
|
|
expect(validated.ok).toBe(true);
|
|
if (!validated.ok) {
|
|
return;
|
|
}
|
|
expect(
|
|
(validated.config.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
|
|
).toBeUndefined();
|
|
expect(
|
|
(validated.config.channels?.googlechat?.accounts?.work as Record<string, unknown> | undefined)
|
|
?.streamMode,
|
|
).toBeUndefined();
|
|
|
|
const res = migrateLegacyConfig(raw);
|
|
expect(res.changes).toContain(
|
|
"Removed channels.googlechat.streamMode (legacy key no longer used).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Removed channels.googlechat.accounts.work.streamMode (legacy key no longer used).",
|
|
);
|
|
expect(
|
|
(res.config?.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
|
|
).toBeUndefined();
|
|
expect(
|
|
(res.config?.channels?.googlechat?.accounts?.work as Record<string, unknown> | undefined)
|
|
?.streamMode,
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate nested channel enabled aliases", () => {
|
|
it("accepts legacy allow aliases through with-plugins validation and normalizes them", () => {
|
|
const raw = {
|
|
channels: {
|
|
slack: {
|
|
channels: {
|
|
ops: {
|
|
allow: false,
|
|
},
|
|
},
|
|
},
|
|
googlechat: {
|
|
groups: {
|
|
"spaces/aaa": {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
discord: {
|
|
guilds: {
|
|
"100": {
|
|
channels: {
|
|
general: {
|
|
allow: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const validated = validateConfigObjectWithPlugins(raw);
|
|
expect(validated.ok).toBe(true);
|
|
if (!validated.ok) {
|
|
return;
|
|
}
|
|
expect(validated.config.channels?.slack?.channels?.ops).toEqual({
|
|
enabled: false,
|
|
});
|
|
expect(validated.config.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
|
|
enabled: true,
|
|
});
|
|
expect(validated.config.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
|
|
enabled: false,
|
|
});
|
|
|
|
const rawValidated = validateConfigObjectRawWithPlugins(raw);
|
|
expect(rawValidated.ok).toBe(true);
|
|
if (!rawValidated.ok) {
|
|
return;
|
|
}
|
|
expect(rawValidated.config.channels?.slack?.channels?.ops).toEqual({
|
|
enabled: false,
|
|
});
|
|
});
|
|
|
|
it("moves legacy allow toggles into enabled for slack, googlechat, discord, matrix, and zalouser", () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
slack: {
|
|
channels: {
|
|
ops: {
|
|
allow: false,
|
|
},
|
|
},
|
|
accounts: {
|
|
work: {
|
|
channels: {
|
|
general: {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
googlechat: {
|
|
groups: {
|
|
"spaces/aaa": {
|
|
allow: false,
|
|
},
|
|
},
|
|
accounts: {
|
|
work: {
|
|
groups: {
|
|
"spaces/bbb": {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
discord: {
|
|
guilds: {
|
|
"100": {
|
|
channels: {
|
|
general: {
|
|
allow: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
accounts: {
|
|
work: {
|
|
guilds: {
|
|
"200": {
|
|
channels: {
|
|
help: {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
matrix: {
|
|
groups: {
|
|
"!ops:example.org": {
|
|
allow: false,
|
|
},
|
|
},
|
|
accounts: {
|
|
work: {
|
|
rooms: {
|
|
"!legacy:example.org": {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
zalouser: {
|
|
groups: {
|
|
"group:trusted": {
|
|
allow: false,
|
|
},
|
|
},
|
|
accounts: {
|
|
work: {
|
|
groups: {
|
|
"group:legacy": {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Moved channels.slack.channels.ops.allow → channels.slack.channels.ops.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.slack.accounts.work.channels.general.allow → channels.slack.accounts.work.channels.general.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.googlechat.groups.spaces/aaa.allow → channels.googlechat.groups.spaces/aaa.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.googlechat.accounts.work.groups.spaces/bbb.allow → channels.googlechat.accounts.work.groups.spaces/bbb.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.discord.guilds.100.channels.general.allow → channels.discord.guilds.100.channels.general.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.discord.accounts.work.guilds.200.channels.help.allow → channels.discord.accounts.work.guilds.200.channels.help.enabled.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (false).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (true).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (false).",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (true).",
|
|
);
|
|
expect(res.config?.channels?.slack?.channels?.ops).toEqual({
|
|
enabled: false,
|
|
});
|
|
expect(res.config?.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
|
|
enabled: false,
|
|
});
|
|
expect(res.config?.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
|
|
enabled: false,
|
|
});
|
|
expect(res.config?.channels?.matrix?.groups?.["!ops:example.org"]).toEqual({
|
|
enabled: false,
|
|
});
|
|
expect(res.config?.channels?.matrix?.accounts?.work?.rooms?.["!legacy:example.org"]).toEqual({
|
|
enabled: true,
|
|
});
|
|
expect(res.config?.channels?.zalouser?.groups?.["group:trusted"]).toEqual({
|
|
enabled: false,
|
|
});
|
|
expect(res.config?.channels?.zalouser?.accounts?.work?.groups?.["group:legacy"]).toEqual({
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("drops legacy allow when enabled is already set", () => {
|
|
const res = migrateLegacyConfig({
|
|
channels: {
|
|
slack: {
|
|
channels: {
|
|
ops: {
|
|
allow: true,
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Removed channels.slack.channels.ops.allow (channels.slack.channels.ops.enabled already set).",
|
|
);
|
|
expect(res.config?.channels?.slack?.channels?.ops).toEqual({
|
|
enabled: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate bundled channel private-network aliases", () => {
|
|
it("accepts legacy Mattermost private-network aliases through validation and normalizes them", () => {
|
|
const raw = {
|
|
channels: {
|
|
mattermost: {
|
|
allowPrivateNetwork: true,
|
|
accounts: {
|
|
work: {
|
|
allowPrivateNetwork: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const validated = validateConfigObjectWithPlugins(raw);
|
|
expect(validated.ok).toBe(true);
|
|
if (!validated.ok) {
|
|
return;
|
|
}
|
|
expect(validated.config.channels?.mattermost).toEqual({
|
|
dmPolicy: "pairing",
|
|
groupPolicy: "allowlist",
|
|
network: {
|
|
dangerouslyAllowPrivateNetwork: true,
|
|
},
|
|
accounts: {
|
|
work: {
|
|
dmPolicy: "pairing",
|
|
groupPolicy: "allowlist",
|
|
network: {
|
|
dangerouslyAllowPrivateNetwork: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const rawValidated = validateConfigObjectRawWithPlugins(raw);
|
|
expect(rawValidated.ok).toBe(true);
|
|
if (!rawValidated.ok) {
|
|
return;
|
|
}
|
|
expect(rawValidated.config.channels?.mattermost).toEqual({
|
|
dmPolicy: "pairing",
|
|
groupPolicy: "allowlist",
|
|
network: {
|
|
dangerouslyAllowPrivateNetwork: true,
|
|
},
|
|
accounts: {
|
|
work: {
|
|
dmPolicy: "pairing",
|
|
groupPolicy: "allowlist",
|
|
network: {
|
|
dangerouslyAllowPrivateNetwork: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const res = migrateLegacyConfig(raw);
|
|
expect(res.changes).toEqual(
|
|
expect.arrayContaining([
|
|
"Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).",
|
|
"Moved channels.mattermost.accounts.work.allowPrivateNetwork → channels.mattermost.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate x_search auth", () => {
|
|
it("moves only legacy x_search auth into plugin-owned xai config", () => {
|
|
const res = migrateLegacyConfig({
|
|
tools: {
|
|
web: {
|
|
x_search: {
|
|
apiKey: "xai-legacy-key",
|
|
enabled: true,
|
|
model: "grok-4-1-fast",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect((res.config?.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
|
|
enabled: true,
|
|
model: "grok-4-1-fast",
|
|
});
|
|
expect(res.config?.plugins?.entries?.xai).toEqual({
|
|
enabled: true,
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-legacy-key",
|
|
},
|
|
},
|
|
});
|
|
expect(res.changes).toEqual([
|
|
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate heartbeat config", () => {
|
|
it("moves top-level heartbeat into agents.defaults.heartbeat", () => {
|
|
const res = migrateLegacyConfig({
|
|
heartbeat: {
|
|
model: "anthropic/claude-3-5-haiku-20241022",
|
|
every: "30m",
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain("Moved heartbeat → agents.defaults.heartbeat.");
|
|
expect(res.config?.agents?.defaults?.heartbeat).toEqual({
|
|
model: "anthropic/claude-3-5-haiku-20241022",
|
|
every: "30m",
|
|
});
|
|
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
|
});
|
|
|
|
it("moves top-level heartbeat visibility into channels.defaults.heartbeat", () => {
|
|
const res = migrateLegacyConfig({
|
|
heartbeat: {
|
|
showOk: true,
|
|
showAlerts: false,
|
|
useIndicator: false,
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain("Moved heartbeat visibility → channels.defaults.heartbeat.");
|
|
expect(res.config?.channels?.defaults?.heartbeat).toEqual({
|
|
showOk: true,
|
|
showAlerts: false,
|
|
useIndicator: false,
|
|
});
|
|
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
|
});
|
|
|
|
it("keeps explicit agents.defaults.heartbeat values when merging top-level heartbeat", () => {
|
|
const res = migrateLegacyConfig({
|
|
heartbeat: {
|
|
model: "anthropic/claude-3-5-haiku-20241022",
|
|
every: "30m",
|
|
},
|
|
agents: {
|
|
defaults: {
|
|
heartbeat: {
|
|
every: "1h",
|
|
target: "telegram",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).",
|
|
);
|
|
expect(res.config?.agents?.defaults?.heartbeat).toEqual({
|
|
every: "1h",
|
|
target: "telegram",
|
|
model: "anthropic/claude-3-5-haiku-20241022",
|
|
});
|
|
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
|
});
|
|
|
|
it("keeps explicit channels.defaults.heartbeat values when merging top-level heartbeat visibility", () => {
|
|
const res = migrateLegacyConfig({
|
|
heartbeat: {
|
|
showOk: true,
|
|
showAlerts: true,
|
|
},
|
|
channels: {
|
|
defaults: {
|
|
heartbeat: {
|
|
showOk: false,
|
|
useIndicator: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.changes).toContain(
|
|
"Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).",
|
|
);
|
|
expect(res.config?.channels?.defaults?.heartbeat).toEqual({
|
|
showOk: false,
|
|
showAlerts: true,
|
|
useIndicator: false,
|
|
});
|
|
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
|
});
|
|
|
|
it("preserves agents.defaults.heartbeat precedence over top-level heartbeat legacy key", () => {
|
|
const res = migrateLegacyConfig({
|
|
agents: {
|
|
defaults: {
|
|
heartbeat: {
|
|
every: "1h",
|
|
target: "telegram",
|
|
},
|
|
},
|
|
},
|
|
heartbeat: {
|
|
every: "30m",
|
|
target: "discord",
|
|
model: "anthropic/claude-3-5-haiku-20241022",
|
|
},
|
|
});
|
|
|
|
expect(res.config?.agents?.defaults?.heartbeat).toEqual({
|
|
every: "1h",
|
|
target: "telegram",
|
|
model: "anthropic/claude-3-5-haiku-20241022",
|
|
});
|
|
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
|
});
|
|
|
|
it("drops blocked prototype keys when migrating top-level heartbeat", () => {
|
|
const res = migrateLegacyConfig(
|
|
JSON.parse(
|
|
'{"heartbeat":{"every":"30m","__proto__":{"polluted":true},"showOk":true}}',
|
|
) as Record<string, unknown>,
|
|
);
|
|
|
|
const heartbeat = res.config?.agents?.defaults?.heartbeat as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
expect(heartbeat?.every).toBe("30m");
|
|
expect((heartbeat as { polluted?: unknown } | undefined)?.polluted).toBeUndefined();
|
|
expect(Object.prototype.hasOwnProperty.call(heartbeat ?? {}, "__proto__")).toBe(false);
|
|
expect(res.config?.channels?.defaults?.heartbeat).toEqual({ showOk: true });
|
|
});
|
|
|
|
it("records a migration change when removing empty top-level heartbeat", () => {
|
|
const res = migrateLegacyConfig({
|
|
heartbeat: {},
|
|
});
|
|
|
|
expect(res.changes).toContain("Removed empty top-level heartbeat.");
|
|
expect(res.config).not.toBeNull();
|
|
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
|
|
it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
|
"http://localhost:18789",
|
|
"http://127.0.0.1:18789",
|
|
]);
|
|
expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true);
|
|
expect(res.changes.some((c) => c.includes("bind=lan"))).toBe(true);
|
|
});
|
|
|
|
it("seeds allowedOrigins using configured port", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "lan",
|
|
port: 9000,
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
|
"http://localhost:9000",
|
|
"http://127.0.0.1:9000",
|
|
]);
|
|
});
|
|
|
|
it("seeds allowedOrigins including custom bind host for bind=custom", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "192.168.1.100",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://192.168.1.100:18789");
|
|
expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://localhost:18789");
|
|
});
|
|
|
|
it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => {
|
|
// When allowedOrigins already exists, the migration is a no-op.
|
|
// applyLegacyDoctorMigrations returns next=null when changes.length===0, so config is null.
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: { mode: "token", token: "tok" },
|
|
controlUi: { allowedOrigins: ["https://control.example.com"] },
|
|
},
|
|
});
|
|
expect(res.config).toBeNull();
|
|
expect(res.changes).toHaveLength(0);
|
|
});
|
|
|
|
it("does not migrate when dangerouslyAllowHostHeaderOriginFallback is set — returns null", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: { mode: "token", token: "tok" },
|
|
controlUi: { dangerouslyAllowHostHeaderOriginFallback: true },
|
|
},
|
|
});
|
|
expect(res.config).toBeNull();
|
|
expect(res.changes).toHaveLength(0);
|
|
});
|
|
|
|
it("seeds allowedOrigins when existing entries are blank strings", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: { mode: "token", token: "tok" },
|
|
controlUi: { allowedOrigins: ["", " "] },
|
|
},
|
|
});
|
|
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
|
"http://localhost:18789",
|
|
"http://127.0.0.1:18789",
|
|
]);
|
|
expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true);
|
|
});
|
|
|
|
it("does not migrate loopback bind — returns null", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "loopback",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
expect(res.config).toBeNull();
|
|
expect(res.changes).toHaveLength(0);
|
|
});
|
|
|
|
it("preserves existing controlUi fields when seeding allowedOrigins", () => {
|
|
const res = migrateLegacyConfig({
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: { mode: "token", token: "tok" },
|
|
controlUi: { basePath: "/app" },
|
|
},
|
|
});
|
|
expect(res.config?.gateway?.controlUi?.basePath).toBe("/app");
|
|
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
|
"http://localhost:18789",
|
|
"http://127.0.0.1:18789",
|
|
]);
|
|
});
|
|
});
|