diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 2e2c3ea8e26..2a2423ffb10 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -316,6 +316,56 @@ function expectPluginLoadedFromLocalPath( expect(result.cfg.plugins?.load?.paths).toContain(expectedPath); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`expected ${label} to be an object`); + } + return value; +} + +function requireArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`expected ${label} to be an array`); + } + return value; +} + +function expectRecordFields(value: unknown, label: string, expected: Record) { + const record = requireRecord(value, label); + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key]).toEqual(expectedValue); + } +} + +type MockWithCalls = { mock: { calls: unknown[][] } }; + +function requireMockCallArg(mock: MockWithCalls, callIndex: number, argIndex = 0) { + return requireRecord(mock.mock.calls[callIndex]?.[argIndex], "mock call argument"); +} + +function requireSelectOptions(select: MockWithCalls) { + return requireArray(requireMockCallArg(select, 0).options, "select options"); +} + +function requireOptionByValue(options: unknown[], value: string) { + const option = options.find( + (candidate) => requireRecord(candidate, "select option").value === value, + ); + return requireRecord(option, `select option ${value}`); +} + +function expectLoadOpenClawPluginFields(expected: Record, callIndex = 0) { + expectRecordFields( + requireMockCallArg(vi.mocked(loadOpenClawPlugins), callIndex), + "loadOpenClawPlugins args", + expected, + ); +} + describe("ensureChannelSetupPluginInstalled", () => { it("installs from npm and enables the plugin", async () => { const runtime = makeRuntime(); @@ -341,19 +391,15 @@ describe("ensureChannelSetupPluginInstalled", () => { expect(result.installed).toBe(true); expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true); expect(result.cfg.plugins?.allow).toContain("bundled-chat"); - expect(result.cfg.plugins?.installs).toEqual({ - "bundled-chat": expect.objectContaining({ - source: "npm", - spec: bundledChatNpmSpec, - installPath: "/tmp/bundled-chat", - }), + expectRecordFields(result.cfg.plugins?.installs?.["bundled-chat"], "plugin install record", { + source: "npm", + spec: bundledChatNpmSpec, + installPath: "/tmp/bundled-chat", + }); + expectRecordFields(requireMockCallArg(installPluginFromNpmSpec, 0), "npm install args", { + expectedIntegrity: bundledChatIntegrity, + spec: bundledChatNpmSpec, }); - expect(installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ - expectedIntegrity: bundledChatIntegrity, - spec: bundledChatNpmSpec, - }), - ); }); it("installs npm channel plugins into the active profile extensions dir", async () => { @@ -378,12 +424,10 @@ describe("ensureChannelSetupPluginInstalled", () => { runtime, }); - expect(installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ - extensionsDir: path.join(profileStateDir, "extensions"), - spec: bundledChatNpmSpec, - }), - ); + expectRecordFields(requireMockCallArg(installPluginFromNpmSpec, 0), "npm install args", { + extensionsDir: path.join(profileStateDir, "extensions"), + spec: bundledChatNpmSpec, + }); }); it("uses local path when selected", async () => { @@ -476,12 +520,10 @@ describe("ensureChannelSetupPluginInstalled", () => { }); expect(select).not.toHaveBeenCalled(); - expect(installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "@openclaw/wecom@beta", - expectedPluginId: "wecom-openclaw-plugin", - }), - ); + expectRecordFields(requireMockCallArg(installPluginFromNpmSpec, 0), "npm install args", { + spec: "@openclaw/wecom@beta", + expectedPluginId: "wecom-openclaw-plugin", + }); expect(result.cfg.plugins?.installs?.["wecom-openclaw-plugin"]?.spec).toBe("@openclaw/wecom"); }); @@ -499,16 +541,15 @@ describe("ensureChannelSetupPluginInstalled", () => { runtime, }); - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: "local", - options: expect.arrayContaining([ - expect.objectContaining({ - value: "local", - hint: bundledPluginRootAt("/opt/openclaw", "bundled-chat"), - }), - ]), - }), + const selectArgs = requireMockCallArg(select, 0); + expect(selectArgs.initialValue).toBe("local"); + expectRecordFields( + requireOptionByValue(requireSelectOptions(select), "local"), + "local option", + { + value: "local", + hint: bundledPluginRootAt("/opt/openclaw", "bundled-chat"), + }, ); }); @@ -560,20 +601,17 @@ describe("ensureChannelSetupPluginInstalled", () => { runtime, }); - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: "npm", - options: [ - expect.objectContaining({ - value: "npm", - label: `Download from npm (${bundledChatForkNpmSpec})`, - }), - expect.objectContaining({ - value: "skip", - }), - ], - }), - ); + const selectArgs = requireMockCallArg(select, 0); + expect(selectArgs.initialValue).toBe("npm"); + const options = requireSelectOptions(select); + expect(options).toHaveLength(2); + expectRecordFields(options[0], "npm option", { + value: "npm", + label: `Download from npm (${bundledChatForkNpmSpec})`, + }); + expectRecordFields(options[1], "skip option", { + value: "skip", + }); }); it("offers ClawHub as the first-class install source for channel catalog entries", async () => { @@ -604,20 +642,17 @@ describe("ensureChannelSetupPluginInstalled", () => { runtime, }); - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: "clawhub", - options: [ - expect.objectContaining({ - value: "clawhub", - label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)", - }), - expect.objectContaining({ - value: "skip", - }), - ], - }), - ); + const selectArgs = requireMockCallArg(select, 0); + expect(selectArgs.initialValue).toBe("clawhub"); + const options = requireSelectOptions(select); + expect(options).toHaveLength(2); + expectRecordFields(options[0], "clawhub option", { + value: "clawhub", + label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)", + }); + expectRecordFields(options[1], "skip option", { + value: "skip", + }); }); it("falls back to local path after npm install failure", async () => { @@ -698,16 +733,14 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - activationSourceConfig: cfg, - autoEnabledReasons: {}, - workspaceDir: "/tmp/openclaw-workspace", - cache: false, - includeSetupOnlyChannelPlugins: true, - }), - ); + expectLoadOpenClawPluginFields({ + config: cfg, + activationSourceConfig: cfg, + autoEnabledReasons: {}, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + includeSetupOnlyChannelPlugins: true, + }); }); it("loads the setup plugin registry from the auto-enabled config snapshot", () => { @@ -740,13 +773,11 @@ describe("ensureChannelSetupPluginInstalled", () => { config: cfg, env: process.env, }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: autoEnabledConfig, - activationSourceConfig: cfg, - autoEnabledReasons: {}, - }), - ); + expectLoadOpenClawPluginFields({ + config: autoEnabledConfig, + activationSourceConfig: cfg, + autoEnabledReasons: {}, + }); }); it("scopes channel reloads when setup starts from an empty registry", () => { @@ -761,17 +792,15 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - activationSourceConfig: cfg, - autoEnabledReasons: {}, - workspaceDir: "/tmp/openclaw-workspace", - cache: false, - onlyPluginIds: ["@vendor/external-chat-plugin"], - includeSetupOnlyChannelPlugins: true, - }), - ); + expectLoadOpenClawPluginFields({ + config: cfg, + activationSourceConfig: cfg, + autoEnabledReasons: {}, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["@vendor/external-chat-plugin"], + includeSetupOnlyChannelPlugins: true, + }); expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", { workspaceDir: "/tmp/openclaw-workspace", }); @@ -799,11 +828,9 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: [], - }), - ); + expectLoadOpenClawPluginFields({ + onlyPluginIds: [], + }); }); it("scopes channel reloads when the global registry is populated but the pinned channel registry is empty", () => { @@ -834,13 +861,11 @@ describe("ensureChannelSetupPluginInstalled", () => { releasePinnedPluginChannelRegistry(pinnedChannelRegistry); } - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - activationSourceConfig: cfg, - autoEnabledReasons: {}, - onlyPluginIds: ["@vendor/external-chat-plugin"], - }), - ); + expectLoadOpenClawPluginFields({ + activationSourceConfig: cfg, + autoEnabledReasons: {}, + onlyPluginIds: ["@vendor/external-chat-plugin"], + }); }); it("can load a channel-scoped snapshot without activating the global registry", () => { @@ -855,18 +880,16 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - activationSourceConfig: cfg, - autoEnabledReasons: {}, - workspaceDir: "/tmp/openclaw-workspace", - cache: false, - onlyPluginIds: ["@vendor/external-chat-plugin"], - includeSetupOnlyChannelPlugins: true, - activate: false, - }), - ); + expectLoadOpenClawPluginFields({ + config: cfg, + activationSourceConfig: cfg, + autoEnabledReasons: {}, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["@vendor/external-chat-plugin"], + includeSetupOnlyChannelPlugins: true, + activate: false, + }); expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", { workspaceDir: "/tmp/openclaw-workspace", }); @@ -886,11 +909,9 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["@vendor/external-chat-plugin"], - }), - ); + expectLoadOpenClawPluginFields({ + onlyPluginIds: ["@vendor/external-chat-plugin"], + }); expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "external-chat", { workspaceDir: "/tmp/openclaw-workspace", }); @@ -920,11 +941,9 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["trusted-external-chat-shadow"], - }), - ); + expectLoadOpenClawPluginFields({ + onlyPluginIds: ["trusted-external-chat-shadow"], + }); expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1); }); @@ -939,11 +958,9 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: [], - }), - ); + expectLoadOpenClawPluginFields({ + onlyPluginIds: [], + }); }); it("scopes snapshots by a unique discovered manifest match when catalog mapping is missing", () => { @@ -966,18 +983,16 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - activationSourceConfig: cfg, - autoEnabledReasons: {}, - workspaceDir: "/tmp/openclaw-workspace", - cache: false, - onlyPluginIds: ["custom-external-chat-plugin"], - includeSetupOnlyChannelPlugins: true, - activate: false, - }), - ); + expectLoadOpenClawPluginFields({ + config: cfg, + activationSourceConfig: cfg, + autoEnabledReasons: {}, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["custom-external-chat-plugin"], + includeSetupOnlyChannelPlugins: true, + activate: false, + }); }); it("scopes snapshots by activation-declared channel ownership when direct channel lists are empty", () => { @@ -992,23 +1007,21 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["custom-external-chat-plugin"], - }), - ); - expect(loadPluginManifestRegistry).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - workspaceDir: "/tmp/openclaw-workspace", - candidates: expect.arrayContaining([ - expect.objectContaining({ - idHint: "custom-external-chat-plugin", - origin: "bundled", - }), - ]), - }), - ); + expectLoadOpenClawPluginFields({ + onlyPluginIds: ["custom-external-chat-plugin"], + }); + const manifestCall = loadPluginManifestRegistry.mock.calls + .map((call) => requireRecord(call[0], "manifest registry args")) + .find((args) => + requireArray(args.candidates, "manifest candidates").some((candidate) => { + const record = requireRecord(candidate, "manifest candidate"); + return record.idHint === "custom-external-chat-plugin" && record.origin === "bundled"; + }), + ); + expectRecordFields(manifestCall, "manifest registry args", { + config: cfg, + workspaceDir: "/tmp/openclaw-workspace", + }); }); it("uses live manifest discovery for activation-declared setup scoping", () => { @@ -1155,17 +1168,15 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(loadOpenClawPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - activationSourceConfig: cfg, - autoEnabledReasons: {}, - workspaceDir: "/tmp/openclaw-workspace", - cache: false, - onlyPluginIds: ["@vendor/external-chat-plugin"], - includeSetupOnlyChannelPlugins: true, - activate: false, - }), - ); + expectLoadOpenClawPluginFields({ + config: cfg, + activationSourceConfig: cfg, + autoEnabledReasons: {}, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["@vendor/external-chat-plugin"], + includeSetupOnlyChannelPlugins: true, + activate: false, + }); }); });