diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index b7ab2cac666..d4a87d82442 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -136,6 +136,38 @@ function buildParams(overrides: Partial = {}): ApplyAuthC }; } +function buildLocalProviderInstallCatalogEntry() { + return { + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + label: LOCAL_PROVIDER_LABEL, + origin: "bundled" as const, + install: { + npmSpec: "@openclaw/local-provider", + }, + }; +} + +function buildInstalledLocalProviderPluginResult() { + return { + cfg: { + plugins: { + entries: { + "local-provider-plugin": { + enabled: true, + }, + }, + }, + }, + installed: true, + pluginId: "local-provider-plugin", + status: "installed" as const, + }; +} + describe("applyAuthChoiceLoadedPluginProvider", () => { beforeEach(() => { vi.clearAllMocks(); @@ -290,32 +322,8 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { it("installs a missing provider plugin and retries setup resolution", async () => { const provider = buildProvider(); - resolveProviderInstallCatalogEntry.mockReturnValue({ - pluginId: "local-provider-plugin", - providerId: LOCAL_PROVIDER_ID, - methodId: LOCAL_AUTH_METHOD_ID, - choiceId: LOCAL_PROVIDER_ID, - choiceLabel: LOCAL_PROVIDER_LABEL, - label: LOCAL_PROVIDER_LABEL, - origin: "bundled", - install: { - npmSpec: "@openclaw/local-provider", - }, - }); - ensureOnboardingPluginInstalled.mockResolvedValue({ - cfg: { - plugins: { - entries: { - "local-provider-plugin": { - enabled: true, - }, - }, - }, - }, - installed: true, - pluginId: "local-provider-plugin", - status: "installed", - }); + resolveProviderInstallCatalogEntry.mockReturnValue(buildLocalProviderInstallCatalogEntry()); + ensureOnboardingPluginInstalled.mockResolvedValue(buildInstalledLocalProviderPluginResult()); resolvePluginProviders.mockReturnValue([provider]); resolveProviderPluginChoice.mockReturnValueOnce(null).mockReturnValueOnce({ provider, @@ -341,18 +349,7 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }); it("does not persist plugin enablement when install is skipped", async () => { - resolveProviderInstallCatalogEntry.mockReturnValue({ - pluginId: "local-provider-plugin", - providerId: LOCAL_PROVIDER_ID, - methodId: LOCAL_AUTH_METHOD_ID, - choiceId: LOCAL_PROVIDER_ID, - choiceLabel: LOCAL_PROVIDER_LABEL, - label: LOCAL_PROVIDER_LABEL, - origin: "bundled", - install: { - npmSpec: "@openclaw/local-provider", - }, - }); + resolveProviderInstallCatalogEntry.mockReturnValue(buildLocalProviderInstallCatalogEntry()); resolveProviderPluginChoice.mockReturnValue(null); const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); @@ -362,32 +359,8 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }); it("preserves install config when the chosen provider still cannot resolve after install", async () => { - resolveProviderInstallCatalogEntry.mockReturnValue({ - pluginId: "local-provider-plugin", - providerId: LOCAL_PROVIDER_ID, - methodId: LOCAL_AUTH_METHOD_ID, - choiceId: LOCAL_PROVIDER_ID, - choiceLabel: LOCAL_PROVIDER_LABEL, - label: LOCAL_PROVIDER_LABEL, - origin: "bundled", - install: { - npmSpec: "@openclaw/local-provider", - }, - }); - ensureOnboardingPluginInstalled.mockResolvedValue({ - cfg: { - plugins: { - entries: { - "local-provider-plugin": { - enabled: true, - }, - }, - }, - }, - installed: true, - pluginId: "local-provider-plugin", - status: "installed", - }); + resolveProviderInstallCatalogEntry.mockReturnValue(buildLocalProviderInstallCatalogEntry()); + ensureOnboardingPluginInstalled.mockResolvedValue(buildInstalledLocalProviderPluginResult()); resolveProviderPluginChoice.mockReturnValue(null); const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index e9d44f3e0a3..12e48bd6a73 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { channelsStatusCommand } from "./channels/status.js"; +import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js"; const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID; @@ -176,17 +177,6 @@ function createTokenOnlyPlugin() { }; } -function createRuntimeCapture() { - const logs: string[] = []; - const errors: string[] = []; - const runtime = { - log: (message: unknown) => logs.push(String(message)), - error: (message: unknown) => errors.push(String(message)), - exit: (_code?: number) => undefined, - }; - return { runtime, logs, errors }; -} - describe("channelsStatusCommand SecretRef fallback flow", () => { beforeEach(() => { mocks.callGateway.mockReset(); @@ -210,7 +200,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { "channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.", ], }); - const { runtime, logs, errors } = createRuntimeCapture(); + const { runtime, logs, errors } = createCapturingTestRuntime(); await channelsStatusCommand({ probe: false }, runtime as never); @@ -239,7 +229,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { effectiveConfig: { secretResolved: true, channels: {} }, diagnostics: [], }); - const { runtime, logs } = createRuntimeCapture(); + const { runtime, logs } = createCapturingTestRuntime(); await channelsStatusCommand({ probe: false }, runtime as never); @@ -267,7 +257,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { effectiveConfig: { secretResolved: true, channels: {} }, diagnostics: [], }); - const { runtime, logs, errors } = createRuntimeCapture(); + const { runtime, logs, errors } = createCapturingTestRuntime(); await channelsStatusCommand({ json: true, probe: false }, runtime as never); diff --git a/src/commands/channels.status.external-env.test.ts b/src/commands/channels.status.external-env.test.ts index 94b97386bc6..faca9e259e0 100644 --- a/src/commands/channels.status.external-env.test.ts +++ b/src/commands/channels.status.external-env.test.ts @@ -11,6 +11,7 @@ import { } from "../plugins/loader.test-fixtures.js"; import { withEnvAsync } from "../test-utils/env.js"; import { channelsStatusCommand } from "./channels/status.js"; +import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js"; const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), @@ -89,17 +90,6 @@ function writeExternalEnvChannelPlugin() { return { pluginDir, fullMarker }; } -function createRuntimeCapture() { - const logs: string[] = []; - const errors: string[] = []; - const runtime = { - log: (message: unknown) => logs.push(String(message)), - error: (message: unknown) => errors.push(String(message)), - exit: (_code?: number) => undefined, - }; - return { runtime, logs, errors }; -} - describe("channelsStatusCommand external env-only channel fallback", () => { beforeEach(() => { mocks.callGateway.mockReset(); @@ -127,7 +117,7 @@ describe("channelsStatusCommand external env-only channel fallback", () => { effectiveConfig: config, diagnostics: [], }); - const { runtime, logs } = createRuntimeCapture(); + const { runtime, logs } = createCapturingTestRuntime(); await withEnvAsync({ EXTERNAL_ENV_CHANNEL_TOKEN: "token" }, async () => { await channelsStatusCommand({ json: true, probe: false }, runtime as never); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index abcb8cff340..e3107422105 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -9,6 +9,12 @@ import { import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; +type InstalledRuntimeDeps = Array<{ + installRoot: string; + missingSpecs: string[]; + installSpecs: string[]; +}>; + function writeJson(filePath: string, value: unknown) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); @@ -25,6 +31,31 @@ function writeBundledChannelPlugin(root: string, id: string, dependencies: Recor }); } +function createInstalledRuntimeDeps(): InstalledRuntimeDeps { + return []; +} + +function createNonInteractivePrompter( + options: { updateInProgress?: boolean } = {}, +): DoctorPrompter { + return { + shouldRepair: false, + shouldForce: false, + repairMode: { + shouldRepair: false, + shouldForce: false, + nonInteractive: true, + canPrompt: false, + updateInProgress: options.updateInProgress ?? false, + }, + confirm: async () => false, + confirmAutoFix: async () => false, + confirmAggressiveAutoFix: async () => false, + confirmRuntimeRepair: async () => false, + select: async (_params: unknown, fallback: unknown) => fallback, + } as DoctorPrompter; +} + describe("doctor bundled plugin runtime deps", () => { it("skips source checkouts", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); @@ -175,31 +206,11 @@ describe("doctor bundled plugin runtime deps", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - const installed: Array<{ - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - }> = []; - const prompter = { - shouldRepair: false, - shouldForce: false, - repairMode: { - shouldRepair: false, - shouldForce: false, - nonInteractive: true, - canPrompt: false, - updateInProgress: false, - }, - confirm: async () => false, - confirmAutoFix: async () => false, - confirmAggressiveAutoFix: async () => false, - confirmRuntimeRepair: async () => false, - select: async (_params: unknown, fallback: unknown) => fallback, - } as DoctorPrompter; + const installed = createInstalledRuntimeDeps(); await maybeRepairBundledPluginRuntimeDeps({ runtime: { error: () => {} } as never, - prompter, + prompter: createNonInteractivePrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -223,31 +234,11 @@ describe("doctor bundled plugin runtime deps", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "feishu", { "@larksuiteoapi/node-sdk": "^1.61.0" }); - const installed: Array<{ - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - }> = []; - const prompter = { - shouldRepair: false, - shouldForce: false, - repairMode: { - shouldRepair: false, - shouldForce: false, - nonInteractive: true, - canPrompt: false, - updateInProgress: true, - }, - confirm: async () => false, - confirmAutoFix: async () => false, - confirmAggressiveAutoFix: async () => false, - confirmRuntimeRepair: async () => false, - select: async (_params: unknown, fallback: unknown) => fallback, - } as DoctorPrompter; + const installed = createInstalledRuntimeDeps(); await maybeRepairBundledPluginRuntimeDeps({ runtime: { error: () => {} } as never, - prompter, + prompter: createNonInteractivePrompter({ updateInProgress: true }), packageRoot: root, includeConfiguredChannels: true, config: { @@ -274,31 +265,11 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw", version: "2026.4.22" }); writeBundledChannelPlugin(root, "slack", { "@slack/web-api": "7.15.1" }); const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installed: Array<{ - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - }> = []; - const prompter = { - shouldRepair: false, - shouldForce: false, - repairMode: { - shouldRepair: false, - shouldForce: false, - nonInteractive: true, - canPrompt: false, - updateInProgress: false, - }, - confirm: async () => false, - confirmAutoFix: async () => false, - confirmAggressiveAutoFix: async () => false, - confirmRuntimeRepair: async () => false, - select: async (_params: unknown, fallback: unknown) => fallback, - } as DoctorPrompter; + const installed = createInstalledRuntimeDeps(); await maybeRepairBundledPluginRuntimeDeps({ runtime: { error: () => {} } as never, - prompter, + prompter: createNonInteractivePrompter(), env, packageRoot: root, config: { @@ -330,31 +301,11 @@ describe("doctor bundled plugin runtime deps", () => { name: "@slack/web-api", version: "7.15.1", }); - const installed: Array<{ - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - }> = []; - const prompter = { - shouldRepair: false, - shouldForce: false, - repairMode: { - shouldRepair: false, - shouldForce: false, - nonInteractive: true, - canPrompt: false, - updateInProgress: false, - }, - confirm: async () => false, - confirmAutoFix: async () => false, - confirmAggressiveAutoFix: async () => false, - confirmRuntimeRepair: async () => false, - select: async (_params: unknown, fallback: unknown) => fallback, - } as DoctorPrompter; + const installed = createInstalledRuntimeDeps(); await maybeRepairBundledPluginRuntimeDeps({ runtime: { error: () => {} } as never, - prompter, + prompter: createNonInteractivePrompter(), packageRoot: root, includeConfiguredChannels: true, config: { diff --git a/src/commands/doctor/shared/channel-doctor.test.ts b/src/commands/doctor/shared/channel-doctor.test.ts index ec059c77213..a5f2ff7ec8e 100644 --- a/src/commands/doctor/shared/channel-doctor.test.ts +++ b/src/commands/doctor/shared/channel-doctor.test.ts @@ -32,6 +32,80 @@ vi.mock("../../../channels/plugins/read-only.js", () => ({ ) => mocks.resolveReadOnlyChannelPluginsForConfig(...args), })); +function createMatrixEnabledConfig() { + return { + channels: { + matrix: { + enabled: true, + }, + }, + }; +} + +function createNormalizeCompatibilityConfig(change = "matrix") { + return vi.fn(({ cfg }: { cfg: unknown }) => ({ + config: cfg, + changes: [change], + })); +} + +function mockReadOnlyMatrixPlugin(doctor?: Record) { + mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ + plugins: [ + { + id: "matrix", + ...(doctor ? { doctor } : {}), + }, + ], + }); +} + +function mockBundledMatrixSetupPlugin(doctor?: Record) { + mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => + id === "matrix" + ? { + id: "matrix", + ...(doctor ? { doctor } : {}), + } + : undefined, + ); +} + +function mockBundledMatrixRuntimePlugin(doctor?: Record) { + mocks.getBundledChannelPlugin.mockImplementation((id: string) => + id === "matrix" + ? { + id: "matrix", + ...(doctor ? { doctor } : {}), + } + : undefined, + ); +} + +function expectMatrixDoctorLookupCalls(cfg?: unknown) { + if (cfg) { + expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, { + includePersistedAuthState: false, + }); + } + expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix"); + expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix"); + expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix"); +} + +async function expectRuntimeWarningFallback(params: { + cfg: unknown; + normalizeCompatibilityConfig: ReturnType; + collectMutableAllowlistWarnings: ReturnType; +}) { + expect(collectChannelDoctorCompatibilityMutations(params.cfg as never)).toHaveLength(1); + await expect( + collectChannelDoctorMutableAllowlistWarnings({ cfg: params.cfg as never }), + ).resolves.toEqual(["runtime warning"]); + expect(params.normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); + expect(params.collectMutableAllowlistWarnings).toHaveBeenCalledTimes(1); +} + describe("channel doctor compatibility mutations", () => { beforeEach(() => { mocks.getLoadedChannelPlugin.mockReset(); @@ -84,224 +158,80 @@ describe("channel doctor compatibility mutations", () => { }); it("uses read-only doctor adapters for configured channel ids", () => { - const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ - config: cfg, - changes: ["matrix"], - })); - mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ - plugins: [ - { - id: "matrix", - doctor: { normalizeCompatibilityConfig }, - }, - ], - }); - - const cfg = { - channels: { - matrix: { - enabled: true, - }, - }, - }; + const normalizeCompatibilityConfig = createNormalizeCompatibilityConfig(); + mockReadOnlyMatrixPlugin({ normalizeCompatibilityConfig }); + const cfg = createMatrixEnabledConfig(); const result = collectChannelDoctorCompatibilityMutations(cfg as never); expect(result).toHaveLength(1); expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); - expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, { - includePersistedAuthState: false, - }); - expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix"); - expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix"); - expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix"); + expectMatrixDoctorLookupCalls(cfg); expect(mocks.getBundledChannelSetupPlugin).not.toHaveBeenCalledWith("discord"); }); it("merges partial doctor adapters instead of masking runtime-only hooks", async () => { - const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ - config: cfg, - changes: ["matrix"], - })); + const normalizeCompatibilityConfig = createNormalizeCompatibilityConfig(); const collectMutableAllowlistWarnings = vi.fn(() => ["runtime warning"]); - mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ - plugins: [ - { - id: "matrix", - doctor: { normalizeCompatibilityConfig }, - }, - ], + mockReadOnlyMatrixPlugin({ normalizeCompatibilityConfig }); + mockBundledMatrixRuntimePlugin({ collectMutableAllowlistWarnings }); + const cfg = createMatrixEnabledConfig(); + + await expectRuntimeWarningFallback({ + cfg, + normalizeCompatibilityConfig, + collectMutableAllowlistWarnings, }); - mocks.getBundledChannelPlugin.mockImplementation((id: string) => - id === "matrix" - ? { - id: "matrix", - doctor: { collectMutableAllowlistWarnings }, - } - : undefined, - ); - - const cfg = { - channels: { - matrix: { - enabled: true, - }, - }, - }; - - expect(collectChannelDoctorCompatibilityMutations(cfg as never)).toHaveLength(1); - await expect( - collectChannelDoctorMutableAllowlistWarnings({ cfg: cfg as never }), - ).resolves.toEqual(["runtime warning"]); - expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); - expect(collectMutableAllowlistWarnings).toHaveBeenCalledTimes(1); }); it("ignores malformed doctor adapter values so valid fallbacks still run", async () => { - const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ - config: cfg, - changes: ["setup"], - })); + const normalizeCompatibilityConfig = createNormalizeCompatibilityConfig("setup"); const collectMutableAllowlistWarnings = vi.fn(() => ["runtime warning"]); - mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ - plugins: [ - { - id: "matrix", - doctor: { - normalizeCompatibilityConfig: null, - collectMutableAllowlistWarnings: "not-a-function", - warnOnEmptyGroupSenderAllowlist: "yes", - }, - }, - ], + mockReadOnlyMatrixPlugin({ + normalizeCompatibilityConfig: null, + collectMutableAllowlistWarnings: "not-a-function", + warnOnEmptyGroupSenderAllowlist: "yes", }); - mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => - id === "matrix" - ? { - id: "matrix", - doctor: { normalizeCompatibilityConfig }, - } - : undefined, - ); - mocks.getBundledChannelPlugin.mockImplementation((id: string) => - id === "matrix" - ? { - id: "matrix", - doctor: { collectMutableAllowlistWarnings }, - } - : undefined, - ); + mockBundledMatrixSetupPlugin({ normalizeCompatibilityConfig }); + mockBundledMatrixRuntimePlugin({ collectMutableAllowlistWarnings }); + const cfg = createMatrixEnabledConfig(); - const cfg = { - channels: { - matrix: { - enabled: true, - }, - }, - }; - - expect(collectChannelDoctorCompatibilityMutations(cfg as never)).toHaveLength(1); - await expect( - collectChannelDoctorMutableAllowlistWarnings({ cfg: cfg as never }), - ).resolves.toEqual(["runtime warning"]); - expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); - expect(collectMutableAllowlistWarnings).toHaveBeenCalledTimes(1); + await expectRuntimeWarningFallback({ + cfg, + normalizeCompatibilityConfig, + collectMutableAllowlistWarnings, + }); }); it("falls back to setup doctor adapters when read-only plugins lack doctor hooks", () => { - const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ - config: cfg, - changes: ["matrix"], - })); - mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ - plugins: [ - { - id: "matrix", - }, - ], - }); - mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => - id === "matrix" - ? { - id: "matrix", - doctor: { normalizeCompatibilityConfig }, - } - : undefined, - ); - - const cfg = { - channels: { - matrix: { - enabled: true, - }, - }, - }; + const normalizeCompatibilityConfig = createNormalizeCompatibilityConfig(); + mockReadOnlyMatrixPlugin(); + mockBundledMatrixSetupPlugin({ normalizeCompatibilityConfig }); + const cfg = createMatrixEnabledConfig(); const result = collectChannelDoctorCompatibilityMutations(cfg as never); expect(result).toHaveLength(1); expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); - expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, { - includePersistedAuthState: false, - }); - expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix"); - expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix"); - expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix"); + expectMatrixDoctorLookupCalls(cfg); }); it("falls back to bundled runtime doctor adapters when setup adapters lack doctor hooks", () => { - const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ - config: cfg, - changes: ["matrix"], - })); - mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ - plugins: [ - { - id: "matrix", - }, - ], - }); - mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => - id === "matrix" - ? { - id: "matrix", - } - : undefined, - ); - mocks.getBundledChannelPlugin.mockImplementation((id: string) => - id === "matrix" - ? { - id: "matrix", - doctor: { normalizeCompatibilityConfig }, - } - : undefined, - ); - - const cfg = { - channels: { - matrix: { - enabled: true, - }, - }, - }; + const normalizeCompatibilityConfig = createNormalizeCompatibilityConfig(); + mockReadOnlyMatrixPlugin(); + mockBundledMatrixSetupPlugin(); + mockBundledMatrixRuntimePlugin({ normalizeCompatibilityConfig }); + const cfg = createMatrixEnabledConfig(); const result = collectChannelDoctorCompatibilityMutations(cfg as never); expect(result).toHaveLength(1); expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); - expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix"); - expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix"); - expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix"); + expectMatrixDoctorLookupCalls(); }); it("passes explicit env into read-only channel plugin discovery", () => { - const cfg = { - channels: { - matrix: { - enabled: true, - }, - }, - }; + const cfg = createMatrixEnabledConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-test-home" }; collectChannelDoctorCompatibilityMutations(cfg as never, { env });