Files
openclaw/src/commands/onboard-channels.e2e.test.ts
2026-04-04 00:11:39 +01:00

1187 lines
40 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
matrixSetupAdapter,
matrixSetupWizard,
} from "../../test/helpers/channels/matrix-setup-contract.js";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
reloadChannelSetupPluginRegistry,
} from "./channel-setup/plugin-install.js";
import { getChannelSetupWizardAdapter } from "./channel-setup/registry.js";
import type { ChannelSetupWizardAdapter } from "./channel-setup/types.js";
import { setupChannels } from "./onboard-channels.js";
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
const catalogMocks = vi.hoisted(() => ({
listChannelPluginCatalogEntries: vi.fn(),
}));
const manifestRegistryMocks = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })),
}));
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(
{
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
},
{ defaultSelect: "__done__" },
);
}
function createUnexpectedPromptGuards() {
return {
multiselect: vi.fn(async () => {
throw new Error("unexpected multiselect");
}),
text: vi.fn(async ({ message }: { message: string }) => {
throw new Error(`unexpected text prompt: ${message}`);
}) as unknown as WizardPrompter["text"],
};
}
type SetupChannelsOptions = Parameters<typeof setupChannels>[3];
function runSetupChannels(
cfg: OpenClawConfig,
prompter: WizardPrompter,
options?: SetupChannelsOptions,
) {
return setupChannels(cfg, createExitThrowingRuntime(), prompter, {
skipConfirm: true,
...options,
});
}
function createQuickstartTelegramSelect(options?: {
configuredAction?: "skip";
strictUnexpected?: boolean;
}) {
return vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
if (options?.configuredAction && message.includes("already configured")) {
return options.configuredAction;
}
if (options?.strictUnexpected) {
throw new Error(`unexpected select prompt: ${message}`);
}
return "__done__";
});
}
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
const { multiselect, text } = createUnexpectedPromptGuards();
return {
prompter: createPrompter({ select, multiselect, text }),
multiselect,
text,
};
}
function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig {
return {
channels: {
telegram: {
botToken,
...(typeof enabled === "boolean" ? { enabled } : {}),
},
},
} as OpenClawConfig;
}
function createMSTeamsCatalogEntry(): ChannelPluginCatalogEntry {
return {
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
install: {
npmSpec: "@openclaw/msteams",
},
};
}
async function setMatrixOnboardingRegistryForTests(): Promise<void> {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "matrix",
label: "Matrix",
capabilities: { chatTypes: ["direct", "group", "thread"] },
}),
meta: {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
blurb: "open protocol; configure a homeserver + access token.",
},
setup: matrixSetupAdapter,
setupWizard: matrixSetupWizard,
},
},
]),
);
}
async function withClearedMatrixSetupEnv<T>(run: () => Promise<T>): Promise<T> {
const previousEnv = {
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
MATRIX_USER_ID: process.env.MATRIX_USER_ID,
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
MATRIX_PASSWORD: process.env.MATRIX_PASSWORD,
MATRIX_DEVICE_ID: process.env.MATRIX_DEVICE_ID,
MATRIX_DEVICE_NAME: process.env.MATRIX_DEVICE_NAME,
};
delete process.env.MATRIX_HOMESERVER;
delete process.env.MATRIX_USER_ID;
delete process.env.MATRIX_ACCESS_TOKEN;
delete process.env.MATRIX_PASSWORD;
delete process.env.MATRIX_DEVICE_ID;
delete process.env.MATRIX_DEVICE_NAME;
try {
return await run();
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
function createMatrixQuickstartPrompter(notes: string[]): WizardPrompter {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "matrix";
}
if (message === "Matrix auth method") {
return "token";
}
throw new Error(`unexpected select prompt: ${message}`);
});
const multiselect = vi.fn(async () => {
throw new Error("unexpected multiselect");
});
const text = vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix homeserver URL") {
return "https://matrix.example.org";
}
if (message === "Matrix access token") {
return "matrix-token";
}
if (message === "Matrix device name (optional)") {
return "OpenClaw Gateway";
}
throw new Error(`unexpected text prompt: ${message}`);
});
const confirm = vi.fn(async ({ message }: { message: string }) => {
if (message === "Enable end-to-end encryption (E2EE)?") {
return false;
}
if (message === "Configure Matrix rooms access?") {
return false;
}
if (message === "Configure DM access policies now? (default: pairing)") {
return false;
}
if (message.startsWith("Matrix env vars detected")) {
return false;
}
throw new Error(`unexpected confirm prompt: ${message}`);
});
return createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text: text as unknown as WizardPrompter["text"],
confirm: confirm as unknown as WizardPrompter["confirm"],
note: vi.fn(async (message: unknown) => {
notes.push(String(message));
}),
});
}
function setMinimalOnboardingRegistryForTests(): void {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
capabilities: { chatTypes: ["direct", "group"] },
}),
setup: {
applyAccountConfig: ({
cfg,
input,
}: {
cfg: OpenClawConfig;
input: { token?: string };
}) =>
({
...cfg,
channels: {
...cfg.channels,
telegram: {
...(cfg.channels?.telegram as Record<string, unknown> | undefined),
...(input.token ? { botToken: input.token } : {}),
},
},
}) as OpenClawConfig,
},
setupWizard: {
channel: "telegram",
status: {
configuredLabel: "configured",
unconfiguredLabel: "not configured",
resolveConfigured: ({ cfg }: { cfg: OpenClawConfig }) =>
Boolean(cfg.channels?.telegram?.botToken),
},
credentials: [
{
inputKey: "token",
providerHint: "BotFather",
credentialLabel: "Telegram bot token",
envPrompt: "Use TELEGRAM_BOT_TOKEN from env?",
keepPrompt: "Keep current Telegram bot token?",
inputPrompt: "Enter Telegram bot token",
inspect: ({ cfg }: { cfg: OpenClawConfig }) => ({
accountConfigured: Boolean(cfg.channels?.telegram?.botToken),
hasConfiguredValue: Boolean(cfg.channels?.telegram?.botToken),
}),
},
],
},
},
},
{
pluginId: "whatsapp",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "whatsapp",
label: "WhatsApp",
capabilities: { chatTypes: ["direct", "group"] },
}),
setup: {
applyAccountConfig: ({
cfg,
input,
}: {
cfg: OpenClawConfig;
input: { account?: string; name?: string };
}) =>
({
...cfg,
channels: {
...cfg.channels,
whatsapp: {
...(cfg.channels?.whatsapp as Record<string, unknown> | undefined),
...(input.account ? { account: input.account } : {}),
...(input.name ? { name: input.name } : {}),
linked: false,
},
},
}) as OpenClawConfig,
},
setupWizard: {
channel: "whatsapp",
status: {
configuredLabel: "configured",
unconfiguredLabel: "not linked",
resolveConfigured: ({ cfg }: { cfg: OpenClawConfig }) =>
Boolean((cfg.channels?.whatsapp as { account?: string } | undefined)?.account),
resolveSelectionHint: async ({ cfg }: { cfg: OpenClawConfig }) =>
(cfg.channels?.whatsapp as { account?: string } | undefined)?.account
? "configured"
: "not linked",
},
credentials: [],
textInputs: [
{
inputKey: "account",
message: "Your personal WhatsApp number",
required: true,
applySet: ({ cfg, value }: { cfg: OpenClawConfig; value: string }) =>
({
...cfg,
channels: {
...cfg.channels,
whatsapp: {
...(cfg.channels?.whatsapp as Record<string, unknown> | undefined),
account: value,
},
},
}) as OpenClawConfig,
},
],
},
},
},
]),
);
}
type ChannelSetupWizardAdapterPatch = Partial<
Pick<
ChannelSetupWizardAdapter,
| "afterConfigWritten"
| "configure"
| "configureInteractive"
| "configureWhenConfigured"
| "getStatus"
>
>;
type PatchedSetupAdapterFields = {
afterConfigWritten?: ChannelSetupWizardAdapter["afterConfigWritten"];
configure?: ChannelSetupWizardAdapter["configure"];
configureInteractive?: ChannelSetupWizardAdapter["configureInteractive"];
configureWhenConfigured?: ChannelSetupWizardAdapter["configureWhenConfigured"];
getStatus?: ChannelSetupWizardAdapter["getStatus"];
};
function createMSTeamsPluginRegistryEntry(params?: { includeSetupWizard?: boolean }) {
return {
pluginId: "@openclaw/msteams-plugin",
source: "test",
plugin: {
id: "msteams",
meta: createMSTeamsCatalogEntry().meta,
capabilities: { chatTypes: ["direct"] as const },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
...(params?.includeSetupWizard
? {
setupWizard: {
channel: "msteams",
status: {
configuredLabel: "configured",
unconfiguredLabel: "installed",
resolveConfigured: () => false,
resolveStatusLines: async () => [],
resolveSelectionHint: async () => "installed",
},
credentials: [],
},
}
: {}),
outbound: { deliveryMode: "direct" as const },
},
};
}
function mockMSTeamsRegistrySnapshot(params?: { includeSetupWizard?: boolean }) {
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation(
({ channel }: { channel: string }) => {
const registry = createEmptyPluginRegistry();
if (channel === "msteams") {
if (params?.includeSetupWizard) {
registry.channelSetups.push(createMSTeamsPluginRegistryEntry(params) as never);
} else {
registry.channels.push(createMSTeamsPluginRegistryEntry(params) as never);
}
}
return registry;
},
);
}
function patchTelegramAdapter(overrides: ChannelSetupWizardAdapterPatch) {
const adapter = getChannelSetupWizardAdapter("telegram");
if (!adapter) {
throw new Error("missing setup adapter for telegram");
}
const patch = {
...overrides,
getStatus:
overrides.getStatus ??
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
};
const previous: PatchedSetupAdapterFields = {};
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
previous.getStatus = adapter.getStatus;
adapter.getStatus = patch.getStatus ?? adapter.getStatus;
}
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
previous.afterConfigWritten = adapter.afterConfigWritten;
adapter.afterConfigWritten = patch.afterConfigWritten;
}
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
previous.configure = adapter.configure;
adapter.configure = patch.configure ?? adapter.configure;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureInteractive")) {
previous.configureInteractive = adapter.configureInteractive;
adapter.configureInteractive = patch.configureInteractive;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureWhenConfigured")) {
previous.configureWhenConfigured = adapter.configureWhenConfigured;
adapter.configureWhenConfigured = patch.configureWhenConfigured;
}
return () => {
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
adapter.getStatus = previous.getStatus!;
}
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
adapter.afterConfigWritten = previous.afterConfigWritten;
}
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
adapter.configure = previous.configure!;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureInteractive")) {
adapter.configureInteractive = previous.configureInteractive;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureWhenConfigured")) {
adapter.configureWhenConfigured = previous.configureWhenConfigured;
}
};
}
function createUnexpectedConfigureCall(message: string) {
return vi.fn(async () => {
throw new Error(message);
});
}
async function expectQuickstartPickerSkipsWithoutRuntime() {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "__skip__";
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
await expect(
runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
}),
).resolves.toEqual({} as OpenClawConfig);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select channel (QuickStart)" }),
);
expect(multiselect).not.toHaveBeenCalled();
}
async function runConfiguredTelegramSetup(params: {
strictUnexpected?: boolean;
configureWhenConfigured: NonNullable<
Parameters<typeof patchTelegramAdapter>[0]["configureWhenConfigured"]
>;
configureErrorMessage: string;
}) {
const select = createQuickstartTelegramSelect({ strictUnexpected: params.strictUnexpected });
const selection = vi.fn();
const onAccountId = vi.fn();
const configure = createUnexpectedConfigureCall(params.configureErrorMessage);
const restore = patchTelegramAdapter({
configureInteractive: undefined,
configureWhenConfigured: params.configureWhenConfigured,
configure,
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try {
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
return { cfg, selection, onAccountId, configure };
} finally {
restore();
}
}
async function runQuickstartTelegramSetupWithInteractive(params: {
configureInteractive: NonNullable<
Parameters<typeof patchTelegramAdapter>[0]["configureInteractive"]
>;
configure?: NonNullable<Parameters<typeof patchTelegramAdapter>[0]["configure"]>;
}) {
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const restore = patchTelegramAdapter({
configureInteractive: params.configureInteractive,
...(params.configure ? { configure: params.configure } : {}),
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try {
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
return { cfg, selection, onAccountId };
} finally {
restore();
}
}
vi.mock("node:fs/promises", () => ({
default: {
access: vi.fn(async () => {
throw new Error("ENOENT");
}),
},
}));
vi.mock("../channel-web.js", () => ({
loginWeb: vi.fn(async () => {}),
}));
vi.mock("../channels/plugins/catalog.js", async () => {
const actual = await vi.importActual<typeof import("../channels/plugins/catalog.js")>(
"../channels/plugins/catalog.js",
);
return {
...actual,
listChannelPluginCatalogEntries: ((...args) => {
const implementation = catalogMocks.listChannelPluginCatalogEntries.getMockImplementation();
if (implementation) {
return catalogMocks.listChannelPluginCatalogEntries(...args);
}
return actual.listChannelPluginCatalogEntries(...args);
}) as typeof actual.listChannelPluginCatalogEntries,
};
});
vi.mock("../plugins/manifest-registry.js", async () => {
const actual = await vi.importActual<typeof import("../plugins/manifest-registry.js")>(
"../plugins/manifest-registry.js",
);
return {
...actual,
loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry,
};
});
vi.mock("../../extensions/matrix/src/matrix/deps.js", () => ({
ensureMatrixSdkInstalled: vi.fn(async () => {}),
isMatrixSdkAvailable: vi.fn(() => true),
}));
vi.mock("./onboard-helpers.js", () => ({
detectBinary: vi.fn(async () => false),
}));
vi.mock("./channel-setup/plugin-install.js", async () => {
const actual = await vi.importActual("./channel-setup/plugin-install.js");
return {
...(actual as Record<string, unknown>),
ensureChannelSetupPluginInstalled: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg,
installed: true,
})),
// Allow tests to simulate an empty plugin registry during setup.
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()),
reloadChannelSetupPluginRegistry: vi.fn(() => {}),
};
});
describe("setupChannels", () => {
beforeEach(() => {
setMinimalOnboardingRegistryForTests();
catalogMocks.listChannelPluginCatalogEntries.mockReset();
manifestRegistryMocks.loadPluginManifestRegistry.mockReset();
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [],
diagnostics: [],
});
vi.mocked(ensureChannelSetupPluginInstalled).mockClear();
vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({
cfg,
installed: true,
}));
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear();
vi.mocked(reloadChannelSetupPluginRegistry).mockClear();
});
it("QuickStart uses single-select (no multiselect) and doesn't prompt for Telegram token when WhatsApp is chosen", async () => {
const select = vi.fn(async () => "whatsapp");
const multiselect = vi.fn(async () => {
throw new Error("unexpected multiselect");
});
const text = vi.fn(async ({ message }: { message: string }) => {
if (message.includes("Enter Telegram bot token")) {
throw new Error("unexpected Telegram token prompt");
}
if (message.includes("Your personal WhatsApp number")) {
return "+15555550123";
}
throw new Error(`unexpected text prompt: ${message}`);
});
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text: text as unknown as WizardPrompter["text"],
});
await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
forceAllowFromChannels: ["whatsapp"],
});
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select channel (QuickStart)" }),
);
expect(multiselect).not.toHaveBeenCalled();
});
it("renders the QuickStart channel picker without requiring the LINE runtime", async () => {
await expectQuickstartPickerSkipsWithoutRuntime();
});
it("runs Matrix guided setup through setupChannels without falling back", async () => {
await withClearedMatrixSetupEnv(async () => {
await setMatrixOnboardingRegistryForTests();
const notes: string[] = [];
const prompter = createMatrixQuickstartPrompter(notes);
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
});
expect(cfg.channels?.matrix).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "matrix-token",
deviceName: "OpenClaw Gateway",
encryption: false,
});
expect(notes.join("\n")).not.toContain("matrix does not support guided setup yet.");
});
});
it("renders the QuickStart channel picker without requiring the Matrix runtime", async () => {
await expectQuickstartPickerSkipsWithoutRuntime();
});
it("continues Telegram setup when the plugin registry is empty", async () => {
// Simulate missing registry entries (the scenario reported in #25545).
setActivePluginRegistry(createEmptyPluginRegistry());
// Avoid accidental env-token configuration changing the prompt path.
process.env.TELEGRAM_BOT_TOKEN = "";
const note = vi.fn(async (_message?: string, _title?: string) => {});
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const text = vi.fn(async () => "123:token");
const prompter = createPrompter({
note,
select: select as unknown as WizardPrompter["select"],
text: text as unknown as WizardPrompter["text"],
});
await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
});
// The new flow should not stop setup with a hard "plugin not available" note.
const sawHardStop = note.mock.calls.some((call) => {
const message = call[0];
const title = call[1];
return (
title === "Channel setup" && String(message).trim() === "telegram plugin not available."
);
});
expect(sawHardStop).toBe(false);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
pluginId: "telegram",
}),
);
expect(reloadChannelSetupPluginRegistry).not.toHaveBeenCalled();
});
it("shows explicit dmScope config command in channel primer", async () => {
const note = vi.fn(async (_message?: string, _title?: string) => {});
const select = vi.fn(async () => "__done__");
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
note,
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
await runSetupChannels({} as OpenClawConfig, prompter);
const sawPrimer = note.mock.calls.some(
([message, title]) =>
title === "How channels work" &&
String(message).includes('config set session.dmScope "per-channel-peer"'),
);
expect(sawPrimer).toBe(true);
expect(multiselect).not.toHaveBeenCalled();
});
it("keeps configured external plugin channels visible when the active registry starts empty", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);
mockMSTeamsRegistrySnapshot();
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
const entries = options as Array<{ value: string; hint?: string }>;
const msteams = entries.find((entry) => entry.value === "msteams");
expect(msteams).toBeDefined();
expect(msteams?.hint ?? "").not.toContain("plugin");
expect(msteams?.hint ?? "").not.toContain("install");
return "__done__";
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
await runSetupChannels(
{
channels: {
msteams: {
tenantId: "tenant-1",
},
},
plugins: {
entries: {
"@openclaw/msteams-plugin": { enabled: true },
},
},
} as OpenClawConfig,
prompter,
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "msteams",
pluginId: "@openclaw/msteams-plugin",
}),
);
expect(multiselect).not.toHaveBeenCalled();
});
it("treats installed external plugin channels as installed without reinstall prompts", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "@openclaw/msteams-plugin",
channels: ["msteams"],
} as never,
],
diagnostics: [],
});
mockMSTeamsRegistrySnapshot({ includeSetupWizard: true });
let channelSelectionCount = 0;
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select a channel") {
channelSelectionCount += 1;
return channelSelectionCount === 1 ? "msteams" : "__done__";
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
await runSetupChannels({} as OpenClawConfig, prompter);
expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled();
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "msteams",
pluginId: "@openclaw/msteams-plugin",
}),
);
expect(multiselect).not.toHaveBeenCalled();
});
it("uses scoped plugin accounts when disabling a configured external channel", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
const setAccountEnabled = vi.fn(
({
cfg,
accountId,
enabled,
}: {
cfg: OpenClawConfig;
accountId: string;
enabled: boolean;
}) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...(cfg.channels?.msteams as Record<string, unknown> | undefined),
accounts: {
...(cfg.channels?.msteams as { accounts?: Record<string, unknown> } | undefined)
?.accounts,
[accountId]: {
...(
cfg.channels?.msteams as
| {
accounts?: Record<string, Record<string, unknown>>;
}
| undefined
)?.accounts?.[accountId],
enabled,
},
},
},
},
}),
);
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation(
({ channel }: { channel: string }) => {
const registry = createEmptyPluginRegistry();
if (channel === "msteams") {
registry.channels.push({
pluginId: "msteams",
source: "test",
plugin: {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg: OpenClawConfig) =>
Object.keys(
(cfg.channels?.msteams as { accounts?: Record<string, unknown> } | undefined)
?.accounts ?? {},
),
resolveAccount: (cfg: OpenClawConfig, accountId: string) =>
(
cfg.channels?.msteams as
| {
accounts?: Record<string, Record<string, unknown>>;
}
| undefined
)?.accounts?.[accountId] ?? { accountId },
setAccountEnabled,
},
setupWizard: {
channel: "msteams",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: ({ cfg }: { cfg: OpenClawConfig }) =>
Boolean((cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId),
resolveStatusLines: async () => [],
resolveSelectionHint: async () => "configured",
},
credentials: [],
},
outbound: { deliveryMode: "direct" },
},
} as never);
}
return registry;
},
);
let channelSelectionCount = 0;
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
channelSelectionCount += 1;
return channelSelectionCount === 1 ? "msteams" : "__done__";
}
if (message.includes("already configured")) {
return "disable";
}
if (message === "Microsoft Teams account") {
const accountOptions = options as Array<{ value: string; label: string }>;
expect(accountOptions.map((option) => option.value)).toEqual(["default", "work"]);
return "work";
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const next = await runSetupChannels(
{
channels: {
msteams: {
tenantId: "tenant-1",
accounts: {
default: { enabled: true },
work: { enabled: true },
},
},
},
plugins: {
entries: {
msteams: { enabled: true },
},
},
} as OpenClawConfig,
prompter,
{ allowDisable: true },
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({ channel: "msteams" }),
);
expect(setAccountEnabled).toHaveBeenCalledWith(
expect.objectContaining({ accountId: "work", enabled: false }),
);
expect(
(
next.channels?.msteams as
| {
accounts?: Record<string, { enabled?: boolean }>;
}
| undefined
)?.accounts?.work?.enabled,
).toBe(false);
expect(multiselect).not.toHaveBeenCalled();
});
it("prompts for configured channel action and skips configuration when told to skip", async () => {
const select = createQuickstartTelegramSelect({
configuredAction: "skip",
strictUnexpected: true,
});
const { prompter, multiselect, text } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
await runSetupChannels(createTelegramCfg("token"), prompter, {
quickstartDefaults: true,
});
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select channel (QuickStart)" }),
);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("already configured") }),
);
expect(multiselect).not.toHaveBeenCalled();
expect(text).not.toHaveBeenCalled();
});
it("adds disabled hint to channel selection when a channel is disabled", async () => {
let selectionCount = 0;
const select = vi.fn(async ({ message }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
selectionCount += 1;
return selectionCount === 1 ? "telegram" : "__done__";
}
if (message.includes("already configured")) {
return "skip";
}
return "__done__";
});
const multiselect = vi.fn(async () => {
throw new Error("unexpected multiselect");
});
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
});
await runSetupChannels(createTelegramCfg("token", false), prompter);
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
const channelSelectCall = select.mock.calls.find(
([params]) => (params as { message?: string }).message === "Select a channel",
);
const telegramOption = (
channelSelectCall?.[0] as { options?: Array<{ value: string; hint?: string }> } | undefined
)?.options?.find((opt) => opt.value === "telegram");
expect(telegramOption?.hint).toContain("disabled");
expect(multiselect).not.toHaveBeenCalled();
});
it("uses configureInteractive skip without mutating selection/account state", async () => {
const configureInteractive = vi.fn(async () => "skip" as const);
const { cfg, selection, onAccountId } = await runQuickstartTelegramSetupWithInteractive({
configureInteractive,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: false, label: expect.any(String) }),
);
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
});
it("applies configureInteractive result cfg/account updates", async () => {
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, botToken: "new-token" },
},
} as OpenClawConfig,
accountId: "acct-1",
}));
const configure = createUnexpectedConfigureCall(
"configure should not be called when configureInteractive is present",
);
const { cfg, selection, onAccountId } = await runQuickstartTelegramSetupWithInteractive({
configureInteractive,
configure,
});
expect(configureInteractive).toHaveBeenCalledTimes(1);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1");
expect(cfg.channels?.telegram?.botToken).toBe("new-token");
});
it("uses configureWhenConfigured when channel is already configured", async () => {
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, botToken: "updated-token" },
},
} as OpenClawConfig,
accountId: "acct-2",
}));
const { cfg, selection, onAccountId, configure } = await runConfiguredTelegramSetup({
configureWhenConfigured,
configureErrorMessage:
"configure should not be called when configureWhenConfigured handles updates",
});
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2");
expect(cfg.channels?.telegram?.botToken).toBe("updated-token");
});
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
const configureWhenConfigured = vi.fn(async () => "skip" as const);
const { cfg, selection, onAccountId, configure } = await runConfiguredTelegramSetup({
strictUnexpected: true,
configureWhenConfigured,
configureErrorMessage: "configure should not run when configureWhenConfigured handles skip",
});
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBe("old-token");
});
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const);
const configureWhenConfigured = vi.fn(async () => {
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
});
const restore = patchTelegramAdapter({
configureInteractive,
configureWhenConfigured,
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try {
await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configureWhenConfigured).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
} finally {
restore();
}
});
});