diff --git a/extensions/feishu/src/monitor.reaction.lifecycle.test.ts b/extensions/feishu/src/monitor.reaction.lifecycle.test.ts deleted file mode 100644 index 499be279745..00000000000 --- a/extensions/feishu/src/monitor.reaction.lifecycle.test.ts +++ /dev/null @@ -1 +0,0 @@ -import "./monitor.reaction.lifecycle.test-support.js"; diff --git a/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts b/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts deleted file mode 100644 index b1f33b61ca3..00000000000 --- a/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts +++ /dev/null @@ -1 +0,0 @@ -import "./monitor.reply-once.lifecycle.test-support.js"; diff --git a/extensions/msteams/src/approval-auth.test.ts b/extensions/msteams/src/approval-auth.test.ts deleted file mode 100644 index b00c4c221c5..00000000000 --- a/extensions/msteams/src/approval-auth.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { msTeamsApprovalAuth } from "./approval-auth.js"; - -describe("msTeamsApprovalAuth", () => { - it("authorizes stable Teams user ids and ignores display-name allowlists", () => { - expect( - msTeamsApprovalAuth.authorizeActorAction({ - cfg: { - channels: { - msteams: { - allowFrom: ["user:123e4567-e89b-12d3-a456-426614174000"], - }, - }, - }, - senderId: "123e4567-e89b-12d3-a456-426614174000", - action: "approve", - approvalKind: "exec", - }), - ).toEqual({ authorized: true }); - - expect( - msTeamsApprovalAuth.authorizeActorAction({ - cfg: { - channels: { msteams: { allowFrom: ["Owner Display"] } }, - }, - senderId: "attacker-aad", - action: "approve", - approvalKind: "exec", - }), - ).toEqual({ authorized: true }); - }); -}); diff --git a/extensions/msteams/src/channel.test.ts b/extensions/msteams/src/channel.test.ts index b6982388b32..cd4b0ea95b1 100644 --- a/extensions/msteams/src/channel.test.ts +++ b/extensions/msteams/src/channel.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; +import { MSTeamsConfigSchema } from "../config-api.js"; import { msTeamsApprovalAuth } from "./approval-auth.js"; import { msteamsPlugin } from "./channel.js"; @@ -46,3 +47,82 @@ describe("msteamsPlugin", () => { expect(looksLikeId?.("user:Jane Doe")).toBe(false); }); }); + +describe("msteams config schema", () => { + it("defaults groupPolicy to allowlist", () => { + const res = MSTeamsConfigSchema.safeParse({}); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.groupPolicy).toBe("allowlist"); + } + }); + + it("accepts historyLimit", () => { + const res = MSTeamsConfigSchema.safeParse({ historyLimit: 4 }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.historyLimit).toBe(4); + } + }); + + it("accepts replyStyle at global/team/channel levels", () => { + const res = MSTeamsConfigSchema.safeParse({ + replyStyle: "top-level", + teams: { + team123: { + replyStyle: "thread", + channels: { + chan456: { replyStyle: "top-level" }, + }, + }, + }, + }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.replyStyle).toBe("top-level"); + expect(res.data.teams?.team123?.replyStyle).toBe("thread"); + expect(res.data.teams?.team123?.channels?.chan456?.replyStyle).toBe("top-level"); + } + }); + + it("rejects invalid replyStyle", () => { + const res = MSTeamsConfigSchema.safeParse({ + replyStyle: "nope", + }); + + expect(res.success).toBe(false); + }); +}); + +describe("msTeamsApprovalAuth", () => { + it("authorizes stable Teams user ids and ignores display-name allowlists", () => { + expect( + msTeamsApprovalAuth.authorizeActorAction({ + cfg: { + channels: { + msteams: { + allowFrom: ["user:123e4567-e89b-12d3-a456-426614174000"], + }, + }, + }, + senderId: "123e4567-e89b-12d3-a456-426614174000", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + msTeamsApprovalAuth.authorizeActorAction({ + cfg: { + channels: { msteams: { allowFrom: ["Owner Display"] } }, + }, + senderId: "attacker-aad", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/msteams/src/config-schema.test.ts b/extensions/msteams/src/config-schema.test.ts deleted file mode 100644 index 1ce94c4c546..00000000000 --- a/extensions/msteams/src/config-schema.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { MSTeamsConfigSchema } from "../config-api.js"; - -describe("msteams config schema", () => { - it("defaults groupPolicy to allowlist", () => { - const res = MSTeamsConfigSchema.safeParse({}); - - expect(res.success).toBe(true); - if (res.success) { - expect(res.data.groupPolicy).toBe("allowlist"); - } - }); - - it("accepts historyLimit", () => { - const res = MSTeamsConfigSchema.safeParse({ historyLimit: 4 }); - - expect(res.success).toBe(true); - if (res.success) { - expect(res.data.historyLimit).toBe(4); - } - }); - - it("accepts replyStyle at global/team/channel levels", () => { - const res = MSTeamsConfigSchema.safeParse({ - replyStyle: "top-level", - teams: { - team123: { - replyStyle: "thread", - channels: { - chan456: { replyStyle: "top-level" }, - }, - }, - }, - }); - - expect(res.success).toBe(true); - if (res.success) { - expect(res.data.replyStyle).toBe("top-level"); - expect(res.data.teams?.team123?.replyStyle).toBe("thread"); - expect(res.data.teams?.team123?.channels?.chan456?.replyStyle).toBe("top-level"); - } - }); - - it("rejects invalid replyStyle", () => { - const res = MSTeamsConfigSchema.safeParse({ - replyStyle: "nope", - }); - - expect(res.success).toBe(false); - }); -}); diff --git a/extensions/msteams/src/presentation.test.ts b/extensions/msteams/src/presentation.test.ts deleted file mode 100644 index 66f1070b44d..00000000000 --- a/extensions/msteams/src/presentation.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildMSTeamsPresentationCard } from "./presentation.js"; - -describe("buildMSTeamsPresentationCard", () => { - it("preserves message text when rendering presentation controls", () => { - expect( - buildMSTeamsPresentationCard({ - text: "Deploy finished", - presentation: { - blocks: [ - { - type: "buttons", - buttons: [{ label: "Open", value: "open" }], - }, - ], - }, - }), - ).toEqual({ - type: "AdaptiveCard", - version: "1.4", - body: [{ type: "TextBlock", text: "Deploy finished", wrap: true }], - actions: [{ type: "Action.Submit", title: "Open", data: { value: "open", label: "Open" } }], - }); - }); -}); diff --git a/extensions/msteams/src/welcome-card.test.ts b/extensions/msteams/src/welcome-card.test.ts index d4e53259269..ebccf06e51a 100644 --- a/extensions/msteams/src/welcome-card.test.ts +++ b/extensions/msteams/src/welcome-card.test.ts @@ -1,6 +1,30 @@ import { describe, expect, it } from "vitest"; +import { buildMSTeamsPresentationCard } from "./presentation.js"; import { buildGroupWelcomeText, buildWelcomeCard } from "./welcome-card.js"; +describe("buildMSTeamsPresentationCard", () => { + it("preserves message text when rendering presentation controls", () => { + expect( + buildMSTeamsPresentationCard({ + text: "Deploy finished", + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Open", value: "open" }], + }, + ], + }, + }), + ).toEqual({ + type: "AdaptiveCard", + version: "1.4", + body: [{ type: "TextBlock", text: "Deploy finished", wrap: true }], + actions: [{ type: "Action.Submit", title: "Open", data: { value: "open", label: "Open" } }], + }); + }); +}); + describe("buildWelcomeCard", () => { it("builds card with default prompt starters", () => { const card = buildWelcomeCard(); diff --git a/extensions/slack/src/blocks-fallback.test.ts b/extensions/slack/src/blocks-fallback.test.ts deleted file mode 100644 index 538ba814282..00000000000 --- a/extensions/slack/src/blocks-fallback.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; - -describe("buildSlackBlocksFallbackText", () => { - it("prefers header text", () => { - expect( - buildSlackBlocksFallbackText([ - { type: "header", text: { type: "plain_text", text: "Deploy status" } }, - ] as never), - ).toBe("Deploy status"); - }); - - it("uses image alt text", () => { - expect( - buildSlackBlocksFallbackText([ - { type: "image", image_url: "https://example.com/image.png", alt_text: "Latency chart" }, - ] as never), - ).toBe("Latency chart"); - }); - - it("uses generic defaults for file and unknown blocks", () => { - expect( - buildSlackBlocksFallbackText([ - { type: "file", source: "remote", external_id: "F123" }, - ] as never), - ).toBe("Shared a file"); - expect(buildSlackBlocksFallbackText([{ type: "divider" }] as never)).toBe( - "Shared a Block Kit message", - ); - }); -}); diff --git a/extensions/slack/src/blocks-input.test.ts b/extensions/slack/src/blocks-input.test.ts deleted file mode 100644 index dba05e8103f..00000000000 --- a/extensions/slack/src/blocks-input.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSlackBlocksInput } from "./blocks-input.js"; - -describe("parseSlackBlocksInput", () => { - it("returns undefined when blocks are missing", () => { - expect(parseSlackBlocksInput(undefined)).toBeUndefined(); - expect(parseSlackBlocksInput(null)).toBeUndefined(); - }); - - it("accepts blocks arrays", () => { - const parsed = parseSlackBlocksInput([{ type: "divider" }]); - expect(parsed).toEqual([{ type: "divider" }]); - }); - - it("accepts JSON blocks strings", () => { - const parsed = parseSlackBlocksInput( - '[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]', - ); - expect(parsed).toEqual([{ type: "section", text: { type: "mrkdwn", text: "hi" } }]); - }); - - it("rejects invalid block payloads", () => { - const cases = [ - { - name: "invalid JSON", - input: "{bad-json", - expectedMessage: /valid JSON/i, - }, - { - name: "non-array payload", - input: { type: "divider" }, - expectedMessage: /must be an array/i, - }, - { - name: "empty array", - input: [], - expectedMessage: /at least one block/i, - }, - { - name: "non-object block", - input: ["not-a-block"], - expectedMessage: /must be an object/i, - }, - { - name: "missing block type", - input: [{}], - expectedMessage: /non-empty string type/i, - }, - ] as const; - - for (const testCase of cases) { - expect(() => parseSlackBlocksInput(testCase.input), testCase.name).toThrow( - testCase.expectedMessage, - ); - } - }); -}); diff --git a/extensions/slack/src/blocks.test.ts b/extensions/slack/src/blocks.test.ts new file mode 100644 index 00000000000..164febb7825 --- /dev/null +++ b/extensions/slack/src/blocks.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { + encodeSlackModalPrivateMetadata, + parseSlackModalPrivateMetadata, +} from "./modal-metadata.js"; + +describe("buildSlackBlocksFallbackText", () => { + it("prefers header text", () => { + expect( + buildSlackBlocksFallbackText([ + { type: "header", text: { type: "plain_text", text: "Deploy status" } }, + ] as never), + ).toBe("Deploy status"); + }); + + it("uses image alt text", () => { + expect( + buildSlackBlocksFallbackText([ + { type: "image", image_url: "https://example.com/image.png", alt_text: "Latency chart" }, + ] as never), + ).toBe("Latency chart"); + }); + + it("uses generic defaults for file and unknown blocks", () => { + expect( + buildSlackBlocksFallbackText([ + { type: "file", source: "remote", external_id: "F123" }, + ] as never), + ).toBe("Shared a file"); + expect(buildSlackBlocksFallbackText([{ type: "divider" }] as never)).toBe( + "Shared a Block Kit message", + ); + }); +}); + +describe("parseSlackBlocksInput", () => { + it("returns undefined when blocks are missing", () => { + expect(parseSlackBlocksInput(undefined)).toBeUndefined(); + expect(parseSlackBlocksInput(null)).toBeUndefined(); + }); + + it("accepts blocks arrays", () => { + const parsed = parseSlackBlocksInput([{ type: "divider" }]); + expect(parsed).toEqual([{ type: "divider" }]); + }); + + it("accepts JSON blocks strings", () => { + const parsed = parseSlackBlocksInput( + '[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]', + ); + expect(parsed).toEqual([{ type: "section", text: { type: "mrkdwn", text: "hi" } }]); + }); + + it("rejects invalid block payloads", () => { + const cases = [ + { + name: "invalid JSON", + input: "{bad-json", + expectedMessage: /valid JSON/i, + }, + { + name: "non-array payload", + input: { type: "divider" }, + expectedMessage: /must be an array/i, + }, + { + name: "empty array", + input: [], + expectedMessage: /at least one block/i, + }, + { + name: "non-object block", + input: ["not-a-block"], + expectedMessage: /must be an object/i, + }, + { + name: "missing block type", + input: [{}], + expectedMessage: /non-empty string type/i, + }, + ] as const; + + for (const testCase of cases) { + expect(() => parseSlackBlocksInput(testCase.input), testCase.name).toThrow( + testCase.expectedMessage, + ); + } + }); +}); + +describe("parseSlackModalPrivateMetadata", () => { + it("returns empty object for missing or invalid values", () => { + expect(parseSlackModalPrivateMetadata(undefined)).toEqual({}); + expect(parseSlackModalPrivateMetadata("")).toEqual({}); + expect(parseSlackModalPrivateMetadata("{bad-json")).toEqual({}); + }); + + it("parses known metadata fields", () => { + expect( + parseSlackModalPrivateMetadata( + JSON.stringify({ + sessionKey: "agent:main:slack:channel:C1", + channelId: "D123", + channelType: "im", + userId: "U123", + ignored: "x", + }), + ), + ).toEqual({ + sessionKey: "agent:main:slack:channel:C1", + channelId: "D123", + channelType: "im", + userId: "U123", + }); + }); +}); + +describe("encodeSlackModalPrivateMetadata", () => { + it("encodes only known non-empty fields", () => { + expect( + JSON.parse( + encodeSlackModalPrivateMetadata({ + sessionKey: "agent:main:slack:channel:C1", + channelId: "", + channelType: "im", + userId: "U123", + }), + ), + ).toEqual({ + sessionKey: "agent:main:slack:channel:C1", + channelType: "im", + userId: "U123", + }); + }); + + it("throws when encoded payload exceeds Slack metadata limit", () => { + expect(() => + encodeSlackModalPrivateMetadata({ + sessionKey: `agent:main:${"x".repeat(4000)}`, + }), + ).toThrow(/cannot exceed 3000 chars/i); + }); +}); diff --git a/extensions/slack/src/message-tool-api.test.ts b/extensions/slack/src/message-tool-api.test.ts deleted file mode 100644 index 8c69b882ae6..00000000000 --- a/extensions/slack/src/message-tool-api.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { describeSlackMessageTool } from "./message-tool-api.js"; - -describe("Slack message tool public API", () => { - it("describes configured Slack message actions without loading channel runtime", () => { - expect( - describeSlackMessageTool({ - cfg: { - channels: { - slack: { - botToken: "xoxb-test", - }, - }, - }, - }), - ).toMatchObject({ - actions: expect.arrayContaining(["send", "upload-file", "read"]), - capabilities: expect.arrayContaining(["presentation"]), - }); - }); - - it("honors account-scoped action gates", () => { - expect( - describeSlackMessageTool({ - cfg: { - channels: { - slack: { - botToken: "xoxb-default", - accounts: { - ops: { - botToken: "xoxb-ops", - actions: { - messages: false, - }, - }, - }, - }, - }, - }, - accountId: "ops", - }).actions, - ).not.toContain("upload-file"); - }); -}); diff --git a/extensions/slack/src/message-actions.test.ts b/extensions/slack/src/message-tools.test.ts similarity index 63% rename from extensions/slack/src/message-actions.test.ts rename to extensions/slack/src/message-tools.test.ts index 5b21e127cc2..84bacdbdd54 100644 --- a/extensions/slack/src/message-actions.test.ts +++ b/extensions/slack/src/message-tools.test.ts @@ -1,8 +1,49 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; import { listSlackMessageActions } from "./message-actions.js"; +import { describeSlackMessageTool } from "./message-tool-api.js"; + +describe("Slack message tools", () => { + it("describes configured Slack message actions without loading channel runtime", () => { + expect( + describeSlackMessageTool({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + }, + }, + }, + }), + ).toMatchObject({ + actions: expect.arrayContaining(["send", "upload-file", "read"]), + capabilities: expect.arrayContaining(["presentation"]), + }); + }); + + it("honors account-scoped action gates", () => { + expect( + describeSlackMessageTool({ + cfg: { + channels: { + slack: { + botToken: "xoxb-default", + accounts: { + ops: { + botToken: "xoxb-ops", + actions: { + messages: false, + }, + }, + }, + }, + }, + }, + accountId: "ops", + }).actions, + ).not.toContain("upload-file"); + }); -describe("listSlackMessageActions", () => { it("includes file actions when message actions are enabled", () => { const cfg = { channels: { diff --git a/extensions/slack/src/modal-metadata.test.ts b/extensions/slack/src/modal-metadata.test.ts deleted file mode 100644 index a7a7ce8224b..00000000000 --- a/extensions/slack/src/modal-metadata.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - encodeSlackModalPrivateMetadata, - parseSlackModalPrivateMetadata, -} from "./modal-metadata.js"; - -describe("parseSlackModalPrivateMetadata", () => { - it("returns empty object for missing or invalid values", () => { - expect(parseSlackModalPrivateMetadata(undefined)).toEqual({}); - expect(parseSlackModalPrivateMetadata("")).toEqual({}); - expect(parseSlackModalPrivateMetadata("{bad-json")).toEqual({}); - }); - - it("parses known metadata fields", () => { - expect( - parseSlackModalPrivateMetadata( - JSON.stringify({ - sessionKey: "agent:main:slack:channel:C1", - channelId: "D123", - channelType: "im", - userId: "U123", - ignored: "x", - }), - ), - ).toEqual({ - sessionKey: "agent:main:slack:channel:C1", - channelId: "D123", - channelType: "im", - userId: "U123", - }); - }); -}); - -describe("encodeSlackModalPrivateMetadata", () => { - it("encodes only known non-empty fields", () => { - expect( - JSON.parse( - encodeSlackModalPrivateMetadata({ - sessionKey: "agent:main:slack:channel:C1", - channelId: "", - channelType: "im", - userId: "U123", - }), - ), - ).toEqual({ - sessionKey: "agent:main:slack:channel:C1", - channelType: "im", - userId: "U123", - }); - }); - - it("throws when encoded payload exceeds Slack metadata limit", () => { - expect(() => - encodeSlackModalPrivateMetadata({ - sessionKey: `agent:main:${"x".repeat(4000)}`, - }), - ).toThrow(/cannot exceed 3000 chars/i); - }); -}); diff --git a/extensions/slack/src/outbound-payload.contract.test.ts b/extensions/slack/src/outbound-payload.contract.test.ts deleted file mode 100644 index b555d3ff74d..00000000000 --- a/extensions/slack/src/outbound-payload.contract.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { installChannelOutboundPayloadContractSuite } from "openclaw/plugin-sdk/testing"; -import { describe } from "vitest"; -import { createSlackOutboundPayloadHarness } from "./outbound-payload.test-harness.js"; - -describe("Slack outbound payload contract", () => { - installChannelOutboundPayloadContractSuite({ - channel: "slack", - chunking: { mode: "passthrough", longTextLength: 5000 }, - createHarness: createSlackOutboundPayloadHarness, - }); -}); diff --git a/extensions/slack/src/outbound-payload.test.ts b/extensions/slack/src/outbound-payload.test.ts index bc753eebae2..f67c8cd9133 100644 --- a/extensions/slack/src/outbound-payload.test.ts +++ b/extensions/slack/src/outbound-payload.test.ts @@ -1,4 +1,5 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { installChannelOutboundPayloadContractSuite } from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; import { createSlackOutboundPayloadHarness } from "../test-api.js"; @@ -94,3 +95,11 @@ describe("slackOutbound sendPayload", () => { expect(sendMock).not.toHaveBeenCalled(); }); }); + +describe("Slack outbound payload contract", () => { + installChannelOutboundPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness: createSlackOutboundPayloadHarness, + }); +});