refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts * refactor(channels): isolate management contracts * refactor(channels): normalize activation contracts * fix(channels): enforce management contracts * refactor(channels): finish setup ownership migration * fix(channels): harden management contracts * fix(channels): enforce lazy loading and runtime ownership * fix(feishu): make multi-instance startup idempotent * fix(webui): render channel setup contracts cleanly * fix(feishu): stop websocket clients cleanly * fix(channels): enforce persistence and activation gates * fix(channels): preserve global feature action scope * fix(channels): apply defaults for single plugins * fix(channels): enforce management contract boundaries * refactor(feishu): remove identity helper indirection * fix(channels): preserve management setup contracts * refactor(channels): generalize instance settings UI * refactor(channels): package channel plugins with web UI metadata * refactor(channels): make built-ins self-contained packages * test(channels): colocate tests with channel packages * fix(dingtalk): use official brand icon * feat(channels): colocate webui translations * docs(channels): clarify plugin ownership * test(exec): remove output wait race * refactor(channels): unify plugin descriptors * fix(channels): enforce descriptor-owned contracts * refactor(channels): finish package-owned plugin setup * refactor(channels): use repository-owned packages only * fix(channels): self-describe dependencies and runtime state * fix(channels): warn about legacy entry points
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
|
||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||
|
||||
const SLACK_SOCKET_MODE_MANIFEST = channelUiPresentation("slack")?.setup?.actions?.find(
|
||||
(action) => action.id === "slack-manifest",
|
||||
)?.copyText;
|
||||
|
||||
describe("Slack setup manifest", () => {
|
||||
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
channelIsRunning,
|
||||
channelSetup,
|
||||
channelStatusLabel,
|
||||
channelToggleChecked,
|
||||
} from "@/components/settings/channels/ChannelIdentity";
|
||||
import type { NanobotFeatureInfo } from "@/lib/types";
|
||||
|
||||
function feature(overrides: Partial<NanobotFeatureInfo>): NanobotFeatureInfo {
|
||||
return {
|
||||
name: "plugin-chat",
|
||||
display_name: "Plugin Chat",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
installed: true,
|
||||
ready: false,
|
||||
status: "not_enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("channelSetup", () => {
|
||||
it("builds editable fields for a plugin-owned backend contract", () => {
|
||||
const setup = channelSetup(feature({
|
||||
setup: {
|
||||
fields: [
|
||||
{
|
||||
key: "channels.plugin-chat.apiToken",
|
||||
field: "apiToken",
|
||||
kind: "secret",
|
||||
choices: [],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "channels.plugin-chat.region",
|
||||
field: "region",
|
||||
kind: "enum",
|
||||
choices: ["us", "eu"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
official_url: "https://plugin.example/setup",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(setup.officialUrl).toBe("https://plugin.example/setup");
|
||||
expect(setup.officialLabel).toBe("Open official setup");
|
||||
expect(setup.fields).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "channels.plugin-chat.apiToken",
|
||||
label: "Api Token",
|
||||
secret: true,
|
||||
optional: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "channels.plugin-chat.region",
|
||||
options: [
|
||||
{ value: "us", label: "Us" },
|
||||
{ value: "eu", label: "Eu" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters catalog-only fields that the backend does not accept", () => {
|
||||
const setup = channelSetup(feature({
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
setup: {
|
||||
fields: [{
|
||||
key: "channels.discord.token",
|
||||
field: "token",
|
||||
kind: "secret",
|
||||
choices: [],
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(setup.fields?.map((field) => field.key)).toEqual(["channels.discord.token"]);
|
||||
expect(setup.manualFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses backend defaults and choices with catalog presentation labels", () => {
|
||||
const setup = channelSetup(feature({
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
setup: {
|
||||
fields: [{
|
||||
key: "channels.discord.groupPolicy",
|
||||
field: "groupPolicy",
|
||||
kind: "enum",
|
||||
choices: ["open"],
|
||||
required: false,
|
||||
default_value: "open",
|
||||
}],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(setup.fields).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "channels.discord.groupPolicy",
|
||||
label: "Group behavior",
|
||||
defaultValue: "open",
|
||||
options: [{ value: "open", label: "All messages" }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads setup copy from the channel-owned locale", () => {
|
||||
const setup = channelSetup(feature({
|
||||
name: "dingtalk",
|
||||
display_name: "DingTalk",
|
||||
webui: "webui/index.ts",
|
||||
}), "zh-CN");
|
||||
|
||||
expect(setup.summary).toBe("钉钉需要 Stream 模式的应用凭据。");
|
||||
expect(setup.steps[0]).toBe("创建或选择一个已启用 Stream 模式的钉钉应用。");
|
||||
expect(setup.fields).toContainEqual(expect.objectContaining({
|
||||
key: "channels.dingtalk.allowFrom",
|
||||
label: "允许的用户",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel runtime state", () => {
|
||||
const tx = (_key: string, fallback: string) => fallback;
|
||||
|
||||
it("only reports a channel on when the runtime is explicitly running", () => {
|
||||
const running = feature({ enabled: true, runtime_status: "running" });
|
||||
const unknown = feature({ enabled: true });
|
||||
|
||||
expect(channelIsRunning(running)).toBe(true);
|
||||
expect(channelToggleChecked(running)).toBe(true);
|
||||
expect(channelStatusLabel(running, tx)).toBe("On");
|
||||
expect(channelIsRunning(unknown)).toBe(false);
|
||||
expect(channelToggleChecked(unknown)).toBe(false);
|
||||
expect(channelStatusLabel(unknown, tx)).toBe("Not running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { channelFieldMessageKey } from "@/channel-plugins/i18n";
|
||||
import { registeredChannelLocales } from "@/channel-plugins/locale-registry";
|
||||
import { registeredChannelUiContributions } from "@/channel-plugins/registry";
|
||||
import { supportedLocales } from "@/i18n/config";
|
||||
|
||||
const expectedChannels = [
|
||||
"dingtalk",
|
||||
"discord",
|
||||
"email",
|
||||
"feishu",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"napcat",
|
||||
"qq",
|
||||
"signal",
|
||||
"slack",
|
||||
"telegram",
|
||||
"websocket",
|
||||
"wecom",
|
||||
"weixin",
|
||||
"whatsapp",
|
||||
];
|
||||
|
||||
function flatten(value: unknown, prefix = ""): Map<string, string> {
|
||||
const entries = new Map<string, string>();
|
||||
if (typeof value === "string") {
|
||||
entries.set(prefix, value);
|
||||
return entries;
|
||||
}
|
||||
if (!value || typeof value !== "object") return entries;
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!prefix && key === "displayName") continue;
|
||||
const childPrefix = prefix ? `${prefix}.${key}` : key;
|
||||
for (const [childKey, text] of flatten(child, childPrefix)) {
|
||||
entries.set(childKey, text);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function interpolationKeys(value: string): string[] {
|
||||
return [...value.matchAll(/{{\s*([\w.-]+)\s*}}/g)]
|
||||
.map((match) => match[1])
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe("channel locale registry", () => {
|
||||
it("loads every supported locale from every built-in channel package", () => {
|
||||
const registrations = registeredChannelLocales();
|
||||
expect([...registrations.keys()].sort()).toEqual(expectedChannels);
|
||||
|
||||
for (const [channel, locales] of registrations) {
|
||||
expect([...locales.keys()].sort()).toEqual(
|
||||
supportedLocales.map(({ code }) => code).sort(),
|
||||
);
|
||||
|
||||
const english = flatten(locales.get("en"));
|
||||
for (const [locale, messages] of locales) {
|
||||
const translated = flatten(messages);
|
||||
expect([...translated.keys()].sort(), `${channel}/${locale} message keys`).toEqual(
|
||||
[...english.keys()].sort(),
|
||||
);
|
||||
for (const [key, source] of english) {
|
||||
expect(
|
||||
interpolationKeys(translated.get(key) ?? ""),
|
||||
`${channel}/${locale}:${key} interpolation keys`,
|
||||
).toEqual(interpolationKeys(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps structural UI definitions aligned with English locale keys", () => {
|
||||
const locales = registeredChannelLocales();
|
||||
for (const { channel, contribution } of registeredChannelUiContributions()) {
|
||||
const messages = locales.get(channel)?.get("en");
|
||||
expect(messages, `${channel} English messages`).toBeDefined();
|
||||
|
||||
const setup = contribution.presentation.setup;
|
||||
for (const field of [...(setup?.fields ?? []), ...(setup?.manualFields ?? [])]) {
|
||||
const messageKey = channelFieldMessageKey(channel, field.key);
|
||||
expect(messages?.setup.fields?.[messageKey], `${channel} field ${messageKey}`).toBeDefined();
|
||||
}
|
||||
for (const action of setup?.actions ?? []) {
|
||||
expect(messages?.setup.actions?.[action.id], `${channel} action ${action.id}`).toBeTypeOf("string");
|
||||
}
|
||||
for (const preset of setup?.presets ?? []) {
|
||||
expect(messages?.setup.presets?.[preset.id], `${channel} preset ${preset.id}`).toBeTypeOf("string");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps i18n initialization independent from channel React modules", () => {
|
||||
const localeRegistry = readFileSync(
|
||||
resolve(process.cwd(), "src/channel-plugins/locale-registry.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8");
|
||||
|
||||
expect(localeRegistry).toContain("webui/locales/*.json");
|
||||
expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i);
|
||||
expect(i18nEntry).toContain("channel-plugins/locale-registry");
|
||||
expect(i18nEntry).not.toContain("channel-plugins/registry");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
channelUiContribution,
|
||||
channelUiOwner,
|
||||
channelUiPresentation,
|
||||
registeredChannelUiContributions,
|
||||
} from "@/channel-plugins/registry";
|
||||
|
||||
describe("channel UI contributions", () => {
|
||||
it("selects channel-owned UI only through the backend manifest entry", () => {
|
||||
expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function");
|
||||
expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function");
|
||||
expect(channelUiContribution("feishu", undefined)).toBeUndefined();
|
||||
expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined();
|
||||
expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined();
|
||||
|
||||
const registrations = registeredChannelUiContributions();
|
||||
const channels = registrations.map((entry) => entry.channel);
|
||||
expect(channels).toEqual(expect.arrayContaining(["feishu", "weixin"]));
|
||||
expect(new Set(channels).size).toBe(channels.length);
|
||||
expect(registrations.every((entry) => /^webui\/index\.tsx?$/.test(entry.webui))).toBe(true);
|
||||
expect(channelUiContribution("slack", "webui/index.ts")?.presentation.displayName).toBe("Slack");
|
||||
});
|
||||
|
||||
it("keeps aliases inside the owning channel contribution", () => {
|
||||
expect(channelUiPresentation("lark")?.displayName).toBe("Lark");
|
||||
expect(channelUiPresentation("wechat")?.displayName).toBe("WeChat");
|
||||
expect(channelUiOwner("lark")).toBe("feishu");
|
||||
expect(channelUiOwner("wechat")).toBe("weixin");
|
||||
});
|
||||
|
||||
it("uses the DingTalk Open Platform brand mark", () => {
|
||||
expect(channelUiPresentation("dingtalk")?.logoUrl).toBe(
|
||||
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the core setup panel independent of concrete channel plugins", () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "src/components/settings/channels/ChannelSetupPanel.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toMatch(/feature\.name\s*===\s*["'](?:feishu|weixin)["']/);
|
||||
expect(source).not.toMatch(/channel-plugins\/(?:feishu|weixin)/);
|
||||
expect(source).not.toMatch(/(?:Feishu|Weixin)(?:AssistantsPanel|ConnectFlow)/);
|
||||
});
|
||||
|
||||
it("discovers UI contributions only from channel-owned packages", () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "src/channel-plugins/registry.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("../../../nanobot/channels/*/webui/**/*.{ts,tsx}");
|
||||
expect(source).not.toContain('"./*/index.tsx"');
|
||||
});
|
||||
|
||||
it("derives channel identity from the package directory", () => {
|
||||
for (const channel of ["feishu", "weixin"]) {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), `../nanobot/channels/${channel}/webui/index.tsx`),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toMatch(/\bchannel\s*:/);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes channel-owned UI in Tailwind's production scan", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "tailwind.config.js"), "utf8");
|
||||
|
||||
expect(source).toContain("../nanobot/channels/*/webui/**/*.{ts,tsx}");
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,48 @@ const LOCALIZED_WORKSPACE_COPY_KEYS = [
|
||||
"workspace.dialog.usePath",
|
||||
"workspace.dialog.absolutePathRequired",
|
||||
];
|
||||
const LOCALIZED_CHANNEL_SHELL_KEYS = [
|
||||
"settings.channels.advanced",
|
||||
"settings.channels.checkAndEnable",
|
||||
"settings.channels.checkConnection",
|
||||
"settings.channels.checkedAndEnabled",
|
||||
"settings.channels.checking",
|
||||
"settings.channels.checkOnly",
|
||||
"settings.channels.commandCopied",
|
||||
"settings.channels.commandCopyFailed",
|
||||
"settings.channels.configuredInstances",
|
||||
"settings.channels.connectPreview",
|
||||
"settings.channels.copyCommand",
|
||||
"settings.channels.filterAll",
|
||||
"settings.channels.filterOff",
|
||||
"settings.channels.filterOn",
|
||||
"settings.channels.helperCopied",
|
||||
"settings.channels.helperCopyFailed",
|
||||
"settings.channels.hideSecret",
|
||||
"settings.channels.instanceConfigured",
|
||||
"settings.channels.instanceNeedsSetup",
|
||||
"settings.channels.managedByWebui",
|
||||
"settings.channels.officialGuide",
|
||||
"settings.channels.optional",
|
||||
"settings.channels.providerPreset",
|
||||
"settings.channels.requiredSetup",
|
||||
"settings.channels.savedSecret",
|
||||
"settings.channels.savedSecretPlaceholder",
|
||||
"settings.channels.savedSettings",
|
||||
"settings.channels.saveSettings",
|
||||
"settings.channels.selectChannel",
|
||||
"settings.channels.setupSteps",
|
||||
"settings.channels.showSecret",
|
||||
"settings.channels.toggleChannel",
|
||||
"settings.channels.toggleInstance",
|
||||
"settings.channels.tryIt",
|
||||
"settings.channels.validation.connected",
|
||||
"settings.channels.validation.configured",
|
||||
"settings.channels.validation.invalid",
|
||||
"settings.channels.validation.needs_setup",
|
||||
"settings.channels.validation.unsupported",
|
||||
"settings.channels.validationFailed",
|
||||
];
|
||||
const INDEX_HTML = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
|
||||
const PREBOOT_SCRIPT = INDEX_HTML.match(
|
||||
/<script>\s*(\(function \(\) \{\s*var localeKey = "nanobot\.locale";[\s\S]*?\}\)\(\);)\s*<\/script>/,
|
||||
@@ -375,7 +417,11 @@ describe("webui i18n", () => {
|
||||
for (const [locale, resource] of Object.entries(resources)) {
|
||||
if (locale === "en") continue;
|
||||
const current = flattenResource(resource.common);
|
||||
const leaked = [...LOCALIZED_SETTINGS_COPY_KEYS, ...LOCALIZED_WORKSPACE_COPY_KEYS].filter(
|
||||
const leaked = [
|
||||
...LOCALIZED_SETTINGS_COPY_KEYS,
|
||||
...LOCALIZED_WORKSPACE_COPY_KEYS,
|
||||
...LOCALIZED_CHANNEL_SHELL_KEYS,
|
||||
].filter(
|
||||
(key) => current.get(key) === english.get(key),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
import type {
|
||||
ChannelSetupContract,
|
||||
ChannelSetupContractField,
|
||||
SettingsPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
@@ -120,6 +124,118 @@ function settingsPayload(): SettingsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function channelSetupField(
|
||||
channel: string,
|
||||
field: string,
|
||||
kind: ChannelSetupContractField["kind"] = "string",
|
||||
options: {
|
||||
required?: boolean;
|
||||
choices?: string[];
|
||||
defaultValue?: string;
|
||||
} = {},
|
||||
): ChannelSetupContractField {
|
||||
return {
|
||||
key: `channels.${channel}.${field}`,
|
||||
field,
|
||||
kind,
|
||||
choices: options.choices ?? [],
|
||||
required: options.required ?? false,
|
||||
...(options.defaultValue === undefined ? {} : { default_value: options.defaultValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function channelSetupContract(
|
||||
channel: "discord" | "email" | "feishu" | "matrix" | "qq",
|
||||
): ChannelSetupContract {
|
||||
const field = (
|
||||
name: string,
|
||||
kind: ChannelSetupContractField["kind"] = "string",
|
||||
options: Parameters<typeof channelSetupField>[3] = {},
|
||||
) => channelSetupField(channel, name, kind, options);
|
||||
|
||||
switch (channel) {
|
||||
case "discord":
|
||||
return {
|
||||
official_url: "https://discord.com/developers/applications",
|
||||
fields: [
|
||||
field("token", "secret", { required: true }),
|
||||
field("allowFrom", "list"),
|
||||
field("allowChannels", "list"),
|
||||
field("groupPolicy", "enum", {
|
||||
choices: ["mention", "open"],
|
||||
defaultValue: "mention",
|
||||
}),
|
||||
],
|
||||
};
|
||||
case "email":
|
||||
return {
|
||||
official_url: "https://support.google.com/accounts/answer/185833",
|
||||
fields: [
|
||||
field("consentGranted", "bool", { required: true, defaultValue: "false" }),
|
||||
field("imapHost", "string", { required: true }),
|
||||
field("imapPort", "int"),
|
||||
field("imapUsername", "string", { required: true }),
|
||||
field("imapPassword", "secret", { required: true }),
|
||||
field("smtpHost", "string", { required: true }),
|
||||
field("smtpPort", "int"),
|
||||
field("smtpUsername", "string", { required: true }),
|
||||
field("smtpPassword", "secret", { required: true }),
|
||||
field("fromAddress"),
|
||||
field("pollIntervalSeconds", "int"),
|
||||
field("allowFrom", "list"),
|
||||
field("verifyDkim", "bool", { defaultValue: "true" }),
|
||||
field("verifySpf", "bool", { defaultValue: "true" }),
|
||||
],
|
||||
};
|
||||
case "feishu":
|
||||
return {
|
||||
official_url: "https://open.feishu.cn/app",
|
||||
fields: [
|
||||
field("appId", "string", { required: true }),
|
||||
field("appSecret", "secret", { required: true }),
|
||||
field("domain", "enum", {
|
||||
choices: ["feishu", "lark"],
|
||||
defaultValue: "feishu",
|
||||
}),
|
||||
field("groupPolicy", "enum", {
|
||||
choices: ["mention", "open"],
|
||||
defaultValue: "mention",
|
||||
}),
|
||||
field("allowFrom", "list"),
|
||||
field("topicIsolation", "bool"),
|
||||
],
|
||||
};
|
||||
case "matrix":
|
||||
return {
|
||||
official_url: "https://matrix.org/ecosystem/clients/",
|
||||
fields: [
|
||||
field("homeserver", "string", { required: true }),
|
||||
field("userId", "string", { required: true }),
|
||||
field("password", "secret"),
|
||||
field("accessToken", "secret"),
|
||||
field("deviceId"),
|
||||
field("groupPolicy", "enum", {
|
||||
choices: ["allowlist", "mention", "open"],
|
||||
defaultValue: "open",
|
||||
}),
|
||||
],
|
||||
};
|
||||
case "qq":
|
||||
return {
|
||||
official_url: "https://q.qq.com/",
|
||||
fields: [
|
||||
field("appId", "string", { required: true }),
|
||||
field("secret", "secret", { required: true }),
|
||||
field("allowFrom", "list"),
|
||||
field("msgFormat", "enum", {
|
||||
choices: ["markdown", "plain"],
|
||||
defaultValue: "plain",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function autoDynamicProviderPayload(
|
||||
options: {
|
||||
configured: boolean;
|
||||
@@ -417,6 +533,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "matrix",
|
||||
display_name: "Matrix",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
installed: false,
|
||||
@@ -433,8 +550,11 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "matrix",
|
||||
display_name: "Matrix",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
@@ -450,6 +570,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "matrix",
|
||||
display_name: "Matrix",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
installed: true,
|
||||
@@ -519,7 +640,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows enabled nanobot channels with missing support as enabled", async () => {
|
||||
it("shows an enabled channel with missing support as failed", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
@@ -532,6 +653,9 @@ describe("SettingsView Apps catalog", () => {
|
||||
display_name: "Matrix",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
running: false,
|
||||
runtime_status: "failed",
|
||||
runtime_error: "Channel dependencies could not be installed. Check gateway logs.",
|
||||
installed: false,
|
||||
ready: false,
|
||||
status: "missing_dependency",
|
||||
@@ -565,11 +689,11 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView({ initialSection: "channels" });
|
||||
|
||||
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 enabled · 1 channels")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("On").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
|
||||
|
||||
@@ -583,6 +707,51 @@ describe("SettingsView Apps catalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a configured channel as failed when its runtime did not start", async () => {
|
||||
const runtimeError = "Channel failed to start. Check gateway logs.";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
|
||||
if (url === "/api/settings/nanobot-features") {
|
||||
return jsonResponse({
|
||||
features: [{
|
||||
name: "matrix",
|
||||
display_name: "Matrix",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
installed: true,
|
||||
ready: false,
|
||||
running: false,
|
||||
runtime_status: "failed",
|
||||
runtime_error: runtimeError,
|
||||
status: "failed",
|
||||
install_supported: true,
|
||||
requires_restart: false,
|
||||
}],
|
||||
enabled_count: 0,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "channels" });
|
||||
|
||||
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
|
||||
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(runtimeError)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts Feishu connect in WebUI instead of showing a CLI command", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
@@ -594,6 +763,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
@@ -651,6 +821,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
@@ -706,6 +877,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
configured: true,
|
||||
@@ -723,16 +895,24 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
instances: [{
|
||||
id: "default",
|
||||
name: "nanobot",
|
||||
domain: "feishu",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
app_id: "cli_test",
|
||||
config_values: { "channels.feishu.appId": "cli_test" },
|
||||
configured_fields: [
|
||||
"channels.feishu.appId",
|
||||
"channels.feishu.appSecret",
|
||||
],
|
||||
}],
|
||||
installed: true,
|
||||
ready: true,
|
||||
@@ -787,6 +967,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
@@ -795,26 +976,42 @@ describe("SettingsView Apps catalog", () => {
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
setup: channelSetupContract("feishu"),
|
||||
instances: [
|
||||
{
|
||||
id: "default",
|
||||
name: "nanobot",
|
||||
display_name: "Support Bot",
|
||||
avatar_url: "https://example.com/support.png",
|
||||
domain: "feishu",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
app_id: "cli_default",
|
||||
config_values: {
|
||||
"channels.feishu.appId": "cli_default",
|
||||
"channels.feishu.domain": "feishu",
|
||||
"channels.feishu.groupPolicy": "mention",
|
||||
"channels.feishu.allowFrom": "",
|
||||
"channels.feishu.topicIsolation": "true",
|
||||
},
|
||||
configured_fields: [
|
||||
"channels.feishu.appId",
|
||||
"channels.feishu.appSecret",
|
||||
"channels.feishu.domain",
|
||||
"channels.feishu.groupPolicy",
|
||||
"channels.feishu.topicIsolation",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "product",
|
||||
name: "Product bot",
|
||||
display_name: "Product Helper",
|
||||
avatar_url: "https://example.com/product.png",
|
||||
domain: "feishu",
|
||||
enabled: false,
|
||||
configured: true,
|
||||
app_id: "cli_product",
|
||||
config_values: { "channels.feishu.appId": "cli_product" },
|
||||
configured_fields: [
|
||||
"channels.feishu.appId",
|
||||
"channels.feishu.appSecret",
|
||||
],
|
||||
},
|
||||
],
|
||||
}],
|
||||
@@ -847,6 +1044,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
"true",
|
||||
);
|
||||
expect(screen.getAllByText("cli_def...ault").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Advanced")).toBeInTheDocument();
|
||||
expect(screen.getByText("Topic isolation")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Product Helper/ }));
|
||||
expect(screen.getByRole("button", { name: /Support Bot/ })).toHaveAttribute(
|
||||
@@ -859,18 +1058,98 @@ describe("SettingsView Apps catalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders external multi-instance channels from the shared contract", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
|
||||
if (url === "/api/settings/nanobot-features") {
|
||||
return jsonResponse({
|
||||
features: [{
|
||||
name: "multiplugin",
|
||||
display_name: "Multi Plugin",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: false,
|
||||
setup: {
|
||||
fields: [
|
||||
channelSetupField("multiplugin", "token", "secret", { required: true }),
|
||||
channelSetupField("multiplugin", "region", "enum", {
|
||||
choices: ["eu", "us"],
|
||||
defaultValue: "us",
|
||||
}),
|
||||
],
|
||||
},
|
||||
instances: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Default worker",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
config_values: { "channels.multiplugin.region": "us" },
|
||||
configured_fields: ["channels.multiplugin.token"],
|
||||
},
|
||||
{
|
||||
id: "product",
|
||||
name: "Product worker",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
config_values: { "channels.multiplugin.region": "eu" },
|
||||
configured_fields: ["channels.multiplugin.token"],
|
||||
},
|
||||
],
|
||||
}],
|
||||
enabled_count: 1,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "channels" });
|
||||
|
||||
expect(await screen.findByText("Default worker")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("switch", { name: "Multi Plugin channel" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Default worker instance" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("switch", { name: "Product worker instance" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Product worker" }));
|
||||
expect(screen.getByRole("radio", { name: "Eu" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText("Saved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a single Feishu assistant without a duplicate assistant list", async () => {
|
||||
const reconnectUrls: string[] = [];
|
||||
const feishuPayload = {
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
instances: [{
|
||||
@@ -878,10 +1157,15 @@ describe("SettingsView Apps catalog", () => {
|
||||
name: "nanobot",
|
||||
display_name: "Support Bot",
|
||||
avatar_url: "https://example.com/support.png",
|
||||
domain: "feishu",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
app_id: "cli_support",
|
||||
config_values: { "channels.feishu.appId": "cli_support" },
|
||||
configured_fields: [
|
||||
"channels.feishu.appId",
|
||||
"channels.feishu.appSecret",
|
||||
],
|
||||
}],
|
||||
}],
|
||||
enabled_count: 1,
|
||||
@@ -920,6 +1204,67 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(document.querySelector('img[src="https://example.com/support.png"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not call a configured Feishu assistant connected after runtime failure", async () => {
|
||||
const runtimeError = "Channel failed to start. Check gateway logs.";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
|
||||
if (url === "/api/settings/nanobot-features") {
|
||||
return jsonResponse({
|
||||
features: [{
|
||||
name: "feishu",
|
||||
display_name: "Feishu",
|
||||
webui: "webui/index.tsx",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
installed: true,
|
||||
ready: false,
|
||||
running: false,
|
||||
runtime_status: "failed",
|
||||
runtime_error: runtimeError,
|
||||
status: "failed",
|
||||
install_supported: true,
|
||||
requires_restart: false,
|
||||
instances: [{
|
||||
id: "default",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
running: false,
|
||||
runtime_status: "failed",
|
||||
runtime_error: runtimeError,
|
||||
config_values: { "channels.feishu.appId": "cli_test" },
|
||||
configured_fields: [
|
||||
"channels.feishu.appId",
|
||||
"channels.feishu.appSecret",
|
||||
],
|
||||
}],
|
||||
}],
|
||||
enabled_count: 0,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "channels" });
|
||||
|
||||
await screen.findByText("No assistant connected");
|
||||
expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Failed").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(runtimeError)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
expect(screen.queryByText("Connected")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows group behavior fields as options", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -933,6 +1278,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
@@ -940,6 +1286,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
setup: channelSetupContract("discord"),
|
||||
}],
|
||||
enabled_count: 1,
|
||||
});
|
||||
@@ -1039,6 +1386,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
@@ -1047,6 +1395,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
status: "not_enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
setup: channelSetupContract("discord"),
|
||||
}],
|
||||
enabled_count: 0,
|
||||
});
|
||||
@@ -1064,14 +1413,18 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
running: true,
|
||||
runtime_status: "running",
|
||||
configured: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
setup: channelSetupContract("discord"),
|
||||
}],
|
||||
enabled_count: 1,
|
||||
requires_restart: false,
|
||||
@@ -1150,6 +1503,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
configured: true,
|
||||
@@ -1167,6 +1521,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
"channels.discord.allowChannels",
|
||||
"channels.discord.groupPolicy",
|
||||
],
|
||||
setup: channelSetupContract("discord"),
|
||||
}],
|
||||
enabled_count: 0,
|
||||
});
|
||||
@@ -1176,6 +1531,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
@@ -1184,6 +1540,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
setup: channelSetupContract("discord"),
|
||||
}],
|
||||
enabled_count: 1,
|
||||
requires_restart: false,
|
||||
@@ -1203,7 +1560,10 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.getByRole("switch", { name: "Discord channel" })).toBeEnabled();
|
||||
expect(screen.getByText("Configured manually")).toBeInTheDocument();
|
||||
expect(screen.getByText("Saved")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Saved secret")).toHaveValue("");
|
||||
const savedSecret = screen.getByPlaceholderText("Saved secret");
|
||||
expect(savedSecret).toHaveValue("");
|
||||
expect(savedSecret).toHaveAttribute("autocomplete", "off");
|
||||
expect(savedSecret.closest("form")).not.toBeNull();
|
||||
expect(screen.queryByDisplayValue("discord-secret-token")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced"));
|
||||
@@ -1235,6 +1595,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "telegram",
|
||||
display_name: "Telegram",
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
installed: true,
|
||||
@@ -1291,6 +1652,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: channels.map(([name, displayName]) => ({
|
||||
name,
|
||||
display_name: displayName,
|
||||
webui: ["feishu", "weixin"].includes(name) ? "webui/index.tsx" : "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: name === "websocket",
|
||||
installed: true,
|
||||
@@ -1301,6 +1663,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
})).concat(hiddenChannels.map(([name, displayName]) => ({
|
||||
name,
|
||||
display_name: displayName,
|
||||
settings_visible: false,
|
||||
type: "channel",
|
||||
enabled: false,
|
||||
installed: true,
|
||||
@@ -1343,6 +1706,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: ["email", "feishu", "matrix", "qq"].map((name) => ({
|
||||
name,
|
||||
display_name: name === "qq" ? "QQ" : name[0].toUpperCase() + name.slice(1),
|
||||
webui: name === "feishu" ? "webui/index.tsx" : "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
@@ -1350,6 +1714,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
setup: channelSetupContract(name as "email" | "feishu" | "matrix" | "qq"),
|
||||
})),
|
||||
enabled_count: 4,
|
||||
});
|
||||
@@ -1409,6 +1774,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
features: [{
|
||||
name: "websocket",
|
||||
display_name: "Websocket",
|
||||
capabilities: ["always_enabled"],
|
||||
webui: "webui/index.ts",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
|
||||
Reference in New Issue
Block a user