refactor(tests): share outbound runner and delivery helpers

This commit is contained in:
Peter Steinberger
2026-02-16 17:22:12 +00:00
parent 71111c9978
commit d688188864
2 changed files with 126 additions and 153 deletions

View File

@@ -45,6 +45,28 @@ vi.mock("./delivery-queue.js", () => ({
const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js");
const telegramChunkConfig: OpenClawConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
const whatsappChunkConfig: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
};
async function deliverWhatsAppPayload(params: {
sendWhatsApp: ReturnType<typeof vi.fn>;
payload: { text: string; mediaUrl?: string };
cfg?: OpenClawConfig;
}) {
return deliverOutboundPayloads({
cfg: params.cfg ?? whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
payloads: [params.payload],
deps: { sendWhatsApp: params.sendWhatsApp },
});
}
describe("deliverOutboundPayloads", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
@@ -65,14 +87,11 @@ describe("deliverOutboundPayloads", () => {
});
it("chunks telegram markdown and passes through accountId", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const results = await deliverOutboundPayloads({
cfg,
cfg: telegramChunkConfig,
channel: "telegram",
to: "123",
payloads: [{ text: "abcd" }],
@@ -98,12 +117,9 @@ describe("deliverOutboundPayloads", () => {
it("passes explicit accountId to sendTelegram", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
await deliverOutboundPayloads({
cfg,
cfg: telegramChunkConfig,
channel: "telegram",
to: "123",
accountId: "default",
@@ -120,12 +136,9 @@ describe("deliverOutboundPayloads", () => {
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
await deliverOutboundPayloads({
cfg,
cfg: telegramChunkConfig,
channel: "telegram",
to: "123",
agentId: "work",
@@ -251,16 +264,9 @@ describe("deliverOutboundPayloads", () => {
it("strips leading blank lines for WhatsApp text payloads", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "\n\nHello from WhatsApp" }],
deps: { sendWhatsApp },
await deliverWhatsAppPayload({
sendWhatsApp,
payload: { text: "\n\nHello from WhatsApp" },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
@@ -274,16 +280,9 @@ describe("deliverOutboundPayloads", () => {
it("drops whitespace-only WhatsApp text payloads when no media is attached", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: " \n\t " }],
deps: { sendWhatsApp },
const results = await deliverWhatsAppPayload({
sendWhatsApp,
payload: { text: " \n\t " },
});
expect(sendWhatsApp).not.toHaveBeenCalled();
@@ -292,16 +291,9 @@ describe("deliverOutboundPayloads", () => {
it("keeps WhatsApp media payloads but clears whitespace-only captions", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: " \n\t ", mediaUrl: "https://example.com/photo.png" }],
deps: { sendWhatsApp },
await deliverWhatsAppPayload({
sendWhatsApp,
payload: { text: " \n\t ", mediaUrl: "https://example.com/photo.png" },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
@@ -504,13 +496,10 @@ describe("deliverOutboundPayloads", () => {
it("mirrors delivered output when mirror options are provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
mocks.appendAssistantMessageToSessionTranscript.mockClear();
await deliverOutboundPayloads({
cfg,
cfg: telegramChunkConfig,
channel: "telegram",
to: "123",
payloads: [{ text: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1" }],

View File

@@ -39,6 +39,45 @@ const whatsappConfig = {
},
} as OpenClawConfig;
async function withSandbox(test: (sandboxDir: string) => Promise<void>) {
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
try {
await test(sandboxDir);
} finally {
await fs.rm(sandboxDir, { recursive: true, force: true });
}
}
const runDryAction = (params: {
cfg: OpenClawConfig;
action: "send" | "thread-reply" | "broadcast";
actionParams: Record<string, unknown>;
toolContext?: Record<string, unknown>;
abortSignal?: AbortSignal;
sandboxRoot?: string;
}) =>
runMessageAction({
cfg: params.cfg,
action: params.action,
params: params.actionParams as never,
toolContext: params.toolContext as never,
dryRun: true,
abortSignal: params.abortSignal,
sandboxRoot: params.sandboxRoot,
});
const runDrySend = (params: {
cfg: OpenClawConfig;
actionParams: Record<string, unknown>;
toolContext?: Record<string, unknown>;
abortSignal?: AbortSignal;
sandboxRoot?: string;
}) =>
runDryAction({
...params,
action: "send",
});
describe("runMessageAction context isolation", () => {
beforeEach(async () => {
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
@@ -80,62 +119,54 @@ describe("runMessageAction context isolation", () => {
});
it("allows send when target matches current channel", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
toolContext: { currentChannelId: "C12345678" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("accepts legacy to parameter for send", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
to: "#C12345678",
message: "hi",
},
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("defaults to current channel when target is omitted", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
message: "hi",
},
toolContext: { currentChannelId: "C12345678" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("allows media-only send when target matches current channel", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
media: "https://example.com/note.ogg",
},
toolContext: { currentChannelId: "C12345678" },
dryRun: true,
});
expect(result.kind).toBe("send");
@@ -143,104 +174,92 @@ describe("runMessageAction context isolation", () => {
it("requires message when no media hint is provided", async () => {
await expect(
runMessageAction({
runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
},
toolContext: { currentChannelId: "C12345678" },
dryRun: true,
}),
).rejects.toThrow(/message required/i);
});
it("blocks send when target differs from current channel", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "channel:C99999999",
message: "hi",
},
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("blocks thread-reply when channelId differs from current channel", async () => {
const result = await runMessageAction({
const result = await runDryAction({
cfg: slackConfig,
action: "thread-reply",
params: {
actionParams: {
channel: "slack",
target: "C99999999",
message: "hi",
},
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
dryRun: true,
});
expect(result.kind).toBe("action");
});
it("allows WhatsApp send when target matches current chat", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: whatsappConfig,
action: "send",
params: {
actionParams: {
channel: "whatsapp",
target: "123@g.us",
message: "hi",
},
toolContext: { currentChannelId: "123@g.us" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("blocks WhatsApp send when target differs from current chat", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: whatsappConfig,
action: "send",
params: {
actionParams: {
channel: "whatsapp",
target: "456@g.us",
message: "hi",
},
toolContext: { currentChannelId: "123@g.us", currentChannelProvider: "whatsapp" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("allows iMessage send when target matches current handle", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: whatsappConfig,
action: "send",
params: {
actionParams: {
channel: "imessage",
target: "imessage:+15551234567",
message: "hi",
},
toolContext: { currentChannelId: "imessage:+15551234567" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("blocks iMessage send when target differs from current handle", async () => {
const result = await runMessageAction({
const result = await runDrySend({
cfg: whatsappConfig,
action: "send",
params: {
actionParams: {
channel: "imessage",
target: "imessage:+15551230000",
message: "hi",
@@ -249,7 +268,6 @@ describe("runMessageAction context isolation", () => {
currentChannelId: "imessage:+15551234567",
currentChannelProvider: "imessage",
},
dryRun: true,
});
expect(result.kind).toBe("send");
@@ -268,14 +286,12 @@ describe("runMessageAction context isolation", () => {
},
} as OpenClawConfig;
const result = await runMessageAction({
const result = await runDrySend({
cfg: multiConfig,
action: "send",
params: {
actionParams: {
message: "hi",
},
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
dryRun: true,
});
expect(result.kind).toBe("send");
@@ -284,16 +300,14 @@ describe("runMessageAction context isolation", () => {
it("blocks cross-provider sends by default", async () => {
await expect(
runMessageAction({
runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "telegram",
target: "telegram:@ops",
message: "hi",
},
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
dryRun: true,
}),
).rejects.toThrow(/Cross-context messaging denied/);
});
@@ -311,16 +325,14 @@ describe("runMessageAction context isolation", () => {
} as OpenClawConfig;
await expect(
runMessageAction({
runDrySend({
cfg,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "channel:C99999999",
message: "hi",
},
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
dryRun: true,
}),
).rejects.toThrow(/Cross-context messaging denied/);
});
@@ -330,15 +342,13 @@ describe("runMessageAction context isolation", () => {
controller.abort();
await expect(
runMessageAction({
runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
dryRun: true,
abortSignal: controller.signal,
}),
).rejects.toMatchObject({ name: "AbortError" });
@@ -349,15 +359,14 @@ describe("runMessageAction context isolation", () => {
controller.abort();
await expect(
runMessageAction({
runDryAction({
cfg: slackConfig,
action: "broadcast",
params: {
actionParams: {
targets: ["channel:C12345678"],
channel: "slack",
message: "hi",
},
dryRun: true,
abortSignal: controller.signal,
}),
).rejects.toMatchObject({ name: "AbortError" });
@@ -461,8 +470,7 @@ describe("runMessageAction sendAttachment hydration", () => {
},
},
} as OpenClawConfig;
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
try {
await withSandbox(async (sandboxDir) => {
await runMessageAction({
cfg,
action: "sendAttachment",
@@ -477,9 +485,7 @@ describe("runMessageAction sendAttachment hydration", () => {
const call = vi.mocked(loadWebMedia).mock.calls[0];
expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png"));
} finally {
await fs.rm(sandboxDir, { recursive: true, force: true });
}
});
});
});
@@ -505,106 +511,84 @@ describe("runMessageAction sandboxed media validation", () => {
});
it("rejects media outside the sandbox root", async () => {
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
try {
await withSandbox(async (sandboxDir) => {
await expect(
runMessageAction({
runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
media: "/etc/passwd",
message: "",
},
sandboxRoot: sandboxDir,
dryRun: true,
}),
).rejects.toThrow(/sandbox/i);
} finally {
await fs.rm(sandboxDir, { recursive: true, force: true });
}
});
});
it("rejects file:// media outside the sandbox root", async () => {
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
try {
await withSandbox(async (sandboxDir) => {
await expect(
runMessageAction({
runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
media: "file:///etc/passwd",
message: "",
},
sandboxRoot: sandboxDir,
dryRun: true,
}),
).rejects.toThrow(/sandbox/i);
} finally {
await fs.rm(sandboxDir, { recursive: true, force: true });
}
});
});
it("rewrites sandbox-relative media paths", async () => {
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
try {
const result = await runMessageAction({
await withSandbox(async (sandboxDir) => {
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
media: "./data/file.txt",
message: "",
},
sandboxRoot: sandboxDir,
dryRun: true,
});
expect(result.kind).toBe("send");
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "file.txt"));
} finally {
await fs.rm(sandboxDir, { recursive: true, force: true });
}
});
});
it("rewrites MEDIA directives under sandbox", async () => {
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
try {
const result = await runMessageAction({
await withSandbox(async (sandboxDir) => {
const result = await runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
message: "Hello\nMEDIA: ./data/note.ogg",
},
sandboxRoot: sandboxDir,
dryRun: true,
});
expect(result.kind).toBe("send");
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "note.ogg"));
} finally {
await fs.rm(sandboxDir, { recursive: true, force: true });
}
});
});
it("rejects data URLs in media params", async () => {
await expect(
runMessageAction({
runDrySend({
cfg: slackConfig,
action: "send",
params: {
actionParams: {
channel: "slack",
target: "#C12345678",
media: "data:image/png;base64,abcd",
message: "",
},
dryRun: true,
}),
).rejects.toThrow(/data:/i);
});