Files
openclaw/test/official-channel-catalog.test.ts
Sliverp 84c7d45f15 refactor(qqbot): install plugin from Tencent package (#107295)
* refactor(qqbot): remove bundled extension source

Mechanical deletion half of the #107295 squashed rebase; the catalog
repoint and host integration land in the follow-up commit.

Co-authored-by: sliverp <870080352@qq.com>

* refactor(qqbot): install plugin from Tencent package

Squashed rebase of #107295 onto current main. Repoints the official
external channel catalog at @tencent-connect/openclaw-qqbot@2.0.1 and
adapts onboarding, doctor migrations, secrets, build guards, and tests.

Documents the known limitation that the external package does not
support structured SecretRef clientSecret values; operators move those
to QQBOT_CLIENT_SECRET or clientSecretFile before upgrading.

Co-authored-by: sliverp <870080352@qq.com>

* fix(doctor): reuse shared hasOwnKey record helper

The rebased QQBot migration carried its own hasOwnKey export, colliding
with the one main now ships in legacy-config-record-shared.ts.

Co-authored-by: sliverp <870080352@qq.com>

* fix(plugins): carry catalog integrity through the update bridge

The externalized-bundled-plugin bridge dropped the official catalog's
expectedIntegrity pin, so bundled-user updates installed the external
npm package without integrity verification. The bridge now carries the
pin for the catalog's exact npm spec and both bridge install calls pass
it through; update-channel spec overrides intentionally skip the pin
since it only covers the pinned version.

Co-authored-by: sliverp <870080352@qq.com>

* chore(plugin-sdk): refresh per-entrypoint API baselines

The QQBot compat export and bundled-type removal shift 26 entrypoint
closure hashes in the new split baseline layout.

Co-authored-by: sliverp <870080352@qq.com>

* refactor(qqbot): drop helper reintroduced during rebase

Main's coercion consolidation added this file after the deletion
commit's base; its only consumers were the removed qqbot sources.

Co-authored-by: sliverp <870080352@qq.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-11 15:10:27 -07:00

923 lines
31 KiB
TypeScript

// Official channel catalog tests validate catalog metadata and entries.
import fs from "node:fs";
import path from "node:path";
import { bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it } from "vitest";
import {
buildOfficialChannelDocsCatalog,
buildOfficialChannelCatalog,
checkOfficialChannelDocsIndex,
checkOfficialChannelCatalogSource,
findDuplicateOfficialChannelDocsNavRoutes,
findMissingOfficialChannelDocsNavRoutes,
findUnexpectedOfficialChannelDocsNavRoutes,
OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH,
OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH,
OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH,
renderOfficialChannelDocsIndex,
writeOfficialChannelCatalog,
writeOfficialChannelDocsIndex,
writeOfficialChannelCatalogSource,
} from "../scripts/write-official-channel-catalog.mts";
import { describePluginInstallSource } from "../src/plugins/install-source-info.js";
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
const tempDirs: string[] = [];
type OfficialChannelCatalogEntry = ReturnType<
typeof buildOfficialChannelCatalog
>["entries"][number];
type OfficialChannelInstall = NonNullable<
NonNullable<OfficialChannelCatalogEntry["openclaw"]>["install"]
>;
function makeRepoRoot(prefix: string): string {
return makeTempRepoRoot(tempDirs, prefix);
}
function writeJson(filePath: string, value: unknown): void {
writeJsonFile(filePath, value);
}
function writeChannelDocContent(repoRoot: string, docsPath: string, content: string): void {
const route = docsPath.replace(/^\/+/u, "");
const filePath = path.join(repoRoot, "docs", `${route}.md`);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
}
function writeChannelDoc(repoRoot: string, docsPath: string, title: string, summary: string): void {
writeChannelDocContent(
repoRoot,
docsPath,
`---\nsummary: ${JSON.stringify(summary)}\ntitle: ${JSON.stringify(title)}\n---\n`,
);
}
function writeExternalChannelDocs(repoRoot: string): void {
const seed = JSON.parse(
fs.readFileSync(path.resolve("scripts/lib/official-external-channel-seed.json"), "utf8"),
) as {
entries: Array<{
openclaw?: { channel?: { docsPath?: string; id?: string; label?: string } };
}>;
};
for (const entry of seed.entries) {
const channel = entry.openclaw?.channel;
if (!channel?.docsPath || !channel.label) {
continue;
}
const title = channel.id === "openclaw-weixin" ? "WeChat" : channel.label;
writeChannelDoc(repoRoot, channel.docsPath, title, `${title} test summary`);
}
writeChannelDoc(repoRoot, "/web/webchat", "WebChat", "Gateway WebChat UI over WebSocket");
}
function writeEnglishDocsNavigation(
repoRoot: string,
channelPages: string[],
otherPages: string[] = [],
): void {
writeJson(path.join(repoRoot, "docs", "docs.json"), {
navigation: {
languages: [
{
language: "en",
tabs: [
{
tab: "Channels",
groups: [{ group: "Test channels", pages: channelPages }],
},
{
tab: "Other",
groups: [{ group: "Other pages", pages: otherPages }],
},
],
},
],
},
});
}
function requireInstall(entry: OfficialChannelCatalogEntry | undefined): OfficialChannelInstall {
const install = entry?.openclaw?.install;
if (!install) {
throw new Error("expected official channel install config");
}
return install;
}
function requireNpmInstallSource(source: ReturnType<typeof describePluginInstallSource>) {
if (!source.npm) {
throw new Error("expected npm install source");
}
return source.npm;
}
function findCatalogEntry(
entries: OfficialChannelCatalogEntry[],
predicate: (entry: OfficialChannelCatalogEntry) => boolean,
): OfficialChannelCatalogEntry {
const entry = entries.find(predicate);
if (!entry) {
throw new Error("expected official channel catalog entry");
}
return entry;
}
function summarizeCatalogEntry(entry: OfficialChannelCatalogEntry) {
return {
name: entry.name,
description: entry.description,
source: entry.source,
plugin: entry.openclaw?.plugin,
catalog: entry.openclaw?.catalog,
contracts: entry.openclaw?.contracts,
channel: entry.openclaw?.channel,
channelConfigs: entry.openclaw?.channelConfigs,
providerEndpoints: entry.openclaw?.providerEndpoints,
install: entry.openclaw?.install,
};
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("buildOfficialChannelCatalog", () => {
it("keeps the committed official catalog synchronized with repository manifests", () => {
expect(checkOfficialChannelCatalogSource({ repoRoot: process.cwd() })).toBe(true);
});
it("keeps the generated channel docs index and navigation synchronized", () => {
expect(checkOfficialChannelDocsIndex({ repoRoot: process.cwd() })).toBe(true);
expect(findMissingOfficialChannelDocsNavRoutes({ repoRoot: process.cwd() })).toEqual([]);
expect(findUnexpectedOfficialChannelDocsNavRoutes({ repoRoot: process.cwd() })).toEqual([]);
expect(findDuplicateOfficialChannelDocsNavRoutes({ repoRoot: process.cwd() })).toEqual([]);
const entries = buildOfficialChannelDocsCatalog({ repoRoot: process.cwd() }).entries;
expect(entries.find((entry) => entry.id === "openclaw-weixin")).toMatchObject({
label: "WeChat",
summary: "WeChat channel setup through the external openclaw-weixin plugin",
});
expect(entries.map((entry) => entry.id)).toEqual(
expect.arrayContaining(["reef", "telegram", "webchat"]),
);
expect(entries.map((entry) => entry.id)).not.toEqual(
expect.arrayContaining(["qa-channel", "voice-call"]),
);
const rendered = renderOfficialChannelDocsIndex({ repoRoot: process.cwd() });
expect(rendered).toContain(
"[Voice Call](/plugins/voice-call) - Telephony via Plivo, Telnyx, or Twilio",
);
expect(rendered).not.toContain("Very well supported right now");
expect(rendered).not.toContain('David Reagans: "Hop on Discord."');
});
it("lets publishable package metadata override same-id seeds and skips non-publishable entries", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-");
writeJson(path.join(repoRoot, "extensions", "wecom", "package.json"), {
name: "@openclaw/wecom",
version: "2026.8.1",
description: "Repository-owned WeCom channel",
openclaw: {
channel: {
id: "wecom",
label: "Repository WeCom",
selectionLabel: "Repository WeCom",
docsPath: "/channels/wecom",
blurb: "package metadata wins",
configuredState: {
env: {
anyOf: ["WECOM_BOT_TOKEN"],
},
},
},
install: {
npmSpec: "@openclaw/wecom",
defaultChoice: "npm",
},
release: {
publishToNpm: true,
},
},
});
writeJson(path.join(repoRoot, "extensions", "wecom", "openclaw.plugin.json"), {
id: "wecom",
catalog: {
featured: true,
order: 45,
},
contracts: {
tools: ["wecom_tool"],
},
channelConfigs: {
wecom: {
label: "Repository WeCom",
description: "Repository WeCom channel.",
schema: {
type: "object",
additionalProperties: true,
},
},
},
providerEndpoints: [
{
endpointClass: "wecom",
hosts: ["api.example.com"],
},
],
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
});
writeJson(path.join(repoRoot, "extensions", "local-only", "package.json"), {
name: "@openclaw/local-only",
openclaw: {
channel: {
id: "local-only",
label: "Local Only",
selectionLabel: "Local Only",
docsPath: "/channels/local-only",
blurb: "dev only",
},
install: {
localPath: bundledPluginRoot("local-only"),
},
release: {
publishToNpm: false,
},
},
});
const entries = buildOfficialChannelCatalog({ repoRoot }).entries;
expect(
summarizeCatalogEntry(
findCatalogEntry(entries, (entry) => entry.openclaw?.channel?.id === "wecom"),
),
).toEqual({
name: "@openclaw/wecom",
description: "Repository-owned WeCom channel",
source: "official",
plugin: undefined,
catalog: {
featured: true,
order: 45,
},
contracts: {
tools: ["wecom_tool"],
},
channel: {
id: "wecom",
label: "Repository WeCom",
selectionLabel: "Repository WeCom",
docsPath: "/channels/wecom",
blurb: "package metadata wins",
configuredState: {
env: {
anyOf: ["WECOM_BOT_TOKEN"],
},
},
},
channelConfigs: {
wecom: {
label: "Repository WeCom",
description: "Repository WeCom channel.",
schema: {
type: "object",
additionalProperties: true,
},
},
},
providerEndpoints: [
{
endpointClass: "wecom",
hosts: ["api.example.com"],
},
],
install: {
npmSpec: "@openclaw/wecom",
defaultChoice: "npm",
},
});
expect(
summarizeCatalogEntry(
findCatalogEntry(entries, (entry) => entry.name === "openclaw-plugin-yuanbao"),
),
).toEqual({
name: "openclaw-plugin-yuanbao",
description: "OpenClaw Yuanbao channel plugin by the Tencent Yuanbao team.",
source: "external",
plugin: {
id: "openclaw-plugin-yuanbao",
label: "Yuanbao",
},
catalog: undefined,
contracts: {
tools: ["query_group_info", "query_session_members", "yuanbao_remind"],
},
channel: {
id: "yuanbao",
label: "Yuanbao",
selectionLabel: "Yuanbao (元宝)",
detailLabel: "Yuanbao",
docsLabel: "yuanbao",
docsPath: "/channels/yuanbao",
blurb: "Tencent Yuanbao AI assistant conversation channel.",
order: 85,
aliases: ["yuanbao", "yb", "tencent-yuanbao", "元宝"],
},
channelConfigs: {
yuanbao: {
label: "Yuanbao",
description: "Tencent Yuanbao AI assistant channel.",
schema: {
type: "object",
additionalProperties: true,
},
},
},
providerEndpoints: undefined,
install: {
npmSpec: "openclaw-plugin-yuanbao@2.15.0",
defaultChoice: "npm",
expectedIntegrity:
"sha512-3GD+mf3EjTSUTOAREjTHAyp/deXdpgqB+q+xE0b19Qtat4ADhUV1mHDwFkVCRqTCBY5ATFKtKcipoDejqFj/+w==",
},
});
expect(
summarizeCatalogEntry(
findCatalogEntry(entries, (entry) => entry.name === "@tencent-connect/openclaw-qqbot"),
),
).toMatchObject({
name: "@tencent-connect/openclaw-qqbot",
source: "external",
plugin: {
id: "openclaw-qqbot",
label: "QQ Bot",
},
contracts: {
tools: ["qqbot_platform_api", "qqbot_remind"],
},
channel: {
id: "qqbot",
docsPath: "/channels/qqbot",
approvalFlags: ["native"],
},
install: {
npmSpec: "@tencent-connect/openclaw-qqbot@2.0.1",
defaultChoice: "npm",
expectedIntegrity:
"sha512-2010PaCummeQaxerLtaGfQ/5HChiXaW/KpTERid7V/1zyTs46S2ACi0hgZQ1SB7tH0t1InWr8tzVBJV/pLss3Q==",
},
});
expect(entries.some((entry) => entry.openclaw?.channel?.id === "local-only")).toBe(false);
});
it("preserves manifest-owned fallback metadata for externalized channel packages", () => {
const entries = buildOfficialChannelCatalog({ repoRoot: process.cwd() }).entries;
const slack = findCatalogEntry(entries, (entry) => entry.openclaw?.channel?.id === "slack");
const raft = findCatalogEntry(entries, (entry) => entry.openclaw?.channel?.id === "raft");
const clickclack = findCatalogEntry(
entries,
(entry) => entry.openclaw?.channel?.id === "clickclack",
);
expect(slack.openclaw.channelConfigs?.slack?.schema).toEqual({
type: "object",
additionalProperties: true,
});
expect(raft.openclaw.channelConfigs?.raft?.schema).toMatchObject({
type: "object",
additionalProperties: false,
});
expect(clickclack.openclaw.contracts?.tools).toEqual(["discussion"]);
});
it("rejects duplicate channel ids from repository packages", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-duplicate-");
for (const dirName of ["first", "second"]) {
writeJson(path.join(repoRoot, "extensions", dirName, "package.json"), {
name: `@openclaw/${dirName}`,
openclaw: {
channel: {
id: "duplicate",
label: dirName,
},
install: {
npmSpec: `@openclaw/${dirName}`,
},
release: {
publishToNpm: true,
},
},
});
}
expect(() => buildOfficialChannelCatalog({ repoRoot })).toThrow(
'duplicate official channel id "duplicate"',
);
});
it("keeps the hand-authored seed limited to out-of-tree external channels", () => {
const seed = JSON.parse(
fs.readFileSync(path.resolve("scripts/lib/official-external-channel-seed.json"), "utf8"),
) as {
entries: Array<{
name?: string;
source?: string;
openclaw?: { channel?: { id?: string } };
}>;
};
const publishableChannelIds = new Set(
fs.readdirSync(path.resolve("extensions")).flatMap((dirName) => {
const packageJsonPath = path.resolve("extensions", dirName, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return [];
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: {
channel?: { id?: string };
release?: { publishToNpm?: boolean };
};
};
const channelId = packageJson.openclaw?.channel?.id;
return channelId && packageJson.openclaw?.release?.publishToNpm === true ? [channelId] : [];
}),
);
const seedChannelIds = seed.entries.map((entry) => entry.openclaw?.channel?.id?.toLowerCase());
expect(seed.entries.every((entry) => entry.source === "external")).toBe(true);
expect(seed.entries.some((entry) => entry.name?.startsWith("@openclaw/"))).toBe(false);
expect(new Set(seedChannelIds).size).toBe(seedChannelIds.length);
expect(
seed.entries.some((entry) => {
const channelId = entry.openclaw?.channel?.id;
return channelId ? publishableChannelIds.has(channelId) : false;
}),
).toBe(false);
});
it("projects bundled, external, and built-in channels into docs while hiding source-only channels", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-docs-");
writeJson(path.join(repoRoot, "package.json"), {
files: ["dist/extensions/**", "!dist/extensions/hidden/**"],
});
writeJson(path.join(repoRoot, "extensions", "bundled", "package.json"), {
name: "@openclaw/bundled",
openclaw: {
channel: {
id: "bundled",
label: "Bundled",
docsPath: "/channels/bundled",
blurb: "bundled test channel",
},
},
});
writeJson(path.join(repoRoot, "extensions", "hidden", "package.json"), {
name: "@openclaw/hidden",
openclaw: {
channel: {
id: "hidden",
label: "Hidden",
docsPath: "/channels/hidden",
blurb: "hidden test channel",
exposure: {
docs: false,
},
},
},
});
writeExternalChannelDocs(repoRoot);
writeChannelDoc(repoRoot, "/channels/bundled", "Bundled Chat", "Public bundled summary");
const entries = buildOfficialChannelDocsCatalog({ repoRoot }).entries;
expect(entries.find((entry) => entry.id === "bundled")).toEqual({
id: "bundled",
label: "Bundled Chat",
docsPath: "/channels/bundled",
summary: "Public bundled summary",
source: "bundled",
});
expect(entries.some((entry) => entry.id === "hidden")).toBe(false);
expect(entries.find((entry) => entry.id === "webchat")).toEqual({
id: "webchat",
label: "WebChat",
docsPath: "/web/webchat",
summary: "Gateway WebChat UI over WebSocket",
source: "built-in",
});
expect(entries.find((entry) => entry.id === "wecom")?.docsPath).toBe("/channels/wecom");
expect(entries.find((entry) => entry.id === "yuanbao")?.docsPath).toBe("/channels/yuanbao");
expect(entries.find((entry) => entry.id === "qqbot")?.source).toBe("official");
});
it("uses the canonical channel docs route when a manifest omits docsPath", () => {
const repoRoot = makeRepoRoot("openclaw-default-channel-docs-route-");
writeJson(path.join(repoRoot, "extensions", "defaulted", "package.json"), {
name: "@openclaw/defaulted",
openclaw: {
channel: {
id: "defaulted",
label: "Defaulted",
},
},
});
writeExternalChannelDocs(repoRoot);
writeChannelDoc(repoRoot, "/channels/defaulted", "Defaulted Chat", "Default route summary");
expect(
buildOfficialChannelDocsCatalog({ repoRoot }).entries.find(
(entry) => entry.id === "defaulted",
),
).toEqual({
id: "defaulted",
label: "Defaulted Chat",
docsPath: "/channels/defaulted",
summary: "Default route summary",
source: "bundled",
});
});
it("rejects docs-visible source-only channels", () => {
const repoRoot = makeRepoRoot("openclaw-source-only-channel-docs-");
writeJson(path.join(repoRoot, "package.json"), {
files: ["dist/extensions/**", "!dist/extensions/source-only/**"],
});
writeJson(path.join(repoRoot, "extensions", "source-only", "package.json"), {
name: "@openclaw/source-only",
openclaw: {
channel: {
id: "source-only",
label: "Source Only",
docsPath: "/channels/source-only",
},
},
});
expect(() => buildOfficialChannelDocsCatalog({ repoRoot })).toThrow(
"docs-visible channel source-only is neither bundled nor installable",
);
});
it.each([
{
name: "missing docs file",
content: null,
error: "channel frontmatter-test docs route does not resolve",
},
{
name: "missing frontmatter",
content: "# Frontmatter test\n",
error: "docs/channels/frontmatter-test.md is missing YAML frontmatter",
},
{
name: "missing title",
content: '---\nsummary: "Summary"\n---\n',
error: "docs/channels/frontmatter-test.md must define title and summary",
},
{
name: "missing summary",
content: '---\ntitle: "Frontmatter test"\n---\n',
error: "docs/channels/frontmatter-test.md must define title and summary",
},
])("rejects channel docs with $name", ({ content, error }) => {
const repoRoot = makeRepoRoot("openclaw-channel-docs-frontmatter-");
writeJson(path.join(repoRoot, "extensions", "frontmatter-test", "package.json"), {
name: "@openclaw/frontmatter-test",
openclaw: {
channel: {
id: "frontmatter-test",
label: "Manifest label",
docsPath: "/channels/frontmatter-test",
blurb: "Manifest blurb",
},
},
});
writeExternalChannelDocs(repoRoot);
if (content !== null) {
writeChannelDocContent(repoRoot, "/channels/frontmatter-test", content);
}
expect(() => buildOfficialChannelDocsCatalog({ repoRoot })).toThrow(error);
});
it("writes the generated docs block and reports missing or hidden navigation routes", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-docs-write-");
writeJson(path.join(repoRoot, "extensions", "bundled", "package.json"), {
name: "@openclaw/bundled",
openclaw: {
channel: {
id: "bundled",
label: "Bundled",
docsPath: "/channels/bundled",
blurb: "bundled test channel",
},
},
});
writeJson(path.join(repoRoot, "extensions", "hidden", "package.json"), {
name: "@openclaw/hidden",
openclaw: {
channel: {
id: "hidden",
label: "Hidden",
docsPath: "/channels/hidden",
exposure: {
docs: false,
},
},
},
});
writeExternalChannelDocs(repoRoot);
writeChannelDoc(repoRoot, "/channels/bundled", "Bundled Chat", "Public bundled summary");
const docsIndexPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
fs.mkdirSync(path.dirname(docsIndexPath), { recursive: true });
fs.writeFileSync(
docsIndexPath,
[
"# Channels",
"",
"<!-- BEGIN GENERATED: official channel catalog -->",
"- stale",
"<!-- END GENERATED: official channel catalog -->",
"",
"Footer",
"",
].join("\n"),
"utf8",
);
writeEnglishDocsNavigation(repoRoot, ["channels/hidden"], ["web/webchat", "channels/bundled"]);
expect(checkOfficialChannelDocsIndex({ repoRoot })).toBe(false);
expect(renderOfficialChannelDocsIndex({ repoRoot })).toContain(
"- [Bundled Chat](/channels/bundled) - Public bundled summary (bundled plugin).",
);
expect(writeOfficialChannelDocsIndex({ repoRoot })).toBe(true);
expect(writeOfficialChannelDocsIndex({ repoRoot })).toBe(false);
expect(checkOfficialChannelDocsIndex({ repoRoot })).toBe(true);
const generatedIndex = fs.readFileSync(docsIndexPath, "utf8");
expect(generatedIndex).toMatch(/^# Channels\n/u);
expect(generatedIndex).toContain("\nFooter\n");
expect(findMissingOfficialChannelDocsNavRoutes({ repoRoot })).toContain("channels/bundled");
expect(findUnexpectedOfficialChannelDocsNavRoutes({ repoRoot })).toEqual(["channels/hidden"]);
expect(findDuplicateOfficialChannelDocsNavRoutes({ repoRoot })).toEqual([]);
writeEnglishDocsNavigation(repoRoot, ["channels/bundled", "channels/bundled"], ["web/webchat"]);
expect(findDuplicateOfficialChannelDocsNavRoutes({ repoRoot })).toEqual(["channels/bundled"]);
});
it("rejects missing or duplicate generated docs markers", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-docs-markers-");
writeExternalChannelDocs(repoRoot);
const docsIndexPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
fs.mkdirSync(path.dirname(docsIndexPath), { recursive: true });
fs.writeFileSync(docsIndexPath, "# Channels\n", "utf8");
expect(() => renderOfficialChannelDocsIndex({ repoRoot })).toThrow(
"must contain exactly one generated channel marker pair",
);
fs.writeFileSync(
docsIndexPath,
[
"<!-- BEGIN GENERATED: official channel catalog -->",
"<!-- BEGIN GENERATED: official channel catalog -->",
"<!-- END GENERATED: official channel catalog -->",
].join("\n"),
"utf8",
);
expect(() => renderOfficialChannelDocsIndex({ repoRoot })).toThrow(
"must contain exactly one generated channel marker pair",
);
});
it("keeps third-party official external catalog npm sources pinned unless they track latest", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-policy-");
const entries = buildOfficialChannelCatalog({ repoRoot }).entries.filter(
(entry) => entry.source === "external" && !entry.name?.startsWith("@openclaw/"),
);
expect(entries.length).toBeGreaterThan(0);
for (const entry of entries) {
const installSource = describePluginInstallSource(requireInstall(entry));
expect(installSource.warnings).toStrictEqual([]);
expect(requireNpmInstallSource(installSource).pinState).toBe("exact-with-integrity");
}
});
it("allows official OpenClaw channel npm specs without integrity during launch", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-openclaw-policy-");
writeJson(path.join(repoRoot, "extensions", "twitch", "package.json"), {
name: "@openclaw/twitch",
openclaw: {
channel: {
id: "twitch",
label: "Twitch",
docsPath: "/channels/twitch",
},
install: {
npmSpec: "@openclaw/twitch",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
},
release: {
publishToNpm: true,
},
},
});
const twitch = buildOfficialChannelCatalog({ repoRoot }).entries.find(
(entry) => entry.openclaw?.channel?.id === "twitch",
);
expect({
name: twitch?.name,
install: twitch?.openclaw?.install,
}).toEqual({
name: "@openclaw/twitch",
install: {
npmSpec: "@openclaw/twitch",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
},
});
const installSource = describePluginInstallSource(requireInstall(twitch));
expect(requireNpmInstallSource(installSource).pinState).toBe("floating-without-integrity");
expect(installSource.warnings).toEqual(["npm-spec-floating", "npm-spec-missing-integrity"]);
});
it("keeps iMessage available for cold install after core package externalization", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-imessage-");
writeJson(path.join(repoRoot, "extensions", "imessage", "package.json"), {
name: "@openclaw/imessage",
openclaw: {
channel: {
id: "imessage",
label: "iMessage",
aliases: ["imsg"],
docsPath: "/channels/imessage",
},
install: {
clawhubSpec: "clawhub:@openclaw/imessage",
npmSpec: "@openclaw/imessage",
defaultChoice: "npm",
minHostVersion: ">=2026.7.2",
allowInvalidConfigRecovery: true,
},
release: {
publishToNpm: true,
},
},
});
const imessage = buildOfficialChannelCatalog({ repoRoot }).entries.find(
(entry) => entry.openclaw?.channel?.id === "imessage",
);
expect({
name: imessage?.name,
aliases: imessage?.openclaw?.channel?.aliases,
install: imessage?.openclaw?.install,
}).toEqual({
name: "@openclaw/imessage",
aliases: ["imsg"],
install: {
clawhubSpec: "clawhub:@openclaw/imessage",
npmSpec: "@openclaw/imessage",
defaultChoice: "npm",
minHostVersion: ">=2026.7.2",
allowInvalidConfigRecovery: true,
},
});
});
it("preserves ClawHub specs when generating publishable channel catalog entries", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-clawhub-");
writeJson(path.join(repoRoot, "extensions", "storepack-chat", "package.json"), {
name: "@openclaw/storepack-chat",
openclaw: {
channel: {
id: "storepack-chat",
label: "Storepack Chat",
selectionLabel: "Storepack Chat",
docsPath: "/channels/storepack-chat",
blurb: "storepack-first channel",
},
install: {
clawhubSpec: "clawhub:@openclaw/storepack-chat",
npmSpec: "@openclaw/storepack-chat",
defaultChoice: "clawhub",
},
release: {
publishToNpm: true,
},
},
});
const entry = buildOfficialChannelCatalog({ repoRoot }).entries.find(
(candidate) => candidate.openclaw?.channel?.id === "storepack-chat",
);
expect(requireInstall(entry)).toEqual({
clawhubSpec: "clawhub:@openclaw/storepack-chat",
npmSpec: "@openclaw/storepack-chat",
defaultChoice: "clawhub",
});
});
it("writes the official catalog under dist", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-write-");
writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), {
name: "@openclaw/whatsapp",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
release: {
publishToNpm: true,
},
},
});
writeOfficialChannelCatalog({ repoRoot });
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
expect(fs.existsSync(outputPath)).toBe(true);
const entries = JSON.parse(fs.readFileSync(outputPath, "utf8")).entries;
expect(entries.map((entry: { name?: string }) => entry.name)).toContain(
"@wecom/wecom-openclaw-plugin",
);
expect(entries.map((entry: { name?: string }) => entry.name)).toContain(
"openclaw-plugin-yuanbao",
);
const whatsappEntry = findCatalogEntry(
entries,
(entry: { openclaw?: { channel?: { id?: string } } }) =>
entry.openclaw?.channel?.id === "whatsapp",
);
expect(summarizeCatalogEntry(whatsappEntry)).toEqual({
name: "@openclaw/whatsapp",
description: undefined,
source: "official",
plugin: undefined,
catalog: undefined,
contracts: undefined,
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
channelConfigs: undefined,
providerEndpoints: undefined,
install: {
npmSpec: "@openclaw/whatsapp",
},
});
const whatsappEntries = entries.filter(
(entry: { openclaw?: { channel?: { id?: string } } }) =>
entry.openclaw?.channel?.id === "whatsapp",
);
expect(whatsappEntries).toHaveLength(1);
});
it("writes and checks the committed official catalog", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-source-");
writeJson(path.join(repoRoot, "extensions", "demo", "package.json"), {
name: "@openclaw/demo",
openclaw: {
channel: {
id: "demo",
label: "Demo",
docsPath: "/channels/demo",
},
install: {
npmSpec: "@openclaw/demo",
},
release: {
publishToNpm: true,
},
},
});
expect(checkOfficialChannelCatalogSource({ repoRoot })).toBe(false);
expect(writeOfficialChannelCatalogSource({ repoRoot })).toBe(true);
expect(checkOfficialChannelCatalogSource({ repoRoot })).toBe(true);
expect(writeOfficialChannelCatalogSource({ repoRoot })).toBe(false);
const sourcePath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH);
fs.writeFileSync(sourcePath, "{}\n", "utf8");
expect(checkOfficialChannelCatalogSource({ repoRoot })).toBe(false);
});
});