From 6bd14d9129d2bbadb856bd1cc11bb9eb620a9d99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 11:44:34 -0700 Subject: [PATCH] test(channels): extend bundled artifact parity and add plugin-shape contract suite (#104618) --- docs/reference/test.md | 1 + extensions/feishu/src/channel.ts | 5 +- extensions/mattermost/src/channel.setup.ts | 2 +- extensions/mattermost/src/channel.ts | 4 +- .../src/gateway-auth-bypass.test.ts | 22 +++--- .../mattermost/src/gateway-auth-bypass.ts | 13 +++- extensions/telegram/src/channel.ts | 5 +- scripts/lib/channel-contract-test-plan.mjs | 2 + scripts/test-projects.test-support.mjs | 23 ++++++ .../gateway-auth-artifact.contract.test.ts | 61 +++++++++++++++ .../message-tool-artifact.contract.test.ts | 46 +++++++++++ .../contracts/plugin-shape.contract.test.ts | 71 +++++++++++++++++ .../session-key-artifact.contract.test.ts | 77 +++++++++++++++++++ .../bundled-channel-plugin-loader.ts | 72 ++++++++++++++--- test/scripts/test-projects.test.ts | 27 +++++++ test/vitest/vitest.contracts-shared.ts | 4 + 16 files changed, 404 insertions(+), 31 deletions(-) create mode 100644 src/channels/plugins/contracts/gateway-auth-artifact.contract.test.ts create mode 100644 src/channels/plugins/contracts/message-tool-artifact.contract.test.ts create mode 100644 src/channels/plugins/contracts/plugin-shape.contract.test.ts create mode 100644 src/channels/plugins/contracts/session-key-artifact.contract.test.ts diff --git a/docs/reference/test.md b/docs/reference/test.md index 9fc64f5866a..3e5719b67b7 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -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/` 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. diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 13f682ad346..f79a1c93b0c 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1702,8 +1702,9 @@ export const feishuPlugin: ChannelPlugin - 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, diff --git a/extensions/mattermost/src/channel.setup.ts b/extensions/mattermost/src/channel.setup.ts index 299bbf0b02d..f2fff701bb9 100644 --- a/extensions/mattermost/src/channel.setup.ts +++ b/extensions/mattermost/src/channel.setup.ts @@ -32,7 +32,7 @@ export const mattermostSetupPlugin: ChannelPlugin = { describeAccount: describeMattermostAccount, }, gateway: { - resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg), + resolveGatewayAuthBypassPaths: resolveMattermostGatewayAuthBypassPaths, }, setup: mattermostSetupAdapter, setupWizard: mattermostSetupWizard, diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index a36cb20dce3..0211d8b48b9 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -868,7 +868,9 @@ export const mattermostPlugin: ChannelPlugin = 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({ diff --git a/extensions/mattermost/src/gateway-auth-bypass.test.ts b/extensions/mattermost/src/gateway-auth-bypass.test.ts index 76df881e023..60ed6912e79 100644 --- a/extensions/mattermost/src/gateway-auth-bypass.test.ts +++ b/extensions/mattermost/src/gateway-auth-bypass.test.ts @@ -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", + }, }, }, }, diff --git a/extensions/mattermost/src/gateway-auth-bypass.ts b/extensions/mattermost/src/gateway-auth-bypass.ts index abc7a0d5ada..f40d8fc9f08 100644 --- a/extensions/mattermost/src/gateway-auth-bypass.ts +++ b/extensions/mattermost/src/gateway-auth-bypass.ts @@ -54,12 +54,17 @@ export function collectMattermostSlashCallbackPaths( return [...paths]; } -export function resolveMattermostGatewayAuthBypassPaths(cfg: { - channels?: Record; +// 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[] { + 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( diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 7300e1efcc8..2ffcd5a9a5c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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, diff --git a/scripts/lib/channel-contract-test-plan.mjs b/scripts/lib/channel-contract-test-plan.mjs index cfba8949bb3..c0517ed80a5 100644 --- a/scripts/lib/channel-contract-test-plan.mjs +++ b/scripts/lib/channel-contract-test-plan.mjs @@ -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], diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 5012dcdaa8d..3107cfb1042 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/src/channels/plugins/contracts/gateway-auth-artifact.contract.test.ts b/src/channels/plugins/contracts/gateway-auth-artifact.contract.test.ts new file mode 100644 index 00000000000..55fb6e69a8f --- /dev/null +++ b/src/channels/plugins/contracts/gateway-auth-artifact.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(); + + 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[]; + const cfg = { + channels: { + mattermost: { + commands: { callbackPath: "/api/channels/mattermost/custom" }, + }, + }, + }; + + expect(resolve({ cfg })).toContain("/api/channels/mattermost/custom"); + }); +}); diff --git a/src/channels/plugins/contracts/message-tool-artifact.contract.test.ts b/src/channels/plugins/contracts/message-tool-artifact.contract.test.ts new file mode 100644 index 00000000000..de313a819ca --- /dev/null +++ b/src/channels/plugins/contracts/message-tool-artifact.contract.test.ts @@ -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(); + + 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); + }, + ); +}); diff --git a/src/channels/plugins/contracts/plugin-shape.contract.test.ts b/src/channels/plugins/contracts/plugin-shape.contract.test.ts new file mode 100644 index 00000000000..a33785096b4 --- /dev/null +++ b/src/channels/plugins/contracts/plugin-shape.contract.test.ts @@ -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>>(); + + 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"); + }); + }); +}); diff --git a/src/channels/plugins/contracts/session-key-artifact.contract.test.ts b/src/channels/plugins/contracts/session-key-artifact.contract.test.ts new file mode 100644 index 00000000000..44cca13ebcb --- /dev/null +++ b/src/channels/plugins/contracts/session-key-artifact.contract.test.ts @@ -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>(); + + 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)); + } + }); +}); diff --git a/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts b/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts index 19c4a460410..7972c95fc5c 100644 --- a/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts +++ b/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts @@ -296,25 +296,75 @@ export async function getBundledChannelDirectoryPluginAsync( } type ChannelThreadBindingArtifactModule = { defaultTopLevelPlacement?: unknown }; +type ChannelSessionKeyArtifactModule = Record; +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( + id: ChannelId, + artifactBasename: string, +): Promise { + return await loadBundledPluginPublicSurface({ + 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 { - return await loadBundledPluginPublicSurface({ - pluginId: id, - artifactBasename: "thread-binding-api.js", - }).catch((error: unknown) => { - if (isMissingBundledThreadBindingArtifact(error, id)) { - return null; - } - throw error; - }); + return await getOptionalBundledChannelArtifactAsync( + 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 { + return await getOptionalBundledChannelArtifactAsync( + 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 { + return await getOptionalBundledChannelArtifactAsync( + 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 { + return await getOptionalBundledChannelArtifactAsync( + id, + "gateway-auth-api.js", + ); } diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 7e98cf08de3..33642c4d04a 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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()); diff --git a/test/vitest/vitest.contracts-shared.ts b/test/vitest/vitest.contracts-shared.ts index 43f7743cd68..a96910ca247 100644 --- a/test/vitest/vitest.contracts-shared.ts +++ b/test/vitest/vitest.contracts-shared.ts @@ -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",