mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:10:49 +00:00
refactor: relocate channel contract helpers
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
# Test Helper Boundary
|
||||
|
||||
This directory holds shared channel test helpers used by core and bundled plugin
|
||||
tests.
|
||||
|
||||
This file adds channel-specific rules on top of `test/helpers/AGENTS.md`.
|
||||
|
||||
## Bundled Plugin Imports
|
||||
|
||||
- Core test helpers in this directory must not hardcode repo-relative imports
|
||||
into `extensions/**`.
|
||||
- When a helper needs a bundled plugin public/test surface, go through
|
||||
`src/test-utils/bundled-plugin-public-surface.ts`.
|
||||
- Prefer `loadBundledPluginTestApiSync(...)` for eager access to exported test
|
||||
helpers.
|
||||
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` when a test needs a
|
||||
module id for dynamic import or mocking.
|
||||
- If `vi.mock(...)` hoisting would evaluate the module id too early, use
|
||||
`vi.doMock(...)` with the resolved module id instead of falling back to a
|
||||
hardcoded path.
|
||||
- For contract helpers, prefer minimal in-memory channel/plugin fixtures when
|
||||
the contract only needs capabilities, session binding hooks, routing metadata,
|
||||
or outbound payload helpers. Do not load broad `api.ts`, `runtime-api.ts`, or
|
||||
`test-api.ts` barrels for incidental setup.
|
||||
- If a bundled plugin parser is the contract under test, load the narrow module
|
||||
that owns that parser or promote a small public artifact. Avoid pulling a full
|
||||
extension barrel just to parse a target id.
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep shared test helpers aligned with the same public/plugin boundary that
|
||||
production code uses.
|
||||
- Avoid drift where core test helpers start reaching into bundled plugin private
|
||||
files by path because it is convenient in one test.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -1,94 +0,0 @@
|
||||
import { listBundledChannelPluginIds as listCatalogBundledChannelPluginIds } from "../../../src/channels/plugins/bundled-ids.js";
|
||||
import type { ChannelId } from "../../../src/channels/plugins/channel-id.types.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
listChannelCatalogEntries,
|
||||
type PluginChannelCatalogEntry,
|
||||
} from "../../../src/plugins/channel-catalog-registry.js";
|
||||
import {
|
||||
loadBundledPluginPublicSurface,
|
||||
loadBundledPluginPublicSurfaceSync,
|
||||
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type ChannelPluginApiModule = Record<string, unknown>;
|
||||
|
||||
const channelPluginCache = new Map<ChannelId, ChannelPlugin | null>();
|
||||
const channelPluginPromiseCache = new Map<ChannelId, Promise<ChannelPlugin | null>>();
|
||||
let channelCatalogEntries: PluginChannelCatalogEntry[] | undefined;
|
||||
|
||||
function isChannelPlugin(value: unknown): value is ChannelPlugin {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as Partial<ChannelPlugin>).id === "string" &&
|
||||
Boolean((value as Partial<ChannelPlugin>).meta) &&
|
||||
Boolean((value as Partial<ChannelPlugin>).config)
|
||||
);
|
||||
}
|
||||
|
||||
export function listBundledChannelPluginIds(): readonly ChannelId[] {
|
||||
return listCatalogBundledChannelPluginIds() as ChannelId[];
|
||||
}
|
||||
|
||||
export function getBundledChannelCatalogEntry(
|
||||
id: ChannelId,
|
||||
): PluginChannelCatalogEntry | undefined {
|
||||
channelCatalogEntries ??= listChannelCatalogEntries({ origin: "bundled" });
|
||||
return channelCatalogEntries.find((entry) => entry.pluginId === id || entry.channel.id === id);
|
||||
}
|
||||
|
||||
export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
|
||||
if (channelPluginCache.has(id)) {
|
||||
return channelPluginCache.get(id) ?? undefined;
|
||||
}
|
||||
|
||||
const loaded = loadBundledPluginPublicSurfaceSync<ChannelPluginApiModule>({
|
||||
pluginId: id,
|
||||
artifactBasename: "channel-plugin-api.js",
|
||||
});
|
||||
const plugin = Object.values(loaded).find(isChannelPlugin) ?? null;
|
||||
channelPluginCache.set(id, plugin);
|
||||
return plugin ?? undefined;
|
||||
}
|
||||
|
||||
export async function getBundledChannelPluginAsync(
|
||||
id: ChannelId,
|
||||
): Promise<ChannelPlugin | undefined> {
|
||||
if (channelPluginCache.has(id)) {
|
||||
return channelPluginCache.get(id) ?? undefined;
|
||||
}
|
||||
|
||||
const cachedPromise = channelPluginPromiseCache.get(id);
|
||||
if (cachedPromise) {
|
||||
return (await cachedPromise) ?? undefined;
|
||||
}
|
||||
|
||||
const loading = loadBundledPluginPublicSurface<ChannelPluginApiModule>({
|
||||
pluginId: id,
|
||||
artifactBasename: "channel-plugin-api.js",
|
||||
})
|
||||
.then((loaded) => {
|
||||
const plugin = Object.values(loaded).find(isChannelPlugin) ?? null;
|
||||
channelPluginCache.set(id, plugin);
|
||||
return plugin;
|
||||
})
|
||||
.finally(() => {
|
||||
channelPluginPromiseCache.delete(id);
|
||||
});
|
||||
channelPluginPromiseCache.set(id, loading);
|
||||
return (await loading) ?? undefined;
|
||||
}
|
||||
|
||||
export function listBundledChannelPlugins(): readonly ChannelPlugin[] {
|
||||
return listBundledChannelPluginIds().flatMap((id) => {
|
||||
const plugin = getBundledChannelPlugin(id);
|
||||
return plugin ? [plugin] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBundledChannelPluginsAsync(): Promise<readonly ChannelPlugin[]> {
|
||||
const plugins = await Promise.all(
|
||||
listBundledChannelPluginIds().map((id) => getBundledChannelPluginAsync(id)),
|
||||
);
|
||||
return plugins.filter((plugin): plugin is ChannelPlugin => Boolean(plugin));
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getChannelPluginCatalogEntry,
|
||||
listChannelPluginCatalogEntries,
|
||||
} from "../../../src/channels/plugins/catalog.js";
|
||||
|
||||
type CatalogEntryMeta = {
|
||||
id: string;
|
||||
label: string;
|
||||
selectionLabel: string;
|
||||
docsPath: string;
|
||||
blurb: string;
|
||||
detailLabel?: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
export function describeChannelCatalogEntryContract(params: {
|
||||
channelId: string;
|
||||
npmSpec: string;
|
||||
alias?: string;
|
||||
}) {
|
||||
describe(`${params.channelId} channel catalog contract`, () => {
|
||||
it("keeps the shipped catalog entry aligned", () => {
|
||||
const entry = getChannelPluginCatalogEntry(params.channelId);
|
||||
expect(entry?.install.npmSpec).toBe(params.npmSpec);
|
||||
if (params.alias) {
|
||||
expect(entry?.meta.aliases).toContain(params.alias);
|
||||
}
|
||||
});
|
||||
|
||||
it("appears in the channel catalog listing", () => {
|
||||
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
||||
expect(ids).toContain(params.channelId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeBundledMetadataOnlyChannelCatalogContract(params: {
|
||||
pluginId: string;
|
||||
packageName: string;
|
||||
npmSpec: string;
|
||||
meta: CatalogEntryMeta;
|
||||
defaultChoice?: string;
|
||||
}) {
|
||||
describe(`${params.pluginId} bundled metadata-only channel catalog contract`, () => {
|
||||
it("includes the bundled metadata-only channel entry when the runtime entrypoint is omitted", () => {
|
||||
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-"));
|
||||
const bundledDir = path.join(packageRoot, "dist", "extensions", params.pluginId);
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw" }),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
extensions: ["./index.js"],
|
||||
channel: params.meta,
|
||||
install: {
|
||||
npmSpec: params.npmSpec,
|
||||
defaultChoice: params.defaultChoice,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(bundledDir, "index.js"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: params.pluginId, channels: [params.meta.id], configSchema: {} }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"),
|
||||
},
|
||||
}).find((item) => item.id === params.meta.id);
|
||||
|
||||
expect(entry?.install.npmSpec).toBe(params.npmSpec);
|
||||
expect(entry?.pluginId).toBe(params.pluginId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOfficialFallbackChannelCatalogContract(params: {
|
||||
channelId: string;
|
||||
npmSpec: string;
|
||||
meta: CatalogEntryMeta;
|
||||
packageName: string;
|
||||
pluginId: string;
|
||||
externalNpmSpec: string;
|
||||
externalLabel: string;
|
||||
}) {
|
||||
describe(`${params.channelId} official fallback channel catalog contract`, () => {
|
||||
it("includes shipped official channel catalog entries when bundled metadata is omitted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-"));
|
||||
const catalogPath = path.join(dir, "channel-catalog.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: params.meta,
|
||||
install: {
|
||||
npmSpec: params.npmSpec,
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
officialCatalogPaths: [catalogPath],
|
||||
}).find((item) => item.id === params.channelId);
|
||||
|
||||
expect(entry?.install.npmSpec).toBe(params.npmSpec);
|
||||
expect(entry?.pluginId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets external catalogs override shipped fallback channel metadata", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-"));
|
||||
const bundledDir = path.join(dir, "dist", "extensions", params.pluginId);
|
||||
const officialCatalogPath = path.join(dir, "channel-catalog.json");
|
||||
const externalCatalogPath = path.join(dir, "catalog.json");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: {
|
||||
...params.meta,
|
||||
label: `${params.meta.label} Bundled`,
|
||||
selectionLabel: `${params.meta.label} Bundled`,
|
||||
blurb: "bundled fallback",
|
||||
},
|
||||
install: { npmSpec: params.npmSpec },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
officialCatalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: {
|
||||
...params.meta,
|
||||
label: `${params.meta.label} Official`,
|
||||
selectionLabel: `${params.meta.label} Official`,
|
||||
blurb: "official fallback",
|
||||
},
|
||||
install: { npmSpec: params.npmSpec },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
externalCatalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.externalNpmSpec,
|
||||
openclaw: {
|
||||
channel: {
|
||||
...params.meta,
|
||||
label: params.externalLabel,
|
||||
selectionLabel: params.externalLabel,
|
||||
blurb: "external override",
|
||||
},
|
||||
install: { npmSpec: params.externalNpmSpec },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
catalogPaths: [externalCatalogPath],
|
||||
officialCatalogPaths: [officialCatalogPath],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"),
|
||||
},
|
||||
}).find((item) => item.id === params.channelId);
|
||||
|
||||
expect(entry?.install.npmSpec).toBe(params.externalNpmSpec);
|
||||
expect(entry?.meta.label).toBe(params.externalLabel);
|
||||
expect(entry?.pluginId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces package-name drift in external channel catalog install metadata", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-drifted-catalog-"));
|
||||
const catalogPath = path.join(dir, "catalog.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: params.meta,
|
||||
install: {
|
||||
npmSpec: `${params.packageName}-fork@1.2.3`,
|
||||
expectedIntegrity: "sha512-drifted",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
catalogPaths: [catalogPath],
|
||||
officialCatalogPaths: [],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
}).find((item) => item.id === params.channelId);
|
||||
|
||||
expect(entry?.installSource?.npm).toMatchObject({
|
||||
packageName: `${params.packageName}-fork`,
|
||||
expectedPackageName: params.packageName,
|
||||
});
|
||||
expect(entry?.installSource?.warnings).toEqual(["npm-spec-package-name-mismatch"]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listChannelPluginCatalogEntries } from "../../../src/channels/plugins/catalog.js";
|
||||
|
||||
function createCatalogEntry(params: {
|
||||
packageName: string;
|
||||
channelId: string;
|
||||
label: string;
|
||||
blurb: string;
|
||||
order?: number;
|
||||
}) {
|
||||
return {
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: params.channelId,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: `/channels/${params.channelId}`,
|
||||
blurb: params.blurb,
|
||||
...(params.order === undefined ? {} : { order: params.order }),
|
||||
},
|
||||
install: {
|
||||
npmSpec: params.packageName,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeCatalogFile(catalogPath: string, entry: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [entry],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function writeDiscoveredChannelPlugin(params: {
|
||||
stateDir: string;
|
||||
packageName: string;
|
||||
channelLabel: string;
|
||||
pluginId: string;
|
||||
blurb: string;
|
||||
}) {
|
||||
const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
extensions: ["./index.js"],
|
||||
channel: {
|
||||
id: "demo-channel",
|
||||
label: params.channelLabel,
|
||||
selectionLabel: params.channelLabel,
|
||||
docsPath: "/channels/demo-channel",
|
||||
blurb: params.blurb,
|
||||
},
|
||||
install: {
|
||||
npmSpec: params.packageName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: params.pluginId,
|
||||
configSchema: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
|
||||
}
|
||||
|
||||
function expectCatalogIdsContain(params: {
|
||||
expectedId: string;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
const ids = listChannelPluginCatalogEntries({
|
||||
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
}).map((entry) => entry.id);
|
||||
expect(ids).toContain(params.expectedId);
|
||||
}
|
||||
|
||||
function findCatalogEntry(params: {
|
||||
channelId: string;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
return listChannelPluginCatalogEntries({
|
||||
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
}).find((entry) => entry.id === params.channelId);
|
||||
}
|
||||
|
||||
function expectCatalogEntryMatch(params: {
|
||||
channelId: string;
|
||||
expected: Record<string, unknown>;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
expect(
|
||||
findCatalogEntry({
|
||||
channelId: params.channelId,
|
||||
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
}),
|
||||
).toMatchObject(params.expected);
|
||||
}
|
||||
|
||||
export function describeChannelPluginCatalogEntriesContract() {
|
||||
describe("channel plugin catalog entries contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "includes external catalog entries",
|
||||
setup: () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-"));
|
||||
const catalogPath = path.join(dir, "catalog.json");
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@openclaw/demo-channel",
|
||||
channelId: "demo-channel",
|
||||
label: "Demo Channel",
|
||||
blurb: "Demo entry",
|
||||
order: 999,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: "demo-channel",
|
||||
catalogPaths: [catalogPath],
|
||||
expected: { id: "demo-channel" },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preserves plugin ids when they differ from channel ids",
|
||||
setup: () => {
|
||||
const stateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-channel-catalog-state-"),
|
||||
);
|
||||
writeDiscoveredChannelPlugin({
|
||||
stateDir,
|
||||
packageName: "@vendor/demo-channel-plugin",
|
||||
channelLabel: "Demo Channel",
|
||||
pluginId: "@vendor/demo-runtime",
|
||||
blurb: "Demo channel",
|
||||
});
|
||||
return {
|
||||
channelId: "demo-channel",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
expected: { pluginId: "@vendor/demo-runtime" },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps discovered plugins ahead of external catalog overrides",
|
||||
setup: () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
|
||||
const catalogPath = path.join(stateDir, "catalog.json");
|
||||
writeDiscoveredChannelPlugin({
|
||||
stateDir,
|
||||
packageName: "@vendor/demo-channel-plugin",
|
||||
channelLabel: "Demo Channel Runtime",
|
||||
pluginId: "@vendor/demo-channel-runtime",
|
||||
blurb: "discovered plugin",
|
||||
});
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@vendor/demo-channel-catalog",
|
||||
channelId: "demo-channel",
|
||||
label: "Demo Channel Catalog",
|
||||
blurb: "external catalog",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: "demo-channel",
|
||||
catalogPaths: [catalogPath],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
expected: {
|
||||
install: { npmSpec: "@vendor/demo-channel-plugin" },
|
||||
meta: { label: "Demo Channel Runtime" },
|
||||
pluginId: "@vendor/demo-channel-runtime",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accepts rich external manifest entries with pinned npm metadata",
|
||||
setup: () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-rich-"));
|
||||
const catalogPath = path.join(dir, "catalog.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
$schema: "./manifest.schema.json",
|
||||
schemaVersion: 1,
|
||||
description:
|
||||
"Extension manifest. Declares plugin packages that OpenClaw can discover during onboarding and install on demand via `openclaw plugins install`.",
|
||||
entries: [
|
||||
{
|
||||
name: "@wecom/wecom-openclaw-plugin",
|
||||
description:
|
||||
"OpenClaw WeCom (企业微信) channel plugin — community maintained, published on npm.",
|
||||
source: "external",
|
||||
kind: "channel",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "wecom",
|
||||
label: "WeCom",
|
||||
selectionLabel: "WeCom (企业微信)",
|
||||
detailLabel: "WeCom",
|
||||
docsPath: "/channels/wecom",
|
||||
docsLabel: "wecom",
|
||||
blurb: "企业微信 (WeCom) bot & conversation channel.",
|
||||
aliases: ["qywx", "wework"],
|
||||
order: 45,
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: ">=2026.4.10",
|
||||
expectedIntegrity: "sha512-wecom",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: "wecom",
|
||||
catalogPaths: [catalogPath],
|
||||
expected: {
|
||||
id: "wecom",
|
||||
meta: {
|
||||
label: "WeCom",
|
||||
selectionLabel: "WeCom (企业微信)",
|
||||
detailLabel: "WeCom",
|
||||
docsPath: "/channels/wecom",
|
||||
docsLabel: "wecom",
|
||||
blurb: "企业微信 (WeCom) bot & conversation channel.",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: ">=2026.4.10",
|
||||
expectedIntegrity: "sha512-wecom",
|
||||
},
|
||||
installSource: {
|
||||
defaultChoice: "npm",
|
||||
npm: {
|
||||
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
|
||||
packageName: "@wecom/wecom-openclaw-plugin",
|
||||
selector: "1.2.3",
|
||||
selectorKind: "exact-version",
|
||||
exactVersion: true,
|
||||
expectedIntegrity: "sha512-wecom",
|
||||
pinState: "exact-with-integrity",
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accepts rich external manifest entries for yuanbao with pinned npm metadata",
|
||||
setup: () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-yuanbao-"));
|
||||
const catalogPath = path.join(dir, "catalog.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
$schema: "./manifest.schema.json",
|
||||
schemaVersion: 1,
|
||||
description:
|
||||
"Extension manifest. Declares plugin packages that OpenClaw can discover during onboarding and install on demand via `openclaw plugins install`.",
|
||||
entries: [
|
||||
{
|
||||
name: "openclaw-plugin-yuanbao",
|
||||
description:
|
||||
"OpenClaw Yuanbao (元宝) channel plugin — community maintained, published on npm.",
|
||||
source: "external",
|
||||
kind: "channel",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "openclaw-plugin-yuanbao",
|
||||
label: "Yuanbao",
|
||||
selectionLabel: "Yuanbao (Tencent Yuanbao)",
|
||||
detailLabel: "Yuanbao",
|
||||
docsPath: "/channels/yuanbao",
|
||||
docsLabel: "yuanbao",
|
||||
blurb: "Tencent Yuanbao AI assistant conversation channel.",
|
||||
aliases: ["yb", "tencent-yuanbao"],
|
||||
order: 78,
|
||||
},
|
||||
install: {
|
||||
npmSpec: "openclaw-plugin-yuanbao@1.0.0",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: ">=2026.4.10",
|
||||
expectedIntegrity: "sha512-yuanbao",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: "openclaw-plugin-yuanbao",
|
||||
catalogPaths: [catalogPath],
|
||||
expected: {
|
||||
id: "openclaw-plugin-yuanbao",
|
||||
meta: {
|
||||
label: "Yuanbao",
|
||||
selectionLabel: "Yuanbao (Tencent Yuanbao)",
|
||||
detailLabel: "Yuanbao",
|
||||
docsPath: "/channels/yuanbao",
|
||||
docsLabel: "yuanbao",
|
||||
blurb: "Tencent Yuanbao AI assistant conversation channel.",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "openclaw-plugin-yuanbao@1.0.0",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: ">=2026.4.10",
|
||||
expectedIntegrity: "sha512-yuanbao",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ setup }) => {
|
||||
const setupResult = setup();
|
||||
const { channelId, expected } = setupResult;
|
||||
expectCatalogEntryMatch({
|
||||
channelId,
|
||||
expected,
|
||||
...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}),
|
||||
...("env" in setupResult ? { env: setupResult.env } : {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeChannelPluginCatalogPathResolutionContract() {
|
||||
describe("channel plugin catalog path resolution contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "uses the provided env for external catalog path resolution",
|
||||
setup: () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
|
||||
const catalogPath = path.join(home, "catalog.json");
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@openclaw/env-demo-channel",
|
||||
channelId: "env-demo-channel",
|
||||
label: "Env Demo Channel",
|
||||
blurb: "Env demo entry",
|
||||
order: 1000,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json",
|
||||
OPENCLAW_HOME: home,
|
||||
HOME: home,
|
||||
},
|
||||
expectedId: "env-demo-channel",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses the provided env for default catalog paths",
|
||||
setup: () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@openclaw/default-env-demo",
|
||||
channelId: "default-env-demo",
|
||||
label: "Default Env Demo",
|
||||
blurb: "Default env demo entry",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
},
|
||||
expectedId: "default-env-demo",
|
||||
};
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ setup }) => {
|
||||
const { env, expectedId } = setup();
|
||||
expectCatalogIdsContain({ env, expectedId });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authorizeConfigWrite,
|
||||
canBypassConfigWritePolicy,
|
||||
formatConfigWriteDeniedMessage,
|
||||
resolveExplicitConfigWriteTarget,
|
||||
resolveConfigWriteTargetFromPath,
|
||||
} from "../../../src/channels/plugins/config-writes.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../../src/utils/message-channel.js";
|
||||
|
||||
const demoOriginChannelId = "demo-origin";
|
||||
const demoTargetChannelId = "demo-target";
|
||||
|
||||
function makeDemoConfigWritesCfg(accountIdKey: string) {
|
||||
return {
|
||||
channels: {
|
||||
[demoOriginChannelId]: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
[accountIdKey]: { configWrites: false },
|
||||
},
|
||||
},
|
||||
[demoTargetChannelId]: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
[accountIdKey]: { configWrites: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectConfigWriteBlocked(params: {
|
||||
disabledAccountId: string;
|
||||
reason: "target-disabled" | "origin-disabled";
|
||||
blockedScope: "target" | "origin";
|
||||
}) {
|
||||
expect(
|
||||
authorizeConfigWrite({
|
||||
cfg: makeDemoConfigWritesCfg(params.disabledAccountId),
|
||||
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
||||
target: resolveExplicitConfigWriteTarget({
|
||||
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
|
||||
accountId: "work",
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: params.reason,
|
||||
blockedScope: {
|
||||
kind: params.blockedScope,
|
||||
scope: {
|
||||
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
|
||||
accountId: params.blockedScope === "target" ? "work" : "default",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expectAuthorizedConfigWriteCase(
|
||||
input: Parameters<typeof authorizeConfigWrite>[0],
|
||||
expected: ReturnType<typeof authorizeConfigWrite>,
|
||||
) {
|
||||
expect(authorizeConfigWrite(input)).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectResolvedConfigWriteTargetCase(pathSegments: readonly string[], expected: unknown) {
|
||||
expect(resolveConfigWriteTargetFromPath([...pathSegments])).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectExplicitConfigWriteTargetCase(
|
||||
input: Parameters<typeof resolveExplicitConfigWriteTarget>[0],
|
||||
expected: ReturnType<typeof resolveExplicitConfigWriteTarget>,
|
||||
) {
|
||||
expect(resolveExplicitConfigWriteTarget(input)).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectFormattedDeniedMessage(
|
||||
result: Exclude<ReturnType<typeof authorizeConfigWrite>, { allowed: true }>,
|
||||
) {
|
||||
expect(
|
||||
formatConfigWriteDeniedMessage({
|
||||
result,
|
||||
}),
|
||||
).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`);
|
||||
}
|
||||
|
||||
export function describeChannelConfigWritePolicyContract() {
|
||||
describe("authorizeConfigWrite policy contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "blocks when a target account disables writes",
|
||||
disabledAccountId: "work",
|
||||
reason: "target-disabled",
|
||||
blockedScope: "target",
|
||||
},
|
||||
{
|
||||
name: "blocks when the origin account disables writes",
|
||||
disabledAccountId: "default",
|
||||
reason: "origin-disabled",
|
||||
blockedScope: "origin",
|
||||
},
|
||||
] as const)("$name", (testCase) => {
|
||||
expectConfigWriteBlocked(testCase);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "allows bypass for internal operator.admin writes",
|
||||
input: {
|
||||
cfg: makeDemoConfigWritesCfg("work"),
|
||||
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
||||
target: resolveExplicitConfigWriteTarget({
|
||||
channelId: demoTargetChannelId,
|
||||
accountId: "work",
|
||||
}),
|
||||
allowBypass: canBypassConfigWritePolicy({
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
},
|
||||
expected: { allowed: true },
|
||||
},
|
||||
{
|
||||
name: "treats non-channel config paths as global writes",
|
||||
input: {
|
||||
cfg: makeDemoConfigWritesCfg("work"),
|
||||
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
||||
target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
|
||||
},
|
||||
expected: { allowed: true },
|
||||
},
|
||||
] as const)("$name", ({ input, expected }) => {
|
||||
expectAuthorizedConfigWriteCase(input, expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeChannelConfigWriteTargetContract() {
|
||||
describe("authorizeConfigWrite target contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "rejects bare channel collection writes",
|
||||
pathSegments: ["channels", "demo-channel"],
|
||||
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
|
||||
},
|
||||
{
|
||||
name: "rejects account collection writes",
|
||||
pathSegments: ["channels", "demo-channel", "accounts"],
|
||||
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
|
||||
},
|
||||
] as const)("$name", ({ pathSegments, expected }) => {
|
||||
expectResolvedConfigWriteTargetCase(pathSegments, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "resolves explicit channel target",
|
||||
input: { channelId: demoOriginChannelId },
|
||||
expected: {
|
||||
kind: "channel",
|
||||
scope: { channelId: demoOriginChannelId },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolves explicit account target",
|
||||
input: { channelId: demoTargetChannelId, accountId: "work" },
|
||||
expected: {
|
||||
kind: "account",
|
||||
scope: { channelId: demoTargetChannelId, accountId: "work" },
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ input, expected }) => {
|
||||
expectExplicitConfigWriteTargetCase(input, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "formats denied messages consistently",
|
||||
result: {
|
||||
allowed: false,
|
||||
reason: "target-disabled",
|
||||
blockedScope: {
|
||||
kind: "target",
|
||||
scope: { channelId: demoTargetChannelId, accountId: "work" },
|
||||
},
|
||||
} as const,
|
||||
},
|
||||
] as const)("$name", ({ result }) => {
|
||||
expectFormattedDeniedMessage(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
expectDirectoryIds,
|
||||
type DirectoryListFn,
|
||||
} from "../../../src/plugin-sdk/test-helpers/directory-ids.js";
|
||||
@@ -1,49 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||
|
||||
type ResolvedGroupPolicy = ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
|
||||
|
||||
export type RuntimeGroupPolicyResolver = (
|
||||
params: Parameters<typeof resolveOpenProviderRuntimeGroupPolicy>[0],
|
||||
) => ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
|
||||
|
||||
export function installChannelRuntimeGroupPolicyFallbackSuite(params: {
|
||||
configuredLabel: string;
|
||||
defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open";
|
||||
missingConfigLabel: string;
|
||||
missingDefaultLabel: string;
|
||||
resolve: RuntimeGroupPolicyResolver;
|
||||
}) {
|
||||
it(params.missingConfigLabel, () => {
|
||||
const resolved = params.resolve({
|
||||
providerConfigPresent: false,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("allowlist");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(true);
|
||||
});
|
||||
|
||||
it(params.configuredLabel, () => {
|
||||
const resolved = params.resolve({
|
||||
providerConfigPresent: true,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("open");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
||||
});
|
||||
|
||||
it(params.missingDefaultLabel, () => {
|
||||
const resolved = params.resolve({
|
||||
providerConfigPresent: false,
|
||||
defaultGroupPolicy: params.defaultGroupPolicyUnderTest,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("allowlist");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function expectResolvedGroupPolicyCase(
|
||||
resolved: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
|
||||
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
|
||||
) {
|
||||
expect(resolved.groupPolicy).toBe(expected.groupPolicy);
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(expected.providerMissingFallbackApplied);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||
|
||||
const resolveWhatsAppRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
|
||||
const resolveZaloRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
|
||||
|
||||
export { resolveWhatsAppRuntimeGroupPolicy, resolveZaloRuntimeGroupPolicy };
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type CreateIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter }) => ChannelPlugin;
|
||||
|
||||
let createIMessageTestPluginCache: CreateIMessageTestPlugin | undefined;
|
||||
|
||||
function getCreateIMessageTestPlugin(): CreateIMessageTestPlugin {
|
||||
if (!createIMessageTestPluginCache) {
|
||||
({ createIMessageTestPlugin: createIMessageTestPluginCache } = loadBundledPluginTestApiSync<{
|
||||
createIMessageTestPlugin: CreateIMessageTestPlugin;
|
||||
}>("imessage"));
|
||||
}
|
||||
return createIMessageTestPluginCache;
|
||||
}
|
||||
|
||||
export const createIMessageTestPlugin: CreateIMessageTestPlugin = (...args) =>
|
||||
getCreateIMessageTestPlugin()(...args);
|
||||
@@ -1,18 +0,0 @@
|
||||
export function createLazyObjectSurface<T extends object>(loadSurface: () => T): T {
|
||||
return new Proxy({} as T, {
|
||||
get(_target, property) {
|
||||
const surface = loadSurface();
|
||||
const value = Reflect.get(surface, property, surface);
|
||||
return typeof value === "function" ? value.bind(surface) : value;
|
||||
},
|
||||
has(_target, property) {
|
||||
return property in loadSurface();
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(loadSurface());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
return Reflect.getOwnPropertyDescriptor(loadSurface(), property);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export const channelPluginSurfaceKeys = [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
] as const;
|
||||
|
||||
export type ChannelPluginSurface = (typeof channelPluginSurfaceKeys)[number];
|
||||
|
||||
export const sessionBindingContractChannelIds = [
|
||||
"bluebubbles",
|
||||
"discord",
|
||||
"feishu",
|
||||
"imessage",
|
||||
"matrix",
|
||||
"telegram",
|
||||
] as const;
|
||||
|
||||
export type SessionBindingContractChannelId = (typeof sessionBindingContractChannelIds)[number];
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { getBundledChannelPluginAsync } from "./bundled-channel-plugin-loader.js";
|
||||
import { channelPluginSurfaceKeys } from "./manifest.js";
|
||||
import { expectChannelPluginContract } from "./registry-contract-suites.js";
|
||||
import { getPluginContractRegistryShardRefs } from "./registry-plugin.js";
|
||||
import {
|
||||
getDirectoryContractRegistryShardRefs,
|
||||
getSurfaceContractRegistryShardIds,
|
||||
getThreadingContractRegistryShardRefs,
|
||||
} from "./surface-contract-registry.js";
|
||||
import { expectChannelSurfaceContract } from "./surface-contract-suite.js";
|
||||
import {
|
||||
expectChannelDirectoryBaseContract,
|
||||
expectChannelThreadingBaseContract,
|
||||
expectChannelThreadingReturnValuesNormalized,
|
||||
} from "./threading-directory-contract-suites.js";
|
||||
|
||||
type ContractShardParams = {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
};
|
||||
|
||||
function installEmptyShardSuite(label: string) {
|
||||
describe(label, () => {
|
||||
it("has no matching bundled channels", () => {
|
||||
// Keeps intentionally empty id-based shards visible to Vitest.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function installSurfaceContractRegistryShard(params: ContractShardParams) {
|
||||
const ids = getSurfaceContractRegistryShardIds(params);
|
||||
if (ids.length === 0) {
|
||||
installEmptyShardSuite("surface contract registry shard");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
describe(`${id} surface contracts`, () => {
|
||||
it("exposes declared surface contracts", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${id}`);
|
||||
}
|
||||
const surfaces = channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface]));
|
||||
for (const surface of surfaces) {
|
||||
expectChannelSurfaceContract({
|
||||
plugin,
|
||||
surface,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function installDirectoryContractRegistryShard(params: ContractShardParams) {
|
||||
const entries = getDirectoryContractRegistryShardRefs(params);
|
||||
if (entries.length === 0) {
|
||||
installEmptyShardSuite("directory contract registry shard");
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
describe(`${entry.id} directory contract`, () => {
|
||||
it("exposes the base directory contract", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(entry.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
|
||||
}
|
||||
await expectChannelDirectoryBaseContract({
|
||||
plugin,
|
||||
coverage: entry.coverage,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function installThreadingContractRegistryShard(params: ContractShardParams) {
|
||||
const entries = getThreadingContractRegistryShardRefs(params);
|
||||
if (entries.length === 0) {
|
||||
installEmptyShardSuite("threading contract registry shard");
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
describe(`${entry.id} threading contract`, () => {
|
||||
it("exposes the base threading contract", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(entry.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
|
||||
}
|
||||
expectChannelThreadingBaseContract(plugin);
|
||||
});
|
||||
|
||||
it("keeps threading return values normalized", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(entry.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
|
||||
}
|
||||
expectChannelThreadingReturnValuesNormalized(plugin);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function installPluginContractRegistryShard(params: ContractShardParams) {
|
||||
const entries = getPluginContractRegistryShardRefs(params);
|
||||
if (entries.length === 0) {
|
||||
installEmptyShardSuite("plugin contract registry shard");
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
describe(`${entry.id} plugin contract`, () => {
|
||||
it("satisfies the base channel plugin contract", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(entry.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
|
||||
}
|
||||
expectChannelPluginContract(plugin);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export {
|
||||
expectChannelPluginContract,
|
||||
installChannelActionsContractSuite,
|
||||
installChannelPluginContractSuite,
|
||||
installChannelSetupContractSuite,
|
||||
installChannelStatusContractSuite,
|
||||
} from "../../../src/plugin-sdk/test-helpers/channel-contract-suites.js";
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ChannelId } from "../../../src/channels/plugins/channel-id.types.js";
|
||||
import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
getBundledChannelCatalogEntry,
|
||||
getBundledChannelPlugin,
|
||||
listBundledChannelPluginIds,
|
||||
listBundledChannelPlugins,
|
||||
} from "./bundled-channel-plugin-loader.js";
|
||||
|
||||
type PluginContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
|
||||
};
|
||||
|
||||
type PluginContractRef = {
|
||||
id: ChannelId;
|
||||
};
|
||||
|
||||
function toPluginContractEntry(plugin: ChannelPlugin): PluginContractEntry {
|
||||
const existingMeta = getBundledChannelCatalogEntry(plugin.id)?.channel;
|
||||
return {
|
||||
id: plugin.id,
|
||||
plugin: {
|
||||
...plugin,
|
||||
meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta, existing: existingMeta }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getBundledChannelPluginIdsForShard(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): readonly ChannelId[] {
|
||||
return listBundledChannelPluginIds().filter(
|
||||
(_id, index) => index % params.shardCount === params.shardIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginContractRegistry(): PluginContractEntry[] {
|
||||
return listBundledChannelPlugins().map(toPluginContractEntry);
|
||||
}
|
||||
|
||||
export function getPluginContractRegistryShard(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): PluginContractEntry[] {
|
||||
return getBundledChannelPluginIdsForShard(params).flatMap((id) => {
|
||||
const plugin = getBundledChannelPlugin(id);
|
||||
return plugin ? [toPluginContractEntry(plugin)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getPluginContractRegistryShardRefs(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): PluginContractRef[] {
|
||||
return getBundledChannelPluginIdsForShard(params).map((id) => ({ id }));
|
||||
}
|
||||
@@ -1,630 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { createChannelConversationBindingManager } from "../../../src/channels/plugins/conversation-bindings.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type SessionBindingCapabilities,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
|
||||
import {
|
||||
sessionBindingContractChannelIds,
|
||||
type SessionBindingContractChannelId,
|
||||
} from "./manifest.js";
|
||||
import { importBundledChannelContractArtifact } from "./runtime-artifacts.js";
|
||||
import "../../../src/channels/plugins/registry.js";
|
||||
|
||||
type SessionBindingContractEntry = {
|
||||
id: string;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
beforeEach?: () => Promise<void> | void;
|
||||
};
|
||||
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
|
||||
|
||||
const matrixSessionBindingStateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
|
||||
);
|
||||
const matrixSessionBindingAuth = {
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
} as const;
|
||||
|
||||
async function getContractApi<T extends Record<string, unknown>>(pluginId: string): Promise<T> {
|
||||
const existing = contractApiPromises.get(pluginId);
|
||||
if (existing) {
|
||||
return (await existing) as T;
|
||||
}
|
||||
const next = importBundledChannelContractArtifact<T>(pluginId, "contract-api");
|
||||
contractApiPromises.set(pluginId, next);
|
||||
return await next;
|
||||
}
|
||||
|
||||
function expectResolvedSessionBinding(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
targetSessionKey: string;
|
||||
}) {
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
|
||||
const service = getSessionBindingService();
|
||||
const removed = await service.unbind({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "contract-test",
|
||||
});
|
||||
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
|
||||
expect(service.resolveByConversation(binding.conversation)).toBeNull();
|
||||
}
|
||||
|
||||
function expectClearedSessionBinding(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
}) {
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
|
||||
function resetMatrixSessionBindingStateDir() {
|
||||
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function createContractMatrixThreadBindingManager() {
|
||||
resetMatrixSessionBindingStateDir();
|
||||
const { setMatrixRuntime, createMatrixThreadBindingManager } =
|
||||
await getContractApi<MatrixContractApi>("matrix");
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => matrixSessionBindingStateDir,
|
||||
},
|
||||
} as never);
|
||||
return await createMatrixThreadBindingManager({
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
auth: matrixSessionBindingAuth,
|
||||
client: {} as never,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
enableSweeper: false,
|
||||
});
|
||||
}
|
||||
|
||||
const baseSessionBindingCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
type ChannelConversationBindingManagerFactory = NonNullable<
|
||||
NonNullable<ChannelPlugin["conversationBindings"]>["createManager"]
|
||||
>;
|
||||
|
||||
type BlueBubblesContractApi = {
|
||||
blueBubblesConversationBindingTesting: {
|
||||
resetBlueBubblesConversationBindingsForTests: () => void;
|
||||
};
|
||||
createBlueBubblesConversationBindingManager: ChannelConversationBindingManagerFactory;
|
||||
};
|
||||
|
||||
type DiscordContractApi = {
|
||||
createThreadBindingManager: (params: {
|
||||
accountId: string;
|
||||
cfg?: OpenClawConfig;
|
||||
persist: boolean;
|
||||
enableSweeper: boolean;
|
||||
}) => unknown;
|
||||
discordThreadBindingTesting: {
|
||||
resetThreadBindingsForTests: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type FeishuContractApi = {
|
||||
createFeishuThreadBindingManager: (params: {
|
||||
accountId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}) => unknown;
|
||||
feishuThreadBindingTesting: {
|
||||
resetFeishuThreadBindingsForTests: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type IMessageContractApi = {
|
||||
createIMessageConversationBindingManager: ChannelConversationBindingManagerFactory;
|
||||
imessageConversationBindingTesting: {
|
||||
resetIMessageConversationBindingsForTests: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type MatrixContractApi = {
|
||||
createMatrixThreadBindingManager: (params: {
|
||||
accountId: string;
|
||||
auth: typeof matrixSessionBindingAuth;
|
||||
client: unknown;
|
||||
idleTimeoutMs: number;
|
||||
maxAgeMs: number;
|
||||
enableSweeper: boolean;
|
||||
}) => Promise<unknown>;
|
||||
resetMatrixThreadBindingsForTests: () => void;
|
||||
setMatrixRuntime: (runtime: unknown) => void;
|
||||
};
|
||||
|
||||
type TelegramContractApi = {
|
||||
createTelegramThreadBindingManager: (params: {
|
||||
accountId: string;
|
||||
persist: boolean;
|
||||
enableSweeper: boolean;
|
||||
}) => unknown;
|
||||
resetTelegramThreadBindingsForTests: () => Promise<void>;
|
||||
};
|
||||
|
||||
function setRegistryBackedConversationBindingPlugin(params: {
|
||||
id: SessionBindingContractChannelId;
|
||||
createManager: ChannelConversationBindingManagerFactory;
|
||||
}) {
|
||||
const plugin = {
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
label: params.id,
|
||||
selectionLabel: params.id,
|
||||
blurb: "session binding contract fixture",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
createManager: params.createManager,
|
||||
},
|
||||
} as unknown as ChannelPlugin;
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: params.id,
|
||||
plugin,
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareBlueBubblesSessionBindingContract() {
|
||||
const api = await getContractApi<BlueBubblesContractApi>("bluebubbles");
|
||||
api.blueBubblesConversationBindingTesting.resetBlueBubblesConversationBindingsForTests();
|
||||
setRegistryBackedConversationBindingPlugin({
|
||||
id: "bluebubbles",
|
||||
createManager: api.createBlueBubblesConversationBindingManager,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareDiscordSessionBindingContract() {
|
||||
const api = await getContractApi<DiscordContractApi>("discord");
|
||||
api.discordThreadBindingTesting.resetThreadBindingsForTests();
|
||||
}
|
||||
|
||||
async function prepareFeishuSessionBindingContract() {
|
||||
const api = await getContractApi<FeishuContractApi>("feishu");
|
||||
api.feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
}
|
||||
|
||||
async function prepareIMessageSessionBindingContract() {
|
||||
const api = await getContractApi<IMessageContractApi>("imessage");
|
||||
api.imessageConversationBindingTesting.resetIMessageConversationBindingsForTests();
|
||||
setRegistryBackedConversationBindingPlugin({
|
||||
id: "imessage",
|
||||
createManager: api.createIMessageConversationBindingManager,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareMatrixSessionBindingContract() {
|
||||
const api = await getContractApi<MatrixContractApi>("matrix");
|
||||
api.resetMatrixThreadBindingsForTests();
|
||||
}
|
||||
|
||||
async function prepareTelegramSessionBindingContract() {
|
||||
const api = await getContractApi<TelegramContractApi>("telegram");
|
||||
await api.resetTelegramThreadBindingsForTests();
|
||||
}
|
||||
|
||||
const sessionBindingContractEntries: Record<
|
||||
SessionBindingContractChannelId,
|
||||
Omit<SessionBindingContractEntry, "id">
|
||||
> = {
|
||||
bluebubbles: {
|
||||
beforeEach: prepareBlueBubblesSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
void createChannelConversationBindingManager({
|
||||
channelId: "bluebubbles",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createChannelConversationBindingManager({
|
||||
channelId: "bluebubbles",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
const manager = await createChannelConversationBindingManager({
|
||||
channelId: "bluebubbles",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
await manager?.stop();
|
||||
expectClearedSessionBinding({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
});
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
beforeEach: prepareDiscordSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:discord:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "discord",
|
||||
label: "discord-child",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
targetSessionKey: "agent:discord:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
});
|
||||
},
|
||||
},
|
||||
feishu: {
|
||||
beforeEach: prepareFeishuSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
const { createFeishuThreadBindingManager } =
|
||||
await getContractApi<FeishuContractApi>("feishu");
|
||||
createFeishuThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createFeishuThreadBindingManager } =
|
||||
await getContractApi<FeishuContractApi>("feishu");
|
||||
createFeishuThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:feishu:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "feishu",
|
||||
label: "feishu-child",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetSessionKey: "agent:feishu:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
});
|
||||
},
|
||||
},
|
||||
imessage: {
|
||||
beforeEach: prepareIMessageSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
void createChannelConversationBindingManager({
|
||||
channelId: "imessage",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createChannelConversationBindingManager({
|
||||
channelId: "imessage",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:imessage:current",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "imessage",
|
||||
label: "imessage-main",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
targetSessionKey: "agent:imessage:current",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
const manager = await createChannelConversationBindingManager({
|
||||
channelId: "imessage",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
await manager?.stop();
|
||||
expectClearedSessionBinding({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
});
|
||||
},
|
||||
},
|
||||
matrix: {
|
||||
beforeEach: prepareMatrixSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
await createContractMatrixThreadBindingManager();
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createContractMatrixThreadBindingManager();
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:matrix:thread",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "matrix",
|
||||
label: "matrix-thread",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example.org",
|
||||
targetSessionKey: "agent:matrix:thread",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
conversationId: "$thread",
|
||||
});
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
beforeEach: prepareTelegramSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
const { createTelegramThreadBindingManager } =
|
||||
await getContractApi<TelegramContractApi>("telegram");
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createTelegramThreadBindingManager } =
|
||||
await getContractApi<TelegramContractApi>("telegram");
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:telegram:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "telegram",
|
||||
label: "telegram-topic",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
targetSessionKey: "agent:telegram:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;
|
||||
|
||||
export function getSessionBindingContractRegistry(): SessionBindingContractEntry[] {
|
||||
sessionBindingContractRegistryCache ??= sessionBindingContractChannelIds.map((id) =>
|
||||
Object.assign({ id }, sessionBindingContractEntries[id]),
|
||||
);
|
||||
return sessionBindingContractRegistryCache;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { resolveBundledChannelWorkspacePath } from "../../../src/plugins/bundled-channel-runtime.js";
|
||||
import {
|
||||
resolvePluginRuntimeModulePath,
|
||||
resolvePluginRuntimeRecord,
|
||||
} from "../../../src/plugins/runtime/runtime-plugin-boundary.js";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
|
||||
function resolveBundledChannelWorkspaceArtifactPath(
|
||||
pluginId: string,
|
||||
entryBaseName: string,
|
||||
): string | null {
|
||||
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
|
||||
const pluginRoot = resolveBundledChannelWorkspacePath({
|
||||
rootDir: REPO_ROOT,
|
||||
pluginId,
|
||||
});
|
||||
if (!pluginRoot) {
|
||||
return null;
|
||||
}
|
||||
for (const extension of ["js", "ts"]) {
|
||||
const candidate = path.join(pluginRoot, `${normalizedEntryBaseName}.${extension}`);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveBundledChannelContractArtifactUrl(
|
||||
pluginId: string,
|
||||
entryBaseName: string,
|
||||
): string {
|
||||
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
|
||||
const record = resolvePluginRuntimeRecord(pluginId, () => {
|
||||
throw new Error(`missing bundled channel plugin '${pluginId}'`);
|
||||
});
|
||||
if (!record) {
|
||||
throw new Error(`missing bundled channel plugin '${pluginId}'`);
|
||||
}
|
||||
const modulePath =
|
||||
resolvePluginRuntimeModulePath(record, normalizedEntryBaseName) ??
|
||||
resolveBundledChannelWorkspaceArtifactPath(pluginId, entryBaseName);
|
||||
if (!modulePath) {
|
||||
throw new Error(`missing ${entryBaseName} for bundled channel plugin '${pluginId}'`);
|
||||
}
|
||||
return pathToFileURL(modulePath).href;
|
||||
}
|
||||
|
||||
export async function importBundledChannelContractArtifact<T extends object>(
|
||||
pluginId: string,
|
||||
entryBaseName: string,
|
||||
): Promise<T> {
|
||||
return (await import(resolveBundledChannelContractArtifactUrl(pluginId, entryBaseName))) as T;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../../../src/config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
type SessionBindingCapabilities,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
|
||||
import { getSessionBindingContractRegistry } from "./registry-session-binding.js";
|
||||
|
||||
function resolveSessionBindingContractRuntimeConfig(id: string) {
|
||||
if (id !== "discord" && id !== "matrix") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
plugins: {
|
||||
entries: {
|
||||
[id]: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installSessionBindingContractSuite(params: {
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
}) {
|
||||
it("registers, binds, unbinds, and cleans up session bindings", async () => {
|
||||
expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities);
|
||||
const binding = await params.bindAndResolve();
|
||||
try {
|
||||
expect(typeof binding.bindingId).toBe("string");
|
||||
expect(binding.bindingId.trim()).not.toBe("");
|
||||
expect(typeof binding.targetSessionKey).toBe("string");
|
||||
expect(binding.targetSessionKey.trim()).not.toBe("");
|
||||
expect(["session", "subagent"]).toContain(binding.targetKind);
|
||||
expect(typeof binding.conversation.channel).toBe("string");
|
||||
expect(typeof binding.conversation.accountId).toBe("string");
|
||||
expect(typeof binding.conversation.conversationId).toBe("string");
|
||||
expect(["active", "ending", "ended"]).toContain(binding.status);
|
||||
expect(typeof binding.boundAt).toBe("number");
|
||||
await params.unbindAndVerify(binding);
|
||||
} finally {
|
||||
await params.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function describeSessionBindingRegistryBackedContract(id: string) {
|
||||
const entry = getSessionBindingContractRegistry().find((item) => item.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`missing session binding contract entry for ${id}`);
|
||||
}
|
||||
|
||||
describe(`${entry.id} session binding contract`, () => {
|
||||
beforeEach(async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
// Keep the suite hermetic; some contract helpers resolve runtime artifacts through config-aware
|
||||
// plugin boundaries, so never fall back to the developer's real ~/.openclaw/openclaw.json here.
|
||||
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
|
||||
// These registry-backed contract suites intentionally exercise bundled runtime facades.
|
||||
// Opt the bundled-runtime cases in so the activation boundary behaves like real runtime usage.
|
||||
setRuntimeConfigSnapshot(runtimeConfig);
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
await entry.beforeEach?.();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: entry.expectedCapabilities,
|
||||
getCapabilities: entry.getCapabilities,
|
||||
bindAndResolve: entry.bindAndResolve,
|
||||
unbindAndVerify: entry.unbindAndVerify,
|
||||
cleanup: entry.cleanup,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import type { ChannelId } from "../../../src/channels/plugins/channel-id.types.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
getBundledChannelPlugin,
|
||||
listBundledChannelPluginIds,
|
||||
listBundledChannelPlugins,
|
||||
} from "./bundled-channel-plugin-loader.js";
|
||||
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
|
||||
|
||||
type SurfaceContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<
|
||||
ChannelPlugin,
|
||||
| "id"
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway"
|
||||
>;
|
||||
surfaces: readonly ChannelPluginSurface[];
|
||||
};
|
||||
|
||||
type ThreadingContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
||||
};
|
||||
|
||||
type ThreadingContractRef = {
|
||||
id: ChannelId;
|
||||
};
|
||||
|
||||
type DirectoryContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
coverage: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type DirectoryContractRef = {
|
||||
id: ChannelId;
|
||||
coverage: "lookups" | "presence";
|
||||
};
|
||||
|
||||
let surfaceContractRegistryCache: SurfaceContractEntry[] | undefined;
|
||||
const surfaceContractEntryCache = new Map<ChannelId, SurfaceContractEntry | null>();
|
||||
let threadingContractRegistryCache: ThreadingContractEntry[] | undefined;
|
||||
let directoryContractRegistryCache: DirectoryContractEntry[] | undefined;
|
||||
|
||||
const threadingContractPluginIds = new Set<ChannelId>([
|
||||
"bluebubbles",
|
||||
"discord",
|
||||
"googlechat",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"slack",
|
||||
"telegram",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
]);
|
||||
|
||||
const directoryContractPluginIds = new Set<ChannelId>([
|
||||
"discord",
|
||||
"feishu",
|
||||
"googlechat",
|
||||
"irc",
|
||||
"line",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"slack",
|
||||
"synology-chat",
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
]);
|
||||
|
||||
function toSurfaceContractEntry(plugin: ChannelPlugin): SurfaceContractEntry {
|
||||
return {
|
||||
id: plugin.id,
|
||||
plugin,
|
||||
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
|
||||
};
|
||||
}
|
||||
|
||||
function getBundledChannelPluginIdsForShard(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): readonly ChannelId[] {
|
||||
return listBundledChannelPluginIds().filter(
|
||||
(_id, index) => index % params.shardCount === params.shardIndex,
|
||||
);
|
||||
}
|
||||
|
||||
function getSurfaceContractEntry(id: ChannelId): SurfaceContractEntry | undefined {
|
||||
if (surfaceContractEntryCache.has(id)) {
|
||||
return surfaceContractEntryCache.get(id) ?? undefined;
|
||||
}
|
||||
const plugin = getBundledChannelPlugin(id);
|
||||
const entry = plugin ? toSurfaceContractEntry(plugin) : null;
|
||||
surfaceContractEntryCache.set(id, entry);
|
||||
return entry ?? undefined;
|
||||
}
|
||||
|
||||
export function getSurfaceContractRegistry(): SurfaceContractEntry[] {
|
||||
surfaceContractRegistryCache ??= listBundledChannelPlugins().map(toSurfaceContractEntry);
|
||||
return surfaceContractRegistryCache;
|
||||
}
|
||||
|
||||
export function getSurfaceContractRegistryShard(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): SurfaceContractEntry[] {
|
||||
return getBundledChannelPluginIdsForShard(params).flatMap((id) => {
|
||||
const entry = getSurfaceContractEntry(id);
|
||||
return entry ? [entry] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getSurfaceContractRegistryShardIds(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): readonly ChannelId[] {
|
||||
return getBundledChannelPluginIdsForShard(params);
|
||||
}
|
||||
|
||||
export function getThreadingContractRegistry(): ThreadingContractEntry[] {
|
||||
threadingContractRegistryCache ??= listBundledChannelPluginIds()
|
||||
.filter((id) => threadingContractPluginIds.has(id))
|
||||
.flatMap((id) => {
|
||||
const entry = getSurfaceContractEntry(id);
|
||||
return entry && entry.surfaces.includes("threading")
|
||||
? [
|
||||
{
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
return threadingContractRegistryCache;
|
||||
}
|
||||
|
||||
export function getThreadingContractRegistryShard(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): ThreadingContractEntry[] {
|
||||
return getBundledChannelPluginIdsForShard(params)
|
||||
.filter((id) => threadingContractPluginIds.has(id))
|
||||
.flatMap((id) => {
|
||||
const entry = getSurfaceContractEntry(id);
|
||||
return entry && entry.surfaces.includes("threading")
|
||||
? [
|
||||
{
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getThreadingContractRegistryShardRefs(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): ThreadingContractRef[] {
|
||||
return getBundledChannelPluginIdsForShard(params)
|
||||
.filter((id) => threadingContractPluginIds.has(id))
|
||||
.map((id) => ({ id }));
|
||||
}
|
||||
|
||||
const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]);
|
||||
|
||||
export function getDirectoryContractRegistry(): DirectoryContractEntry[] {
|
||||
directoryContractRegistryCache ??= listBundledChannelPluginIds()
|
||||
.filter((id) => directoryContractPluginIds.has(id))
|
||||
.flatMap((id) => {
|
||||
const entry = getSurfaceContractEntry(id);
|
||||
return entry && entry.surfaces.includes("directory")
|
||||
? [
|
||||
{
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
return directoryContractRegistryCache;
|
||||
}
|
||||
|
||||
export function getDirectoryContractRegistryShard(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): DirectoryContractEntry[] {
|
||||
return getBundledChannelPluginIdsForShard(params)
|
||||
.filter((id) => directoryContractPluginIds.has(id))
|
||||
.flatMap((id) => {
|
||||
const entry = getSurfaceContractEntry(id);
|
||||
return entry && entry.surfaces.includes("directory")
|
||||
? [
|
||||
{
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getDirectoryContractRegistryShardRefs(params: {
|
||||
shardIndex: number;
|
||||
shardCount: number;
|
||||
}): DirectoryContractRef[] {
|
||||
return getBundledChannelPluginIdsForShard(params)
|
||||
.filter((id) => directoryContractPluginIds.has(id))
|
||||
.map((id) => ({
|
||||
id,
|
||||
coverage: directoryPresenceOnlyIds.has(id) ? "presence" : "lookups",
|
||||
}));
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
|
||||
export function installChannelSurfaceContractSuite(params: {
|
||||
plugin: Pick<
|
||||
ChannelPlugin,
|
||||
| "id"
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway"
|
||||
>;
|
||||
surface:
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway";
|
||||
}) {
|
||||
it(`exposes the ${params.surface} surface contract`, () => {
|
||||
expectChannelSurfaceContract(params);
|
||||
});
|
||||
}
|
||||
|
||||
export function expectChannelSurfaceContract(params: {
|
||||
plugin: Pick<
|
||||
ChannelPlugin,
|
||||
| "id"
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway"
|
||||
>;
|
||||
surface:
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway";
|
||||
}) {
|
||||
const { plugin, surface } = params;
|
||||
|
||||
if (surface === "actions") {
|
||||
expect(plugin.actions).toBeDefined();
|
||||
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "setup") {
|
||||
expect(plugin.setup).toBeDefined();
|
||||
expect(typeof plugin.setup?.applyAccountConfig).toBe("function");
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "status") {
|
||||
expect(plugin.status).toBeDefined();
|
||||
expect(typeof plugin.status?.buildAccountSnapshot).toBe("function");
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "outbound") {
|
||||
const outbound = plugin.outbound;
|
||||
expect(outbound).toBeDefined();
|
||||
expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode);
|
||||
expect(
|
||||
[
|
||||
outbound?.sendPayload,
|
||||
outbound?.sendFormattedText,
|
||||
outbound?.sendFormattedMedia,
|
||||
outbound?.sendText,
|
||||
outbound?.sendMedia,
|
||||
outbound?.sendPoll,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "messaging") {
|
||||
const messaging = plugin.messaging;
|
||||
expect(messaging).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
messaging?.normalizeTarget,
|
||||
messaging?.parseExplicitTarget,
|
||||
messaging?.inferTargetChatType,
|
||||
messaging?.buildCrossContextPresentation,
|
||||
messaging?.enableInteractiveReplies,
|
||||
messaging?.hasStructuredReplyPayload,
|
||||
messaging?.formatTargetDisplay,
|
||||
messaging?.resolveOutboundSessionRoute,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
if (messaging?.targetResolver) {
|
||||
if (messaging.targetResolver.looksLikeId) {
|
||||
expect(typeof messaging.targetResolver.looksLikeId).toBe("function");
|
||||
}
|
||||
if (messaging.targetResolver.hint !== undefined) {
|
||||
expect(typeof messaging.targetResolver.hint).toBe("string");
|
||||
expect(messaging.targetResolver.hint.trim()).not.toBe("");
|
||||
}
|
||||
if (messaging.targetResolver.resolveTarget) {
|
||||
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "threading") {
|
||||
const threading = plugin.threading;
|
||||
expect(threading).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
threading?.resolveReplyToMode,
|
||||
threading?.buildToolContext,
|
||||
threading?.resolveAutoThreadId,
|
||||
threading?.resolveReplyTransport,
|
||||
threading?.resolveFocusedBinding,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "directory") {
|
||||
const directory = plugin.directory;
|
||||
expect(directory).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
directory?.self,
|
||||
directory?.listPeers,
|
||||
directory?.listPeersLive,
|
||||
directory?.listGroups,
|
||||
directory?.listGroupsLive,
|
||||
directory?.listGroupMembers,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const gateway = plugin.gateway;
|
||||
expect(gateway).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
gateway?.startAccount,
|
||||
gateway?.stopAccount,
|
||||
gateway?.loginWithQrStart,
|
||||
gateway?.loginWithQrWait,
|
||||
gateway?.logoutAccount,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelFocusedBindingContext,
|
||||
ChannelReplyTransport,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../../src/channels/plugins/types.core.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
|
||||
let contractRuntime: RuntimeEnv | undefined;
|
||||
|
||||
async function getDirectoryContractRuntime(): Promise<RuntimeEnv> {
|
||||
if (contractRuntime) {
|
||||
return contractRuntime;
|
||||
}
|
||||
const { createNonExitingRuntime } = await import("../../../src/runtime.js");
|
||||
contractRuntime = createNonExitingRuntime();
|
||||
return contractRuntime;
|
||||
}
|
||||
|
||||
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
|
||||
expect(["user", "group", "channel"]).toContain(entry.kind);
|
||||
expect(typeof entry.id).toBe("string");
|
||||
expect(entry.id.trim()).not.toBe("");
|
||||
if (entry.name !== undefined) {
|
||||
expect(typeof entry.name).toBe("string");
|
||||
}
|
||||
if (entry.handle !== undefined) {
|
||||
expect(typeof entry.handle).toBe("string");
|
||||
}
|
||||
if (entry.avatarUrl !== undefined) {
|
||||
expect(typeof entry.avatarUrl).toBe("string");
|
||||
}
|
||||
if (entry.rank !== undefined) {
|
||||
expect(typeof entry.rank).toBe("number");
|
||||
}
|
||||
}
|
||||
|
||||
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
|
||||
if (context.currentChannelId !== undefined) {
|
||||
expect(typeof context.currentChannelId).toBe("string");
|
||||
}
|
||||
if (context.currentChannelProvider !== undefined) {
|
||||
expect(typeof context.currentChannelProvider).toBe("string");
|
||||
}
|
||||
if (context.currentThreadTs !== undefined) {
|
||||
expect(typeof context.currentThreadTs).toBe("string");
|
||||
}
|
||||
if (context.currentMessageId !== undefined) {
|
||||
expect(["string", "number"]).toContain(typeof context.currentMessageId);
|
||||
}
|
||||
if (context.replyToMode !== undefined) {
|
||||
expect(["off", "first", "all"]).toContain(context.replyToMode);
|
||||
}
|
||||
if (context.hasRepliedRef !== undefined) {
|
||||
expect(typeof context.hasRepliedRef).toBe("object");
|
||||
}
|
||||
if (context.skipCrossContextDecoration !== undefined) {
|
||||
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function expectReplyTransportShape(transport: ChannelReplyTransport) {
|
||||
if (transport.replyToId !== undefined && transport.replyToId !== null) {
|
||||
expect(typeof transport.replyToId).toBe("string");
|
||||
}
|
||||
if (transport.threadId !== undefined && transport.threadId !== null) {
|
||||
expect(["string", "number"]).toContain(typeof transport.threadId);
|
||||
}
|
||||
}
|
||||
|
||||
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
|
||||
expect(typeof binding.conversationId).toBe("string");
|
||||
expect(binding.conversationId.trim()).not.toBe("");
|
||||
if (binding.parentConversationId !== undefined) {
|
||||
expect(typeof binding.parentConversationId).toBe("string");
|
||||
}
|
||||
expect(["current", "child"]).toContain(binding.placement);
|
||||
expect(typeof binding.labelNoun).toBe("string");
|
||||
expect(binding.labelNoun.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
export function installChannelThreadingContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
||||
}) {
|
||||
it("exposes the base threading contract", () => {
|
||||
expectChannelThreadingBaseContract(params.plugin);
|
||||
});
|
||||
|
||||
it("keeps threading return values normalized", () => {
|
||||
expectChannelThreadingReturnValuesNormalized(params.plugin);
|
||||
});
|
||||
}
|
||||
|
||||
export function expectChannelThreadingBaseContract(
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">,
|
||||
) {
|
||||
expect(plugin.threading).toBeDefined();
|
||||
}
|
||||
|
||||
export function expectChannelThreadingReturnValuesNormalized(
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">,
|
||||
) {
|
||||
const threading = plugin.threading;
|
||||
expect(threading).toBeDefined();
|
||||
|
||||
if (threading?.resolveReplyToMode) {
|
||||
expect(
|
||||
["off", "first", "all"].includes(
|
||||
threading.resolveReplyToMode({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
chatType: "group",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const repliedRef = { value: false };
|
||||
const toolContext = threading?.buildToolContext?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
context: {
|
||||
Channel: "group:test",
|
||||
From: "user:test",
|
||||
To: "group:test",
|
||||
ChatType: "group",
|
||||
CurrentMessageId: "msg-1",
|
||||
ReplyToId: "msg-0",
|
||||
ReplyToIdFull: "thread-0",
|
||||
MessageThreadId: "thread-0",
|
||||
NativeChannelId: "native:test",
|
||||
},
|
||||
hasRepliedRef: repliedRef,
|
||||
});
|
||||
|
||||
if (toolContext) {
|
||||
expectThreadingToolContextShape(toolContext);
|
||||
if (toolContext.hasRepliedRef) {
|
||||
expect(toolContext.hasRepliedRef).toBe(repliedRef);
|
||||
}
|
||||
}
|
||||
|
||||
const autoThreadId = threading?.resolveAutoThreadId?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
to: "group:test",
|
||||
toolContext,
|
||||
replyToId: null,
|
||||
});
|
||||
if (autoThreadId !== undefined) {
|
||||
expect(typeof autoThreadId).toBe("string");
|
||||
expect(autoThreadId.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
const replyTransport = threading?.resolveReplyTransport?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
threadId: "thread-0",
|
||||
replyToId: "msg-0",
|
||||
});
|
||||
if (replyTransport) {
|
||||
expectReplyTransportShape(replyTransport);
|
||||
}
|
||||
|
||||
const focusedBinding = threading?.resolveFocusedBinding?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
context: {
|
||||
Channel: "group:test",
|
||||
From: "user:test",
|
||||
To: "group:test",
|
||||
ChatType: "group",
|
||||
CurrentMessageId: "msg-1",
|
||||
ReplyToId: "msg-0",
|
||||
ReplyToIdFull: "thread-0",
|
||||
MessageThreadId: "thread-0",
|
||||
NativeChannelId: "native:test",
|
||||
},
|
||||
});
|
||||
if (focusedBinding) {
|
||||
expectFocusedBindingShape(focusedBinding);
|
||||
}
|
||||
}
|
||||
|
||||
export function installChannelDirectoryContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
coverage?: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) {
|
||||
it("exposes the base directory contract", async () => {
|
||||
await expectChannelDirectoryBaseContract(params);
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectChannelDirectoryBaseContract(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
coverage?: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) {
|
||||
const directory = params.plugin.directory;
|
||||
expect(directory).toBeDefined();
|
||||
|
||||
if (params.coverage === "presence") {
|
||||
return;
|
||||
}
|
||||
const runtime = await getDirectoryContractRuntime();
|
||||
const self = await directory?.self?.({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
runtime,
|
||||
});
|
||||
if (self) {
|
||||
expectDirectoryEntryShape(self);
|
||||
}
|
||||
|
||||
const peers =
|
||||
(await directory?.listPeers?.({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime,
|
||||
})) ?? [];
|
||||
expect(Array.isArray(peers)).toBe(true);
|
||||
for (const peer of peers) {
|
||||
expectDirectoryEntryShape(peer);
|
||||
}
|
||||
|
||||
const groups =
|
||||
(await directory?.listGroups?.({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime,
|
||||
})) ?? [];
|
||||
expect(Array.isArray(groups)).toBe(true);
|
||||
for (const group of groups) {
|
||||
expectDirectoryEntryShape(group);
|
||||
}
|
||||
|
||||
if (directory?.listGroupMembers && groups[0]?.id) {
|
||||
const members = await directory.listGroupMembers({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
groupId: groups[0].id,
|
||||
limit: 5,
|
||||
runtime,
|
||||
});
|
||||
expect(Array.isArray(members)).toBe(true);
|
||||
for (const member of members) {
|
||||
expectDirectoryEntryShape(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user