From c66f16ac55e96170a9ee1ce292c07f83bc9ff6f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 06:40:06 +0100 Subject: [PATCH] test: trim doctor command hotspots --- src/commands/doctor-config-flow.test.ts | 127 +++++++++++ .../doctor-legacy-config.migrations.test.ts | 205 ------------------ src/commands/doctor.e2e-harness.ts | 12 + .../shared/legacy-config-core-migrate.ts | 12 +- 4 files changed, 144 insertions(+), 212 deletions(-) diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 7a20b489935..8732f1b3b35 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -111,6 +111,133 @@ vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ }), })); +vi.mock("../channels/plugins/doctor-contract-api.js", () => ({ + loadBundledChannelDoctorContractApi: vi.fn(() => undefined), +})); + +vi.mock("../channels/plugins/setup-promotion-helpers.js", () => { + const commonSingleAccountKeys = new Set([ + "name", + "token", + "tokenFile", + "botToken", + "appToken", + "account", + "signalNumber", + "authDir", + "cliPath", + "dbPath", + "httpUrl", + "httpHost", + "httpPort", + "webhookPath", + "webhookUrl", + "webhookSecret", + "service", + "region", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "url", + "code", + "dmPolicy", + "allowFrom", + "groupPolicy", + "groupAllowFrom", + "defaultTo", + ]); + const fallbackSingleAccountKeys: Record = { + telegram: ["streaming"], + }; + const namedAccountPromotionKeys: Record = { + telegram: ["botToken", "tokenFile"], + }; + + return { + resolveSingleAccountKeysToMove: ({ + channelKey, + channel, + }: { + channelKey: string; + channel: Record; + }) => { + const accounts = + channel.accounts && typeof channel.accounts === "object" && !Array.isArray(channel.accounts) + ? (channel.accounts as Record) + : {}; + const hasNamedAccounts = Object.keys(accounts).filter(Boolean).length > 0; + const allowedNamedKeys = namedAccountPromotionKeys[channelKey]; + return Object.entries(channel) + .filter(([key, value]) => { + if (key === "accounts" || key === "enabled" || value === undefined) { + return false; + } + const isKnownKey = + commonSingleAccountKeys.has(key) || + (fallbackSingleAccountKeys[channelKey]?.includes(key) ?? false); + if (!isKnownKey) { + return false; + } + if (hasNamedAccounts && allowedNamedKeys && !allowedNamedKeys.includes(key)) { + return false; + } + return true; + }) + .map(([key]) => key); + }, + }; +}); + +vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({ + applyChannelDoctorCompatibilityMigrations: (cfg: Record) => ({ + next: cfg, + changes: [], + }), +})); + +vi.mock("./doctor/channel-capabilities.js", () => { + const byChannel = { + googlechat: { + dmAllowFromMode: "nestedOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }, + matrix: { + dmAllowFromMode: "nestedOnly", + groupModel: "sender", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: true, + }, + msteams: { + dmAllowFromMode: "topOnly", + groupModel: "hybrid", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: true, + }, + zalouser: { + dmAllowFromMode: "topOnly", + groupModel: "hybrid", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }, + } as const; + const fallback = { + dmAllowFromMode: "topOnly", + groupModel: "sender", + groupAllowFromFallbackToAllowFrom: true, + warnOnEmptyGroupSenderAllowlist: true, + }; + return { + getDoctorChannelCapabilities: (channelName?: string) => + channelName && channelName in byChannel + ? byChannel[channelName as keyof typeof byChannel] + : fallback, + }; +}); + vi.mock("../plugins/doctor-contract-registry.js", () => { function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index c29ac1c8cb6..eded7102752 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -12,16 +12,6 @@ vi.mock("../plugins/setup-registry.js", () => ({ }), })); -function asLegacyConfig(value: unknown): OpenClawConfig { - return value as OpenClawConfig; -} - -function getLegacyProperty(value: unknown, key: string): unknown { - if (!value || typeof value !== "object") { - return undefined; - } - return (value as Record)[key]; -} describe("normalizeCompatibilityConfigValues", () => { let previousOauthDir: string | undefined; let tempOauthDir = ""; @@ -90,201 +80,6 @@ describe("normalizeCompatibilityConfigValues", () => { }); }); - it("migrates Slack dm.policy/dm.allowFrom to dmPolicy/allowFrom aliases", () => { - const res = normalizeCompatibilityConfigValues({ - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - }, - }, - }); - - expect(res.config.channels?.slack?.dmPolicy).toBe("open"); - expect(res.config.channels?.slack?.allowFrom).toEqual(["*"]); - expect(res.config.channels?.slack?.dm).toEqual({ - enabled: true, - }); - expect(res.changes).toEqual([ - "Moved channels.slack.dm.policy → channels.slack.dmPolicy.", - "Moved channels.slack.dm.allowFrom → channels.slack.allowFrom.", - ]); - }); - - it("migrates Discord account dm.policy/dm.allowFrom to dmPolicy/allowFrom aliases", () => { - const res = normalizeCompatibilityConfigValues({ - channels: { - discord: { - accounts: { - work: { - dm: { policy: "allowlist", allowFrom: ["123"], groupEnabled: true }, - }, - }, - }, - }, - }); - - expect(res.config.channels?.discord?.accounts?.work?.dmPolicy).toBe("allowlist"); - expect(res.config.channels?.discord?.accounts?.work?.allowFrom).toEqual(["123"]); - expect(res.config.channels?.discord?.accounts?.work?.dm).toEqual({ - groupEnabled: true, - }); - expect(res.changes).toEqual([ - "Moved channels.discord.accounts.work.dm.policy → channels.discord.accounts.work.dmPolicy.", - "Moved channels.discord.accounts.work.dm.allowFrom → channels.discord.accounts.work.allowFrom.", - ]); - }); - - it("migrates Discord streaming boolean alias into nested streaming.mode", () => { - const res = normalizeCompatibilityConfigValues( - asLegacyConfig({ - channels: { - discord: { - streaming: true, - accounts: { - work: { - streaming: false, - }, - }, - }, - }, - }), - ); - - expect(res.config.channels?.discord?.streaming).toEqual({ mode: "partial" }); - expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined(); - expect(res.config.channels?.discord?.accounts?.work?.streaming).toEqual({ mode: "off" }); - expect( - getLegacyProperty(res.config.channels?.discord?.accounts?.work, "streamMode"), - ).toBeUndefined(); - expect(res.changes).toEqual([ - "Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).", - "Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).", - ]); - }); - - it("migrates Discord legacy streamMode into nested streaming.mode", () => { - const res = normalizeCompatibilityConfigValues( - asLegacyConfig({ - channels: { - discord: { - streaming: false, - streamMode: "block", - }, - }, - }), - ); - - expect(res.config.channels?.discord?.streaming).toEqual({ mode: "block" }); - expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined(); - expect(res.changes).toEqual([ - "Moved channels.discord.streamMode → channels.discord.streaming.mode (block).", - ]); - }); - - it("migrates Telegram streamMode into nested streaming.mode", () => { - const res = normalizeCompatibilityConfigValues( - asLegacyConfig({ - channels: { - telegram: { - streamMode: "block", - }, - }, - }), - ); - - expect(res.config.channels?.telegram?.streaming).toEqual({ mode: "block" }); - expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined(); - expect(res.changes).toEqual([ - "Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).", - ]); - }); - - it("migrates Slack legacy streaming keys into nested streaming config", () => { - const res = normalizeCompatibilityConfigValues( - asLegacyConfig({ - channels: { - slack: { - streaming: false, - streamMode: "status_final", - }, - }, - }), - ); - - expect(res.config.channels?.slack?.streaming).toEqual({ - mode: "progress", - nativeTransport: false, - }); - expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined(); - expect(res.changes).toEqual([ - "Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).", - "Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.", - ]); - }); - - it("preserves top-level Telegram allowlist fallback for existing named accounts", () => { - const res = normalizeCompatibilityConfigValues({ - channels: { - telegram: { - enabled: true, - dmPolicy: "allowlist", - allowFrom: ["123"], - groupPolicy: "allowlist", - accounts: { - bot1: { - enabled: true, - botToken: "bot-1-token", - }, - bot2: { - enabled: true, - botToken: "bot-2-token", - }, - }, - }, - }, - }); - - expect(res.config.channels?.telegram?.dmPolicy).toBe("allowlist"); - expect(res.config.channels?.telegram?.allowFrom).toEqual(["123"]); - expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist"); - expect(res.config.channels?.telegram?.accounts?.bot1?.botToken).toBe("bot-1-token"); - expect(res.config.channels?.telegram?.accounts?.bot2?.botToken).toBe("bot-2-token"); - expect(res.changes).not.toContain( - "Moved channels.telegram single-account top-level values into channels.telegram.accounts.default.", - ); - }); - - it("keeps Telegram policy fallback top-level while still seeding default auth", () => { - const res = normalizeCompatibilityConfigValues({ - channels: { - telegram: { - enabled: true, - botToken: "legacy-token", - dmPolicy: "allowlist", - allowFrom: ["123"], - groupPolicy: "allowlist", - accounts: { - bot1: { - enabled: true, - botToken: "bot-1-token", - }, - }, - }, - }, - }); - - expect(res.config.channels?.telegram?.accounts?.default).toMatchObject({ - botToken: "legacy-token", - }); - expect(res.config.channels?.telegram?.botToken).toBeUndefined(); - expect(res.config.channels?.telegram?.dmPolicy).toBe("allowlist"); - expect(res.config.channels?.telegram?.allowFrom).toEqual(["123"]); - expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist"); - expect(res.changes).toContain( - "Moved channels.telegram single-account top-level values into channels.telegram.accounts.default.", - ); - }); - it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => { const res = normalizeCompatibilityConfigValues({ browser: { diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index a1245a45db4..2d8edda83ce 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -364,10 +364,22 @@ vi.mock("./doctor-memory-search.js", () => ({ })); vi.mock("../plugins/doctor-contract-registry.js", () => ({ + applyPluginDoctorCompatibilityMigrations: (config: unknown) => ({ + config, + changes: [], + }), collectRelevantDoctorPluginIds, listPluginDoctorLegacyConfigRules, })); +vi.mock("../channels/plugins/doctor-contract-api.js", () => ({ + loadBundledChannelDoctorContractApi: vi.fn(() => undefined), +})); + +vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ + getBootstrapChannelPlugin: vi.fn(() => undefined), +})); + vi.mock("./doctor-bundled-plugin-runtime-deps.js", () => ({ maybeRepairBundledPluginRuntimeDeps: vi.fn(async () => {}), })); diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts index 76e3bc76574..210ff4e7c90 100644 --- a/src/commands/doctor/shared/legacy-config-core-migrate.ts +++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js"; -import { collectChannelDoctorCompatibilityMutations } from "./channel-doctor.js"; +import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js"; import { normalizeLegacyBrowserConfig, normalizeLegacyCrossContextMessageConfig, @@ -48,12 +48,10 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { next = normalizeLegacyCrossContextMessageConfig(next, changes); next = normalizeLegacyMediaProviderOptions(next, changes); next = normalizeLegacyMistralModelMaxTokens(next, changes); - for (const mutation of collectChannelDoctorCompatibilityMutations(next)) { - if (mutation.changes.length === 0) { - continue; - } - next = mutation.config; - changes.push(...mutation.changes); + const channelMigrations = applyChannelDoctorCompatibilityMigrations(next); + if (channelMigrations.changes.length > 0) { + next = channelMigrations.next; + changes.push(...channelMigrations.changes); } return { config: next, changes };