Files
openclaw/extensions/slack/src/send.blocks.test.ts
Peter Steinberger f81f9d8570 chore: enforce max-lines suppression ratchet (#107315)
* ci: enforce max-lines suppression ratchet

* chore: prune stale max-lines suppression

* fix: close max-lines ratchet enforcement gaps

* fix: harden max-lines ratchet checks

* fix(ci): satisfy max-lines ratchet checks

* style: format max-lines declarations

* fix(ci): match oxlint suppression grammar

* test: isolate max-lines git fixtures

* chore: prune resolved max-lines debt

* test: skip newline path fixture on Windows

* fix: harden max-lines suppression ratchet

* chore: refresh max-lines baseline

* fix: close max-lines ratchet bypasses

* fix: derive ratchet base from PR merge tree

* fix: support older Git in staged ratchet

* fix: align max-lines declarations and baseline

* chore: refresh max-lines baseline for current main

* fix: exclude generated wizard locales from max-lines

* chore: prune resolved max-lines debt
2026-07-14 09:27:02 -07:00

1360 lines
45 KiB
TypeScript

// Slack tests cover send.blocks plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { createSlackSendTestClient } from "./blocks.test-helpers.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
} from "./sent-thread-cache.js";
const { sendMessageSlack } = await import("./send.js");
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
const SLACK_TEXT_LIMIT = 8000;
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
function mockObjectArg(
source: MockCallSource,
label: string,
callIndex = 0,
argIndex = 0,
): Record<string, unknown> {
const call = source.mock.calls[callIndex];
if (!call) {
throw new Error(`Expected ${label} call ${callIndex} to exist`);
}
const value = call[argIndex];
if (!value || typeof value !== "object") {
throw new Error(`Expected ${label} call ${callIndex} argument ${argIndex} to be an object`);
}
return value as Record<string, unknown>;
}
function postedMessage(client: ReturnType<typeof createSlackSendTestClient>, callIndex = 0) {
return mockObjectArg(client.chat.postMessage, "chat.postMessage", callIndex);
}
function interleavedNativeDataBlocks(): Array<Record<string, unknown>> {
return [
{ type: "section", text: { type: "mrkdwn", text: "Before" } },
{
type: "data_table",
caption: "Pipeline report",
rows: [
[
{ type: "raw_text", text: "Account" },
{ type: "raw_text", text: "ARR" },
],
[
{ type: "raw_text", text: "Acme" },
{ type: "raw_number", value: 125000, text: "$125k" },
],
],
},
{ type: "section", text: { type: "mrkdwn", text: "After" } },
{
type: "actions",
block_id: "private-block",
elements: [
{
type: "button",
action_id: "private-button",
text: { type: "plain_text", text: "Approve" },
value: "private-value",
},
{
type: "static_select",
action_id: "private-select",
placeholder: { type: "plain_text", text: "Choose owner" },
options: [
{ text: { type: "plain_text", text: "Secret option" }, value: "private-option" },
],
},
],
},
];
}
const INTERLEAVED_NATIVE_DATA_ACCESSIBILITY = [
"Outside",
"Before",
"Pipeline report (table)\nAccount\tARR\nAcme\t$125k",
"After",
"Approve\nChoose owner",
].join("\n\n");
function slackDnsRequestError(): Error {
return Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN slack.com"), {
code: "slack_webapi_request_error",
original: Object.assign(new Error("getaddrinfo EAI_AGAIN slack.com"), {
code: "EAI_AGAIN",
syscall: "getaddrinfo",
hostname: "slack.com",
}),
});
}
describe("sendMessageSlack NO_REPLY guard", () => {
it("suppresses NO_REPLY text before any Slack API call", async () => {
const client = createSlackSendTestClient();
const result = await sendMessageSlack("channel:C123", "NO_REPLY", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(client.chat.postMessage).not.toHaveBeenCalled();
expect(result.messageId).toBe("suppressed");
expect(result.receipt.platformMessageIds).toStrictEqual([]);
});
it("suppresses NO_REPLY with surrounding whitespace", async () => {
const client = createSlackSendTestClient();
const result = await sendMessageSlack("channel:C123", " NO_REPLY ", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(client.chat.postMessage).not.toHaveBeenCalled();
expect(result.messageId).toBe("suppressed");
});
it("does not suppress substantive text containing NO_REPLY", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "This is not a NO_REPLY situation", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(client.chat.postMessage).toHaveBeenCalled();
});
it("does not suppress NO_REPLY when blocks are attached", async () => {
const client = createSlackSendTestClient();
const result = await sendMessageSlack("channel:C123", "NO_REPLY", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "section", text: { type: "mrkdwn", text: "content" } }],
});
expect(client.chat.postMessage).toHaveBeenCalled();
expect(result.messageId).toBe("171234.567");
});
});
describe("sendMessageSlack thread participation", () => {
it("records participation after a successful threaded send", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
const result = await sendMessageSlack("channel:C123", "hello thread", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "1712345678.123456",
});
expect(result.threadTs).toBe("1712345678.123456");
expect(result.receipt.threadId).toBe("1712345678.123456");
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(true);
});
it("records canonical Slack response thread participation instead of requested child thread", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
client.chat.postMessage.mockResolvedValueOnce({
ts: "1781932190.115869",
channel: "C123",
message: {
ts: "1781932190.115869",
thread_ts: "1781803536.235489",
},
});
const result = await sendMessageSlack("channel:C123", "hello thread", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "1781932168.648159",
});
expect(postedMessage(client).thread_ts).toBe("1781932168.648159");
expect(result.threadTs).toBe("1781803536.235489");
expect(result.receipt.threadId).toBe("1781803536.235489");
expect(hasSlackThreadParticipation("default", "C123", "1781803536.235489")).toBe(true);
expect(hasSlackThreadParticipation("default", "C123", "1781932168.648159")).toBe(false);
});
it("does not record participation for unthreaded sends", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "hello channel", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(false);
});
it("does not record participation for invalid thread ids", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "hello invalid thread", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "not-a-slack-thread",
});
expect(hasSlackThreadParticipation("default", "C123", "not-a-slack-thread")).toBe(false);
});
});
describe("sendMessageSlack chunking", () => {
it("preserves boundary whitespace for formatting-disabled fallback text", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", " literal fallback ", {
cfg: SLACK_TEST_CFG,
client,
textIsSlackPlainText: true,
});
expect(postedMessage(client, 0)).toMatchObject({
text: " literal fallback ",
mrkdwn: false,
});
});
it("keeps 4205-character text in a single Slack post by default", async () => {
const client = createSlackSendTestClient();
const message = "a".repeat(4205);
await sendMessageSlack("channel:C123", message, {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(postedMessage(client).channel).toBe("C123");
expect(postedMessage(client).text).toBe(message);
});
it("splits oversized fallback text through the normal Slack sender", async () => {
const client = createSlackSendTestClient();
const message = "a".repeat(8500);
await sendMessageSlack("channel:C123", message, {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
const postedTexts = client.chat.postMessage.mock.calls.map((call) => call[0].text);
expect(postedTexts).toHaveLength(2);
expect(
postedTexts
.map((text, index) => ({ index, length: typeof text === "string" ? text.length : null }))
.filter((text) => text.length === null || text.length > 8000),
).toStrictEqual([]);
expect(postedTexts.join("")).toBe(message);
});
it("keeps Slack mrkdwn code spans closed around protected tokens when chunking", async () => {
const client = createSlackSendTestClient();
const message = `\`${"a".repeat(SLACK_TEXT_LIMIT - 5)}<@U123>${"b".repeat(20)}\``;
await sendMessageSlack("channel:C123", message, {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
textIsSlackMrkdwn: true,
});
const postedTexts = client.chat.postMessage.mock.calls.map((call) => call[0].text);
expect(postedTexts.length).toBeGreaterThan(1);
const mentionChunk = postedTexts.find((text) => text?.includes("<@U123>"));
expect(mentionChunk).toBeDefined();
expect(mentionChunk?.startsWith("`")).toBe(true);
expect(mentionChunk?.endsWith("`")).toBe(true);
expect(postedTexts.every((text) => (text?.match(/`/gu) ?? []).length % 2 === 0)).toBe(true);
});
it("reports the first Slack chunk before a later chunk fails", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage
.mockResolvedValueOnce({ ts: "m1", channel: "C123" })
.mockRejectedValueOnce(new Error("second chunk failed"));
const onDeliveryResult = vi.fn();
await expect(
sendMessageSlack("channel:C123", "a".repeat(8500), {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
onDeliveryResult,
}),
).rejects.toThrow("second chunk failed");
expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual(["m1"]);
});
it("preserves the first canonical response thread across chunked sends", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
client.chat.postMessage
.mockResolvedValueOnce({
ts: "1781932190.115869",
channel: "C123",
message: {
ts: "1781932190.115869",
thread_ts: "1781803536.235489",
},
})
.mockResolvedValueOnce({
ts: "1781932191.000000",
channel: "C123",
});
const message = "a".repeat(8500);
const result = await sendMessageSlack("channel:C123", message, {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "1781932168.648159",
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client).thread_ts).toBe("1781932168.648159");
expect(postedMessage(client, 1).thread_ts).toBe("1781932168.648159");
expect(result.threadTs).toBe("1781803536.235489");
expect(result.receipt.threadId).toBe("1781803536.235489");
expect(hasSlackThreadParticipation("default", "C123", "1781803536.235489")).toBe(true);
expect(hasSlackThreadParticipation("default", "C123", "1781932168.648159")).toBe(false);
});
});
describe("sendMessageSlack blocks", () => {
it("posts blocks with fallback text when message is empty", async () => {
const client = createSlackSendTestClient();
const result = await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "divider" }],
});
expect(client.conversations.open).not.toHaveBeenCalled();
const post = postedMessage(client);
expect(post.channel).toBe("C123");
expect(post.text).toBe("Shared a Block Kit message");
expect(post.blocks).toEqual([{ type: "divider" }]);
expect(result.messageId).toBe("171234.567");
expect(result.channelId).toBe("C123");
expect(result.receipt.primaryPlatformMessageId).toBe("171234.567");
expect(result.receipt.platformMessageIds).toEqual(["171234.567"]);
const receiptPart = result.receipt.parts[0];
expect(receiptPart?.platformMessageId).toBe("171234.567");
expect(receiptPart?.kind).toBe("card");
expect((receiptPart?.raw as Record<string, unknown> | undefined)?.channel).toBe("slack");
expect((receiptPart?.raw as Record<string, unknown> | undefined)?.channelId).toBe("C123");
});
it("includes sibling block text in top-level fallback for raw block sends", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "Summary", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [
{ type: "section", text: { type: "mrkdwn", text: "Details" } },
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Approve" },
},
],
},
],
});
expect(postedMessage(client)).toMatchObject({
text: "Summary\n\nDetails\n\nApprove",
});
});
it("keeps interleaved native data and raw controls ordered in accepted accessibility text", async () => {
const client = createSlackSendTestClient();
const blocks = interleavedNativeDataBlocks();
await sendMessageSlack("channel:C123", "Outside", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: blocks as never,
nativeDataFallbackBaseText: "Outside",
});
expect(client.chat.postMessage).toHaveBeenCalledOnce();
expect(postedMessage(client)).toMatchObject({
blocks: blocks as never,
mrkdwn: false,
text: INTERLEAVED_NATIVE_DATA_ACCESSIBILITY,
});
expect(postedMessage(client).text).not.toMatch(/private|Secret option/u);
});
it("keeps interleaved native data and raw controls ordered after invalid_blocks", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } });
const blocks = interleavedNativeDataBlocks();
await sendMessageSlack("channel:C123", "Outside", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: blocks as never,
nativeDataFallbackBaseText: "Outside",
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 0).text).toBe(INTERLEAVED_NATIVE_DATA_ACCESSIBILITY);
expect(postedMessage(client, 1)).toMatchObject({
blocks: [
{ type: "section", text: { type: "plain_text", text: "Outside" } },
blocks[0],
{
type: "section",
text: {
type: "plain_text",
text: "Pipeline report (table)\nAccount\tARR\nAcme\t$125k",
},
},
blocks[2],
blocks[3],
],
mrkdwn: false,
text: INTERLEAVED_NATIVE_DATA_ACCESSIBILITY,
});
for (const index of [0, 1]) {
expect(postedMessage(client, index).text).not.toMatch(/private|Secret option/u);
}
});
it("retries rejected native charts as accessible text", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce(
Object.assign(new Error("An API error occurred: invalid_blocks"), {
data: { error: "invalid_blocks" },
}),
);
const blocks = [
{
type: "data_visualization",
title: "Revenue mix",
chart: {
type: "pie",
segments: [
{ label: "Product", value: 60 },
{ label: "Services", value: 40 },
],
},
},
];
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
expect(postedMessage(client, 0).text).toBe(
"Revenue mix (pie chart)\n- Product: 60\n- Services: 40",
);
expect(postedMessage(client, 1).blocks).toBeUndefined();
expect(postedMessage(client, 1).text).toBe(
"Revenue mix (pie chart)\n- Product: 60\n- Services: 40",
);
expect(postedMessage(client, 1).mrkdwn).toBe(false);
});
it("uses 40k blockless chunks only for oversized native data with no survivors", async () => {
const client = createSlackSendTestClient();
const caption = "c".repeat(41_000);
const blocks = [
{
type: "data_table",
caption,
rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]],
},
] as never;
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
const posts = [postedMessage(client, 0), postedMessage(client, 1)];
expect(posts.every((post) => post.blocks === undefined)).toBe(true);
expect(posts.every((post) => post.mrkdwn === false)).toBe(true);
expect(posts.every((post) => String(post.text).length <= 40_000)).toBe(true);
expect(posts.map((post) => post.text).join("")).toBe(`${caption} (table)\nAccount\nAcme`);
});
it("splits non-native blocks before their accessibility text exceeds 40k", async () => {
const client = createSlackSendTestClient();
const blocks = Array.from({ length: 20 }, (_entry, index) => ({
type: "section",
text: { type: "plain_text", text: `${String(index)}-${"x".repeat(2_990)}` },
}));
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
authoredTextPlacement: "none",
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
const posts = [postedMessage(client, 0), postedMessage(client, 1)];
expect(posts.every((post) => String(post.text).length <= 40_000)).toBe(true);
expect(posts.every((post) => post.mrkdwn === false)).toBe(true);
expect(posts.flatMap((post) => post.blocks as unknown[])).toEqual(blocks);
});
it("keeps ordered non-native accessibility complete above the normal 8k text limit", async () => {
const client = createSlackSendTestClient();
const blocks = Array.from({ length: 3 }, (_entry, index) => ({
type: "section",
text: { type: "plain_text", text: `${String(index)}-${"x".repeat(2_990)}` },
}));
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
authoredTextPlacement: "none",
});
expect(client.chat.postMessage).toHaveBeenCalledOnce();
expect(String(postedMessage(client, 0).text).length).toBeGreaterThan(8_000);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
});
it("retries rejected native tables once with complete accessible text", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } });
const blocks = [
{ type: "section", text: { type: "mrkdwn", text: "Overview" } },
{
type: "data_table",
caption: "Pipeline report",
rows: [
[
{ type: "raw_text", text: "Account" },
{ type: "raw_text", text: "ARR" },
],
[
{ type: "raw_text", text: "<@U123>" },
{ type: "raw_number", value: 125000, text: "$125k" },
],
[
{ type: "raw_text", text: "Globex" },
{ type: "raw_number", value: 82000, text: "$82k" },
],
],
row_header_column_index: 0,
},
] as never;
const fallback = [
"Overview",
"",
"Pipeline report (table)",
"Account\tARR",
"<@U123>\t$125k",
"Globex\t$82k",
].join("\n");
await sendMessageSlack("channel:C123", "Overview", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
authoredTextPlacement: "blocks",
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
expect(postedMessage(client, 0).text).toBe(fallback);
expect(postedMessage(client, 0).mrkdwn).toBe(false);
expect(postedMessage(client, 1).blocks).toEqual([
blocks[0],
{
type: "section",
text: { type: "plain_text", text: fallback.split("\n\n")[1] },
},
]);
expect(postedMessage(client, 1)).toMatchObject({
mrkdwn: false,
text: fallback,
});
});
it("preserves controls, delivery semantics, and complete mixed native-data fallback", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage
.mockRejectedValueOnce({ data: { error: "invalid_blocks" } })
.mockResolvedValueOnce({
ts: "171234.568",
channel: "C999",
message: { thread_ts: "171111.100" },
});
const onPlatformSendDispatch = vi.fn(async () => undefined);
const onDeliveryResult = vi.fn(async (_result: { messageId: string }) => undefined);
const metadata = {
event_type: "assistant_thread_context",
event_payload: { team_id: "T123" },
};
const section = { type: "section", text: { type: "mrkdwn", text: "Overview" } };
const actions = {
type: "actions",
elements: [
{
type: "button",
action_id: "approve",
text: { type: "plain_text", text: "Approve" },
value: "yes",
},
],
};
const blocks = [
section,
{
type: "data_visualization",
title: "Revenue mix",
chart: {
type: "pie",
segments: [
{ label: "Product", value: 60 },
{ label: "Services", value: 40 },
],
},
},
{
type: "data_table",
caption: "Pipeline report",
rows: [
[
{ type: "raw_text", text: "Account" },
{ type: "raw_text", text: "ARR" },
],
[
{ type: "raw_text", text: "Acme" },
{ type: "raw_number", value: 125000, text: "$125k" },
],
],
},
actions,
] as never;
const result = await sendMessageSlack("channel:C123", "Overview", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
authoredTextPlacement: "blocks",
threadTs: "171000.100",
replyBroadcast: true,
identity: { username: "Claw", iconEmoji: ":crab:" },
metadata,
deliveryQueueId: "queue-1",
onPlatformSendDispatch,
onDeliveryResult,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
expect(postedMessage(client, 1)).toMatchObject({
blocks: [
section,
{
type: "section",
text: {
type: "plain_text",
text: "Revenue mix (pie chart)\n- Product: 60\n- Services: 40",
},
},
{
type: "section",
text: {
type: "plain_text",
text: "Pipeline report (table)\nAccount\tARR\nAcme\t$125k",
},
},
actions,
],
thread_ts: "171000.100",
reply_broadcast: true,
username: "Claw",
icon_emoji: ":crab:",
metadata: {
event_type: "assistant_thread_context",
event_payload: {
team_id: "T123",
openclaw_delivery_part_index: 0,
openclaw_delivery_part_count: 1,
},
},
mrkdwn: false,
});
expect(postedMessage(client, 1).text).toContain(
"Revenue mix (pie chart)\n- Product: 60\n- Services: 40",
);
expect(postedMessage(client, 1).text).toContain(
"Pipeline report (table)\nAccount\tARR\nAcme\t$125k",
);
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual(["171234.568"]);
expect(result).toMatchObject({
messageId: "171234.568",
channelId: "C999",
threadTs: "171111.100",
});
expect(result.receipt.platformMessageIds).toEqual(["171234.568"]);
});
it("propagates invalid_blocks when Slack also rejects the retained controls", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage
.mockRejectedValueOnce({ data: { error: "invalid_blocks" } })
.mockRejectedValueOnce(
Object.assign(new Error("An API error occurred: invalid_blocks"), {
data: { error: "invalid_blocks" },
}),
);
const onPlatformSendDispatch = vi.fn(async () => undefined);
const onDeliveryResult = vi.fn(async () => undefined);
const actions = {
type: "actions",
elements: [
{
type: "button",
action_id: "approve",
text: { type: "plain_text", text: "Approve" },
value: "yes",
},
],
};
const blocks = [
{
type: "data_table",
caption: "Pipeline",
rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]],
},
actions,
] as never;
await expect(
sendMessageSlack("channel:C123", "Pipeline", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
onPlatformSendDispatch,
onDeliveryResult,
}),
).rejects.toThrow("invalid_blocks");
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 1).blocks).toEqual([
{ type: "section", text: { type: "plain_text", text: "Pipeline" } },
{
type: "section",
text: { type: "plain_text", text: "Pipeline (table)\nAccount\nAcme" },
},
actions,
]);
expect(postedMessage(client, 1).text).toBe(
"Pipeline\n\nPipeline (table)\nAccount\nAcme\n\nApprove",
);
expect(postedMessage(client, 1).text).not.toContain("yes");
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
expect(onDeliveryResult).not.toHaveBeenCalled();
});
it("uses a visible text fallback for a malformed rejected native table", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } });
const onPlatformSendDispatch = vi.fn(async () => undefined);
const result = await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "data_table" }] as never,
onPlatformSendDispatch,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 1).blocks).toBeUndefined();
expect(postedMessage(client, 1)).toMatchObject({
text: "Slack could not render this chart or table data.",
mrkdwn: false,
});
expect(result.receipt.platformMessageIds).toEqual(["171234.567"]);
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
});
it("posts a valid native table once when its accessibility fallback is overlong", async () => {
const client = createSlackSendTestClient();
const header = "Account".padEnd(80, "x");
const accounts = Array.from({ length: 100 }, (_entry, index) =>
(index === 0 ? "<@U123>" : `account-${String(index)}`).padEnd(99, "x"),
);
const blocks = [
{
type: "data_table",
caption: "Large pipeline",
rows: [
[{ type: "raw_text", text: header }],
...accounts.map((text) => [{ type: "raw_text", text }]),
],
},
] as never;
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
});
expect(client.chat.postMessage).toHaveBeenCalledOnce();
expect(postedMessage(client).blocks).toEqual(blocks);
expect(postedMessage(client).text).toBe(
["Large pipeline (table)", header, ...accounts].join("\n"),
);
expect(String(postedMessage(client).text).length).toBeGreaterThan(SLACK_TEXT_LIMIT);
expect(String(postedMessage(client).text).length).toBeLessThanOrEqual(40_000);
expect(postedMessage(client).mrkdwn).toBe(false);
});
it("retries an overlong native table once as complete blockless text", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } });
const onPlatformSendDispatch = vi.fn(async () => undefined);
const header = "Account".padEnd(80, "x");
const accounts = Array.from({ length: 100 }, (_entry, index) =>
(index === 0 ? "<@U123>" : `account-${String(index)}`).padEnd(99, "x"),
);
const blocks = [
{
type: "data_table",
caption: "Large pipeline",
rows: [
[{ type: "raw_text", text: header }],
...accounts.map((text) => [{ type: "raw_text", text }]),
],
},
] as never;
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
onPlatformSendDispatch,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
expect(postedMessage(client, 0).text).toBe(
["Large pipeline (table)", header, ...accounts].join("\n"),
);
expect(postedMessage(client, 0).mrkdwn).toBe(false);
const fallbackPost = postedMessage(client, 1);
expect(fallbackPost.blocks).toBeUndefined();
expect(fallbackPost.mrkdwn).toBe(false);
expect(String(fallbackPost.text).length).toBeLessThanOrEqual(40_000);
const deliveredText = fallbackPost.text;
expect(deliveredText).toBe(["Large pipeline (table)", header, ...accounts].join("\n"));
expect(deliveredText).toContain("<@U123>");
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
});
it("batches more than 50 ordered fallback blocks across Web API posts", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } });
const onPlatformSendDispatch = vi.fn(async () => undefined);
const caption = "c".repeat(9_000);
const actions = {
type: "actions",
elements: [
{
type: "button",
action_id: "approve",
text: { type: "plain_text", text: "Approve" },
value: "yes",
},
],
};
const blocks = [
...Array.from({ length: 48 }, () => ({ type: "divider" })),
actions,
{
type: "data_table",
caption,
rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]],
},
] as never;
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
onPlatformSendDispatch,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(3);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
const fallbackPosts = client.chat.postMessage.mock.calls
.slice(1)
.map((_call, index) => postedMessage(client, index + 1));
expect(fallbackPosts).toHaveLength(2);
expect(fallbackPosts.every((post) => (post.blocks as unknown[]).length <= 50)).toBe(true);
const firstFallbackBlocks = fallbackPosts[0]?.blocks as Array<{ type?: string }> | undefined;
expect(firstFallbackBlocks?.[48]?.type).toBe("actions");
expect(fallbackPosts.every((post) => post.mrkdwn === false)).toBe(true);
expect(fallbackPosts.every((post) => String(post.text).length <= 40_000)).toBe(true);
const fallbackSectionText = fallbackPosts
.flatMap((post) => post.blocks as Array<{ text?: { type?: string; text?: string } }>)
.flatMap((block) =>
block.text?.type === "plain_text" && block.text.text ? [block.text.text] : [],
)
.join("");
expect(fallbackSectionText).toBe(`${caption} (table)\nAccount\nAcme`);
expect(fallbackPosts.map((post) => post.text).join("\n")).not.toContain("yes");
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
});
it("does not fall back from non-invalid_blocks native table errors", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce(
Object.assign(new Error("An API error occurred: ratelimited"), {
data: { error: "ratelimited" },
}),
);
await expect(
sendMessageSlack("channel:C123", "Overview", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [
{
type: "data_table",
caption: "Pipeline",
rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]],
},
] as never,
}),
).rejects.toThrow("ratelimited");
expect(client.chat.postMessage).toHaveBeenCalledOnce();
});
it("does not retry invalid non-data blocks", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce(
Object.assign(new Error("An API error occurred: invalid_blocks"), {
data: { error: "invalid_blocks" },
}),
);
await expect(
sendMessageSlack("channel:C123", "Overview", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "divider" }],
}),
).rejects.toThrow("invalid_blocks");
expect(client.chat.postMessage).toHaveBeenCalledOnce();
});
it("includes native chart data in successful mixed-block accessibility text", async () => {
const client = createSlackSendTestClient();
const blocks = [
{ type: "section", text: { type: "mrkdwn", text: "Overview" } },
{
type: "data_visualization",
title: "Revenue mix",
chart: {
type: "pie",
segments: [
{ label: "Product", value: 60 },
{ label: "Services", value: 40 },
],
},
},
];
await sendMessageSlack("channel:C123", "Overview", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
authoredTextPlacement: "blocks",
});
expect(postedMessage(client, 0).text).toBe(
"Overview\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40",
);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
});
it("preserves non-data siblings and chart data when mixed blocks are rejected", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } });
const blocks = [
{ type: "section", text: { type: "mrkdwn", text: "Overview" } },
{
type: "data_visualization",
title: "Revenue mix",
chart: {
type: "pie",
segments: [
{ label: "Product", value: 60 },
{ label: "Services", value: 40 },
],
},
},
];
await sendMessageSlack("channel:C123", "Overview", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
authoredTextPlacement: "blocks",
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client, 0).blocks).toEqual(blocks);
expect(postedMessage(client, 1).blocks).toEqual([
blocks[0],
{
type: "section",
text: {
type: "plain_text",
text: "Revenue mix (pie chart)\n- Product: 60\n- Services: 40",
},
},
]);
expect(postedMessage(client, 1).text).toBe(
"Overview\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40",
);
});
it("uses canonical Slack response thread for block receipts and participation", async () => {
clearSlackThreadParticipationCache();
const client = createSlackSendTestClient();
client.chat.postMessage.mockResolvedValueOnce({
ts: "1781932190.115869",
channel: "C123",
message: {
ts: "1781932190.115869",
thread_ts: "1781803536.235489",
},
});
const result = await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "1781932168.648159",
blocks: [{ type: "divider" }],
});
expect(postedMessage(client).thread_ts).toBe("1781932168.648159");
expect(result.threadTs).toBe("1781803536.235489");
expect(result.receipt.threadId).toBe("1781803536.235489");
expect(result.receipt.parts[0]?.kind).toBe("card");
expect(hasSlackThreadParticipation("default", "C123", "1781803536.235489")).toBe(true);
expect(hasSlackThreadParticipation("default", "C123", "1781932168.648159")).toBe(false);
});
it("posts user-target block messages directly without conversations.open", async () => {
const client = createSlackSendTestClient();
client.conversations.open.mockRejectedValueOnce(new Error("missing_scope"));
client.chat.postMessage.mockResolvedValueOnce({ ts: "171234.567", channel: "D123" });
const result = await sendMessageSlack("user:U123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "divider" }],
});
expect(client.conversations.open).not.toHaveBeenCalled();
expect(postedMessage(client).channel).toBe("U123");
expect(postedMessage(client).text).toBe("Shared a Block Kit message");
expect(result.messageId).toBe("171234.567");
expect(result.channelId).toBe("D123");
expect(result.receipt.platformMessageIds).toEqual(["171234.567"]);
expect(result.receipt.parts[0]?.raw).toMatchObject({ channelId: "D123" });
});
it("retries Slack postMessage DNS request errors without enabling broad write retries", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage
.mockRejectedValueOnce(slackDnsRequestError())
.mockResolvedValueOnce({ ts: "171234.999" });
const result = await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(result.messageId).toBe("171234.999");
expect(result.channelId).toBe("C123");
expect(result.receipt.parts[0]?.platformMessageId).toBe("171234.999");
expect(result.receipt.parts[0]?.kind).toBe("text");
});
it("retries Slack conversations.open DNS request errors for threaded DMs", async () => {
const client = createSlackSendTestClient();
client.conversations.open
.mockRejectedValueOnce(slackDnsRequestError())
.mockResolvedValueOnce({ channel: { id: "D123" } });
const result = await sendMessageSlack("user:U123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "171234.100",
});
expect(client.conversations.open).toHaveBeenCalledTimes(2);
expect(postedMessage(client).channel).toBe("D123");
expect(postedMessage(client).thread_ts).toBe("171234.100");
expect(result.messageId).toBe("171234.567");
expect(result.channelId).toBe("D123");
expect(result.receipt.threadId).toBe("171234.100");
});
it("passes reply_broadcast for threaded text sends only on the first chunk", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "a".repeat(8500), {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "171234.100",
replyBroadcast: true,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(postedMessage(client).thread_ts).toBe("171234.100");
expect(postedMessage(client).reply_broadcast).toBe(true);
expect(postedMessage(client, 1)).not.toHaveProperty("reply_broadcast");
});
it("does not pass reply_broadcast when no thread is selected", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
replyBroadcast: true,
});
expect(postedMessage(client)).not.toHaveProperty("reply_broadcast");
});
it("does not retry Slack platform errors", async () => {
const client = createSlackSendTestClient();
const platformError = Object.assign(
new Error("An API error occurred: message_limit_exceeded"),
{
data: { ok: false, error: "message_limit_exceeded" },
},
);
client.chat.postMessage.mockRejectedValue(platformError);
await expect(
sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
}),
).rejects.toThrow("message_limit_exceeded");
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
});
it("derives fallback text from image blocks", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_text: "Build chart" }],
});
expect(postedMessage(client).text).toBe("Build chart");
});
it("derives fallback text from video blocks", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [
{
type: "video",
title: { type: "plain_text", text: "Release demo" },
video_url: "https://example.com/demo.mp4",
thumbnail_url: "https://example.com/thumb.jpg",
alt_text: "demo",
},
],
});
expect(postedMessage(client).text).toBe("Release demo");
});
it("derives fallback text from file blocks", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{ type: "file", source: "remote", external_id: "F123" }],
});
expect(postedMessage(client).text).toBe("Shared a file");
});
it("caps long fallback text while preserving blocks", async () => {
const client = createSlackSendTestClient();
const longContextText = "a".repeat(3000);
const blocks = [
{
type: "context",
elements: [
{ type: "mrkdwn", text: longContextText },
{ type: "mrkdwn", text: longContextText },
{ type: "mrkdwn", text: longContextText },
],
},
];
await sendMessageSlack("channel:C123", "", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
});
const post = postedMessage(client);
expect(String(post.text).endsWith("…")).toBe(true);
expect(post.blocks).toBe(blocks);
expect(post.text).toHaveLength(SLACK_TEXT_LIMIT);
});
it("rejects blocks combined with mediaUrl", async () => {
const client = createSlackSendTestClient();
await expect(
sendMessageSlack("channel:C123", "hi", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
mediaUrl: "https://example.com/image.png",
blocks: [{ type: "divider" }],
}),
).rejects.toThrow(/does not support blocks with mediaUrl/i);
expect(client.chat.postMessage).not.toHaveBeenCalled();
});
it("rejects replyBroadcast combined with mediaUrl", async () => {
const client = createSlackSendTestClient();
await expect(
sendMessageSlack("channel:C123", "hi", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
mediaUrl: "https://example.com/image.png",
threadTs: "171234.100",
replyBroadcast: true,
}),
).rejects.toThrow(/replyBroadcast is only supported for text or block thread replies/i);
expect(client.chat.postMessage).not.toHaveBeenCalled();
});
it("rejects empty blocks arrays from runtime callers", async () => {
const client = createSlackSendTestClient();
await expect(
sendMessageSlack("channel:C123", "hi", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [],
}),
).rejects.toThrow(/must contain at least one block/i);
expect(client.chat.postMessage).not.toHaveBeenCalled();
});
it("rejects blocks arrays above Slack max count", async () => {
const client = createSlackSendTestClient();
const blocks = Array.from({ length: 51 }, () => ({ type: "divider" }));
await expect(
sendMessageSlack("channel:C123", "hi", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks,
}),
).rejects.toThrow(/cannot exceed 50 items/i);
expect(client.chat.postMessage).not.toHaveBeenCalled();
});
it("rejects blocks missing type from runtime callers", async () => {
const client = createSlackSendTestClient();
await expect(
sendMessageSlack("channel:C123", "hi", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
blocks: [{} as { type: string }],
}),
).rejects.toThrow(/non-empty string type/i);
expect(client.chat.postMessage).not.toHaveBeenCalled();
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */