mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
test: dedupe fixtures and test harness setup
This commit is contained in:
@@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
|
||||
const handleAction = bluebubblesMessageActions.handleAction!;
|
||||
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
|
||||
handleAction({ channel: "bluebubbles", ...ctx });
|
||||
const blueBubblesConfig = (): OpenClawConfig => ({
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
});
|
||||
const runReactAction = async (params: Record<string, unknown>) => {
|
||||
return await callHandleAction({
|
||||
action: "react",
|
||||
params,
|
||||
cfg: blueBubblesConfig(),
|
||||
accountId: null,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
|
||||
it("sends reaction successfully with chatGuid", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await callHandleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
const result = await runReactAction({
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
@@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
|
||||
it("sends reaction removal successfully", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await callHandleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
remove: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
const result = await runReactAction({
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
remove: true,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
|
||||
@@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
setBlueBubblesRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
|
||||
const largeBuffer = new Uint8Array(params.bufferBytes);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
}
|
||||
|
||||
it("throws when guid is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = {};
|
||||
await expect(
|
||||
@@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("throws when attachment exceeds max bytes", async () => {
|
||||
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
await expectAttachmentTooLarge({
|
||||
bufferBytes: 10 * 1024 * 1024,
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("uses default max bytes when not specified", async () => {
|
||||
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
|
||||
});
|
||||
|
||||
it("uses attachment mimeType as fallback when response has no content-type", async () => {
|
||||
|
||||
@@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
function mockOkTextResponse() {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCalledUrlIncludesPassword(params: {
|
||||
password: string;
|
||||
invoke: () => Promise<void>;
|
||||
}) {
|
||||
mockOkTextResponse();
|
||||
await params.invoke();
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(`password=${params.password}`);
|
||||
}
|
||||
|
||||
async function expectCalledUrlUsesConfigCredentials(params: {
|
||||
serverHost: string;
|
||||
password: string;
|
||||
invoke: (cfg: {
|
||||
channels: { bluebubbles: { serverUrl: string; password: string } };
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
mockOkTextResponse();
|
||||
await params.invoke({
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: `http://${params.serverHost}`,
|
||||
password: params.password,
|
||||
},
|
||||
},
|
||||
});
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(params.serverHost);
|
||||
expect(calledUrl).toContain(`password=${params.password}`);
|
||||
}
|
||||
|
||||
describe("markBlueBubblesChatRead", () => {
|
||||
it("does nothing when chatGuid is empty or whitespace", async () => {
|
||||
for (const chatGuid of ["", " "]) {
|
||||
@@ -73,18 +111,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("chat-123", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
await expectCalledUrlIncludesPassword({
|
||||
password: "my-secret",
|
||||
invoke: () =>
|
||||
markBlueBubblesChatRead("chat-123", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
@@ -119,25 +153,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
await expectCalledUrlUsesConfigCredentials({
|
||||
serverHost: "config-server:9999",
|
||||
password: "config-pass",
|
||||
invoke: (cfg) =>
|
||||
markBlueBubblesChatRead("chat-123", {
|
||||
cfg,
|
||||
}),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("chat-123", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:9999",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:9999");
|
||||
expect(calledUrl).toContain("password=config-pass");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,18 +559,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
await expectCalledUrlIncludesPassword({
|
||||
password: "my-secret",
|
||||
invoke: () =>
|
||||
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
@@ -582,25 +601,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
await expectCalledUrlUsesConfigCredentials({
|
||||
serverHost: "config-server:9999",
|
||||
password: "config-pass",
|
||||
invoke: (cfg) =>
|
||||
setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
||||
cfg,
|
||||
}),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:9999",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:9999");
|
||||
expect(calledUrl).toContain("password=config-pass");
|
||||
});
|
||||
|
||||
it("includes filename in multipart body", async () => {
|
||||
|
||||
@@ -19,6 +19,27 @@ describe("reactions", () => {
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesReaction", () => {
|
||||
async function expectRemovedReaction(emoji: string) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji,
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
}
|
||||
|
||||
it("throws when chatGuid is empty", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
@@ -208,45 +229,11 @@ describe("reactions", () => {
|
||||
});
|
||||
|
||||
it("sends reaction removal with dash prefix", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "love",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
await expectRemovedReaction("love");
|
||||
});
|
||||
|
||||
it("strips leading dash from emoji when remove flag is set", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "-love",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
await expectRemovedReaction("-love");
|
||||
});
|
||||
|
||||
it("uses custom partIndex when provided", async () => {
|
||||
|
||||
@@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockNewChatSendResponse(guid: string) {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("send", () => {
|
||||
describe("resolveChatGuidForTarget", () => {
|
||||
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
|
||||
@@ -453,20 +470,7 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("strips markdown when creating a new chat", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-stripped" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
mockNewChatSendResponse("new-msg-stripped");
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -483,20 +487,7 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("creates a new chat when handle target is missing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-guid" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
mockNewChatSendResponse("new-msg-guid");
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedLineAccount,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
@@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccount(
|
||||
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
|
||||
cfg: OpenClawConfig,
|
||||
|
||||
@@ -4,9 +4,9 @@ import type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedLineAccount,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
@@ -33,20 +33,10 @@ function createRuntime() {
|
||||
return { runtime, probeLineBot, monitorLineProvider };
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
token: string;
|
||||
secret: string;
|
||||
runtime: RuntimeEnv;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedLineAccount> {
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: "default",
|
||||
|
||||
@@ -5,6 +5,12 @@ import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
|
||||
import {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
||||
@@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
|
||||
};
|
||||
}
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
let tempDir = "";
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathAlt = process.env.Path;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
const originalPathExtAlt = process.env.Pathext;
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createLobsterTool } = await import("./lobster-tool.js"));
|
||||
@@ -79,29 +74,7 @@ describe("lobster plugin tool", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalPathAlt === undefined) {
|
||||
delete process.env.Path;
|
||||
} else {
|
||||
process.env.Path = originalPathAlt;
|
||||
}
|
||||
if (originalPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
}
|
||||
if (originalPathExtAlt === undefined) {
|
||||
delete process.env.Pathext;
|
||||
} else {
|
||||
process.env.Pathext = originalPathExtAlt;
|
||||
}
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -156,17 +129,6 @@ describe("lobster plugin tool", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createWindowsShimFixture = async (params: {
|
||||
shimPath: string;
|
||||
scriptPath: string;
|
||||
scriptToken: string;
|
||||
}) => {
|
||||
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
|
||||
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8");
|
||||
};
|
||||
|
||||
it("runs lobster and returns parsed envelope in details", async () => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
@@ -281,10 +243,10 @@ describe("lobster plugin tool", () => {
|
||||
setProcessPlatform("win32");
|
||||
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
|
||||
await createWindowsShimFixture({
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
scriptPath: shimScriptPath,
|
||||
scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs",
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
process.env.PATHEXT = ".CMD;.EXE";
|
||||
process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;
|
||||
|
||||
56
extensions/lobster/src/test-helpers.ts
Normal file
56
extensions/lobster/src/test-helpers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext";
|
||||
|
||||
const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const;
|
||||
|
||||
export type PlatformPathEnvSnapshot = {
|
||||
platformDescriptor: PropertyDescriptor | undefined;
|
||||
env: Record<PathEnvKey, string | undefined>;
|
||||
};
|
||||
|
||||
export function setProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot {
|
||||
return {
|
||||
platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"),
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
Path: process.env.Path,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
Pathext: process.env.Pathext,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void {
|
||||
if (snapshot.platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", snapshot.platformDescriptor);
|
||||
}
|
||||
|
||||
for (const key of PATH_ENV_KEYS) {
|
||||
const value = snapshot.env[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWindowsCmdShimFixture(params: {
|
||||
shimPath: string;
|
||||
scriptPath: string;
|
||||
shimLine: string;
|
||||
}): Promise<void> {
|
||||
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
|
||||
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8");
|
||||
}
|
||||
@@ -2,22 +2,17 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathAlt = process.env.Path;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
const originalPathExtAlt = process.env.Pathext;
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
@@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalPathAlt === undefined) {
|
||||
delete process.env.Path;
|
||||
} else {
|
||||
process.env.Path = originalPathAlt;
|
||||
}
|
||||
if (originalPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
}
|
||||
if (originalPathExtAlt === undefined) {
|
||||
delete process.env.Pathext;
|
||||
} else {
|
||||
process.env.Pathext = originalPathExtAlt;
|
||||
}
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
@@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
it("unwraps cmd shim with %dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
scriptPath,
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
@@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
it("unwraps cmd shim with %~dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
scriptPath,
|
||||
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadMSTeamsAttachments } from "./attachments/download.js";
|
||||
import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js";
|
||||
import { buildMSTeamsMediaPayload } from "./attachments/payload.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
|
||||
@@ -52,7 +48,58 @@ const runtimeStub = {
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
type AttachmentsModule = typeof import("./attachments.js");
|
||||
type DownloadAttachmentsParams = Parameters<AttachmentsModule["downloadMSTeamsAttachments"]>[0];
|
||||
type DownloadGraphMediaParams = Parameters<AttachmentsModule["downloadMSTeamsGraphMedia"]>[0];
|
||||
|
||||
const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123";
|
||||
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
||||
const DEFAULT_ALLOW_HOSTS = ["x"];
|
||||
|
||||
const createOkFetchMock = (contentType: string, payload = "png") =>
|
||||
vi.fn(async () => {
|
||||
return new Response(Buffer.from(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
});
|
||||
|
||||
const buildDownloadParams = (
|
||||
attachments: DownloadAttachmentsParams["attachments"],
|
||||
overrides: Partial<
|
||||
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
|
||||
> &
|
||||
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn"> = {},
|
||||
): DownloadAttachmentsParams => {
|
||||
return {
|
||||
attachments,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
allowHosts: DEFAULT_ALLOW_HOSTS,
|
||||
resolveFn: publicResolveFn,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const buildDownloadGraphParams = (
|
||||
fetchFn: typeof fetch,
|
||||
overrides: Partial<
|
||||
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
|
||||
> = {},
|
||||
): DownloadGraphMediaParams => {
|
||||
return {
|
||||
messageUrl: DEFAULT_MESSAGE_URL,
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
fetchFn,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("msteams attachments", () => {
|
||||
const load = async () => {
|
||||
return await import("./attachments.js");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
detectMimeMock.mockClear();
|
||||
saveMediaBufferMock.mockClear();
|
||||
@@ -62,11 +109,13 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||
it("returns empty string when no attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
|
||||
expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
|
||||
});
|
||||
|
||||
it("returns image placeholder for image attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{ contentType: "image/png", contentUrl: "https://x/img.png" },
|
||||
@@ -81,6 +130,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("treats Teams file.download.info image attachments as images", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{
|
||||
@@ -92,6 +142,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("returns document placeholder for non-image attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
|
||||
@@ -106,6 +157,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("counts inline images in text/html attachments", async () => {
|
||||
const { buildMSTeamsAttachmentPlaceholder } = await load();
|
||||
expect(
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{
|
||||
@@ -127,20 +179,13 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("downloadMSTeamsAttachments", () => {
|
||||
it("downloads and stores image contentUrl attachments", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolveFn,
|
||||
});
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = createOkFetchMock("image/png");
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(saveMediaBufferMock).toHaveBeenCalled();
|
||||
@@ -149,50 +194,38 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("supports Teams file.download.info downloadUrl attachments", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.teams.file.download.info",
|
||||
content: { downloadUrl: "https://x/dl", fileType: "png" },
|
||||
},
|
||||
],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolveFn,
|
||||
});
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = createOkFetchMock("image/png");
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams(
|
||||
[
|
||||
{
|
||||
contentType: "application/vnd.microsoft.teams.file.download.info",
|
||||
content: { downloadUrl: "https://x/dl", fileType: "png" },
|
||||
},
|
||||
],
|
||||
{ fetchFn: fetchMock as unknown as typeof fetch },
|
||||
),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(media).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("downloads non-image file attachments (PDF)", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("pdf"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/pdf" },
|
||||
});
|
||||
});
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = createOkFetchMock("application/pdf", "pdf");
|
||||
detectMimeMock.mockResolvedValueOnce("application/pdf");
|
||||
saveMediaBufferMock.mockResolvedValueOnce({
|
||||
path: "/tmp/saved.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolveFn,
|
||||
});
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], {
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(media).toHaveLength(1);
|
||||
@@ -201,48 +234,42 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("downloads inline image URLs from html attachments", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<img src="https://x/inline.png" />',
|
||||
},
|
||||
],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolveFn,
|
||||
});
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = createOkFetchMock("image/png");
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams(
|
||||
[
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<img src="https://x/inline.png" />',
|
||||
},
|
||||
],
|
||||
{ fetchFn: fetchMock as unknown as typeof fetch },
|
||||
),
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores inline data:image base64 payloads", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const base64 = Buffer.from("png").toString("base64");
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams([
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: `<img src="data:image/png;base64,${base64}" />`,
|
||||
},
|
||||
],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
});
|
||||
]),
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(1);
|
||||
expect(saveMediaBufferMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries with auth when the first request is unauthorized", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const headers = new Headers(opts?.headers);
|
||||
const hasAuth = Boolean(headers.get("Authorization"));
|
||||
@@ -255,21 +282,20 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
allowHosts: ["x"],
|
||||
authAllowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolveFn,
|
||||
});
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
authAllowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(media).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips auth retries when the host is not in auth allowlist", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const headers = new Headers(opts?.headers);
|
||||
@@ -283,17 +309,17 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
|
||||
],
|
||||
maxBytes: 1024 * 1024,
|
||||
tokenProvider,
|
||||
allowHosts: ["azureedge.net"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolveFn,
|
||||
});
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams(
|
||||
[{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }],
|
||||
{
|
||||
tokenProvider,
|
||||
allowHosts: ["azureedge.net"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(0);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
@@ -301,13 +327,15 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("skips urls outside the allowlist", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["graph.microsoft.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], {
|
||||
allowHosts: ["graph.microsoft.com"],
|
||||
resolveFn: undefined,
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(media).toHaveLength(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -316,6 +344,7 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("buildMSTeamsGraphMessageUrls", () => {
|
||||
it("builds channel message urls", async () => {
|
||||
const { buildMSTeamsGraphMessageUrls } = await load();
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "channel",
|
||||
conversationId: "19:thread@thread.tacv2",
|
||||
@@ -326,6 +355,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("builds channel reply urls when replyToId is present", async () => {
|
||||
const { buildMSTeamsGraphMessageUrls } = await load();
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "channel",
|
||||
messageId: "reply-id",
|
||||
@@ -338,6 +368,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("builds chat message urls", async () => {
|
||||
const { buildMSTeamsGraphMessageUrls } = await load();
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "groupChat",
|
||||
conversationId: "19:chat@thread.v2",
|
||||
@@ -349,6 +380,7 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("downloadMSTeamsGraphMedia", () => {
|
||||
it("downloads hostedContents images", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const base64 = Buffer.from("png").toString("base64");
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
@@ -371,12 +403,9 @@ describe("msteams attachments", () => {
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
maxBytes: 1024 * 1024,
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
const media = await downloadMSTeamsGraphMedia(
|
||||
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
|
||||
);
|
||||
|
||||
expect(media.media).toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
@@ -384,6 +413,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("merges SharePoint reference attachments with hosted content", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const hostedBase64 = Buffer.from("png").toString("base64");
|
||||
const shareUrl = "https://contoso.sharepoint.com/site/file";
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
@@ -440,17 +470,15 @@ describe("msteams attachments", () => {
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
maxBytes: 1024 * 1024,
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
const media = await downloadMSTeamsGraphMedia(
|
||||
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
|
||||
);
|
||||
|
||||
expect(media.media).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const shareUrl = "https://contoso.sharepoint.com/site/file";
|
||||
const escapedUrl = "https://evil.example/internal.pdf";
|
||||
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
|
||||
@@ -515,13 +543,11 @@ describe("msteams attachments", () => {
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
const media = await downloadMSTeamsGraphMedia(
|
||||
buildDownloadGraphParams(fetchMock as unknown as typeof fetch, {
|
||||
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(media.media).toHaveLength(0);
|
||||
const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
@@ -534,6 +560,7 @@ describe("msteams attachments", () => {
|
||||
|
||||
describe("buildMSTeamsMediaPayload", () => {
|
||||
it("returns single and multi-file fields", async () => {
|
||||
const { buildMSTeamsMediaPayload } = await load();
|
||||
const payload = buildMSTeamsMediaPayload([
|
||||
{ path: "/tmp/a.png", contentType: "image/png" },
|
||||
{ path: "/tmp/b.png", contentType: "image/png" },
|
||||
|
||||
@@ -49,6 +49,28 @@ const runtimeStub = {
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
const createNoopAdapter = (): MSTeamsAdapter => ({
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
});
|
||||
|
||||
const createRecordedSendActivity = (
|
||||
sink: string[],
|
||||
failFirstWithStatusCode?: number,
|
||||
): ((activity: unknown) => Promise<{ id: string }>) => {
|
||||
let attempts = 0;
|
||||
return async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
const content = text ?? "";
|
||||
sink.push(content);
|
||||
attempts += 1;
|
||||
if (failFirstWithStatusCode !== undefined && attempts === 1) {
|
||||
throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
|
||||
}
|
||||
return { id: `id:${content}` };
|
||||
};
|
||||
};
|
||||
|
||||
describe("msteams messenger", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
@@ -117,17 +139,9 @@ describe("msteams messenger", () => {
|
||||
it("sends thread messages via the provided context", async () => {
|
||||
const sent: string[] = [];
|
||||
const ctx = {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
sent.push(text ?? "");
|
||||
return { id: `id:${text ?? ""}` };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
@@ -149,11 +163,7 @@ describe("msteams messenger", () => {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
seen.reference = reference;
|
||||
await logic({
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
seen.texts.push(text ?? "");
|
||||
return { id: `id:${text ?? ""}` };
|
||||
},
|
||||
sendActivity: createRecordedSendActivity(seen.texts),
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
@@ -192,10 +202,7 @@ describe("msteams messenger", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
@@ -242,20 +249,9 @@ describe("msteams messenger", () => {
|
||||
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
|
||||
|
||||
const ctx = {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
attempts.push(text ?? "");
|
||||
if (attempts.length === 1) {
|
||||
throw Object.assign(new Error("throttled"), { statusCode: 429 });
|
||||
}
|
||||
return { id: `id:${text ?? ""}` };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
sendActivity: createRecordedSendActivity(attempts, 429),
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
@@ -280,10 +276,7 @@ describe("msteams messenger", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
await expect(
|
||||
sendMSTeamsMessages({
|
||||
@@ -303,18 +296,7 @@ describe("msteams messenger", () => {
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
attempts.push(text ?? "");
|
||||
if (attempts.length === 1) {
|
||||
throw Object.assign(new Error("server error"), {
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
return { id: `id:${text ?? ""}` };
|
||||
},
|
||||
});
|
||||
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
|
||||
},
|
||||
process: async () => {},
|
||||
};
|
||||
|
||||
@@ -204,6 +204,23 @@ describe("nostr-profile-http", () => {
|
||||
});
|
||||
|
||||
describe("PUT /api/channels/nostr/:accountId/profile", () => {
|
||||
async function expectPrivatePictureRejected(pictureUrl: string) {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "hacker",
|
||||
picture: pictureUrl,
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
const data = JSON.parse(res._getData());
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.error).toContain("private");
|
||||
}
|
||||
|
||||
it("validates profile and publishes", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
@@ -263,37 +280,11 @@ describe("nostr-profile-http", () => {
|
||||
});
|
||||
|
||||
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "hacker",
|
||||
picture: "https://127.0.0.1/evil.jpg",
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
const data = JSON.parse(res._getData());
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.error).toContain("private");
|
||||
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
|
||||
});
|
||||
|
||||
it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "hacker",
|
||||
picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg",
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
const data = JSON.parse(res._getData());
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.error).toContain("private");
|
||||
await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg");
|
||||
});
|
||||
|
||||
it("rejects non-https URLs", async () => {
|
||||
|
||||
@@ -23,14 +23,14 @@ async function settleTimers<T>(promise: Promise<T>): Promise<T> {
|
||||
return promise;
|
||||
}
|
||||
|
||||
function mockSuccessResponse() {
|
||||
function mockResponse(statusCode: number, body: string) {
|
||||
const httpsRequest = vi.mocked(https.request);
|
||||
httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
|
||||
const res = new EventEmitter() as any;
|
||||
res.statusCode = 200;
|
||||
res.statusCode = statusCode;
|
||||
process.nextTick(() => {
|
||||
callback(res);
|
||||
res.emit("data", Buffer.from('{"success":true}'));
|
||||
res.emit("data", Buffer.from(body));
|
||||
res.emit("end");
|
||||
});
|
||||
const req = new EventEmitter() as any;
|
||||
@@ -41,22 +41,12 @@ function mockSuccessResponse() {
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessResponse() {
|
||||
mockResponse(200, '{"success":true}');
|
||||
}
|
||||
|
||||
function mockFailureResponse(statusCode = 500) {
|
||||
const httpsRequest = vi.mocked(https.request);
|
||||
httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
|
||||
const res = new EventEmitter() as any;
|
||||
res.statusCode = statusCode;
|
||||
process.nextTick(() => {
|
||||
callback(res);
|
||||
res.emit("data", Buffer.from("error"));
|
||||
res.emit("end");
|
||||
});
|
||||
const req = new EventEmitter() as any;
|
||||
req.write = vi.fn();
|
||||
req.end = vi.fn();
|
||||
req.destroy = vi.fn();
|
||||
return req;
|
||||
});
|
||||
mockResponse(statusCode, "error");
|
||||
}
|
||||
|
||||
describe("sendMessage", () => {
|
||||
|
||||
@@ -80,6 +80,24 @@ describe("createWebhookHandler", () => {
|
||||
};
|
||||
});
|
||||
|
||||
async function expectForbiddenByPolicy(params: {
|
||||
account: Partial<ResolvedSynologyChatAccount>;
|
||||
bodyContains: string;
|
||||
}) {
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount(params.account),
|
||||
deliver: vi.fn(),
|
||||
log,
|
||||
});
|
||||
|
||||
const req = makeReq("POST", validBody);
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toContain(params.bodyContains);
|
||||
}
|
||||
|
||||
it("rejects non-POST methods with 405", async () => {
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount(),
|
||||
@@ -129,36 +147,20 @@ describe("createWebhookHandler", () => {
|
||||
});
|
||||
|
||||
it("returns 403 for unauthorized user with allowlist policy", async () => {
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({
|
||||
await expectForbiddenByPolicy({
|
||||
account: {
|
||||
dmPolicy: "allowlist",
|
||||
allowedUserIds: ["456"],
|
||||
}),
|
||||
deliver: vi.fn(),
|
||||
log,
|
||||
},
|
||||
bodyContains: "not authorized",
|
||||
});
|
||||
|
||||
const req = makeReq("POST", validBody);
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toContain("not authorized");
|
||||
});
|
||||
|
||||
it("returns 403 when DMs are disabled", async () => {
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({ dmPolicy: "disabled" }),
|
||||
deliver: vi.fn(),
|
||||
log,
|
||||
await expectForbiddenByPolicy({
|
||||
account: { dmPolicy: "disabled" },
|
||||
bodyContains: "disabled",
|
||||
});
|
||||
|
||||
const req = makeReq("POST", validBody);
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toContain("disabled");
|
||||
});
|
||||
|
||||
it("returns 429 when rate limited", async () => {
|
||||
|
||||
@@ -4,9 +4,9 @@ import type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedTelegramAccount,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { telegramPlugin } from "./channel.js";
|
||||
import { setTelegramRuntime } from "./runtime.js";
|
||||
|
||||
@@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedTelegramAccount> {
|
||||
const account = telegramPlugin.config.resolveAccount(
|
||||
params.cfg,
|
||||
|
||||
12
extensions/test-utils/runtime-env.ts
Normal file
12
extensions/test-utils/runtime-env.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,38 @@ describe("checkTwitchAccessControl", () => {
|
||||
channel: "testchannel",
|
||||
};
|
||||
|
||||
function runAccessCheck(params: {
|
||||
account?: Partial<TwitchAccountConfig>;
|
||||
message?: Partial<TwitchChatMessage>;
|
||||
}) {
|
||||
return checkTwitchAccessControl({
|
||||
message: {
|
||||
...mockMessage,
|
||||
...params.message,
|
||||
},
|
||||
account: {
|
||||
...mockAccount,
|
||||
...params.account,
|
||||
},
|
||||
botUsername: "testbot",
|
||||
});
|
||||
}
|
||||
|
||||
function expectSingleRoleAllowed(params: {
|
||||
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
|
||||
message: Partial<TwitchChatMessage>;
|
||||
}) {
|
||||
const result = runAccessCheck({
|
||||
account: { allowedRoles: [params.role] },
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
...params.message,
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("when no restrictions are configured", () => {
|
||||
it("allows messages that mention the bot (default requireMention)", () => {
|
||||
const message: TwitchChatMessage = {
|
||||
@@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => {
|
||||
|
||||
describe("allowedRoles", () => {
|
||||
it("allows users with matching role", () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["moderator"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isMod: true,
|
||||
};
|
||||
|
||||
const result = checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
const result = expectSingleRoleAllowed({
|
||||
role: "moderator",
|
||||
message: { isMod: true },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.matchSource).toBe("role");
|
||||
});
|
||||
|
||||
@@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => {
|
||||
});
|
||||
|
||||
it("handles moderator role", () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["moderator"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isMod: true,
|
||||
};
|
||||
|
||||
const result = checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
expectSingleRoleAllowed({
|
||||
role: "moderator",
|
||||
message: { isMod: true },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("handles subscriber role", () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["subscriber"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isSub: true,
|
||||
};
|
||||
|
||||
const result = checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
expectSingleRoleAllowed({
|
||||
role: "subscriber",
|
||||
message: { isSub: true },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("handles owner role", () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["owner"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isOwner: true,
|
||||
};
|
||||
|
||||
const result = checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
expectSingleRoleAllowed({
|
||||
role: "owner",
|
||||
message: { isOwner: true },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("handles vip role", () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["vip"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isVip: true,
|
||||
};
|
||||
|
||||
const result = checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
expectSingleRoleAllowed({
|
||||
role: "vip",
|
||||
message: { isVip: true },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => {
|
||||
});
|
||||
|
||||
it("checks allowlist before allowedRoles", () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowFrom: ["123456"],
|
||||
allowedRoles: ["owner"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isOwner: false,
|
||||
};
|
||||
|
||||
const result = checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
const result = runAccessCheck({
|
||||
account: {
|
||||
allowFrom: ["123456"],
|
||||
allowedRoles: ["owner"],
|
||||
},
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
isOwner: false,
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.matchSource).toBe("allowlist");
|
||||
|
||||
@@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createRejectingInboundContext(): {
|
||||
ctx: CallManagerContext;
|
||||
hangupCalls: HangupCallInput[];
|
||||
} {
|
||||
const hangupCalls: HangupCallInput[] = [];
|
||||
const provider = createProvider({
|
||||
hangupCall: async (input: HangupCallInput): Promise<void> => {
|
||||
hangupCalls.push(input);
|
||||
},
|
||||
});
|
||||
const ctx = createContext({
|
||||
config: createInboundDisabledConfig(),
|
||||
provider,
|
||||
});
|
||||
return { ctx, hangupCalls };
|
||||
}
|
||||
|
||||
describe("processEvent (functional)", () => {
|
||||
it("calls provider hangup when rejecting inbound call", () => {
|
||||
const hangupCalls: HangupCallInput[] = [];
|
||||
const provider = createProvider({
|
||||
hangupCall: async (input: HangupCallInput): Promise<void> => {
|
||||
hangupCalls.push(input);
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = createContext({
|
||||
config: createInboundDisabledConfig(),
|
||||
provider,
|
||||
});
|
||||
const { ctx, hangupCalls } = createRejectingInboundContext();
|
||||
const event = createInboundInitiatedEvent({
|
||||
id: "evt-1",
|
||||
providerCallId: "prov-1",
|
||||
@@ -118,16 +125,7 @@ describe("processEvent (functional)", () => {
|
||||
});
|
||||
|
||||
it("calls hangup only once for duplicate events for same rejected call", () => {
|
||||
const hangupCalls: HangupCallInput[] = [];
|
||||
const provider = createProvider({
|
||||
hangupCall: async (input: HangupCallInput): Promise<void> => {
|
||||
hangupCalls.push(input);
|
||||
},
|
||||
});
|
||||
const ctx = createContext({
|
||||
config: createInboundDisabledConfig(),
|
||||
provider,
|
||||
});
|
||||
const { ctx, hangupCalls } = createRejectingInboundContext();
|
||||
const event1 = createInboundInitiatedEvent({
|
||||
id: "evt-init",
|
||||
providerCallId: "prov-dup",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentSideConnection, PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import type { PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
|
||||
function createConnection(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
||||
|
||||
describe("acp prompt cwd prefix", () => {
|
||||
async function runPromptWithCwd(cwd: string) {
|
||||
@@ -33,14 +28,14 @@ describe("acp prompt cwd prefix", () => {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const gateway = {
|
||||
request: requestSpy,
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const agent = new AcpGatewayAgent(createConnection(), gateway, {
|
||||
sessionStore,
|
||||
prefixCwd: true,
|
||||
});
|
||||
const agent = new AcpGatewayAgent(
|
||||
createAcpConnection(),
|
||||
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
|
||||
{
|
||||
sessionStore,
|
||||
prefixCwd: true,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
LoadSessionRequest,
|
||||
NewSessionRequest,
|
||||
PromptRequest,
|
||||
@@ -8,20 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
|
||||
function createConnection(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function createGateway(
|
||||
request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"],
|
||||
): GatewayClient {
|
||||
return {
|
||||
request,
|
||||
} as unknown as GatewayClient;
|
||||
}
|
||||
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
||||
|
||||
function createNewSessionRequest(cwd = "/tmp"): NewSessionRequest {
|
||||
return {
|
||||
@@ -55,7 +41,7 @@ function createPromptRequest(
|
||||
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
|
||||
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const agent = new AcpGatewayAgent(createConnection(), createGateway(request), {
|
||||
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
await agent.loadSession(createLoadSessionRequest(params.sessionId));
|
||||
@@ -74,7 +60,7 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text:
|
||||
describe("acp session creation rate limit", () => {
|
||||
it("rate limits excessive newSession bursts", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const agent = new AcpGatewayAgent(createConnection(), createGateway(), {
|
||||
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
|
||||
sessionStore,
|
||||
sessionCreateRateLimit: {
|
||||
maxRequests: 2,
|
||||
@@ -93,7 +79,7 @@ describe("acp session creation rate limit", () => {
|
||||
|
||||
it("does not count loadSession refreshes for an existing session ID", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const agent = new AcpGatewayAgent(createConnection(), createGateway(), {
|
||||
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
|
||||
sessionStore,
|
||||
sessionCreateRateLimit: {
|
||||
maxRequests: 1,
|
||||
|
||||
17
src/acp/translator.test-helpers.ts
Normal file
17
src/acp/translator.test-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
|
||||
export function createAcpConnection(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
export function createAcpGateway(
|
||||
request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"],
|
||||
): GatewayClient {
|
||||
return {
|
||||
request,
|
||||
} as unknown as GatewayClient;
|
||||
}
|
||||
@@ -46,28 +46,33 @@ function pollStatus(result: Awaited<ReturnType<ReturnType<typeof createProcessTo
|
||||
return (result.details as { status?: string }).status;
|
||||
}
|
||||
|
||||
test("process poll waits for completion when timeout is provided", async () => {
|
||||
async function expectCompletedPollWithTimeout(params: {
|
||||
sessionId: string;
|
||||
callId: string;
|
||||
timeout: number | string;
|
||||
advanceMs: number;
|
||||
assertUnresolvedAtMs?: number;
|
||||
}) {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sessionId = "sess";
|
||||
const { processTool, session } = createProcessSessionHarness(sessionId);
|
||||
const { processTool, session } = createProcessSessionHarness(params.sessionId);
|
||||
|
||||
setTimeout(() => {
|
||||
appendOutput(session, "stdout", "done\n");
|
||||
markExited(session, 0, null, "completed");
|
||||
}, 10);
|
||||
|
||||
const pollPromise = pollSession(processTool, "toolcall", sessionId, 2000);
|
||||
const pollPromise = pollSession(processTool, params.callId, params.sessionId, params.timeout);
|
||||
if (params.assertUnresolvedAtMs !== undefined) {
|
||||
let resolved = false;
|
||||
void pollPromise.finally(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(params.assertUnresolvedAtMs);
|
||||
expect(resolved).toBe(false);
|
||||
}
|
||||
|
||||
let resolved = false;
|
||||
void pollPromise.finally(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.advanceTimersByTimeAsync(params.advanceMs);
|
||||
const poll = await pollPromise;
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
expect(details.status).toBe("completed");
|
||||
@@ -75,27 +80,25 @@ test("process poll waits for completion when timeout is provided", async () => {
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
test("process poll waits for completion when timeout is provided", async () => {
|
||||
await expectCompletedPollWithTimeout({
|
||||
sessionId: "sess",
|
||||
callId: "toolcall",
|
||||
timeout: 2000,
|
||||
assertUnresolvedAtMs: 200,
|
||||
advanceMs: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test("process poll accepts string timeout values", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sessionId = "sess-2";
|
||||
const { processTool, session } = createProcessSessionHarness(sessionId);
|
||||
setTimeout(() => {
|
||||
appendOutput(session, "stdout", "done\n");
|
||||
markExited(session, 0, null, "completed");
|
||||
}, 10);
|
||||
|
||||
const pollPromise = pollSession(processTool, "toolcall", sessionId, "2000");
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
const poll = await pollPromise;
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain("done");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await expectCompletedPollWithTimeout({
|
||||
sessionId: "sess-2",
|
||||
callId: "toolcall",
|
||||
timeout: "2000",
|
||||
advanceMs: 350,
|
||||
});
|
||||
});
|
||||
|
||||
test("process poll exposes adaptive retryInMs for repeated no-output polls", async () => {
|
||||
|
||||
@@ -35,6 +35,18 @@ async function resolveBedrockProvider() {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectBedrockAuthSource(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
expectedSource: string;
|
||||
}) {
|
||||
await withEnvAsync(params.env, async () => {
|
||||
const resolved = await resolveBedrockProvider();
|
||||
expect(resolved.mode).toBe("aws-sdk");
|
||||
expect(resolved.apiKey).toBeUndefined();
|
||||
expect(resolved.source).toContain(params.expectedSource);
|
||||
});
|
||||
}
|
||||
|
||||
describe("getApiKeyForModel", () => {
|
||||
it("migrates legacy oauth.json into auth-profiles.json", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
|
||||
@@ -226,57 +238,39 @@ describe("getApiKeyForModel", () => {
|
||||
});
|
||||
|
||||
it("prefers Bedrock bearer token over access keys and profile", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
await expectBedrockAuthSource({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bedrock-token",
|
||||
AWS_ACCESS_KEY_ID: "access-key",
|
||||
AWS_SECRET_ACCESS_KEY: "secret-key",
|
||||
AWS_PROFILE: "profile",
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveBedrockProvider();
|
||||
|
||||
expect(resolved.mode).toBe("aws-sdk");
|
||||
expect(resolved.apiKey).toBeUndefined();
|
||||
expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK");
|
||||
},
|
||||
);
|
||||
expectedSource: "AWS_BEARER_TOKEN_BEDROCK",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers Bedrock access keys over profile", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
await expectBedrockAuthSource({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_ACCESS_KEY_ID: "access-key",
|
||||
AWS_SECRET_ACCESS_KEY: "secret-key",
|
||||
AWS_PROFILE: "profile",
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveBedrockProvider();
|
||||
|
||||
expect(resolved.mode).toBe("aws-sdk");
|
||||
expect(resolved.apiKey).toBeUndefined();
|
||||
expect(resolved.source).toContain("AWS_ACCESS_KEY_ID");
|
||||
},
|
||||
);
|
||||
expectedSource: "AWS_ACCESS_KEY_ID",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bedrock profile when access keys are missing", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
await expectBedrockAuthSource({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_SECRET_ACCESS_KEY: undefined,
|
||||
AWS_PROFILE: "profile",
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveBedrockProvider();
|
||||
|
||||
expect(resolved.mode).toBe("aws-sdk");
|
||||
expect(resolved.apiKey).toBeUndefined();
|
||||
expect(resolved.source).toContain("AWS_PROFILE");
|
||||
},
|
||||
);
|
||||
expectedSource: "AWS_PROFILE",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts VOYAGE_API_KEY for voyage", async () => {
|
||||
|
||||
@@ -107,6 +107,60 @@ function createOverrideFailureRun(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function makeSingleProviderStore(params: {
|
||||
provider: string;
|
||||
usageStat: NonNullable<AuthProfileStore["usageStats"]>[string];
|
||||
}): AuthProfileStore {
|
||||
const profileId = `${params.provider}:default`;
|
||||
return {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "api_key",
|
||||
provider: params.provider,
|
||||
key: "test-key",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
[profileId]: params.usageStat,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackOnlyRun() {
|
||||
return vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||
if (providerId === "fallback") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSkippedUnavailableProvider(params: {
|
||||
providerPrefix: string;
|
||||
usageStat: NonNullable<AuthProfileStore["usageStats"]>[string];
|
||||
expectedReason: string;
|
||||
}) {
|
||||
const provider = `${params.providerPrefix}-${crypto.randomUUID()}`;
|
||||
const cfg = makeProviderFallbackCfg(provider);
|
||||
const store = makeSingleProviderStore({
|
||||
provider,
|
||||
usageStat: params.usageStat,
|
||||
});
|
||||
const run = createFallbackOnlyRun();
|
||||
|
||||
const result = await runWithStoredAuth({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
|
||||
expect(result.attempts[0]?.reason).toBe(params.expectedReason);
|
||||
}
|
||||
|
||||
describe("runWithModelFallback", () => {
|
||||
it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => {
|
||||
const cfg = makeCfg();
|
||||
@@ -310,86 +364,26 @@ describe("runWithModelFallback", () => {
|
||||
});
|
||||
|
||||
it("skips providers when all profiles are in cooldown", async () => {
|
||||
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: "test-key",
|
||||
},
|
||||
await expectSkippedUnavailableProvider({
|
||||
providerPrefix: "cooldown-test",
|
||||
usageStat: {
|
||||
cooldownUntil: Date.now() + 5 * 60_000,
|
||||
},
|
||||
usageStats: {
|
||||
[profileId]: {
|
||||
cooldownUntil: Date.now() + 5 * 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cfg = makeProviderFallbackCfg(provider);
|
||||
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||
if (providerId === "fallback") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||
expectedReason: "rate_limit",
|
||||
});
|
||||
|
||||
const result = await runWithStoredAuth({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
|
||||
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
||||
});
|
||||
|
||||
it("propagates disabled reason when all profiles are unavailable", async () => {
|
||||
const provider = `disabled-test-${crypto.randomUUID()}`;
|
||||
const profileId = `${provider}:default`;
|
||||
const now = Date.now();
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: "test-key",
|
||||
},
|
||||
await expectSkippedUnavailableProvider({
|
||||
providerPrefix: "disabled-test",
|
||||
usageStat: {
|
||||
disabledUntil: now + 5 * 60_000,
|
||||
disabledReason: "billing",
|
||||
failureCounts: { rate_limit: 4 },
|
||||
},
|
||||
usageStats: {
|
||||
[profileId]: {
|
||||
disabledUntil: now + 5 * 60_000,
|
||||
disabledReason: "billing",
|
||||
failureCounts: { rate_limit: 4 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cfg = makeProviderFallbackCfg(provider);
|
||||
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||
if (providerId === "fallback") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||
expectedReason: "billing",
|
||||
});
|
||||
|
||||
const result = await runWithStoredAuth({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
|
||||
expect(result.attempts[0]?.reason).toBe("billing");
|
||||
});
|
||||
|
||||
it("does not skip when any profile is available", async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
withModelsTempHome as withTempHome,
|
||||
} from "./models-config.e2e-harness.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
|
||||
@@ -34,11 +35,9 @@ describe("models-config", () => {
|
||||
|
||||
await ensureOpenClawModelsJson(validated.config);
|
||||
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: Record<string, { api?: string; models?: Array<{ id: string; api?: string }> }>;
|
||||
};
|
||||
}>();
|
||||
|
||||
expect(parsed.providers.anthropic?.api).toBe("anthropic-messages");
|
||||
expect(parsed.providers.anthropic?.models?.[0]?.api).toBe("anthropic-messages");
|
||||
@@ -74,11 +73,9 @@ describe("models-config", () => {
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
|
||||
};
|
||||
}>();
|
||||
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("MiniMax-VL-01");
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
describe("models-config", () => {
|
||||
installModelsConfigTestHooks();
|
||||
@@ -47,11 +45,9 @@ describe("models-config", () => {
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: Record<string, { models: Array<{ id: string }> }>;
|
||||
};
|
||||
}>();
|
||||
const ids = parsed.providers.google?.models?.map((model) => model.id);
|
||||
expect(ids).toEqual(["gemini-3-pro-preview", "gemini-3-flash-preview"]);
|
||||
});
|
||||
|
||||
9
src/agents/models-config.test-utils.ts
Normal file
9
src/agents/models-config.test-utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
|
||||
export async function readGeneratedModelsJson<T>(): Promise<T> {
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
@@ -280,107 +280,105 @@ describe("parseNdjsonStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function withMockNdjsonFetch(
|
||||
lines: string[],
|
||||
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
const payload = lines.join("\n");
|
||||
return new Response(`${payload}\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
try {
|
||||
await run(fetchMock);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
async function createOllamaTestStream(params: {
|
||||
baseUrl: string;
|
||||
options?: { maxTokens?: number; signal?: AbortSignal };
|
||||
}) {
|
||||
const streamFn = createOllamaStreamFn(params.baseUrl);
|
||||
return streamFn(
|
||||
{
|
||||
id: "qwen3:32b",
|
||||
api: "ollama",
|
||||
provider: "custom-ollama",
|
||||
contextWindow: 131072,
|
||||
} as unknown as Parameters<typeof streamFn>[0],
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
} as unknown as Parameters<typeof streamFn>[1],
|
||||
(params.options ?? {}) as unknown as Parameters<typeof streamFn>[2],
|
||||
);
|
||||
}
|
||||
|
||||
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
|
||||
const events: T[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("createOllamaStreamFn", () => {
|
||||
it("normalizes /v1 baseUrl and maps maxTokens + signal", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
const payload = [
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
|
||||
].join("\n");
|
||||
return new Response(`${payload}\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
],
|
||||
async (fetchMock) => {
|
||||
const signal = new AbortController().signal;
|
||||
const stream = await createOllamaTestStream({
|
||||
baseUrl: "http://ollama-host:11434/v1/",
|
||||
options: { maxTokens: 123, signal },
|
||||
});
|
||||
|
||||
try {
|
||||
const streamFn = createOllamaStreamFn("http://ollama-host:11434/v1/");
|
||||
const signal = new AbortController().signal;
|
||||
const stream = await streamFn(
|
||||
{
|
||||
id: "qwen3:32b",
|
||||
api: "ollama",
|
||||
provider: "custom-ollama",
|
||||
contextWindow: 131072,
|
||||
} as unknown as Parameters<typeof streamFn>[0],
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
} as unknown as Parameters<typeof streamFn>[1],
|
||||
{
|
||||
maxTokens: 123,
|
||||
signal,
|
||||
} as unknown as Parameters<typeof streamFn>[2],
|
||||
);
|
||||
const events = await collectStreamEvents(stream);
|
||||
expect(events.at(-1)?.type).toBe("done");
|
||||
|
||||
const events = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(events.at(-1)?.type).toBe("done");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("http://ollama-host:11434/api/chat");
|
||||
expect(requestInit.signal).toBe(signal);
|
||||
if (typeof requestInit.body !== "string") {
|
||||
throw new Error("Expected string request body");
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("http://ollama-host:11434/api/chat");
|
||||
expect(requestInit.signal).toBe(signal);
|
||||
if (typeof requestInit.body !== "string") {
|
||||
throw new Error("Expected string request body");
|
||||
}
|
||||
|
||||
const requestBody = JSON.parse(requestInit.body) as {
|
||||
options: { num_ctx?: number; num_predict?: number };
|
||||
};
|
||||
expect(requestBody.options.num_ctx).toBe(131072);
|
||||
expect(requestBody.options.num_predict).toBe(123);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
const requestBody = JSON.parse(requestInit.body) as {
|
||||
options: { num_ctx?: number; num_predict?: number };
|
||||
};
|
||||
expect(requestBody.options.num_ctx).toBe(131072);
|
||||
expect(requestBody.options.num_predict).toBe(123);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates reasoning chunks when content is empty", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
const payload = [
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}',
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":" output"},"done":false}',
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":2}',
|
||||
].join("\n");
|
||||
return new Response(`${payload}\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
],
|
||||
async () => {
|
||||
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
|
||||
const events = await collectStreamEvents(stream);
|
||||
|
||||
try {
|
||||
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
|
||||
const stream = await streamFn(
|
||||
{
|
||||
id: "qwen3:32b",
|
||||
api: "ollama",
|
||||
provider: "custom-ollama",
|
||||
contextWindow: 131072,
|
||||
} as unknown as Parameters<typeof streamFn>[0],
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
} as unknown as Parameters<typeof streamFn>[1],
|
||||
{} as unknown as Parameters<typeof streamFn>[2],
|
||||
);
|
||||
const doneEvent = events.at(-1);
|
||||
if (!doneEvent || doneEvent.type !== "done") {
|
||||
throw new Error("Expected done event");
|
||||
}
|
||||
|
||||
const events = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const doneEvent = events.at(-1);
|
||||
if (!doneEvent || doneEvent.type !== "done") {
|
||||
throw new Error("Expected done event");
|
||||
}
|
||||
|
||||
expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,17 +43,13 @@ vi.mock("../usage.js", () => ({
|
||||
normalizeUsage: vi.fn((usage?: unknown) =>
|
||||
usage && typeof usage === "object" ? usage : undefined,
|
||||
),
|
||||
derivePromptTokens: vi.fn(
|
||||
(usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => {
|
||||
if (!usage) {
|
||||
return undefined;
|
||||
}
|
||||
const input = usage.input ?? 0;
|
||||
const cacheRead = usage.cacheRead ?? 0;
|
||||
const cacheWrite = usage.cacheWrite ?? 0;
|
||||
const sum = input + cacheRead + cacheWrite;
|
||||
return sum > 0 ? sum : undefined;
|
||||
},
|
||||
derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) =>
|
||||
usage
|
||||
? (() => {
|
||||
const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
return sum > 0 ? sum : undefined;
|
||||
})()
|
||||
: undefined,
|
||||
),
|
||||
hasNonzeroUsage: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
@@ -78,6 +78,21 @@ async function emitPngMediaToolResult(
|
||||
});
|
||||
}
|
||||
|
||||
async function emitUntrustedToolMediaResult(
|
||||
ctx: EmbeddedPiSubscribeContext,
|
||||
mediaPathOrUrl: string,
|
||||
) {
|
||||
await handleToolExecutionEnd(ctx, {
|
||||
type: "tool_execution_end",
|
||||
toolName: "plugin_tool",
|
||||
toolCallId: "tc-1",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: `MEDIA:${mediaPathOrUrl}` }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("handleToolExecutionEnd media emission", () => {
|
||||
it("does not warn for read tool when path is provided via file_path alias", async () => {
|
||||
const ctx = createMockContext();
|
||||
@@ -107,15 +122,7 @@ describe("handleToolExecutionEnd media emission", () => {
|
||||
const onToolResult = vi.fn();
|
||||
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
|
||||
|
||||
await handleToolExecutionEnd(ctx, {
|
||||
type: "tool_execution_end",
|
||||
toolName: "plugin_tool",
|
||||
toolCallId: "tc-1",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }],
|
||||
},
|
||||
});
|
||||
await emitUntrustedToolMediaResult(ctx, "/tmp/secret.png");
|
||||
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -124,15 +131,7 @@ describe("handleToolExecutionEnd media emission", () => {
|
||||
const onToolResult = vi.fn();
|
||||
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
|
||||
|
||||
await handleToolExecutionEnd(ctx, {
|
||||
type: "tool_execution_end",
|
||||
toolName: "plugin_tool",
|
||||
toolCallId: "tc-1",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }],
|
||||
},
|
||||
});
|
||||
await emitUntrustedToolMediaResult(ctx, "https://example.com/file.png");
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith({
|
||||
mediaUrls: ["https://example.com/file.png"],
|
||||
|
||||
@@ -41,6 +41,24 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
return { emit, subscription };
|
||||
}
|
||||
|
||||
function createWriteFailureHarness(params: {
|
||||
runId: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}): ReturnType<typeof createToolErrorHarness> {
|
||||
const harness = createToolErrorHarness(params.runId);
|
||||
emitToolRun({
|
||||
emit: harness.emit,
|
||||
toolName: "write",
|
||||
toolCallId: "w1",
|
||||
args: { path: params.path, content: params.content },
|
||||
isError: true,
|
||||
result: { error: "disk full" },
|
||||
});
|
||||
expect(harness.subscription.getLastToolError()?.toolName).toBe("write");
|
||||
return harness;
|
||||
}
|
||||
|
||||
function emitToolRun(params: {
|
||||
emit: (evt: unknown) => void;
|
||||
toolName: string;
|
||||
@@ -389,17 +407,11 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
});
|
||||
|
||||
it("keeps unresolved mutating failure when an unrelated tool succeeds", () => {
|
||||
const { emit, subscription } = createToolErrorHarness("run-tools-1");
|
||||
|
||||
emitToolRun({
|
||||
emit,
|
||||
toolName: "write",
|
||||
toolCallId: "w1",
|
||||
args: { path: "/tmp/demo.txt", content: "next" },
|
||||
isError: true,
|
||||
result: { error: "disk full" },
|
||||
const { emit, subscription } = createWriteFailureHarness({
|
||||
runId: "run-tools-1",
|
||||
path: "/tmp/demo.txt",
|
||||
content: "next",
|
||||
});
|
||||
expect(subscription.getLastToolError()?.toolName).toBe("write");
|
||||
|
||||
emitToolRun({
|
||||
emit,
|
||||
@@ -414,17 +426,11 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
});
|
||||
|
||||
it("clears unresolved mutating failure when the same action succeeds", () => {
|
||||
const { emit, subscription } = createToolErrorHarness("run-tools-2");
|
||||
|
||||
emitToolRun({
|
||||
emit,
|
||||
toolName: "write",
|
||||
toolCallId: "w1",
|
||||
args: { path: "/tmp/demo.txt", content: "next" },
|
||||
isError: true,
|
||||
result: { error: "disk full" },
|
||||
const { emit, subscription } = createWriteFailureHarness({
|
||||
runId: "run-tools-2",
|
||||
path: "/tmp/demo.txt",
|
||||
content: "next",
|
||||
});
|
||||
expect(subscription.getLastToolError()?.toolName).toBe("write");
|
||||
|
||||
emitToolRun({
|
||||
emit,
|
||||
|
||||
@@ -113,6 +113,34 @@ describe("Agent-specific tool filtering", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createExecHostDefaultsConfig(
|
||||
agents: Array<{ id: string; execHost?: "gateway" | "sandbox" }>,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: agents.map((agent) => ({
|
||||
id: agent.id,
|
||||
...(agent.execHost
|
||||
? {
|
||||
tools: {
|
||||
exec: {
|
||||
host: agent.execHost,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("should apply global tool policy when no agent-specific policy exists", () => {
|
||||
const cfg = createMainAgentConfig({
|
||||
tools: {
|
||||
@@ -646,30 +674,10 @@ describe("Agent-specific tool filtering", () => {
|
||||
});
|
||||
|
||||
it("should apply agent-specific exec host defaults over global defaults", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "helper",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const cfg = createExecHostDefaultsConfig([
|
||||
{ id: "main", execHost: "gateway" },
|
||||
{ id: "helper" },
|
||||
]);
|
||||
|
||||
const mainTools = createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
@@ -716,27 +724,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
});
|
||||
|
||||
it("applies explicit agentId exec defaults when sessionKey is opaque", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const cfg = createExecHostDefaultsConfig([{ id: "main", execHost: "gateway" }]);
|
||||
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
|
||||
@@ -18,13 +18,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
|
||||
return {
|
||||
...actual,
|
||||
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
|
||||
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../shared/config-eval.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../shared/config-eval.js")>();
|
||||
|
||||
@@ -5,10 +5,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
||||
import { createTempHomeEnv } from "../test-utils/temp-home.js";
|
||||
import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js";
|
||||
import { installSkill } from "./skills-install.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
const scanDirectoryWithSummaryMock = vi.fn();
|
||||
const fetchWithSsrFGuardMock = vi.fn();
|
||||
import {
|
||||
fetchWithSsrFGuardMock,
|
||||
runCommandWithTimeoutMock,
|
||||
scanDirectoryWithSummaryMock,
|
||||
} from "./skills-install.test-mocks.js";
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
@@ -18,13 +19,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
|
||||
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
|
||||
return {
|
||||
...actual,
|
||||
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
|
||||
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
|
||||
}));
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -77,8 +75,8 @@ function mockTarExtractionFlow(params: {
|
||||
verboseListOutput: string;
|
||||
extract: "ok" | "reject";
|
||||
}) {
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
|
||||
const cmd = argv as string[];
|
||||
runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => {
|
||||
const cmd = (argv[0] ?? []) as string[];
|
||||
if (cmd[0] === "tar" && cmd[1] === "tf") {
|
||||
return runCommandResult({ stdout: params.listOutput });
|
||||
}
|
||||
|
||||
@@ -12,13 +12,10 @@ vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
|
||||
return {
|
||||
...actual,
|
||||
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
|
||||
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
|
||||
}));
|
||||
|
||||
async function writeInstallableSkill(workspaceDir: string, name: string): Promise<string> {
|
||||
const skillDir = path.join(workspaceDir, "skills", name);
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
|
||||
import { writeSkill } from "./skills.e2e-test-helpers.js";
|
||||
import { buildWorkspaceSkillsPrompt } from "./skills.js";
|
||||
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
async function createCaseDir(prefix: string): Promise<string> {
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
const fixtureSuite = createFixtureSuite("openclaw-skills-prompt-suite-");
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-prompt-suite-"));
|
||||
await fixtureSuite.setup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
await fixtureSuite.cleanup();
|
||||
});
|
||||
|
||||
describe("buildWorkspaceSkillsPrompt", () => {
|
||||
it("prefers workspace skills over managed skills", async () => {
|
||||
const workspaceDir = await createCaseDir("workspace");
|
||||
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||
const managedDir = path.join(workspaceDir, ".managed");
|
||||
const bundledDir = path.join(workspaceDir, ".bundled");
|
||||
const managedSkillDir = path.join(managedDir, "demo-skill");
|
||||
@@ -62,7 +55,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
expect(prompt).not.toContain(path.join(bundledSkillDir, "SKILL.md"));
|
||||
});
|
||||
it("gates by bins, config, and always", async () => {
|
||||
const workspaceDir = await createCaseDir("workspace");
|
||||
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||
const skillsDir = path.join(workspaceDir, "skills");
|
||||
const binDir = path.join(workspaceDir, "bin");
|
||||
|
||||
@@ -130,7 +123,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
expect(gatedPrompt).not.toContain("config-skill");
|
||||
});
|
||||
it("uses skillKey for config lookups", async () => {
|
||||
const workspaceDir = await createCaseDir("workspace");
|
||||
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||
const skillDir = path.join(workspaceDir, "skills", "alias-skill");
|
||||
await writeSkill({
|
||||
dir: skillDir,
|
||||
|
||||
@@ -59,106 +59,92 @@ vi.mock("./subagent-registry.js", () => ({
|
||||
|
||||
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
|
||||
|
||||
type AnnounceFlowParams = Parameters<typeof runSubagentAnnounceFlow>[0];
|
||||
|
||||
const defaultSessionConfig = {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
} as const;
|
||||
|
||||
const baseAnnounceFlowParams = {
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1_000,
|
||||
cleanup: "keep",
|
||||
roundOneReply: "done",
|
||||
waitForCompletion: false,
|
||||
outcome: { status: "ok" as const },
|
||||
} satisfies Omit<AnnounceFlowParams, "childRunId">;
|
||||
|
||||
function setConfiguredAnnounceTimeout(timeoutMs: number): void {
|
||||
configOverride = {
|
||||
session: defaultSessionConfig,
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
announceTimeoutMs: timeoutMs,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runAnnounceFlowForTest(
|
||||
childRunId: string,
|
||||
overrides: Partial<AnnounceFlowParams> = {},
|
||||
): Promise<void> {
|
||||
await runSubagentAnnounceFlow({
|
||||
...baseAnnounceFlowParams,
|
||||
childRunId,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function findGatewayCall(predicate: (call: GatewayCall) => boolean): GatewayCall | undefined {
|
||||
return gatewayCalls.find(predicate);
|
||||
}
|
||||
|
||||
describe("subagent announce timeout config", () => {
|
||||
beforeEach(() => {
|
||||
gatewayCalls.length = 0;
|
||||
sessionStore = {};
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
session: defaultSessionConfig,
|
||||
};
|
||||
});
|
||||
|
||||
it("uses 60s timeout by default for direct announce agent call", async () => {
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-default-timeout",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1_000,
|
||||
cleanup: "keep",
|
||||
roundOneReply: "done",
|
||||
waitForCompletion: false,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
await runAnnounceFlowForTest("run-default-timeout");
|
||||
|
||||
const directAgentCall = gatewayCalls.find(
|
||||
const directAgentCall = findGatewayCall(
|
||||
(call) => call.method === "agent" && call.expectFinal === true,
|
||||
);
|
||||
expect(directAgentCall?.timeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("honors configured announce timeout for direct announce agent call", async () => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
announceTimeoutMs: 90_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setConfiguredAnnounceTimeout(90_000);
|
||||
await runAnnounceFlowForTest("run-config-timeout-agent");
|
||||
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-config-timeout-agent",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1_000,
|
||||
cleanup: "keep",
|
||||
roundOneReply: "done",
|
||||
waitForCompletion: false,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
const directAgentCall = gatewayCalls.find(
|
||||
const directAgentCall = findGatewayCall(
|
||||
(call) => call.method === "agent" && call.expectFinal === true,
|
||||
);
|
||||
expect(directAgentCall?.timeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("honors configured announce timeout for completion direct send call", async () => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
announceTimeoutMs: 90_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-config-timeout-send",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
setConfiguredAnnounceTimeout(90_000);
|
||||
await runAnnounceFlowForTest("run-config-timeout-send", {
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
to: "12345",
|
||||
},
|
||||
task: "do thing",
|
||||
timeoutMs: 1_000,
|
||||
cleanup: "keep",
|
||||
roundOneReply: "done",
|
||||
waitForCompletion: false,
|
||||
outcome: { status: "ok" },
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
const sendCall = gatewayCalls.find((call) => call.method === "send");
|
||||
const sendCall = findGatewayCall((call) => call.method === "send");
|
||||
expect(sendCall?.timeoutMs).toBe(90_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ vi.mock("./subagent-registry.store.js", () => ({
|
||||
|
||||
describe("subagent registry steer restarts", () => {
|
||||
let mod: typeof import("./subagent-registry.js");
|
||||
type RegisterSubagentRunInput = Parameters<typeof mod.registerSubagentRun>[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
mod = await import("./subagent-registry.js");
|
||||
@@ -90,6 +91,42 @@ describe("subagent registry steer restarts", () => {
|
||||
}
|
||||
};
|
||||
|
||||
const createDeferredAnnounceResolver = (): ((value: boolean) => void) => {
|
||||
let resolveAnnounce!: (value: boolean) => void;
|
||||
announceSpy.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveAnnounce = resolve;
|
||||
}),
|
||||
);
|
||||
return (value: boolean) => {
|
||||
resolveAnnounce(value);
|
||||
};
|
||||
};
|
||||
|
||||
const registerCompletionModeRun = (
|
||||
runId: string,
|
||||
childSessionKey: string,
|
||||
task: string,
|
||||
options: Partial<Pick<RegisterSubagentRunInput, "spawnMode">> = {},
|
||||
): void => {
|
||||
mod.registerSubagentRun({
|
||||
runId,
|
||||
childSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
accountId: "work",
|
||||
},
|
||||
task,
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
announceSpy.mockClear();
|
||||
announceSpy.mockResolvedValue(true);
|
||||
@@ -159,29 +196,13 @@ describe("subagent registry steer restarts", () => {
|
||||
|
||||
it("defers subagent_ended hook for completion-mode runs until announce delivery resolves", async () => {
|
||||
await withPendingAgentWait(async () => {
|
||||
let resolveAnnounce!: (value: boolean) => void;
|
||||
announceSpy.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveAnnounce = resolve;
|
||||
}),
|
||||
const resolveAnnounce = createDeferredAnnounceResolver();
|
||||
registerCompletionModeRun(
|
||||
"run-completion-delayed",
|
||||
"agent:main:subagent:completion-delayed",
|
||||
"completion-mode task",
|
||||
);
|
||||
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-completion-delayed",
|
||||
childSessionKey: "agent:main:subagent:completion-delayed",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
accountId: "work",
|
||||
},
|
||||
task: "completion-mode task",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-completion-delayed",
|
||||
@@ -211,30 +232,14 @@ describe("subagent registry steer restarts", () => {
|
||||
|
||||
it("does not emit subagent_ended on completion for persistent session-mode runs", async () => {
|
||||
await withPendingAgentWait(async () => {
|
||||
let resolveAnnounce!: (value: boolean) => void;
|
||||
announceSpy.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveAnnounce = resolve;
|
||||
}),
|
||||
const resolveAnnounce = createDeferredAnnounceResolver();
|
||||
registerCompletionModeRun(
|
||||
"run-persistent-session",
|
||||
"agent:main:subagent:persistent-session",
|
||||
"persistent session task",
|
||||
{ spawnMode: "session" },
|
||||
);
|
||||
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-persistent-session",
|
||||
childSessionKey: "agent:main:subagent:persistent-session",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
accountId: "work",
|
||||
},
|
||||
task: "persistent session task",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
spawnMode: "session",
|
||||
});
|
||||
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-persistent-session",
|
||||
|
||||
@@ -32,53 +32,46 @@ async function runReplyToCurrentCase(home: string, text: string) {
|
||||
return Array.isArray(res) ? res[0] : res;
|
||||
}
|
||||
|
||||
async function expectThinkStatusForReasoningModel(params: {
|
||||
reasoning: boolean;
|
||||
expectedLevel: "low" | "off";
|
||||
}): Promise<void> {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: params.reasoning,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
|
||||
);
|
||||
|
||||
const text = replyText(res);
|
||||
expect(text).toContain(`Current thinking level: ${params.expectedLevel}`);
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
describe("directive behavior", () => {
|
||||
installDirectiveBehaviorE2EHooks();
|
||||
|
||||
it("defaults /think to low for reasoning-capable models when no default set", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
|
||||
);
|
||||
|
||||
const text = replyText(res);
|
||||
expect(text).toContain("Current thinking level: low");
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
await expectThinkStatusForReasoningModel({
|
||||
reasoning: true,
|
||||
expectedLevel: "low",
|
||||
});
|
||||
});
|
||||
it("shows off when /think has no argument and model lacks reasoning", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
|
||||
);
|
||||
|
||||
const text = replyText(res);
|
||||
expect(text).toContain("Current thinking level: off");
|
||||
expect(text).toContain("Options: off, minimal, low, medium, high.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
await expectThinkStatusForReasoningModel({
|
||||
reasoning: false,
|
||||
expectedLevel: "off",
|
||||
});
|
||||
});
|
||||
it("persists /reasoning off on discord even when model defaults reasoning on", async () => {
|
||||
|
||||
@@ -5,46 +5,19 @@ import {
|
||||
installDirectiveBehaviorE2EHooks,
|
||||
loadModelCatalog,
|
||||
makeWhatsAppDirectiveConfig,
|
||||
replyText,
|
||||
runEmbeddedPiAgent,
|
||||
sessionStorePath,
|
||||
withTempHome,
|
||||
} from "./reply.directive.directive-behavior.e2e-harness.js";
|
||||
import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
async function runModelDirective(
|
||||
home: string,
|
||||
body: string,
|
||||
options: {
|
||||
defaults?: Record<string, unknown>;
|
||||
extra?: Record<string, unknown>;
|
||||
} = {},
|
||||
): Promise<string | undefined> {
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
makeWhatsAppDirectiveConfig(
|
||||
home,
|
||||
{
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
...options.defaults,
|
||||
},
|
||||
{ session: { store: sessionStorePath(home) }, ...options.extra },
|
||||
),
|
||||
);
|
||||
return replyText(res);
|
||||
}
|
||||
|
||||
describe("directive behavior", () => {
|
||||
installDirectiveBehaviorE2EHooks();
|
||||
|
||||
it("aliases /model list to /models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const text = await runModelDirective(home, "/model list");
|
||||
const text = await runModelDirectiveText(home, "/model list");
|
||||
expect(text).toContain("Providers:");
|
||||
expect(text).toContain("- anthropic");
|
||||
expect(text).toContain("- openai");
|
||||
@@ -56,7 +29,7 @@ describe("directive behavior", () => {
|
||||
it("shows current model when catalog is unavailable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
|
||||
const text = await runModelDirective(home, "/model");
|
||||
const text = await runModelDirectiveText(home, "/model");
|
||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||
@@ -71,7 +44,7 @@ describe("directive behavior", () => {
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||
{ id: "grok-4", name: "Grok 4", provider: "xai" },
|
||||
]);
|
||||
const text = await runModelDirective(home, "/model list", {
|
||||
const text = await runModelDirectiveText(home, "/model list", {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
@@ -101,7 +74,7 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
|
||||
]);
|
||||
const text = await runModelDirective(home, "/models minimax", {
|
||||
const text = await runModelDirectiveText(home, "/models minimax", {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
@@ -129,7 +102,7 @@ describe("directive behavior", () => {
|
||||
});
|
||||
it("does not repeat missing auth labels on /model list", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const text = await runModelDirective(home, "/model list", {
|
||||
const text = await runModelDirectiveText(home, "/model list", {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
makeWhatsAppDirectiveConfig,
|
||||
replyText,
|
||||
sessionStorePath,
|
||||
} from "./reply.directive.directive-behavior.e2e-harness.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
export async function runModelDirectiveText(
|
||||
home: string,
|
||||
body: string,
|
||||
options: {
|
||||
defaults?: Record<string, unknown>;
|
||||
extra?: Record<string, unknown>;
|
||||
includeSessionStore?: boolean;
|
||||
} = {},
|
||||
): Promise<string | undefined> {
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
makeWhatsAppDirectiveConfig(
|
||||
home,
|
||||
{
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
...options.defaults,
|
||||
},
|
||||
{
|
||||
...(options.includeSessionStore === false
|
||||
? {}
|
||||
: { session: { store: sessionStorePath(home) } }),
|
||||
...options.extra,
|
||||
},
|
||||
),
|
||||
);
|
||||
return replyText(res);
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
installDirectiveBehaviorE2EHooks,
|
||||
makeEmbeddedTextResult,
|
||||
makeWhatsAppDirectiveConfig,
|
||||
replyText,
|
||||
replyTexts,
|
||||
runEmbeddedPiAgent,
|
||||
sessionStorePath,
|
||||
withTempHome,
|
||||
} from "./reply.directive.directive-behavior.e2e-harness.js";
|
||||
import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
function makeRunConfig(home: string, storePath: string) {
|
||||
@@ -69,24 +69,6 @@ async function runInFlightVerboseToggleCase(params: {
|
||||
return { res };
|
||||
}
|
||||
|
||||
async function runModelDirectiveAndGetText(
|
||||
home: string,
|
||||
body: string,
|
||||
): Promise<string | undefined> {
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
makeWhatsAppDirectiveConfig(home, {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return replyText(res);
|
||||
}
|
||||
|
||||
describe("directive behavior", () => {
|
||||
installDirectiveBehaviorE2EHooks();
|
||||
|
||||
@@ -119,7 +101,7 @@ describe("directive behavior", () => {
|
||||
});
|
||||
it("shows summary on /model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const text = await runModelDirectiveAndGetText(home, "/model");
|
||||
const text = await runModelDirectiveText(home, "/model", { includeSessionStore: false });
|
||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||
@@ -130,7 +112,9 @@ describe("directive behavior", () => {
|
||||
});
|
||||
it("lists allowlisted models on /model status", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const text = await runModelDirectiveAndGetText(home, "/model status");
|
||||
const text = await runModelDirectiveText(home, "/model status", {
|
||||
includeSessionStore: false,
|
||||
});
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).not.toContain("claude-sonnet-4-1");
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRunEmbeddedPiAgentMock,
|
||||
installTriggerHandlingE2eTestHooks,
|
||||
installTriggerHandlingReplyHarness,
|
||||
makeCfg,
|
||||
runGreetingPromptForBareNewOrReset,
|
||||
withTempHome,
|
||||
} from "./reply.triggers.trigger-handling.test-harness.js";
|
||||
|
||||
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
|
||||
beforeAll(async () => {
|
||||
({ getReplyFromConfig } = await import("./reply.js"));
|
||||
installTriggerHandlingReplyHarness((loader) => {
|
||||
getReplyFromConfig = loader;
|
||||
});
|
||||
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
|
||||
async function expectResetBlockedForNonOwner(params: {
|
||||
home: string;
|
||||
commandAuthorized: boolean;
|
||||
@@ -68,6 +66,7 @@ describe("trigger handling", () => {
|
||||
expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("injects group activation context into the system prompt", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getRunEmbeddedPiAgentMock().mockResolvedValue({
|
||||
@@ -112,16 +111,19 @@ describe("trigger handling", () => {
|
||||
expect(extra).toContain("Activation: always-on");
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a greeting prompt for a bare /reset", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await runGreetingPromptForBareNewOrReset({ home, body: "/reset", getReplyFromConfig });
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a greeting prompt for a bare /new", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reset for unauthorized /reset", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await expectResetBlockedForNonOwner({
|
||||
@@ -131,6 +133,7 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks /reset for non-owner senders", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await expectResetBlockedForNonOwner({
|
||||
|
||||
@@ -222,6 +222,17 @@ export async function loadGetReplyFromConfig() {
|
||||
return (await import("./reply.js")).getReplyFromConfig;
|
||||
}
|
||||
|
||||
export function installTriggerHandlingReplyHarness(
|
||||
setGetReplyFromConfig: (
|
||||
getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig,
|
||||
) => void,
|
||||
): void {
|
||||
beforeAll(async () => {
|
||||
setGetReplyFromConfig(await loadGetReplyFromConfig());
|
||||
});
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
}
|
||||
|
||||
export function requireSessionStorePath(cfg: { session?: { store?: string } }): string {
|
||||
const storePath = cfg.session?.store;
|
||||
if (!storePath) {
|
||||
|
||||
@@ -144,6 +144,17 @@ describe("chrome extension relay server", () => {
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
async function startRelayWithExtension() {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
|
||||
});
|
||||
await waitForOpen(ext);
|
||||
return { port, ext };
|
||||
}
|
||||
|
||||
it("advertises CDP WS only when extension is connected", async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
@@ -269,14 +280,7 @@ describe("chrome extension relay server", () => {
|
||||
it(
|
||||
"tracks attached page targets and exposes them via CDP + /json/list",
|
||||
async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
|
||||
});
|
||||
await waitForOpen(ext);
|
||||
const { port, ext } = await startRelayWithExtension();
|
||||
|
||||
// Simulate a tab attach coming from the extension.
|
||||
ext.send(
|
||||
@@ -391,14 +395,7 @@ describe("chrome extension relay server", () => {
|
||||
);
|
||||
|
||||
it("rebroadcasts attach when a session id is reused for a new target", async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
|
||||
});
|
||||
await waitForOpen(ext);
|
||||
const { port, ext } = await startRelayWithExtension();
|
||||
|
||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
|
||||
|
||||
@@ -74,6 +74,27 @@ function createSequentialPageLister<T>(responses: T[]) {
|
||||
});
|
||||
}
|
||||
|
||||
type JsonListEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
type: "page";
|
||||
};
|
||||
|
||||
function createJsonListFetchMock(entries: JsonListEntry[]) {
|
||||
return vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => entries,
|
||||
} as unknown as Response;
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser server-context remote profile tab operations", () => {
|
||||
it("uses Playwright tab operations when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
@@ -238,24 +259,15 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
it("falls back to /json/list when Playwright is not available", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
const fetchMock = createJsonListFetchMock([
|
||||
{
|
||||
id: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
|
||||
const { remote } = createRemoteRouteHarness(fetchMock);
|
||||
|
||||
@@ -271,24 +283,15 @@ describe("browser server-context tab selection state", () => {
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
const fetchMock = createJsonListFetchMock([
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
|
||||
@@ -148,18 +148,4 @@ const baseRegistry = createTestRegistry([
|
||||
{ pluginId: "slack", source: "test", plugin: createStubPlugin("slack") },
|
||||
]);
|
||||
|
||||
const createMSTeamsPlugin = (): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "Bot Framework; enterprise support.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
});
|
||||
const createMSTeamsPlugin = (): ChannelPlugin => createStubPlugin("msteams");
|
||||
|
||||
@@ -536,6 +536,22 @@ const FINAL_BACKLOG_TARGET_KEYS = [
|
||||
] as const;
|
||||
|
||||
describe("config help copy quality", () => {
|
||||
function expectOperationalGuidance(
|
||||
keys: readonly string[],
|
||||
guidancePattern: RegExp,
|
||||
minLength = 80,
|
||||
) {
|
||||
for (const key of keys) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(minLength);
|
||||
expect(
|
||||
guidancePattern.test(help),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
it("keeps root section labels and help complete", () => {
|
||||
for (const key of ROOT_SECTIONS) {
|
||||
expect(FIELD_LABELS[key], `missing root label for ${key}`).toBeDefined();
|
||||
@@ -550,57 +566,31 @@ describe("config help copy quality", () => {
|
||||
});
|
||||
|
||||
it("covers the target confusing fields with non-trivial explanations", () => {
|
||||
for (const key of TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|selects|sets|defines)/i.test(help),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expectOperationalGuidance(
|
||||
TARGET_KEYS,
|
||||
/(default|keep|use|enable|disable|controls|selects|sets|defines)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("covers tools/hooks help keys with non-trivial operational guidance", () => {
|
||||
for (const key of TOOLS_HOOKS_TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
|
||||
help,
|
||||
),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expectOperationalGuidance(
|
||||
TOOLS_HOOKS_TARGET_KEYS,
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("covers channels/agents help keys with non-trivial operational guidance", () => {
|
||||
for (const key of CHANNELS_AGENTS_TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
|
||||
help,
|
||||
),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expectOperationalGuidance(
|
||||
CHANNELS_AGENTS_TARGET_KEYS,
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("covers final backlog help keys with non-trivial operational guidance", () => {
|
||||
for (const key of FINAL_BACKLOG_TARGET_KEYS) {
|
||||
const help = FIELD_HELP[key];
|
||||
expect(help, `missing help for ${key}`).toBeDefined();
|
||||
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
|
||||
expect(
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
|
||||
help,
|
||||
),
|
||||
`help should include operational guidance for ${key}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expectOperationalGuidance(
|
||||
FINAL_BACKLOG_TARGET_KEYS,
|
||||
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("documents option behavior for enum-style fields", () => {
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFixtureSuite } from "../../test-utils/fixture-suite.js";
|
||||
import { capEntryCount, pruneStaleEntries, rotateSessionFile } from "./store.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
async function createCaseDir(prefix: string): Promise<string> {
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
const fixtureSuite = createFixtureSuite("openclaw-pruning-suite-");
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pruning-suite-"));
|
||||
await fixtureSuite.setup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
await fixtureSuite.cleanup();
|
||||
});
|
||||
|
||||
function makeEntry(updatedAt: number): SessionEntry {
|
||||
@@ -82,7 +75,7 @@ describe("rotateSessionFile", () => {
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await createCaseDir("rotate");
|
||||
testDir = await fixtureSuite.createCaseDir("rotate");
|
||||
storePath = path.join(testDir, "sessions.json");
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,63 @@ import {
|
||||
} from "./isolated-agent.test-harness.js";
|
||||
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
|
||||
|
||||
async function createTelegramDeliveryFixture(home: string): Promise<{
|
||||
storePath: string;
|
||||
deps: CliDeps;
|
||||
}> {
|
||||
const storePath = await writeSessionStore(home, {
|
||||
lastProvider: "telegram",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
const deps: CliDeps = {
|
||||
sendMessageSlack: vi.fn(),
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
return { storePath, deps };
|
||||
}
|
||||
|
||||
function mockEmbeddedAgentPayloads(payloads: Array<{ text: string; mediaUrl?: string }>) {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads,
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runTelegramAnnounceTurn(params: {
|
||||
home: string;
|
||||
storePath: string;
|
||||
deps: CliDeps;
|
||||
cfg?: ReturnType<typeof makeCfg>;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
return runCronIsolatedAgentTurn({
|
||||
cfg: params.cfg ?? makeCfg(params.home, params.storePath),
|
||||
deps: params.deps,
|
||||
job: {
|
||||
...makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
}),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
signal: params.signal,
|
||||
lane: "cron",
|
||||
});
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn", () => {
|
||||
beforeEach(() => {
|
||||
setupIsolatedAgentTurnMocks({ fast: true });
|
||||
@@ -19,45 +76,17 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
|
||||
it("handles media heartbeat delivery and announce cleanup modes", async () => {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, {
|
||||
lastProvider: "telegram",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
const deps: CliDeps = {
|
||||
sendMessageSlack: vi.fn(),
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
const { storePath, deps } = await createTelegramDeliveryFixture(home);
|
||||
|
||||
// Media should still be delivered even if text is just HEARTBEAT_OK.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockEmbeddedAgentPayloads([
|
||||
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
|
||||
]);
|
||||
|
||||
const mediaRes = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
const mediaRes = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
}),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(mediaRes.status).toBe("ok");
|
||||
@@ -66,13 +95,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
|
||||
vi.mocked(runSubagentAnnounceFlow).mockClear();
|
||||
vi.mocked(deps.sendMessageTelegram).mockClear();
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]);
|
||||
|
||||
const cfg = makeCfg(home, storePath);
|
||||
cfg.agents = {
|
||||
@@ -136,47 +159,19 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
|
||||
it("skips structured outbound delivery when timeout abort is already set", async () => {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, {
|
||||
lastProvider: "telegram",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
const deps: CliDeps = {
|
||||
sendMessageSlack: vi.fn(),
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
const { storePath, deps } = await createTelegramDeliveryFixture(home);
|
||||
const controller = new AbortController();
|
||||
controller.abort("cron: job execution timed out");
|
||||
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockEmbeddedAgentPayloads([
|
||||
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
|
||||
]);
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
}),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
signal: controller.signal,
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("error");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { vi } from "vitest";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import { makeCfg, makeJob } from "./isolated-agent.test-harness.js";
|
||||
|
||||
export function createCliDeps(overrides: Partial<CliDeps> = {}): CliDeps {
|
||||
return {
|
||||
@@ -27,3 +29,29 @@ export function mockAgentPayloads(
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runTelegramAnnounceTurn(params: {
|
||||
home: string;
|
||||
storePath: string;
|
||||
deps: CliDeps;
|
||||
delivery: {
|
||||
mode: "announce";
|
||||
channel: string;
|
||||
to?: string;
|
||||
bestEffort?: boolean;
|
||||
};
|
||||
}): Promise<Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>> {
|
||||
return runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(params.home, params.storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps: params.deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: params.delivery,
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import "./isolated-agent.mocks.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import {
|
||||
makeCfg,
|
||||
makeJob,
|
||||
withTempCronHome,
|
||||
writeSessionStore,
|
||||
} from "./isolated-agent.test-harness.js";
|
||||
createCliDeps,
|
||||
mockAgentPayloads,
|
||||
runTelegramAnnounceTurn,
|
||||
} from "./isolated-agent.delivery.test-helpers.js";
|
||||
import { withTempCronHome, writeSessionStore } from "./isolated-agent.test-harness.js";
|
||||
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
|
||||
|
||||
describe("runCronIsolatedAgentTurn forum topic delivery", () => {
|
||||
@@ -22,18 +20,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "forum message" }]);
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
@@ -56,18 +47,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "plain message" }]);
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
|
||||
@@ -3,7 +3,11 @@ import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
|
||||
import {
|
||||
createCliDeps,
|
||||
mockAgentPayloads,
|
||||
runTelegramAnnounceTurn,
|
||||
} from "./isolated-agent.delivery.test-helpers.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import {
|
||||
makeCfg,
|
||||
@@ -13,32 +17,22 @@ import {
|
||||
} from "./isolated-agent.test-harness.js";
|
||||
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
|
||||
|
||||
async function runTelegramAnnounceTurn(params: {
|
||||
async function runExplicitTelegramAnnounceTurn(params: {
|
||||
home: string;
|
||||
storePath: string;
|
||||
deps: CliDeps;
|
||||
delivery: {
|
||||
mode: "announce";
|
||||
channel: string;
|
||||
to?: string;
|
||||
bestEffort?: boolean;
|
||||
};
|
||||
}): Promise<Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>> {
|
||||
return runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(params.home, params.storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps: params.deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: params.delivery,
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
return runTelegramAnnounceTurn({
|
||||
...params,
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
}
|
||||
|
||||
function expectDeliveredOk(result: Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>): void {
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.delivered).toBe(true);
|
||||
}
|
||||
|
||||
async function expectBestEffortTelegramNotDelivered(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
@@ -75,15 +69,13 @@ async function expectExplicitTelegramTargetAnnounce(params: {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads(params.payloads);
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
const res = await runExplicitTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(true);
|
||||
expectDeliveredOk(res);
|
||||
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
|
||||
| {
|
||||
@@ -210,15 +202,13 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
|
||||
});
|
||||
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
const res = await runExplicitTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(true);
|
||||
expectDeliveredOk(res);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -11,25 +11,24 @@ export async function withTempCronHome<T>(fn: (home: string) => Promise<T>): Pro
|
||||
export async function writeSessionStore(
|
||||
home: string,
|
||||
session: { lastProvider: string; lastTo: string; lastChannel?: string },
|
||||
): Promise<string> {
|
||||
return writeSessionStoreEntries(home, {
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: Date.now(),
|
||||
...session,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeSessionStoreEntries(
|
||||
home: string,
|
||||
entries: Record<string, Record<string, unknown>>,
|
||||
): Promise<string> {
|
||||
const dir = path.join(home, ".openclaw", "sessions");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: Date.now(),
|
||||
...session,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(storePath, JSON.stringify(entries, null, 2), "utf-8");
|
||||
return storePath;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js";
|
||||
import {
|
||||
makeCfg,
|
||||
makeJob,
|
||||
withTempCronHome,
|
||||
writeSessionStore,
|
||||
writeSessionStoreEntries,
|
||||
} from "./isolated-agent.test-harness.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
const withTempHome = withTempCronHome;
|
||||
|
||||
@@ -44,33 +50,6 @@ function expectEmbeddedProviderModel(expected: { provider: string; model: string
|
||||
expect(call?.model).toBe(expected.model);
|
||||
}
|
||||
|
||||
async function writeSessionStore(
|
||||
home: string,
|
||||
entries: Record<string, Record<string, unknown>> = {},
|
||||
) {
|
||||
const dir = path.join(home, ".openclaw", "sessions");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "webchat",
|
||||
lastTo: "",
|
||||
},
|
||||
...entries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
return storePath;
|
||||
}
|
||||
|
||||
async function readSessionEntry(storePath: string, key: string) {
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const store = JSON.parse(raw) as Record<string, { sessionId?: string; label?: string }>;
|
||||
@@ -98,7 +77,17 @@ type RunCronTurnOptions = {
|
||||
};
|
||||
|
||||
async function runCronTurn(home: string, options: RunCronTurnOptions = {}) {
|
||||
const storePath = options.storePath ?? (await writeSessionStore(home, options.storeEntries));
|
||||
const storePath =
|
||||
options.storePath ??
|
||||
(await writeSessionStoreEntries(home, {
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "webchat",
|
||||
lastTo: "",
|
||||
},
|
||||
...options.storeEntries,
|
||||
}));
|
||||
const deps = options.deps ?? makeDeps();
|
||||
if (options.mockTexts === null) {
|
||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||
@@ -468,7 +457,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
|
||||
it("starts a fresh session id for each cron run", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = makeDeps();
|
||||
|
||||
const first = (
|
||||
@@ -502,7 +491,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
|
||||
it("preserves an existing cron session label", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
|
||||
store["agent:main:cron:job-1"] = {
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { CronService, type CronServiceDeps } from "./service.js";
|
||||
import {
|
||||
createCronStoreHarness,
|
||||
createNoopLogger,
|
||||
withCronServiceForTest,
|
||||
} from "./service.test-harness.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
async function makeStorePath() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-"));
|
||||
return {
|
||||
storePath: path.join(dir, "cron", "jobs.json"),
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
const noopLogger = createNoopLogger();
|
||||
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-delivery-" });
|
||||
|
||||
type DeliveryMode = "none" | "announce";
|
||||
|
||||
@@ -40,27 +28,15 @@ async function withCronService(
|
||||
requestHeartbeatNow: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const cron = new CronService({
|
||||
cronEnabled: true,
|
||||
storePath: store.storePath,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob:
|
||||
params.runIsolatedAgentJob ??
|
||||
(vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
try {
|
||||
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
|
||||
} finally {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
await withCronServiceForTest(
|
||||
{
|
||||
makeStorePath,
|
||||
logger: noopLogger,
|
||||
cronEnabled: true,
|
||||
runIsolatedAgentJob: params.runIsolatedAgentJob,
|
||||
},
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
async function addIsolatedAgentTurnJob(
|
||||
|
||||
@@ -3,25 +3,35 @@ import { createMockCronStateForJobs } from "./service.test-harness.js";
|
||||
import { recomputeNextRunsForMaintenance } from "./service/jobs.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
function createCronSystemEventJob(now: number, overrides: Partial<CronJob> = {}): CronJob {
|
||||
const { state, ...jobOverrides } = overrides;
|
||||
return {
|
||||
id: "test-job",
|
||||
name: "test job",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "test" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
...jobOverrides,
|
||||
state: state ? { ...state } : {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue #13992 regression - cron jobs skip execution", () => {
|
||||
it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => {
|
||||
const now = Date.now();
|
||||
const pastDue = now - 60_000; // 1 minute ago
|
||||
|
||||
const job: CronJob = {
|
||||
id: "test-job",
|
||||
name: "test job",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "test" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
const job = createCronSystemEventJob(now, {
|
||||
createdAtMs: now - 3600_000,
|
||||
updatedAtMs: now - 3600_000,
|
||||
state: {
|
||||
nextRunAtMs: pastDue, // This is in the past and should NOT be recomputed
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
|
||||
recomputeNextRunsForMaintenance(state);
|
||||
@@ -33,20 +43,11 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
|
||||
it("should compute missing nextRunAtMs during maintenance", () => {
|
||||
const now = Date.now();
|
||||
|
||||
const job: CronJob = {
|
||||
id: "test-job",
|
||||
name: "test job",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "test" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
const job = createCronSystemEventJob(now, {
|
||||
state: {
|
||||
// nextRunAtMs is missing
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
|
||||
recomputeNextRunsForMaintenance(state);
|
||||
@@ -60,20 +61,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
|
||||
const now = Date.now();
|
||||
const futureTime = now + 3600_000;
|
||||
|
||||
const job: CronJob = {
|
||||
id: "test-job",
|
||||
name: "test job",
|
||||
const job = createCronSystemEventJob(now, {
|
||||
enabled: false, // Disabled
|
||||
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "test" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
state: {
|
||||
nextRunAtMs: futureTime,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
|
||||
recomputeNextRunsForMaintenance(state);
|
||||
@@ -87,21 +80,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
|
||||
const stuckTime = now - 3 * 60 * 60_000; // 3 hours ago (> 2 hour threshold)
|
||||
const futureTime = now + 3600_000;
|
||||
|
||||
const job: CronJob = {
|
||||
id: "test-job",
|
||||
name: "test job",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "test" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
const job = createCronSystemEventJob(now, {
|
||||
state: {
|
||||
nextRunAtMs: futureTime,
|
||||
runningAtMs: stuckTime, // Stuck running marker
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
|
||||
recomputeNextRunsForMaintenance(state);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CronService } from "./service.js";
|
||||
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
|
||||
import {
|
||||
createCronStoreHarness,
|
||||
createNoopLogger,
|
||||
withCronServiceForTest,
|
||||
} from "./service.test-harness.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
const noopLogger = createNoopLogger();
|
||||
@@ -30,25 +34,15 @@ async function withCronService(
|
||||
requestHeartbeatNow: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
try {
|
||||
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
|
||||
} finally {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
await withCronServiceForTest(
|
||||
{
|
||||
makeStorePath,
|
||||
logger: noopLogger,
|
||||
cronEnabled,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
},
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CronService", () => {
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CronService } from "./service.js";
|
||||
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
|
||||
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
|
||||
import { loadCronStore } from "./store.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
async function makeStorePath() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-migrate-"));
|
||||
return {
|
||||
dir,
|
||||
storePath: path.join(dir, "cron", "jobs.json"),
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
const noopLogger = createNoopLogger();
|
||||
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-migrate-" });
|
||||
|
||||
async function writeLegacyStore(storePath: string, legacyJob: Record<string, unknown>) {
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import type { CronEvent } from "./service.js";
|
||||
import type { CronEvent, CronServiceDeps } from "./service.js";
|
||||
import { CronService } from "./service.js";
|
||||
import { createCronServiceState, type CronServiceState } from "./service/state.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
@@ -140,6 +140,42 @@ export function createStartedCronServiceWithFinishedBarrier(params: {
|
||||
return { cron, enqueueSystemEvent, requestHeartbeatNow, finished };
|
||||
}
|
||||
|
||||
export async function withCronServiceForTest(
|
||||
params: {
|
||||
makeStorePath: () => Promise<{ storePath: string; cleanup: () => Promise<void> }>;
|
||||
logger: ReturnType<typeof createNoopLogger>;
|
||||
cronEnabled: boolean;
|
||||
runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"];
|
||||
},
|
||||
run: (context: {
|
||||
cron: CronService;
|
||||
enqueueSystemEvent: ReturnType<typeof vi.fn>;
|
||||
requestHeartbeatNow: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const store = await params.makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const cron = new CronService({
|
||||
cronEnabled: params.cronEnabled,
|
||||
storePath: store.storePath,
|
||||
log: params.logger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob:
|
||||
params.runIsolatedAgentJob ??
|
||||
(vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
try {
|
||||
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
|
||||
} finally {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function createRunningCronServiceState(params: {
|
||||
storePath: string;
|
||||
log: ReturnType<typeof createNoopLogger>;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
const loadConfig = vi.fn();
|
||||
const resolveGatewayPort = vi.fn();
|
||||
const pickPrimaryTailnetIPv4 = vi.fn();
|
||||
const pickPrimaryLanIPv4 = vi.fn();
|
||||
import {
|
||||
loadConfigMock as loadConfig,
|
||||
pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4,
|
||||
pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4,
|
||||
resolveGatewayPortMock as resolveGatewayPort,
|
||||
} from "./gateway-connection.test-mocks.js";
|
||||
|
||||
let lastClientOptions: {
|
||||
url?: string;
|
||||
@@ -19,27 +20,6 @@ let startMode: StartMode = "hello";
|
||||
let closeCode = 1006;
|
||||
let closeReason = "";
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig,
|
||||
resolveGatewayPort,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4,
|
||||
}));
|
||||
|
||||
vi.mock("./net.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./net.js")>();
|
||||
return {
|
||||
...actual,
|
||||
pickPrimaryLanIPv4,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
describeGatewayCloseCode: (code: number) => {
|
||||
if (code === 1000) {
|
||||
|
||||
27
src/gateway/gateway-connection.test-mocks.ts
Normal file
27
src/gateway/gateway-connection.test-mocks.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const loadConfigMock = vi.fn();
|
||||
export const resolveGatewayPortMock = vi.fn();
|
||||
export const pickPrimaryTailnetIPv4Mock = vi.fn();
|
||||
export const pickPrimaryLanIPv4Mock = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: loadConfigMock,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: pickPrimaryTailnetIPv4Mock,
|
||||
}));
|
||||
|
||||
vi.mock("./net.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./net.js")>();
|
||||
return {
|
||||
...actual,
|
||||
pickPrimaryLanIPv4: pickPrimaryLanIPv4Mock,
|
||||
};
|
||||
});
|
||||
@@ -17,6 +17,8 @@ vi.mock("./hooks.js", async (importOriginal) => {
|
||||
|
||||
import { createHooksRequestHandler } from "./server-http.js";
|
||||
|
||||
type HooksHandlerDeps = Parameters<typeof createHooksRequestHandler>[0];
|
||||
|
||||
function createHooksConfig(): HooksConfigResolved {
|
||||
return {
|
||||
basePath: "/hooks",
|
||||
@@ -66,6 +68,30 @@ function createResponse(): {
|
||||
return { res, end, setHeader };
|
||||
}
|
||||
|
||||
function createHandler(params?: {
|
||||
dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"];
|
||||
dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"];
|
||||
}) {
|
||||
return createHooksRequestHandler({
|
||||
getHooksConfig: () => createHooksConfig(),
|
||||
bindHost: "127.0.0.1",
|
||||
port: 18789,
|
||||
logHooks: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||
dispatchWakeHook:
|
||||
params?.dispatchWakeHook ??
|
||||
((() => {
|
||||
return;
|
||||
}) as HooksHandlerDeps["dispatchWakeHook"]),
|
||||
dispatchAgentHook:
|
||||
params?.dispatchAgentHook ?? ((() => "run-1") as HooksHandlerDeps["dispatchAgentHook"]),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createHooksRequestHandler timeout status mapping", () => {
|
||||
beforeEach(() => {
|
||||
readJsonBodyMock.mockClear();
|
||||
@@ -75,19 +101,7 @@ describe("createHooksRequestHandler timeout status mapping", () => {
|
||||
readJsonBodyMock.mockResolvedValue({ ok: false, error: "request body timeout" });
|
||||
const dispatchWakeHook = vi.fn();
|
||||
const dispatchAgentHook = vi.fn(() => "run-1");
|
||||
const handler = createHooksRequestHandler({
|
||||
getHooksConfig: () => createHooksConfig(),
|
||||
bindHost: "127.0.0.1",
|
||||
port: 18789,
|
||||
logHooks: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||
dispatchWakeHook,
|
||||
dispatchAgentHook,
|
||||
});
|
||||
const handler = createHandler({ dispatchWakeHook, dispatchAgentHook });
|
||||
const req = createRequest();
|
||||
const { res, end } = createResponse();
|
||||
|
||||
@@ -101,19 +115,7 @@ describe("createHooksRequestHandler timeout status mapping", () => {
|
||||
});
|
||||
|
||||
test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => {
|
||||
const handler = createHooksRequestHandler({
|
||||
getHooksConfig: () => createHooksConfig(),
|
||||
bindHost: "127.0.0.1",
|
||||
port: 18789,
|
||||
logHooks: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||
dispatchWakeHook: vi.fn(),
|
||||
dispatchAgentHook: vi.fn(() => "run-1"),
|
||||
});
|
||||
const handler = createHandler();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const req = createRequest({
|
||||
|
||||
@@ -124,6 +124,40 @@ function createChatContext(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
type ChatContext = ReturnType<typeof createChatContext>;
|
||||
|
||||
async function runNonStreamingChatSend(params: {
|
||||
context: ChatContext;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
idempotencyKey: string;
|
||||
message?: string;
|
||||
}) {
|
||||
await chatHandlers["chat.send"]({
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: params.message ?? "hello",
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
},
|
||||
respond: params.respond as unknown as Parameters<
|
||||
(typeof chatHandlers)["chat.send"]
|
||||
>[0]["respond"],
|
||||
req: {} as never,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
context: params.context as GatewayRequestContext,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
(params.context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
const chatCall = (params.context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(chatCall?.[0]).toBe("chat");
|
||||
return chatCall?.[1];
|
||||
}
|
||||
|
||||
describe("chat directive tag stripping for non-streaming final payloads", () => {
|
||||
it("chat.inject keeps message defined when directive tag is the only content", async () => {
|
||||
createTranscriptFixture("openclaw-chat-inject-directive-only-");
|
||||
@@ -160,33 +194,20 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
const respond = vi.fn();
|
||||
const context = createChatContext();
|
||||
|
||||
await chatHandlers["chat.send"]({
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-directive-only",
|
||||
},
|
||||
const payload = await runNonStreamingChatSend({
|
||||
context,
|
||||
respond,
|
||||
req: {} as never,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
context: context as GatewayRequestContext,
|
||||
idempotencyKey: "idem-directive-only",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(chatCall?.[0]).toBe("chat");
|
||||
expect(chatCall?.[1]).toEqual(
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "idem-directive-only",
|
||||
state: "final",
|
||||
message: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(extractFirstTextBlock(chatCall?.[1])).toBe("");
|
||||
expect(extractFirstTextBlock(payload)).toBe("");
|
||||
});
|
||||
|
||||
it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => {
|
||||
@@ -218,25 +239,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
const respond = vi.fn();
|
||||
const context = createChatContext();
|
||||
|
||||
await chatHandlers["chat.send"]({
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-untrusted-context",
|
||||
},
|
||||
const payload = await runNonStreamingChatSend({
|
||||
context,
|
||||
respond,
|
||||
req: {} as never,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
context: context as GatewayRequestContext,
|
||||
idempotencyKey: "idem-untrusted-context",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(chatCall?.[0]).toBe("chat");
|
||||
expect(extractFirstTextBlock(chatCall?.[1])).toBe("hello");
|
||||
expect(extractFirstTextBlock(payload)).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,24 +13,32 @@ import {
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
|
||||
let startedServer: Awaited<ReturnType<typeof startServerWithClient>> | null = null;
|
||||
|
||||
function requireWs(): Awaited<ReturnType<typeof startServerWithClient>>["ws"] {
|
||||
if (!startedServer) {
|
||||
throw new Error("gateway test server not started");
|
||||
}
|
||||
return startedServer.ws;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServerWithClient(undefined, { controlUiEnabled: true });
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
await connectOk(ws);
|
||||
startedServer = await startServerWithClient(undefined, { controlUiEnabled: true });
|
||||
await connectOk(requireWs());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (!startedServer) {
|
||||
return;
|
||||
}
|
||||
startedServer.ws.close();
|
||||
await startedServer.server.close();
|
||||
startedServer = null;
|
||||
});
|
||||
|
||||
describe("gateway config methods", () => {
|
||||
it("rejects config.patch when raw is not an object", async () => {
|
||||
const res = await rpcReq<{ ok?: boolean }>(ws, "config.patch", {
|
||||
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", {
|
||||
raw: "[]",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
@@ -78,7 +86,7 @@ describe("gateway server sessions", () => {
|
||||
|
||||
const homeSessions = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
}>(requireWs(), "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: "home",
|
||||
@@ -91,7 +99,7 @@ describe("gateway server sessions", () => {
|
||||
|
||||
const workSessions = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
}>(requireWs(), "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: "work",
|
||||
@@ -119,13 +127,13 @@ describe("gateway server sessions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
|
||||
const resolved = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.resolve", {
|
||||
key: "main",
|
||||
});
|
||||
expect(resolved.ok).toBe(true);
|
||||
expect(resolved.payload?.key).toBe("agent:ops:work");
|
||||
|
||||
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
|
||||
const patched = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.patch", {
|
||||
key: "main",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
|
||||
@@ -85,6 +85,28 @@ async function waitForCondition(check: () => boolean, timeoutMs = 2000) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupCronTestRun(params: {
|
||||
ws: { close: () => void };
|
||||
server: { close: () => Promise<void> };
|
||||
dir: string;
|
||||
prevSkipCron: string | undefined;
|
||||
clearSessionConfig?: boolean;
|
||||
}) {
|
||||
params.ws.close();
|
||||
await params.server.close();
|
||||
await rmTempDir(params.dir);
|
||||
testState.cronStorePath = undefined;
|
||||
if (params.clearSessionConfig) {
|
||||
testState.sessionConfig = undefined;
|
||||
}
|
||||
testState.cronEnabled = undefined;
|
||||
if (params.prevSkipCron === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CRON;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_SKIP_CRON = params.prevSkipCron;
|
||||
}
|
||||
|
||||
describe("gateway server cron", () => {
|
||||
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
|
||||
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
|
||||
@@ -352,17 +374,13 @@ describe("gateway server cron", () => {
|
||||
const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined;
|
||||
expect(disabled?.enabled).toBe(false);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
await rmTempDir(dir);
|
||||
testState.cronStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
if (prevSkipCron === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CRON;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
|
||||
}
|
||||
await cleanupCronTestRun({
|
||||
ws,
|
||||
server,
|
||||
dir,
|
||||
prevSkipCron,
|
||||
clearSessionConfig: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -466,16 +484,7 @@ describe("gateway server cron", () => {
|
||||
const runs = autoEntries?.entries ?? [];
|
||||
expect(runs.at(-1)?.jobId).toBe(autoJobId);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
await rmTempDir(dir);
|
||||
testState.cronStorePath = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
if (prevSkipCron === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CRON;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
|
||||
}
|
||||
await cleanupCronTestRun({ ws, server, dir, prevSkipCron });
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
@@ -650,16 +659,7 @@ describe("gateway server cron", () => {
|
||||
await yieldToEventLoop();
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
await rmTempDir(dir);
|
||||
testState.cronStorePath = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
if (prevSkipCron === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CRON;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
|
||||
}
|
||||
await cleanupCronTestRun({ ws, server, dir, prevSkipCron });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -51,6 +51,43 @@ function createForwarder(params: {
|
||||
return { deliver, forwarder };
|
||||
}
|
||||
|
||||
function makeSessionCfg(options: { discordExecApprovalsEnabled?: boolean } = {}): OpenClawConfig {
|
||||
return {
|
||||
...(options.discordExecApprovalsEnabled
|
||||
? {
|
||||
channels: {
|
||||
discord: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function expectDiscordSessionTargetRequest(params: {
|
||||
cfg: OpenClawConfig;
|
||||
expectedAccepted: boolean;
|
||||
expectedDeliveryCount: number;
|
||||
}) {
|
||||
vi.useFakeTimers();
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg: params.cfg,
|
||||
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
|
||||
});
|
||||
|
||||
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(params.expectedAccepted);
|
||||
if (params.expectedDeliveryCount === 0) {
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount);
|
||||
}
|
||||
|
||||
describe("exec approval forwarder", () => {
|
||||
it("forwards to session target and resolves", async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -124,66 +161,27 @@ describe("exec approval forwarder", () => {
|
||||
});
|
||||
|
||||
it("returns false when all targets are skipped", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg,
|
||||
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
|
||||
await expectDiscordSessionTargetRequest({
|
||||
cfg: makeSessionCfg({ discordExecApprovalsEnabled: true }),
|
||||
expectedAccepted: false,
|
||||
expectedDeliveryCount: 0,
|
||||
});
|
||||
|
||||
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(false);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards to discord when discord exec approvals handler is disabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg,
|
||||
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
|
||||
await expectDiscordSessionTargetRequest({
|
||||
cfg: makeSessionCfg(),
|
||||
expectedAccepted: true,
|
||||
expectedDeliveryCount: 1,
|
||||
});
|
||||
|
||||
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips discord forwarding when discord exec approvals handler is enabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg,
|
||||
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
|
||||
await expectDiscordSessionTargetRequest({
|
||||
cfg: makeSessionCfg({ discordExecApprovalsEnabled: true }),
|
||||
expectedAccepted: false,
|
||||
expectedDeliveryCount: 0,
|
||||
});
|
||||
|
||||
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(false);
|
||||
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can forward resolved notices without pending cache when request payload is present", async () => {
|
||||
|
||||
@@ -115,6 +115,28 @@ function createEaccesProcStatSpy() {
|
||||
});
|
||||
}
|
||||
|
||||
function createPortProbeConnectionSpy(result: "connect" | "refused") {
|
||||
return vi.spyOn(net, "createConnection").mockImplementation(() => {
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = vi.fn();
|
||||
setImmediate(() => {
|
||||
if (result === "connect") {
|
||||
socket.emit("connect");
|
||||
return;
|
||||
}
|
||||
socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }));
|
||||
});
|
||||
return socket;
|
||||
});
|
||||
}
|
||||
|
||||
async function writeRecentLockFile(env: NodeJS.ProcessEnv, startTime = 111) {
|
||||
await writeLockFile(env, {
|
||||
startTime,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway lock", () => {
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
|
||||
@@ -214,18 +236,8 @@ describe("gateway lock", () => {
|
||||
it("treats lock as stale when owner pid is alive but configured port is free", async () => {
|
||||
vi.useRealTimers();
|
||||
const env = await makeEnv();
|
||||
await writeLockFile(env, {
|
||||
startTime: 111,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const connectSpy = vi.spyOn(net, "createConnection").mockImplementation(() => {
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = vi.fn();
|
||||
setImmediate(() => {
|
||||
socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }));
|
||||
});
|
||||
return socket;
|
||||
});
|
||||
await writeRecentLockFile(env);
|
||||
const connectSpy = createPortProbeConnectionSpy("refused");
|
||||
|
||||
const lock = await acquireForTest(env, {
|
||||
timeoutMs: 80,
|
||||
@@ -242,18 +254,8 @@ describe("gateway lock", () => {
|
||||
it("keeps lock when configured port is busy and owner pid is alive", async () => {
|
||||
vi.useRealTimers();
|
||||
const env = await makeEnv();
|
||||
await writeLockFile(env, {
|
||||
startTime: 111,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const connectSpy = vi.spyOn(net, "createConnection").mockImplementation(() => {
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = vi.fn();
|
||||
setImmediate(() => {
|
||||
socket.emit("connect");
|
||||
});
|
||||
return socket;
|
||||
});
|
||||
await writeRecentLockFile(env);
|
||||
const connectSpy = createPortProbeConnectionSpy("connect");
|
||||
try {
|
||||
const pending = acquireForTest(env, {
|
||||
timeoutMs: 20,
|
||||
|
||||
@@ -5,6 +5,40 @@ import {
|
||||
resolveExecApprovalDecision,
|
||||
} from "./exec-policy.js";
|
||||
|
||||
type EvaluatePolicyParams = Parameters<typeof evaluateSystemRunPolicy>[0];
|
||||
type EvaluatePolicyDecision = ReturnType<typeof evaluateSystemRunPolicy>;
|
||||
|
||||
const buildPolicyParams = (overrides: Partial<EvaluatePolicyParams>): EvaluatePolicyParams => {
|
||||
return {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: false,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const expectDeniedDecision = (decision: EvaluatePolicyDecision) => {
|
||||
expect(decision.allowed).toBe(false);
|
||||
if (decision.allowed) {
|
||||
throw new Error("expected denied decision");
|
||||
}
|
||||
return decision;
|
||||
};
|
||||
|
||||
const expectAllowedDecision = (decision: EvaluatePolicyDecision) => {
|
||||
expect(decision.allowed).toBe(true);
|
||||
if (!decision.allowed) {
|
||||
throw new Error("expected allowed decision");
|
||||
}
|
||||
return decision;
|
||||
};
|
||||
|
||||
describe("resolveExecApprovalDecision", () => {
|
||||
it("accepts known approval decisions", () => {
|
||||
expect(resolveExecApprovalDecision("allow-once")).toBe("allow-once");
|
||||
@@ -42,143 +76,68 @@ describe("formatSystemRunAllowlistMissMessage", () => {
|
||||
|
||||
describe("evaluateSystemRunPolicy", () => {
|
||||
it("denies when security mode is deny", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "deny",
|
||||
ask: "off",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: false,
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
if (decision.allowed) {
|
||||
throw new Error("expected denied decision");
|
||||
}
|
||||
expect(decision.eventReason).toBe("security=deny");
|
||||
expect(decision.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny");
|
||||
const denied = expectDeniedDecision(
|
||||
evaluateSystemRunPolicy(buildPolicyParams({ security: "deny" })),
|
||||
);
|
||||
expect(denied.eventReason).toBe("security=deny");
|
||||
expect(denied.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny");
|
||||
});
|
||||
|
||||
it("requires approval when ask policy requires it", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: false,
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
if (decision.allowed) {
|
||||
throw new Error("expected denied decision");
|
||||
}
|
||||
expect(decision.eventReason).toBe("approval-required");
|
||||
expect(decision.requiresAsk).toBe(true);
|
||||
const denied = expectDeniedDecision(
|
||||
evaluateSystemRunPolicy(buildPolicyParams({ ask: "always" })),
|
||||
);
|
||||
expect(denied.eventReason).toBe("approval-required");
|
||||
expect(denied.requiresAsk).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist miss when explicit approval is provided", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
approvalDecision: "allow-once",
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: false,
|
||||
});
|
||||
expect(decision.allowed).toBe(true);
|
||||
if (!decision.allowed) {
|
||||
throw new Error("expected allowed decision");
|
||||
}
|
||||
expect(decision.approvedByAsk).toBe(true);
|
||||
const allowed = expectAllowedDecision(
|
||||
evaluateSystemRunPolicy(
|
||||
buildPolicyParams({
|
||||
ask: "on-miss",
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
approvalDecision: "allow-once",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(allowed.approvedByAsk).toBe(true);
|
||||
});
|
||||
|
||||
it("denies allowlist misses without approval", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: false,
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
if (decision.allowed) {
|
||||
throw new Error("expected denied decision");
|
||||
}
|
||||
expect(decision.eventReason).toBe("allowlist-miss");
|
||||
expect(decision.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss");
|
||||
const denied = expectDeniedDecision(
|
||||
evaluateSystemRunPolicy(buildPolicyParams({ analysisOk: false, allowlistSatisfied: false })),
|
||||
);
|
||||
expect(denied.eventReason).toBe("allowlist-miss");
|
||||
expect(denied.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss");
|
||||
});
|
||||
|
||||
it("treats shell wrappers as allowlist misses", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: true,
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
if (decision.allowed) {
|
||||
throw new Error("expected denied decision");
|
||||
}
|
||||
expect(decision.shellWrapperBlocked).toBe(true);
|
||||
expect(decision.errorMessage).toContain("shell wrappers like sh/bash/zsh -c");
|
||||
const denied = expectDeniedDecision(
|
||||
evaluateSystemRunPolicy(buildPolicyParams({ shellWrapperInvocation: true })),
|
||||
);
|
||||
expect(denied.shellWrapperBlocked).toBe(true);
|
||||
expect(denied.errorMessage).toContain("shell wrappers like sh/bash/zsh -c");
|
||||
});
|
||||
|
||||
it("keeps Windows-specific guidance for cmd.exe wrappers", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: true,
|
||||
cmdInvocation: true,
|
||||
shellWrapperInvocation: true,
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
if (decision.allowed) {
|
||||
throw new Error("expected denied decision");
|
||||
}
|
||||
expect(decision.shellWrapperBlocked).toBe(true);
|
||||
expect(decision.windowsShellWrapperBlocked).toBe(true);
|
||||
expect(decision.errorMessage).toContain("Windows shell wrappers like cmd.exe /c");
|
||||
const denied = expectDeniedDecision(
|
||||
evaluateSystemRunPolicy(
|
||||
buildPolicyParams({ isWindows: true, cmdInvocation: true, shellWrapperInvocation: true }),
|
||||
),
|
||||
);
|
||||
expect(denied.shellWrapperBlocked).toBe(true);
|
||||
expect(denied.windowsShellWrapperBlocked).toBe(true);
|
||||
expect(denied.errorMessage).toContain("Windows shell wrappers like cmd.exe /c");
|
||||
});
|
||||
|
||||
it("allows execution when policy checks pass", () => {
|
||||
const decision = evaluateSystemRunPolicy({
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
approvalDecision: null,
|
||||
approved: false,
|
||||
isWindows: false,
|
||||
cmdInvocation: false,
|
||||
shellWrapperInvocation: false,
|
||||
});
|
||||
expect(decision.allowed).toBe(true);
|
||||
if (!decision.allowed) {
|
||||
throw new Error("expected allowed decision");
|
||||
}
|
||||
expect(decision.requiresAsk).toBe(false);
|
||||
expect(decision.analysisOk).toBe(true);
|
||||
expect(decision.allowlistSatisfied).toBe(true);
|
||||
const allowed = expectAllowedDecision(
|
||||
evaluateSystemRunPolicy(buildPolicyParams({ ask: "on-miss" })),
|
||||
);
|
||||
expect(allowed.requiresAsk).toBe(false);
|
||||
expect(allowed.analysisOk).toBe(true);
|
||||
expect(allowed.allowlistSatisfied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,25 @@ describe("monitorSlackProvider tool results", () => {
|
||||
return (replyMock.mock.calls[0]?.[0] ?? {}) as { WasMentioned?: boolean };
|
||||
}
|
||||
|
||||
function setRequireMentionChannelConfig(mentionPatterns?: string[]) {
|
||||
slackTestState.config = {
|
||||
...(mentionPatterns
|
||||
? {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
groupChat: { mentionPatterns },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
channels: { C1: { allow: true, requireMention: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runDirectMessageEvent(ts: string, extraEvent: Record<string, unknown> = {}) {
|
||||
await runSlackMessageOnce(monitorSlackProvider, {
|
||||
event: makeSlackMessageEvent({ ts, ...extraEvent }),
|
||||
@@ -325,18 +344,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
async function expectMentionPatternMessageAccepted(text: string): Promise<void> {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
channels: { C1: { allow: true, requireMention: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
setRequireMentionChannelConfig(["\\bopenclaw\\b"]);
|
||||
replyMock.mockResolvedValue({ text: "hi" });
|
||||
|
||||
await runSlackMessageOnce(monitorSlackProvider, {
|
||||
@@ -359,14 +367,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("treats replies to bot threads as implicit mentions", async () => {
|
||||
slackTestState.config = {
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
channels: { C1: { allow: true, requireMention: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
setRequireMentionChannelConfig();
|
||||
replyMock.mockResolvedValue({ text: "hi" });
|
||||
|
||||
await runSlackMessageOnce(monitorSlackProvider, {
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { installSlackBlockTestMocks } from "./blocks.test-helpers.js";
|
||||
|
||||
// --- Module mocks (must precede dynamic import) ---
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveSlackAccount: () => ({
|
||||
accountId: "default",
|
||||
botToken: "xoxb-test",
|
||||
botTokenSource: "config",
|
||||
config: {},
|
||||
}),
|
||||
}));
|
||||
installSlackBlockTestMocks();
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
loadWebMedia: vi.fn(async () => ({
|
||||
|
||||
29
src/test-utils/fixture-suite.ts
Normal file
29
src/test-utils/fixture-suite.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function createFixtureSuite(rootPrefix: string) {
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
return {
|
||||
async setup(): Promise<void> {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), rootPrefix));
|
||||
},
|
||||
async cleanup(): Promise<void> {
|
||||
if (!fixtureRoot) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
fixtureRoot = "";
|
||||
},
|
||||
async createCaseDir(prefix: string): Promise<string> {
|
||||
if (!fixtureRoot) {
|
||||
throw new Error("Fixture suite not initialized");
|
||||
}
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,34 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadConfigMock as loadConfig,
|
||||
pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4,
|
||||
pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4,
|
||||
resolveGatewayPortMock as resolveGatewayPort,
|
||||
} from "../gateway/gateway-connection.test-mocks.js";
|
||||
import { captureEnv, withEnv } from "../test-utils/env.js";
|
||||
|
||||
const loadConfig = vi.fn();
|
||||
const resolveGatewayPort = vi.fn();
|
||||
const pickPrimaryTailnetIPv4 = vi.fn();
|
||||
const pickPrimaryLanIPv4 = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig,
|
||||
resolveGatewayPort,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/net.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../gateway/net.js")>();
|
||||
return {
|
||||
...actual,
|
||||
pickPrimaryLanIPv4,
|
||||
// Allow all URLs in tests - security validation is tested separately
|
||||
isSecureWebSocketUrl: () => true,
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveGatewayConnection } = await import("./gateway-chat.js");
|
||||
|
||||
describe("resolveGatewayConnection", () => {
|
||||
|
||||
@@ -26,6 +26,23 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function runSingleUpsertAndCapture(upsert: unknown) {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await openMonitor(onMessage);
|
||||
const sock = getSock();
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
return { onMessage, listener };
|
||||
}
|
||||
|
||||
function expectSingleGroupMessage(
|
||||
onMessage: ReturnType<typeof vi.fn>,
|
||||
expected: Record<string, unknown>,
|
||||
) {
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||
}
|
||||
|
||||
it("captures media path for image messages", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await openMonitor(onMessage);
|
||||
@@ -203,10 +220,7 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
it("unwraps ephemeral messages, preserves mentions, and still delivers group pings", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await openMonitor(onMessage);
|
||||
const sock = getSock();
|
||||
const upsert = {
|
||||
const { onMessage, listener } = await runSingleUpsertAndCapture({
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
@@ -228,22 +242,14 @@ describe("web monitor inbox", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatType: "group",
|
||||
conversationId: "424242@g.us",
|
||||
body: "oh hey @Clawd UK !",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
senderE164: "+888",
|
||||
}),
|
||||
);
|
||||
|
||||
});
|
||||
expectSingleGroupMessage(onMessage, {
|
||||
chatType: "group",
|
||||
conversationId: "424242@g.us",
|
||||
body: "oh hey @Clawd UK !",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
senderE164: "+888",
|
||||
});
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
@@ -262,10 +268,7 @@ describe("web monitor inbox", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await openMonitor(onMessage);
|
||||
const sock = getSock();
|
||||
const upsert = {
|
||||
const { onMessage, listener } = await runSingleUpsertAndCapture({
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
@@ -283,24 +286,16 @@ describe("web monitor inbox", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatType: "group",
|
||||
from: "55555@g.us",
|
||||
senderE164: "+777",
|
||||
senderJid: "777@s.whatsapp.net",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
selfE164: "+123",
|
||||
selfJid: "123@s.whatsapp.net",
|
||||
}),
|
||||
);
|
||||
|
||||
});
|
||||
expectSingleGroupMessage(onMessage, {
|
||||
chatType: "group",
|
||||
from: "55555@g.us",
|
||||
senderE164: "+777",
|
||||
senderJid: "777@s.whatsapp.net",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
selfE164: "+123",
|
||||
selfJid: "123@s.whatsapp.net",
|
||||
});
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user