mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
test(channels): extend bundled artifact parity and add plugin-shape contract suite (#104618)
This commit is contained in:
@@ -101,6 +101,7 @@ Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` sum
|
||||
- **TUI PTY tests:** `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` runs the fast fake-backend PTY lane. `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` or `pnpm tui:pty:test:watch --mode local` runs the slower `tui --local` smoke, which mocks only the external model endpoint. Assert stable visible text or fixture calls, not raw ANSI snapshots.
|
||||
- `pnpm test:extensions` and `pnpm test extensions` run all extension/plugin shards. Heavy channel plugins, the browser plugin, and OpenAI run as dedicated shards; other plugin groups stay batched. `pnpm test extensions/<id>` runs one bundled plugin lane.
|
||||
- Source files with sibling tests map to that sibling before falling back to wider directory globs. Helper edits under `src/channels/plugins/contracts/test-helpers`, `src/plugin-sdk/test-helpers`, and `src/plugins/contracts` use a local import graph to run importing tests instead of broad-running every shard when the dependency path is precise.
|
||||
- Contract directory targets fan out to their contract lanes: `pnpm test src/channels/plugins/contracts` runs the four channel contract configs and `pnpm test src/plugins/contracts` runs the plugin contracts config, since the generic `channels`/`plugins` projects exclude `contracts/**`.
|
||||
- `auto-reply` splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.
|
||||
- Selected `plugin-sdk` and `commands` test files route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
|
||||
- Base Vitest config defaults to `pool: "threads"` and `isolate: false`, with the shared non-isolated runner enabled across repo configs.
|
||||
|
||||
@@ -1702,8 +1702,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
}
|
||||
return { to: `chat:${parsed?.chatId ?? conversationId.trim()}` };
|
||||
},
|
||||
resolveSessionConversation: ({ kind, rawId }) =>
|
||||
resolveFeishuSessionConversation({ kind, rawId }),
|
||||
// Same function as the public session-key artifact so the pre-registry
|
||||
// fast path cannot drift from plugin behavior (pinned by contract test).
|
||||
resolveSessionConversation: resolveFeishuSessionConversation,
|
||||
resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeFeishuId,
|
||||
|
||||
@@ -32,7 +32,7 @@ export const mattermostSetupPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
describeAccount: describeMattermostAccount,
|
||||
},
|
||||
gateway: {
|
||||
resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg),
|
||||
resolveGatewayAuthBypassPaths: resolveMattermostGatewayAuthBypassPaths,
|
||||
},
|
||||
setup: mattermostSetupAdapter,
|
||||
setupWizard: mattermostSetupWizard,
|
||||
|
||||
@@ -868,7 +868,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
}),
|
||||
}),
|
||||
gateway: {
|
||||
resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg),
|
||||
// Same function as the public gateway-auth artifact so the pre-plugin
|
||||
// fast path and the loaded plugin cannot drift (pinned by contract test).
|
||||
resolveGatewayAuthBypassPaths: resolveMattermostGatewayAuthBypassPaths,
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
|
||||
@@ -18,16 +18,18 @@ describe("Mattermost gateway auth bypass paths", () => {
|
||||
it("keeps only Mattermost channel callback paths", () => {
|
||||
expect(
|
||||
resolveMattermostGatewayAuthBypassPaths({
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
callbackUrl: "https://gateway.example.com/api/channels/nostr/default/profile",
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
commands: {
|
||||
callbackPath: "/api/channels/mattermost/work",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
callbackUrl: "https://gateway.example.com/api/channels/nostr/default/profile",
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
commands: {
|
||||
callbackPath: "/api/channels/mattermost/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -54,12 +54,17 @@ export function collectMattermostSlashCallbackPaths(
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
export function resolveMattermostGatewayAuthBypassPaths(cfg: {
|
||||
channels?: Record<string, unknown>;
|
||||
// Params shape is the core gateway-auth artifact contract: core invokes the
|
||||
// public `gateway-auth-api.js` export as `resolveGatewayAuthBypassPaths({ cfg })`
|
||||
// (src/channels/plugins/gateway-auth-bypass.ts), so a positional `cfg` param
|
||||
// would silently drop configured callback paths on the pre-plugin fast path.
|
||||
export function resolveMattermostGatewayAuthBypassPaths(params: {
|
||||
cfg: { channels?: Record<string, unknown> };
|
||||
}): string[] {
|
||||
const channels = params.cfg.channels;
|
||||
const base =
|
||||
cfg.channels?.mattermost && typeof cfg.channels.mattermost === "object"
|
||||
? (cfg.channels.mattermost as MattermostConfigInput)
|
||||
channels?.mattermost && typeof channels.mattermost === "object"
|
||||
? (channels.mattermost as MattermostConfigInput)
|
||||
: undefined;
|
||||
const callbackPaths = new Set(
|
||||
collectMattermostSlashCallbackPaths(readMattermostCommands(base?.commands)).filter(
|
||||
|
||||
@@ -837,8 +837,9 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
resolveTelegramInboundConversation({ to, conversationId, threadId }),
|
||||
resolveDeliveryTarget: ({ conversationId, parentConversationId }) =>
|
||||
resolveTelegramDeliveryTarget({ conversationId, parentConversationId }),
|
||||
resolveSessionConversation: ({ kind, rawId }) =>
|
||||
resolveTelegramSessionConversation({ kind, rawId }),
|
||||
// Same function as the public session-key artifact so the pre-registry
|
||||
// fast path cannot drift from plugin behavior (pinned by contract test).
|
||||
resolveSessionConversation: resolveTelegramSessionConversation,
|
||||
resolveSessionTarget: ({ kind, id }) => resolveTelegramSessionTarget({ kind, id }),
|
||||
inferTargetChatType: ({ to }) => resolveTelegramRouteTarget(to).chatType,
|
||||
preserveHeartbeatThreadIdForGroupRoute: true,
|
||||
|
||||
@@ -9,6 +9,8 @@ function listContractTestFiles(rootDir = "src/channels/plugins/contracts") {
|
||||
const CONTRACT_FILE_WEIGHTS = new Map([
|
||||
["channel-import-guardrails.test.ts", 18],
|
||||
["outbound-payload.contract.test.ts", 18],
|
||||
// Loads every bundled channel plugin surface in one file.
|
||||
["plugin-shape.contract.test.ts", 48],
|
||||
["plugins-core.catalog.paths.contract.test.ts", 28],
|
||||
["plugins-core.catalog.entries.contract.test.ts", 16],
|
||||
["session-binding.registry-backed.contract.test.ts", 40],
|
||||
|
||||
@@ -2344,6 +2344,7 @@ const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
|
||||
"src/channels/plugins/contracts/channel-catalog.contract.test.ts",
|
||||
"src/channels/plugins/contracts/channel-import-guardrails.test.ts",
|
||||
"src/channels/plugins/contracts/group-policy.fallback.contract.test.ts",
|
||||
"src/channels/plugins/contracts/message-tool-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/outbound-payload.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-a.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-e.contract.test.ts",
|
||||
@@ -2352,6 +2353,7 @@ const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
|
||||
[
|
||||
CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG,
|
||||
[
|
||||
"src/channels/plugins/contracts/gateway-auth-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.authorize-config-write.policy.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.authorize-config-write.targets.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.catalog.entries.contract.test.ts",
|
||||
@@ -2362,6 +2364,7 @@ const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
|
||||
[
|
||||
CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG,
|
||||
[
|
||||
"src/channels/plugins/contracts/plugin-shape.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.catalog.paths.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.loader.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.registry.contract.test.ts",
|
||||
@@ -2375,6 +2378,7 @@ const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map([
|
||||
"src/channels/plugins/contracts/plugins-core.resolve-config-writes.contract.test.ts",
|
||||
"src/channels/plugins/contracts/registry.contract.test.ts",
|
||||
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
|
||||
"src/channels/plugins/contracts/session-key-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/thread-binding-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-d.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-h.contract.test.ts",
|
||||
@@ -2714,6 +2718,22 @@ function expandExplicitSourceTestTargets(targetArgs, cwd) {
|
||||
if (relative === "src/commands" && isExistingDirectoryTarget(targetArg, cwd)) {
|
||||
return [COMMANDS_LIGHT_VITEST_CONFIG, COMMANDS_VITEST_CONFIG];
|
||||
}
|
||||
// Contract directory targets must fan out to the owning contract lanes; the
|
||||
// generic channels/plugins projects exclude contracts/**, so routing a
|
||||
// contracts directory there silently runs zero tests (passWithNoTests).
|
||||
if (isExistingDirectoryTarget(targetArg, cwd)) {
|
||||
if (isPathAtOrUnder(relative, "src/channels/plugins/contracts")) {
|
||||
return [
|
||||
CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG,
|
||||
CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG,
|
||||
CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG,
|
||||
CONTRACTS_CHANNEL_SESSION_VITEST_CONFIG,
|
||||
];
|
||||
}
|
||||
if (isPathAtOrUnder(relative, "src/plugins/contracts")) {
|
||||
return [CONTRACTS_PLUGIN_VITEST_CONFIG];
|
||||
}
|
||||
}
|
||||
const exactDirectoryTargets = resolveExactSourceDirectoryTestTargets(targetArg, cwd);
|
||||
if (exactDirectoryTargets) {
|
||||
return exactDirectoryTargets;
|
||||
@@ -3201,6 +3221,7 @@ function resolveChannelContractTargetKind(relative) {
|
||||
"channel-catalog.contract.test.ts",
|
||||
"channel-import-guardrails.test.ts",
|
||||
"group-policy.fallback.contract.test.ts",
|
||||
"message-tool-artifact.contract.test.ts",
|
||||
"outbound-payload.contract.test.ts",
|
||||
].includes(name)
|
||||
) {
|
||||
@@ -3208,6 +3229,7 @@ function resolveChannelContractTargetKind(relative) {
|
||||
}
|
||||
if (
|
||||
[
|
||||
"gateway-auth-artifact.contract.test.ts",
|
||||
"plugins-core.authorize-config-write.policy.contract.test.ts",
|
||||
"plugins-core.authorize-config-write.targets.contract.test.ts",
|
||||
"plugins-core.catalog.entries.contract.test.ts",
|
||||
@@ -3217,6 +3239,7 @@ function resolveChannelContractTargetKind(relative) {
|
||||
}
|
||||
if (
|
||||
[
|
||||
"plugin-shape.contract.test.ts",
|
||||
"plugins-core.catalog.paths.contract.test.ts",
|
||||
"plugins-core.loader.contract.test.ts",
|
||||
"plugins-core.registry.contract.test.ts",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Gateway-auth artifact parity contract for bundled channel plugins.
|
||||
//
|
||||
// Core resolves unauthenticated Gateway callback paths from lightweight
|
||||
// `gateway-auth-api` artifacts (src/channels/plugins/gateway-auth-bypass.ts),
|
||||
// which invoke the export with a `{ cfg }` params object. This suite pins each
|
||||
// artifact export to the exact function the loaded plugin's gateway surface
|
||||
// uses and pins the params-object call shape core relies on.
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBundledChannelGatewayAuthArtifactAsync,
|
||||
getBundledChannelPluginAsync,
|
||||
listBundledChannelPluginIds,
|
||||
} from "./test-helpers/bundled-channel-plugin-loader.js";
|
||||
|
||||
// Bundled channels expected to ship a top-level gateway-auth artifact.
|
||||
const GATEWAY_AUTH_ARTIFACT_PLUGIN_IDS = ["mattermost"] as const;
|
||||
|
||||
describe("bundled channel gateway-auth artifact parity", () => {
|
||||
const artifactResolvers = new Map<string, unknown>();
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const id of listBundledChannelPluginIds()) {
|
||||
const artifact = await getBundledChannelGatewayAuthArtifactAsync(id);
|
||||
if (artifact) {
|
||||
artifactResolvers.set(id, artifact.resolveGatewayAuthBypassPaths);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the artifact table in sync with bundled channels that ship one", () => {
|
||||
expect([...artifactResolvers.keys()].toSorted()).toEqual([...GATEWAY_AUTH_ARTIFACT_PLUGIN_IDS]);
|
||||
});
|
||||
|
||||
it.each(GATEWAY_AUTH_ARTIFACT_PLUGIN_IDS)(
|
||||
"keeps the %s artifact resolver identical to the plugin gateway surface",
|
||||
async (id) => {
|
||||
const resolveGatewayAuthBypassPaths = artifactResolvers.get(id);
|
||||
expect(typeof resolveGatewayAuthBypassPaths).toBe("function");
|
||||
|
||||
const plugin = await getBundledChannelPluginAsync(id);
|
||||
expect(plugin?.gateway?.resolveGatewayAuthBypassPaths).toBe(resolveGatewayAuthBypassPaths);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves mattermost bypass paths through core's { cfg } call shape", () => {
|
||||
// Regression pin: the artifact once took `cfg` positionally, so core's
|
||||
// `{ cfg }` invocation silently dropped configured callback paths.
|
||||
const resolve = artifactResolvers.get("mattermost") as (params: {
|
||||
cfg: { channels?: Record<string, unknown> };
|
||||
}) => string[];
|
||||
const cfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: { callbackPath: "/api/channels/mattermost/custom" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolve({ cfg })).toContain("/api/channels/mattermost/custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Message-tool artifact parity contract for bundled channel plugins.
|
||||
//
|
||||
// Core discovers message-tool schemas from lightweight `message-tool-api`
|
||||
// artifacts without loading full channel plugins
|
||||
// (src/channels/plugins/message-tool-api.ts). This suite pins each artifact
|
||||
// export to the exact function the loaded plugin's action surface uses so
|
||||
// discovery cannot drift from runtime behavior.
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBundledChannelMessageToolArtifactAsync,
|
||||
getBundledChannelPluginAsync,
|
||||
listBundledChannelPluginIds,
|
||||
} from "./test-helpers/bundled-channel-plugin-loader.js";
|
||||
|
||||
// Bundled channels expected to ship a top-level message-tool artifact.
|
||||
const MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS = ["imessage", "slack"] as const;
|
||||
|
||||
describe("bundled channel message-tool artifact parity", () => {
|
||||
const artifactDescribers = new Map<string, unknown>();
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const id of listBundledChannelPluginIds()) {
|
||||
const artifact = await getBundledChannelMessageToolArtifactAsync(id);
|
||||
if (artifact) {
|
||||
artifactDescribers.set(id, artifact.describeMessageTool);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the artifact table in sync with bundled channels that ship one", () => {
|
||||
expect([...artifactDescribers.keys()].toSorted()).toEqual([
|
||||
...MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS)(
|
||||
"keeps the %s artifact describer identical to the plugin action surface",
|
||||
async (id) => {
|
||||
const describeMessageTool = artifactDescribers.get(id);
|
||||
expect(typeof describeMessageTool).toBe("function");
|
||||
|
||||
const plugin = await getBundledChannelPluginAsync(id);
|
||||
expect(plugin?.actions?.describeMessageTool).toBe(describeMessageTool);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Plugin-shape coherence contract for bundled channel plugins.
|
||||
//
|
||||
// Catalog routing keys off plugin ids, docs surfaces render `meta.docsPath`,
|
||||
// and capability flags gate feature discovery, so every bundled channel must
|
||||
// keep identity metadata aligned with its catalog id and capability flags
|
||||
// coherent with the adapters that implement them. Per-surface behavior checks
|
||||
// live in the registry-backed shard suites; this suite pins the static shape.
|
||||
//
|
||||
// Capability rules verified against every bundled channel before pinning.
|
||||
// Dropped for legitimate exceptions rather than special-casing:
|
||||
// - threads=true does not imply a threading adapter (clickclack/tlon bind
|
||||
// threads through conversationBindings only).
|
||||
// - nativeCommands=true does not imply a commands adapter (mattermost serves
|
||||
// slash commands through gateway HTTP routes).
|
||||
// - blockStreaming=true does not imply a streaming adapter (coalesce tuning
|
||||
// is optional).
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBundledChannelPluginAsync,
|
||||
listBundledChannelPluginIds,
|
||||
} from "./test-helpers/bundled-channel-plugin-loader.js";
|
||||
|
||||
const CHAT_TYPES = new Set(["direct", "group", "channel", "thread"]);
|
||||
const bundledChannelPluginIds = listBundledChannelPluginIds();
|
||||
|
||||
describe("bundled channel plugin shape coherence", () => {
|
||||
const plugins = new Map<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const id of bundledChannelPluginIds) {
|
||||
plugins.set(id, await getBundledChannelPluginAsync(id));
|
||||
}
|
||||
});
|
||||
|
||||
it("discovers bundled channel plugins from the catalog", () => {
|
||||
expect(bundledChannelPluginIds.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe.each(bundledChannelPluginIds)("%s", (id) => {
|
||||
it("keeps plugin identity aligned with the catalog id", () => {
|
||||
const plugin = plugins.get(id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${id}`);
|
||||
}
|
||||
expect(plugin.id).toBe(id);
|
||||
expect(plugin.meta.id).toBe(id);
|
||||
});
|
||||
|
||||
it("ships non-empty docs metadata", () => {
|
||||
const plugin = plugins.get(id);
|
||||
expect(plugin?.meta.docsPath.trim()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("declares known chat types", () => {
|
||||
const chatTypes = plugins.get(id)?.capabilities.chatTypes ?? [];
|
||||
expect(chatTypes.length).toBeGreaterThan(0);
|
||||
expect(chatTypes.filter((chatType) => !CHAT_TYPES.has(chatType))).toEqual([]);
|
||||
});
|
||||
|
||||
it("backs declared reactions with a message action surface", () => {
|
||||
const plugin = plugins.get(id);
|
||||
if (!plugin?.capabilities.reactions) {
|
||||
return;
|
||||
}
|
||||
// Reactions are delivered through the shared `message` tool, so a channel
|
||||
// declaring the capability without an actions adapter ships a dead flag.
|
||||
expect(plugin.actions).toBeDefined();
|
||||
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// Session-key artifact parity contract for bundled channel plugins.
|
||||
//
|
||||
// Core resolves threaded session conversations from lightweight `session-key-api`
|
||||
// artifacts before full plugin loading (src/channels/plugins/session-conversation.ts).
|
||||
// This suite pins each artifact export to the exact function the loaded plugin's
|
||||
// messaging surface uses so the fast path cannot drift from plugin behavior.
|
||||
//
|
||||
// Artifact exports are intentionally non-uniform: telegram/feishu ship the
|
||||
// `resolveSessionConversation` hook core probes, while discord ships only its
|
||||
// explicit session-key normalizer. Pin what each channel ships instead of
|
||||
// forcing one shape.
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBundledChannelPluginAsync,
|
||||
getBundledChannelSessionKeyArtifactAsync,
|
||||
listBundledChannelPluginIds,
|
||||
} from "./test-helpers/bundled-channel-plugin-loader.js";
|
||||
|
||||
// Bundled channels expected to ship a top-level session-key artifact.
|
||||
const SESSION_KEY_ARTIFACT_PLUGIN_IDS = ["discord", "feishu", "telegram"] as const;
|
||||
const SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS = ["feishu", "telegram"] as const;
|
||||
|
||||
type ExplicitSessionKeyNormalizer = (
|
||||
sessionKey: string,
|
||||
ctx: { ChatType?: string; From?: string; SenderId?: string },
|
||||
) => string;
|
||||
|
||||
describe("bundled channel session-key artifact parity", () => {
|
||||
const artifacts = new Map<string, Record<string, unknown>>();
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const id of listBundledChannelPluginIds()) {
|
||||
const artifact = await getBundledChannelSessionKeyArtifactAsync(id);
|
||||
if (artifact) {
|
||||
artifacts.set(id, artifact);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the artifact table in sync with bundled channels that ship one", () => {
|
||||
expect([...artifacts.keys()].toSorted()).toEqual([...SESSION_KEY_ARTIFACT_PLUGIN_IDS]);
|
||||
});
|
||||
|
||||
it.each(SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS)(
|
||||
"keeps the %s artifact resolver identical to the plugin messaging hook",
|
||||
async (id) => {
|
||||
const resolveSessionConversation = artifacts.get(id)?.resolveSessionConversation;
|
||||
expect(typeof resolveSessionConversation).toBe("function");
|
||||
|
||||
const plugin = await getBundledChannelPluginAsync(id);
|
||||
expect(plugin?.messaging?.resolveSessionConversation).toBe(resolveSessionConversation);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the discord artifact normalizer behind the plugin messaging hook", async () => {
|
||||
const normalize = artifacts.get("discord")?.normalizeExplicitDiscordSessionKey as
|
||||
| ExplicitSessionKeyNormalizer
|
||||
| undefined;
|
||||
expect(typeof normalize).toBe("function");
|
||||
|
||||
const plugin = await getBundledChannelPluginAsync("discord");
|
||||
const pluginNormalize = plugin?.messaging?.normalizeExplicitSessionKey;
|
||||
expect(typeof pluginNormalize).toBe("function");
|
||||
|
||||
// The plugin hook adapts core's params-object shape onto the artifact's
|
||||
// positional export, so pin behavioral parity over representative keys.
|
||||
const cases = [
|
||||
{ sessionKey: "discord:channel:123", ctx: { ChatType: "direct", SenderId: "123" } },
|
||||
{ sessionKey: "discord:dm:42", ctx: { ChatType: "dm", From: "discord:42" } },
|
||||
{ sessionKey: "agent:m:discord:channel:9", ctx: { ChatType: "direct", From: "discord:9" } },
|
||||
{ sessionKey: "Discord:Channel:77", ctx: { ChatType: "group", SenderId: "77" } },
|
||||
] as const;
|
||||
for (const { sessionKey, ctx } of cases) {
|
||||
expect(pluginNormalize?.({ sessionKey, ctx })).toBe(normalize?.(sessionKey, ctx));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -296,25 +296,75 @@ export async function getBundledChannelDirectoryPluginAsync(
|
||||
}
|
||||
|
||||
type ChannelThreadBindingArtifactModule = { defaultTopLevelPlacement?: unknown };
|
||||
type ChannelSessionKeyArtifactModule = Record<string, unknown>;
|
||||
type ChannelMessageToolArtifactModule = { describeMessageTool?: unknown };
|
||||
type ChannelGatewayAuthArtifactModule = { resolveGatewayAuthBypassPaths?: unknown };
|
||||
|
||||
function isMissingBundledThreadBindingArtifact(error: unknown, id: ChannelId): boolean {
|
||||
function isMissingBundledArtifact(
|
||||
error: unknown,
|
||||
id: ChannelId,
|
||||
artifactBasename: string,
|
||||
): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message === `Unable to resolve bundled plugin public surface ${id}/thread-binding-api.js`
|
||||
error.message === `Unable to resolve bundled plugin public surface ${id}/${artifactBasename}`
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight artifacts are optional per channel; contract suites discover the
|
||||
// shipping set from the catalog, so a missing artifact resolves to null while
|
||||
// present-but-broken artifacts still fail the load.
|
||||
async function getOptionalBundledChannelArtifactAsync<T extends object>(
|
||||
id: ChannelId,
|
||||
artifactBasename: string,
|
||||
): Promise<T | null> {
|
||||
return await loadBundledPluginPublicSurface<T>({
|
||||
pluginId: id,
|
||||
artifactBasename,
|
||||
}).catch((error: unknown) => {
|
||||
if (isMissingBundledArtifact(error, id, artifactBasename)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns a bundled channel's thread-binding artifact, or null when it ships none. */
|
||||
export async function getBundledChannelThreadBindingArtifactAsync(
|
||||
id: ChannelId,
|
||||
): Promise<ChannelThreadBindingArtifactModule | null> {
|
||||
return await loadBundledPluginPublicSurface<ChannelThreadBindingArtifactModule>({
|
||||
pluginId: id,
|
||||
artifactBasename: "thread-binding-api.js",
|
||||
}).catch((error: unknown) => {
|
||||
if (isMissingBundledThreadBindingArtifact(error, id)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return await getOptionalBundledChannelArtifactAsync<ChannelThreadBindingArtifactModule>(
|
||||
id,
|
||||
"thread-binding-api.js",
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a bundled channel's session-key artifact, or null when it ships none. */
|
||||
export async function getBundledChannelSessionKeyArtifactAsync(
|
||||
id: ChannelId,
|
||||
): Promise<ChannelSessionKeyArtifactModule | null> {
|
||||
return await getOptionalBundledChannelArtifactAsync<ChannelSessionKeyArtifactModule>(
|
||||
id,
|
||||
"session-key-api.js",
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a bundled channel's message-tool artifact, or null when it ships none. */
|
||||
export async function getBundledChannelMessageToolArtifactAsync(
|
||||
id: ChannelId,
|
||||
): Promise<ChannelMessageToolArtifactModule | null> {
|
||||
return await getOptionalBundledChannelArtifactAsync<ChannelMessageToolArtifactModule>(
|
||||
id,
|
||||
"message-tool-api.js",
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a bundled channel's gateway-auth artifact, or null when it ships none. */
|
||||
export async function getBundledChannelGatewayAuthArtifactAsync(
|
||||
id: ChannelId,
|
||||
): Promise<ChannelGatewayAuthArtifactModule | null> {
|
||||
return await getOptionalBundledChannelArtifactAsync<ChannelGatewayAuthArtifactModule>(
|
||||
id,
|
||||
"gateway-auth-api.js",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2941,6 +2941,33 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("fans contract directory targets out to the owning contract lanes", () => {
|
||||
// Regression: the generic channels project excludes contracts/**, so the
|
||||
// directory target used to run zero tests and exit green.
|
||||
const plans = buildVitestRunPlans(["src/channels/plugins/contracts"]);
|
||||
|
||||
expect(plans.map((plan) => plan.config)).toEqual([
|
||||
"test/vitest/vitest.contracts-channel-surface.config.ts",
|
||||
"test/vitest/vitest.contracts-channel-config.config.ts",
|
||||
"test/vitest/vitest.contracts-channel-registry.config.ts",
|
||||
"test/vitest/vitest.contracts-channel-session.config.ts",
|
||||
]);
|
||||
expect(plans.every((plan) => plan.includePatterns === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes the plugin contracts directory to the plugin contracts lane", () => {
|
||||
const plans = buildVitestRunPlans(["src/plugins/contracts"]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.contracts-plugin.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: null,
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes misc extensions to the misc extension shard", () => {
|
||||
const plans = buildVitestRunPlans(["extensions/thread-ownership"], process.cwd());
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ export const channelSurfaceContractPatterns = [
|
||||
"src/channels/plugins/contracts/channel-catalog.contract.test.ts",
|
||||
"src/channels/plugins/contracts/channel-import-guardrails.test.ts",
|
||||
"src/channels/plugins/contracts/group-policy.fallback.contract.test.ts",
|
||||
"src/channels/plugins/contracts/message-tool-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/outbound-payload.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-a.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-e.contract.test.ts",
|
||||
];
|
||||
|
||||
export const channelConfigContractPatterns = [
|
||||
"src/channels/plugins/contracts/gateway-auth-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.authorize-config-write.policy.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.authorize-config-write.targets.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.catalog.entries.contract.test.ts",
|
||||
@@ -25,6 +27,7 @@ export const channelConfigContractPatterns = [
|
||||
];
|
||||
|
||||
export const channelRegistryContractPatterns = [
|
||||
"src/channels/plugins/contracts/plugin-shape.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.catalog.paths.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.loader.contract.test.ts",
|
||||
"src/channels/plugins/contracts/plugins-core.registry.contract.test.ts",
|
||||
@@ -36,6 +39,7 @@ export const channelSessionContractPatterns = [
|
||||
"src/channels/plugins/contracts/plugins-core.resolve-config-writes.contract.test.ts",
|
||||
"src/channels/plugins/contracts/registry.contract.test.ts",
|
||||
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
|
||||
"src/channels/plugins/contracts/session-key-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/thread-binding-artifact.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-d.contract.test.ts",
|
||||
"src/channels/plugins/contracts/*-shard-h.contract.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user