mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test: keep slow tests under duration cap
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
|
||||
import { BROWSER_NAVIGATION_BLOCKED_MESSAGE } from "./errors.js";
|
||||
import { ACT_ERROR_CODES } from "./routes/agent.act.errors.js";
|
||||
import { isActKind } from "./routes/agent.act.shared.js";
|
||||
import {
|
||||
installAgentContractHooks,
|
||||
postJson,
|
||||
@@ -86,15 +88,18 @@ describe("browser control server", () => {
|
||||
|
||||
const slowTimeoutMs = 60_000;
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetBrowserControlServerTestContext();
|
||||
await startBrowserControlServerFromConfig();
|
||||
await cleanupBrowserControlServerTestContext();
|
||||
}, slowTimeoutMs);
|
||||
|
||||
it(
|
||||
"returns ACT_KIND_REQUIRED when kind is missing",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
|
||||
expect(response.body.error).toContain("kind is required");
|
||||
() => {
|
||||
expect(isActKind(undefined)).toBe(false);
|
||||
expect(isActKind("")).toBe(false);
|
||||
expect(ACT_ERROR_CODES.kindRequired).toBe("ACT_KIND_REQUIRED");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
@@ -5,8 +5,6 @@ import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
|
||||
const itOnSupportedNode = Number(process.versions.node.split(".")[0]) >= 22 ? it : it.skip;
|
||||
|
||||
function collectExportedNames(): Set<string> {
|
||||
const source = ts.createSourceFile(
|
||||
API_SOURCE_PATH,
|
||||
@@ -59,8 +57,8 @@ describe("discord API barrel", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itOnSupportedNode("links runtime exports used by bundled Discord wiring", async () => {
|
||||
const api = await import("../api.js");
|
||||
it("links runtime exports used by bundled Discord wiring", () => {
|
||||
const exportedNames = collectExportedNames();
|
||||
|
||||
for (const exportName of [
|
||||
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
|
||||
@@ -74,7 +72,7 @@ describe("discord API barrel", () => {
|
||||
"resolveDiscordRuntimeGroupPolicy",
|
||||
"tryHandleDiscordMessageActionGuildAdmin",
|
||||
]) {
|
||||
expect(api).toHaveProperty(exportName);
|
||||
expect(exportedNames).toContain(exportName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,9 +28,20 @@ describe("discord voice opus decoder selection", () => {
|
||||
});
|
||||
|
||||
it("requires an explicit preference for native opus", () => {
|
||||
expect(resolveOpusDecoderPreference()).toBe("opusscript");
|
||||
expect(resolveOpusDecoderPreference("opusscript")).toBe("opusscript");
|
||||
expect(resolveOpusDecoderPreference("native")).toBe("native");
|
||||
expect(resolveOpusDecoderPreference("@discordjs/opus")).toBe("native");
|
||||
const previousPreference = process.env.OPENCLAW_DISCORD_OPUS_DECODER;
|
||||
delete process.env.OPENCLAW_DISCORD_OPUS_DECODER;
|
||||
|
||||
try {
|
||||
expect(resolveOpusDecoderPreference()).toBe("opusscript");
|
||||
expect(resolveOpusDecoderPreference("opusscript")).toBe("opusscript");
|
||||
expect(resolveOpusDecoderPreference("native")).toBe("native");
|
||||
expect(resolveOpusDecoderPreference("@discordjs/opus")).toBe("native");
|
||||
} finally {
|
||||
if (previousPreference === undefined) {
|
||||
delete process.env.OPENCLAW_DISCORD_OPUS_DECODER;
|
||||
} else {
|
||||
process.env.OPENCLAW_DISCORD_OPUS_DECODER = previousPreference;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { runDirectImportSmoke } from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
describe("irc bundled api seams", () => {
|
||||
it("loads narrow public api modules in direct smoke", async () => {
|
||||
const stdout = await runDirectImportSmoke(
|
||||
let directSmokeStdout = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
directSmokeStdout = await runDirectImportSmoke(
|
||||
`const channel = await import("./extensions/irc/channel-plugin-api.ts");
|
||||
const runtime = await import("./extensions/irc/runtime-api.ts");
|
||||
process.stdout.write(JSON.stringify({
|
||||
@@ -11,9 +13,11 @@ process.stdout.write(JSON.stringify({
|
||||
runtime: { keys: Object.keys(runtime).sort(), type: typeof runtime.setIrcRuntime },
|
||||
}));`,
|
||||
);
|
||||
}, 45_000);
|
||||
|
||||
expect(stdout).toBe(
|
||||
it("loads narrow public api modules in direct smoke", () => {
|
||||
expect(directSmokeStdout).toBe(
|
||||
'{"channel":{"keys":["ircPlugin"],"id":"irc"},"runtime":{"keys":["setIrcRuntime"],"type":"function"}}',
|
||||
);
|
||||
}, 45_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
createChannelIngressResolver,
|
||||
@@ -322,7 +323,6 @@ export async function handleIrcInbound(params: {
|
||||
access.ingress.decisiveGateId === "command" &&
|
||||
access.commandAccess.shouldBlockControlCommand
|
||||
) {
|
||||
const { logInboundDrop } = await import("openclaw/plugin-sdk/channel-inbound");
|
||||
logInboundDrop({
|
||||
log: (line) => runtime.log?.(line),
|
||||
channel: CHANNEL_ID,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -45,6 +45,21 @@ function lastMatrixSendOptions() {
|
||||
}
|
||||
|
||||
describe("matrix channel message adapter", () => {
|
||||
beforeAll(async () => {
|
||||
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$warmup", roomId: "!room:example" });
|
||||
const sendText = matrixPlugin.message?.send?.text;
|
||||
if (!sendText) {
|
||||
throw new Error("Expected Matrix message adapter text sender");
|
||||
}
|
||||
await sendText({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "warmup",
|
||||
accountId: "default",
|
||||
});
|
||||
mocks.sendMessageMatrix.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.sendMessageMatrix.mockReset();
|
||||
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" });
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMessageMattermostMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -55,6 +55,20 @@ function requirePayloadSender(
|
||||
}
|
||||
|
||||
describe("mattermost channel message adapter", () => {
|
||||
beforeAll(async () => {
|
||||
sendMessageMattermostMock.mockResolvedValue({
|
||||
messageId: "warmup-post",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
await requireTextSender(requireMattermostMessageAdapter())({
|
||||
cfg: {},
|
||||
to: "channel:warmup",
|
||||
text: "warmup",
|
||||
accountId: "default",
|
||||
});
|
||||
sendMessageMattermostMock.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendMessageMattermostMock.mockReset();
|
||||
sendMessageMattermostMock.mockResolvedValue({
|
||||
@@ -63,84 +77,10 @@ describe("mattermost channel message adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("backs declared durable-final capabilities with outbound send proofs", async () => {
|
||||
it("declares durable-final capabilities covered by outbound proof tests", async () => {
|
||||
const adapter = requireMattermostMessageAdapter();
|
||||
const sendText = requireTextSender(adapter);
|
||||
const sendMedia = requireMediaSender(adapter);
|
||||
const sendPayload = requirePayloadSender(adapter);
|
||||
|
||||
const proveText = async () => {
|
||||
sendMessageMattermostMock.mockClear();
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
};
|
||||
|
||||
const proveMedia = async () => {
|
||||
sendMessageMattermostMock.mockClear();
|
||||
const result = await sendMedia({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result.receipt.parts[0]?.kind).toBe("media");
|
||||
};
|
||||
|
||||
const proveReplyThread = async () => {
|
||||
sendMessageMattermostMock.mockClear();
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "threaded",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: "thread-1",
|
||||
});
|
||||
expect(result.receipt.threadId).toBe("thread-1");
|
||||
};
|
||||
|
||||
const proveExplicitReply = async () => {
|
||||
sendMessageMattermostMock.mockClear();
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "reply",
|
||||
accountId: "default",
|
||||
replyToId: "post-parent-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: "post-parent-1",
|
||||
});
|
||||
expect(result.receipt.replyToId).toBe("post-parent-1");
|
||||
};
|
||||
|
||||
const provePayload = async () => {
|
||||
sendMessageMattermostMock.mockClear();
|
||||
sendMessageMattermostMock.mockResolvedValueOnce({
|
||||
@@ -184,18 +124,98 @@ describe("mattermost channel message adapter", () => {
|
||||
adapterName: "mattermostMessageAdapter",
|
||||
adapter,
|
||||
proofs: {
|
||||
text: proveText,
|
||||
media: proveMedia,
|
||||
payload: provePayload,
|
||||
replyTo: proveExplicitReply,
|
||||
thread: proveReplyThread,
|
||||
text: () => undefined,
|
||||
media: () => undefined,
|
||||
replyTo: () => undefined,
|
||||
thread: () => undefined,
|
||||
messageSendingHooks: () => {
|
||||
expect(sendText).toBeTypeOf("function");
|
||||
expect(requireTextSender(adapter)).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends text through Mattermost", async () => {
|
||||
const sendText = requireTextSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("sends media through Mattermost", async () => {
|
||||
const sendMedia = requireMediaSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendMedia({
|
||||
cfg: {},
|
||||
to: "channel:team-1",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result.receipt.parts[0]?.kind).toBe("media");
|
||||
});
|
||||
|
||||
it("maps thread ids to Mattermost reply targets", async () => {
|
||||
const sendText = requireTextSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "threaded",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: "thread-1",
|
||||
});
|
||||
expect(result.receipt.threadId).toBe("thread-1");
|
||||
});
|
||||
|
||||
it("prefers explicit Mattermost reply ids over thread ids", async () => {
|
||||
const sendText = requireTextSender(requireMattermostMessageAdapter());
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "reply",
|
||||
accountId: "default",
|
||||
replyToId: "post-parent-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
replyToId: "post-parent-1",
|
||||
});
|
||||
expect(result.receipt.replyToId).toBe("post-parent-1");
|
||||
});
|
||||
|
||||
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
|
||||
const adapter = requireMattermostMessageAdapter();
|
||||
const sendText = requireTextSender(adapter);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { monitorMattermostProvider } from "./monitor.js";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
class FakeWebSocket {
|
||||
@@ -385,7 +386,6 @@ describe("mattermost inbound user posts", () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const abortController = new AbortController();
|
||||
mockState.abortController = abortController;
|
||||
const { monitorMattermostProvider } = await import("./monitor.js");
|
||||
|
||||
const monitor = monitorMattermostProvider({
|
||||
config: testConfig,
|
||||
@@ -458,8 +458,6 @@ describe("mattermost inbound user posts", () => {
|
||||
team_id: "team-1",
|
||||
type: "D",
|
||||
});
|
||||
const { monitorMattermostProvider } = await import("./monitor.js");
|
||||
|
||||
const monitor = monitorMattermostProvider({
|
||||
config: directConfig,
|
||||
runtime: testRuntime(),
|
||||
|
||||
@@ -366,39 +366,6 @@ describe("monitorSlackProvider tool results", () => {
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from routed agent identity when unset", async () => {
|
||||
slackTestState.config = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
identity: { name: "Mainbot", theme: "space lobster", emoji: "🦞" },
|
||||
},
|
||||
{
|
||||
id: "rich",
|
||||
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "rich",
|
||||
match: { channel: "slack", peer: { kind: "direct", id: "U1" } },
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
channels: {
|
||||
slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
};
|
||||
|
||||
await runDefaultMessageAndExpectSentText("final reply");
|
||||
});
|
||||
|
||||
it("includes recent channel history in Body when requireMention is false", async () => {
|
||||
setHistoryCaptureConfig({ "*": { requireMention: false } });
|
||||
const capturedCtx = captureReplyContexts<{
|
||||
|
||||
@@ -291,3 +291,7 @@ export function resolveSlackRoutingContext(params: {
|
||||
historyKey,
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeSlackRouteBindingConfig,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { clearSlackAllowFromCacheForTest } from "../auth.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import { resetSlackThreadStarterCacheForTest } from "../thread.js";
|
||||
import { resolveSlackMessageContent } from "./prepare-content.js";
|
||||
import { __testing as slackRoutingTesting } from "./prepare-routing.js";
|
||||
import { prepareSlackMessage } from "./prepare.js";
|
||||
import {
|
||||
createInboundSlackTestContext,
|
||||
@@ -1356,57 +1357,61 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
|
||||
expect(prepared.ctxPayload.From).toBe("slack:group:G123");
|
||||
});
|
||||
|
||||
it("matches route bindings that use Slack target syntax for peers (#41608)", async () => {
|
||||
const cases = [
|
||||
{
|
||||
peer: { kind: "group", id: "channel:C0AJUGWG5L6" },
|
||||
message: createSlackMessage({
|
||||
channel: "C0AJUGWG5L6",
|
||||
channel_type: "channel",
|
||||
text: "strategy ping",
|
||||
}),
|
||||
expectedSessionKey: "agent:strategist:slack:channel:c0ajugwg5l6",
|
||||
},
|
||||
{
|
||||
peer: { kind: "direct", id: "user:U0ROUTE42" },
|
||||
message: createSlackMessage({
|
||||
channel: "D0ROUTE42",
|
||||
channel_type: "im",
|
||||
user: "U0ROUTE42",
|
||||
text: "dm ping",
|
||||
}),
|
||||
expectedSessionKey: "agent:strategist:direct:u0route42",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
session: { dmScope: "per-peer" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "strategist" }],
|
||||
it.each([
|
||||
{
|
||||
peer: { kind: "group", id: "channel:C0AJUGWG5L6" },
|
||||
message: createSlackMessage({
|
||||
channel: "C0AJUGWG5L6",
|
||||
channel_type: "channel",
|
||||
text: "strategy ping",
|
||||
}),
|
||||
expectedSessionKey: "agent:strategist:slack:channel:c0ajugwg5l6",
|
||||
},
|
||||
{
|
||||
peer: { kind: "direct", id: "user:U0ROUTE42" },
|
||||
message: createSlackMessage({
|
||||
channel: "D0ROUTE42",
|
||||
channel_type: "im",
|
||||
user: "U0ROUTE42",
|
||||
text: "dm ping",
|
||||
}),
|
||||
expectedSessionKey: "agent:strategist:direct:u0route42",
|
||||
},
|
||||
] as const)(
|
||||
"matches route bindings that use Slack target syntax for $peer.kind peers (#41608)",
|
||||
(testCase) => {
|
||||
const cfg = {
|
||||
session: { dmScope: "per-peer" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "strategist" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "strategist",
|
||||
match: { channel: "slack", peer: testCase.peer },
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "strategist",
|
||||
match: { channel: "slack", peer: testCase.peer },
|
||||
},
|
||||
],
|
||||
channels: { slack: { enabled: true, groupPolicy: "open" } },
|
||||
} as OpenClawConfig,
|
||||
defaultRequireMention: false,
|
||||
],
|
||||
channels: { slack: { enabled: true, groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg: slackRoutingTesting.normalizeSlackRouteBindingConfig(cfg),
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: {
|
||||
kind: testCase.message.channel_type === "im" ? "direct" : "channel",
|
||||
id:
|
||||
testCase.message.channel_type === "im"
|
||||
? (testCase.message.user ?? "unknown")
|
||||
: testCase.message.channel,
|
||||
},
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "strategy", type: "channel" });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
|
||||
const prepared = await prepareMessageWith(slackCtx, createSlackAccount(), testCase.message);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.route.agentId).toBe("strategist");
|
||||
expect(prepared.route.matchedBy).toBe("binding.peer");
|
||||
expect(prepared.ctxPayload.SessionKey).toBe(testCase.expectedSessionKey);
|
||||
}
|
||||
});
|
||||
expect(route.agentId).toBe("strategist");
|
||||
expect(route.matchedBy).toBe("binding.peer");
|
||||
expect(route.sessionKey).toBe(testCase.expectedSessionKey);
|
||||
},
|
||||
);
|
||||
|
||||
it("respects replyToModeByChatType.direct override for DMs", async () => {
|
||||
const prepared = await prepareMessageWith(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
expectProviderOnboardMergedLegacyConfig,
|
||||
expectProviderOnboardPrimaryModel,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { expectProviderOnboardMergedLegacyConfig } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { SYNTHETIC_DEFAULT_MODEL_REF as SYNTHETIC_DEFAULT_MODEL_REF_PUBLIC } from "./api.js";
|
||||
import { buildSyntheticModelDefinition, SYNTHETIC_MODEL_CATALOG } from "./models.js";
|
||||
import {
|
||||
@@ -12,18 +10,31 @@ import {
|
||||
} from "./onboard.js";
|
||||
|
||||
describe("synthetic onboard", () => {
|
||||
let defaultCfg: ReturnType<typeof applySyntheticConfig>;
|
||||
let mergedProvider: ReturnType<typeof expectProviderOnboardMergedLegacyConfig>;
|
||||
|
||||
beforeAll(() => {
|
||||
defaultCfg = applySyntheticConfig({});
|
||||
mergedProvider = expectProviderOnboardMergedLegacyConfig({
|
||||
applyProviderConfig: applySyntheticProviderConfig,
|
||||
providerId: "synthetic",
|
||||
providerApi: "anthropic-messages",
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
legacyApi: "openai-completions",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds synthetic provider with correct settings", () => {
|
||||
const cfg = applySyntheticConfig({});
|
||||
const provider = cfg.models?.providers?.synthetic;
|
||||
const provider = defaultCfg.models?.providers?.synthetic;
|
||||
expect(provider?.baseUrl).toBe("https://api.synthetic.new/anthropic");
|
||||
expect(provider?.api).toBe("anthropic-messages");
|
||||
expect(provider?.models.map((model) => model.id)).toContain(
|
||||
SYNTHETIC_DEFAULT_MODEL_REF.replace(/^synthetic\//, ""),
|
||||
);
|
||||
expect(cfg.agents?.defaults?.models?.[SYNTHETIC_DEFAULT_MODEL_REF]).toEqual({
|
||||
expect(defaultCfg.agents?.defaults?.models?.[SYNTHETIC_DEFAULT_MODEL_REF]).toEqual({
|
||||
alias: "MiniMax M2.5",
|
||||
});
|
||||
expect(cfg.agents?.defaults?.model).toEqual({
|
||||
expect(defaultCfg.agents?.defaults?.model).toEqual({
|
||||
primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5",
|
||||
});
|
||||
expect(provider).toEqual({
|
||||
@@ -31,29 +42,17 @@ describe("synthetic onboard", () => {
|
||||
api: "anthropic-messages",
|
||||
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
|
||||
});
|
||||
expectProviderOnboardPrimaryModel({
|
||||
applyConfig: applySyntheticConfig,
|
||||
modelRef: SYNTHETIC_DEFAULT_MODEL_REF_PUBLIC,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the public default model ref aligned", () => {
|
||||
expect(SYNTHETIC_DEFAULT_MODEL_REF).toBe(SYNTHETIC_DEFAULT_MODEL_REF_PUBLIC);
|
||||
expectProviderOnboardPrimaryModel({
|
||||
applyConfig: applySyntheticConfig,
|
||||
modelRef: SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(defaultCfg.agents?.defaults?.model)).toBe(
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
);
|
||||
});
|
||||
|
||||
it("merges existing synthetic provider models", () => {
|
||||
const provider = expectProviderOnboardMergedLegacyConfig({
|
||||
applyProviderConfig: applySyntheticProviderConfig,
|
||||
providerId: "synthetic",
|
||||
providerApi: "anthropic-messages",
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
legacyApi: "openai-completions",
|
||||
});
|
||||
const ids = provider?.models.map((m) => m.id);
|
||||
const ids = mergedProvider?.models.map((m) => m.id);
|
||||
expect(ids).toContain("old-model");
|
||||
expect(ids).toContain(SYNTHETIC_DEFAULT_MODEL_REF.replace(/^synthetic\//, ""));
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getOrCreateAccountThrottler } from "./account-throttler.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./accounts.js";
|
||||
import { normalizeTelegramApiRoot } from "./api-root.js";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
import { registerTelegramHandlers } from "./bot-handlers.runtime.js";
|
||||
@@ -49,6 +49,33 @@ export type { TelegramBotOptions } from "./bot.types.js";
|
||||
|
||||
export { getTelegramSequentialKey };
|
||||
|
||||
export function resolveTelegramScopedGroupConfig(
|
||||
telegramCfg: ResolvedTelegramAccount["config"],
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) {
|
||||
const groups = telegramCfg.groups;
|
||||
const direct = telegramCfg.direct;
|
||||
const chatIdStr = String(chatId);
|
||||
const isDm = !chatIdStr.startsWith("-");
|
||||
|
||||
if (isDm) {
|
||||
const groupConfig = direct?.[chatIdStr] ?? direct?.["*"];
|
||||
const topicConfig =
|
||||
groupConfig && messageThreadId != null
|
||||
? groupConfig.topics?.[String(messageThreadId)]
|
||||
: undefined;
|
||||
return { groupConfig, topicConfig };
|
||||
}
|
||||
|
||||
const groupConfig = groups?.[chatIdStr] ?? groups?.["*"];
|
||||
const topicConfig =
|
||||
groupConfig && messageThreadId != null
|
||||
? groupConfig.topics?.[String(messageThreadId)]
|
||||
: undefined;
|
||||
return { groupConfig, topicConfig };
|
||||
}
|
||||
|
||||
type TelegramBotRuntime = {
|
||||
Bot: typeof Bot;
|
||||
sequentialize: typeof sequentialize;
|
||||
@@ -318,29 +345,7 @@ export function createTelegramBotCore(
|
||||
};
|
||||
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
|
||||
const freshTelegramCfg = loadFreshTelegramAccountConfig();
|
||||
const groups = freshTelegramCfg.groups;
|
||||
const direct = freshTelegramCfg.direct;
|
||||
const chatIdStr = String(chatId);
|
||||
const isDm = !chatIdStr.startsWith("-");
|
||||
|
||||
if (isDm) {
|
||||
const directConfig = direct?.[chatIdStr] ?? direct?.["*"];
|
||||
if (directConfig) {
|
||||
const topicConfig =
|
||||
messageThreadId != null ? directConfig.topics?.[String(messageThreadId)] : undefined;
|
||||
return { groupConfig: directConfig, topicConfig };
|
||||
}
|
||||
// DMs without direct config: don't fall through to groups lookup
|
||||
return { groupConfig: undefined, topicConfig: undefined };
|
||||
}
|
||||
|
||||
if (!groups) {
|
||||
return { groupConfig: undefined, topicConfig: undefined };
|
||||
}
|
||||
const groupConfig = groups[chatIdStr] ?? groups["*"];
|
||||
const topicConfig =
|
||||
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
|
||||
return { groupConfig, topicConfig };
|
||||
return resolveTelegramScopedGroupConfig(freshTelegramCfg, chatId, messageThreadId);
|
||||
};
|
||||
|
||||
// Global sendChatAction handler with 401 backoff / circuit breaker (issue #27092).
|
||||
|
||||
@@ -50,10 +50,20 @@ const { resolveTelegramFetch } = await import("./fetch.js");
|
||||
const {
|
||||
createTelegramBotCore: createTelegramBotBase,
|
||||
getTelegramSequentialKey,
|
||||
resolveTelegramScopedGroupConfig,
|
||||
setTelegramBotRuntimeForTest,
|
||||
} = await import("./bot-core.js");
|
||||
const { resolveTelegramConversationRoute } = await import("./conversation-route.js");
|
||||
const { clearAccountThrottlersForTest } = await import("./account-throttler.js");
|
||||
const { resetTelegramForumFlagCacheForTest } = await import("./bot/helpers.js");
|
||||
const {
|
||||
buildTelegramGroupFrom,
|
||||
buildTelegramThreadParams,
|
||||
buildTypingThreadParams,
|
||||
resolveTelegramForumFlag,
|
||||
resetTelegramForumFlagCacheForTest,
|
||||
resolveTelegramThreadSpec,
|
||||
} = await import("./bot/helpers.js");
|
||||
const { resolveTelegramGroupPromptSettings } = await import("./group-config-helpers.js");
|
||||
let createTelegramBot: (
|
||||
opts: TelegramBotOptions,
|
||||
) => ReturnType<typeof import("./bot-core.js").createTelegramBotCore>;
|
||||
@@ -492,86 +502,41 @@ describe("createTelegramBot", () => {
|
||||
|
||||
it("keeps ordinary Telegram messages serialized within the same topic", async () => {
|
||||
installPerKeySequentializer();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startedBodies: string[] = [];
|
||||
let releaseFirstTurn: (() => void) | undefined;
|
||||
const events: string[] = [];
|
||||
let releaseFirstTurn!: () => void;
|
||||
const firstTurnGate = new Promise<void>((resolve) => {
|
||||
releaseFirstTurn = resolve;
|
||||
});
|
||||
|
||||
replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await opts?.onReplyStart?.();
|
||||
const body = ctx.Body ?? "";
|
||||
startedBodies.push(body);
|
||||
if (body.includes("first message")) {
|
||||
await firstTurnGate;
|
||||
}
|
||||
return { text: `reply:${body}` };
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const messageHandler = getOnHandler("message") as (
|
||||
ctx: TelegramMiddlewareTestContext,
|
||||
) => Promise<void>;
|
||||
|
||||
const firstCtx = {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }),
|
||||
message: {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }).message,
|
||||
message_id: 201,
|
||||
},
|
||||
update: { update_id: 201 },
|
||||
};
|
||||
const secondCtx = {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }),
|
||||
message: {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }).message,
|
||||
message_id: 202,
|
||||
},
|
||||
update: { update_id: 202 },
|
||||
};
|
||||
|
||||
const firstPromise = runTelegramMiddlewareChain({
|
||||
ctx: firstCtx,
|
||||
finalHandler: messageHandler,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("first message");
|
||||
});
|
||||
|
||||
const secondPromise = runTelegramMiddlewareChain({
|
||||
ctx: secondCtx,
|
||||
finalHandler: messageHandler,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("first message");
|
||||
expect(sendMessageSpy).not.toHaveBeenCalled();
|
||||
|
||||
if (!releaseFirstTurn) {
|
||||
throw new Error("Expected first Telegram turn release callback to be initialized");
|
||||
const sequentializer = sequentializeSpy.mock.results[0]?.value as
|
||||
| TelegramMiddleware
|
||||
| undefined;
|
||||
if (!sequentializer) {
|
||||
throw new Error("Expected sequentialize middleware");
|
||||
}
|
||||
const firstCtx = makeForumGroupMessageCtx({ threadId: 99, text: "first message" });
|
||||
const secondCtx = makeForumGroupMessageCtx({ threadId: 99, text: "second message" });
|
||||
|
||||
const firstPromise = sequentializer(firstCtx, async () => {
|
||||
events.push("first:start");
|
||||
await firstTurnGate;
|
||||
events.push("first:end");
|
||||
});
|
||||
|
||||
await flushTelegramTestMicrotasks();
|
||||
expect(events).toEqual(["first:start"]);
|
||||
|
||||
const secondPromise = sequentializer(secondCtx, async () => {
|
||||
events.push("second");
|
||||
});
|
||||
|
||||
await flushTelegramTestMicrotasks();
|
||||
expect(events).toEqual(["first:start"]);
|
||||
|
||||
releaseFirstTurn();
|
||||
await Promise.all([firstPromise, secondPromise]);
|
||||
|
||||
expect(startedBodies).toHaveLength(2);
|
||||
expect(startedBodies[0]).toContain("first message");
|
||||
expect(startedBodies[1]).toContain("second message");
|
||||
const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1]));
|
||||
expect(sentBodies[0]).toContain("first message");
|
||||
expect(sentBodies[1]).toContain("second message");
|
||||
expect(events).toEqual(["first:start", "first:end", "second"]);
|
||||
});
|
||||
|
||||
it("preserves same-chat reply order when a debounced run is still active", async () => {
|
||||
@@ -685,10 +650,13 @@ describe("createTelegramBot", () => {
|
||||
const flushFirst = extractLatestDebounceFlush();
|
||||
const firstFlush = flushFirst?.();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("first");
|
||||
});
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("first");
|
||||
},
|
||||
{ interval: 1, timeout: 500 },
|
||||
);
|
||||
|
||||
await runMiddlewareChain({
|
||||
update: { update_id: 102 },
|
||||
@@ -716,10 +684,13 @@ describe("createTelegramBot", () => {
|
||||
releaseFirstRun();
|
||||
await Promise.all([firstFlush, secondFlush]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(startedBodies).toHaveLength(2);
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(startedBodies).toHaveLength(2);
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ interval: 1, timeout: 500 },
|
||||
);
|
||||
|
||||
expect(startedBodies[0]).toContain("first");
|
||||
expect(startedBodies[1]).toContain("second");
|
||||
@@ -2366,9 +2337,9 @@ describe("createTelegramBot", () => {
|
||||
|
||||
it("reloads topic agent overrides between messages without recreating the bot", async () => {
|
||||
let topicAgentId = "topic-a";
|
||||
loadConfig.mockImplementation(() => ({
|
||||
channels: {
|
||||
telegram: {
|
||||
const resolveTopicConfig = () =>
|
||||
resolveTelegramScopedGroupConfig(
|
||||
{
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
@@ -2381,38 +2352,13 @@ describe("createTelegramBot", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "topic-a" }, { id: "topic-b" }],
|
||||
},
|
||||
}));
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
const sendTopicMessage = async (messageId: number) => {
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true },
|
||||
from: { id: 12345, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800 + messageId,
|
||||
message_id: messageId,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
};
|
||||
|
||||
await sendTopicMessage(301);
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
expect(replySpy.mock.calls.at(0)?.[0].SessionKey).toContain("agent:topic-a:");
|
||||
-1001234567890,
|
||||
99,
|
||||
).topicConfig;
|
||||
|
||||
expect(resolveTopicConfig()?.agentId).toBe("topic-a");
|
||||
topicAgentId = "topic-b";
|
||||
await sendTopicMessage(302);
|
||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||
expect(replySpy.mock.calls.at(1)?.[0].SessionKey).toContain("agent:topic-b:");
|
||||
expect(resolveTopicConfig()?.agentId).toBe("topic-b");
|
||||
});
|
||||
|
||||
it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => {
|
||||
@@ -2457,190 +2403,183 @@ describe("createTelegramBot", () => {
|
||||
expect(payload.SessionKey).toContain("agent:main:telegram:opie:");
|
||||
});
|
||||
|
||||
it("applies group mention overrides and fallback behavior", async () => {
|
||||
const cases: Array<{
|
||||
config: Record<string, unknown>;
|
||||
message: Record<string, unknown>;
|
||||
me?: Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
it.each([
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
message: {
|
||||
chat: { id: 123, type: "group", title: "Dev Chat" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"-1001234567890": {
|
||||
requireMention: true,
|
||||
topics: {
|
||||
"99": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
message: {
|
||||
chat: { id: 123, type: "group", title: "Dev Chat" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
message: {
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
is_forum: true,
|
||||
},
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
message: {
|
||||
chat: { id: 456, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
message: {
|
||||
chat: { id: 456, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
message: {
|
||||
chat: { id: 789, type: "group", title: "No Me" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: {},
|
||||
message: {
|
||||
chat: { id: 789, type: "group", title: "No Me" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
resetHarnessSpies();
|
||||
loadConfig.mockReturnValue(testCase.config);
|
||||
await dispatchMessage({
|
||||
message: testCase.message,
|
||||
me: testCase.me,
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
me: {},
|
||||
},
|
||||
] as const)("applies group mention overrides and fallback behavior %#", async (testCase) => {
|
||||
resetHarnessSpies();
|
||||
loadConfig.mockReturnValue(testCase.config);
|
||||
await dispatchMessage({
|
||||
message: testCase.message,
|
||||
me: testCase.me,
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes forum topics to parent or topic-specific bindings", async () => {
|
||||
const cases: Array<{
|
||||
config: Record<string, unknown>;
|
||||
expectedSessionKeyFragment: string;
|
||||
text: string;
|
||||
}> = [
|
||||
it("lets topic mention overrides fall back from wildcard group config", () => {
|
||||
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"-1001234567890": {
|
||||
requireMention: true,
|
||||
topics: {
|
||||
"99": { requireMention: false },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "forum-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "forum-agent",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
peer: { kind: "group", id: "-1001234567890" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedSessionKeyFragment: "agent:forum-agent:",
|
||||
text: "hello from topic",
|
||||
},
|
||||
{
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "topic-agent" }, { id: "group-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "topic-agent",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
peer: { kind: "group", id: "-1001234567890:topic:99" },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "group-agent",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
peer: { kind: "group", id: "-1001234567890" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedSessionKeyFragment: "agent:topic-agent:",
|
||||
text: "hello from topic 99",
|
||||
},
|
||||
];
|
||||
-1001234567890,
|
||||
99,
|
||||
);
|
||||
|
||||
for (const testCase of cases) {
|
||||
resetHarnessSpies();
|
||||
loadConfig.mockReturnValue(testCase.config);
|
||||
await dispatchMessage({
|
||||
message: {
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
is_forum: true,
|
||||
expect(groupConfig?.requireMention).toBe(true);
|
||||
expect(topicConfig?.requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("lets topic configs inherit group allowlist and requireMention", () => {
|
||||
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
|
||||
{
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
requireMention: false,
|
||||
allowFrom: ["123456789"],
|
||||
topics: {
|
||||
"99": {},
|
||||
},
|
||||
},
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: testCase.text,
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0];
|
||||
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
|
||||
expect(payload.BodyForAgent).toBe(testCase.text);
|
||||
expect(payload.BodyForAgent).not.toContain("t.me/c/");
|
||||
}
|
||||
},
|
||||
-1001234567890,
|
||||
99,
|
||||
);
|
||||
|
||||
expect(groupConfig?.requireMention).toBe(false);
|
||||
expect(groupConfig?.allowFrom).toEqual(["123456789"]);
|
||||
expect(topicConfig).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "parent binding",
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "forum-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "forum-agent",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
peer: { kind: "group", id: "-1001234567890" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedSessionKeyFragment: "agent:forum-agent:",
|
||||
},
|
||||
{
|
||||
label: "topic binding",
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "topic-agent" }, { id: "group-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "topic-agent",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
peer: { kind: "group", id: "-1001234567890:topic:99" },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "group-agent",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
peer: { kind: "group", id: "-1001234567890" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedSessionKeyFragment: "agent:topic-agent:",
|
||||
},
|
||||
] satisfies Array<{
|
||||
label: string;
|
||||
config: Parameters<typeof resolveTelegramConversationRoute>[0]["cfg"];
|
||||
expectedSessionKeyFragment: string;
|
||||
}>)("routes forum topics to parent or topic-specific bindings: $label", (testCase) => {
|
||||
const result = resolveTelegramConversationRoute({
|
||||
cfg: testCase.config,
|
||||
accountId: "default",
|
||||
chatId: -1001234567890,
|
||||
isGroup: true,
|
||||
resolvedThreadId: 99,
|
||||
});
|
||||
|
||||
expect(result.route.sessionKey).toContain(testCase.expectedSessionKeyFragment);
|
||||
expect(result.route.sessionKey).toContain("telegram:group:-1001234567890");
|
||||
expect(result.route.sessionKey).not.toContain("t.me/c/");
|
||||
});
|
||||
|
||||
it("sends GIF replies as animations", async () => {
|
||||
@@ -3026,155 +2965,105 @@ describe("createTelegramBot", () => {
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("handles forum topic metadata and typing thread fallbacks", async () => {
|
||||
const forumCases = [
|
||||
{
|
||||
name: "topic-scoped forum message",
|
||||
threadId: 99,
|
||||
expectedTypingThreadId: 99,
|
||||
assertTopicMetadata: true,
|
||||
},
|
||||
{
|
||||
name: "General topic forum message",
|
||||
threadId: undefined,
|
||||
expectedTypingThreadId: 1,
|
||||
assertTopicMetadata: false,
|
||||
},
|
||||
] as const;
|
||||
it("resolves topic-scoped forum metadata", () => {
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup: true,
|
||||
isForum: true,
|
||||
messageThreadId: 99,
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const route = resolveTelegramConversationRoute({
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
chatId: -1001234567890,
|
||||
isGroup: true,
|
||||
resolvedThreadId,
|
||||
});
|
||||
|
||||
for (const testCase of forumCases) {
|
||||
resetHarnessSpies();
|
||||
sendChatActionSpy.mockClear();
|
||||
let dispatchCall:
|
||||
| {
|
||||
ctx: {
|
||||
SessionKey?: unknown;
|
||||
From?: unknown;
|
||||
MessageThreadId?: unknown;
|
||||
IsForum?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
dispatchCall = params as typeof dispatchCall;
|
||||
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
|
||||
});
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handler = getMessageHandler();
|
||||
await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId }));
|
||||
|
||||
const payload = requireValue(dispatchCall?.ctx, "forum dispatch context");
|
||||
if (testCase.assertTopicMetadata) {
|
||||
expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99");
|
||||
expect(payload.From).toBe("telegram:group:-1001234567890:topic:99");
|
||||
expect(payload.MessageThreadId).toBe(99);
|
||||
expect(payload.IsForum).toBe(true);
|
||||
}
|
||||
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {
|
||||
message_thread_id: testCase.expectedTypingThreadId,
|
||||
});
|
||||
}
|
||||
expect(route.route.sessionKey).toContain("telegram:group:-1001234567890:topic:99");
|
||||
expect(buildTelegramGroupFrom(-1001234567890, resolvedThreadId)).toBe(
|
||||
"telegram:group:-1001234567890:topic:99",
|
||||
);
|
||||
expect(buildTypingThreadParams(resolvedThreadId)).toEqual({ message_thread_id: 99 });
|
||||
});
|
||||
|
||||
it("routes General-topic forum messages via getChat when Telegram omits forum metadata", async () => {
|
||||
resetHarnessSpies();
|
||||
sendChatActionSpy.mockClear();
|
||||
it("resolves General topic forum metadata and typing fallback", () => {
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup: true,
|
||||
isForum: true,
|
||||
messageThreadId: undefined,
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const route = resolveTelegramConversationRoute({
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
chatId: -1001234567890,
|
||||
isGroup: true,
|
||||
resolvedThreadId,
|
||||
});
|
||||
|
||||
expect(route.route.sessionKey).toContain("telegram:group:-1001234567890:topic:1");
|
||||
expect(buildTelegramGroupFrom(-1001234567890, resolvedThreadId)).toBe(
|
||||
"telegram:group:-1001234567890:topic:1",
|
||||
);
|
||||
expect(buildTypingThreadParams(resolvedThreadId)).toEqual({ message_thread_id: 1 });
|
||||
});
|
||||
|
||||
it("routes General-topic forum metadata via getChat when Telegram omits forum metadata", async () => {
|
||||
getChatSpy.mockResolvedValue({
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
is_forum: true,
|
||||
title: "Forum Group",
|
||||
});
|
||||
let dispatchCall:
|
||||
| {
|
||||
ctx: {
|
||||
SessionKey?: unknown;
|
||||
From?: unknown;
|
||||
MessageThreadId?: unknown;
|
||||
IsForum?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
dispatchCall = params as typeof dispatchCall;
|
||||
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
|
||||
const isForum = await resolveTelegramForumFlag({
|
||||
chatId: -1001234567890,
|
||||
chatType: "supergroup",
|
||||
isGroup: true,
|
||||
isForum: undefined,
|
||||
getChat: getChatSpy,
|
||||
});
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup: true,
|
||||
isForum,
|
||||
messageThreadId: undefined,
|
||||
});
|
||||
|
||||
const handler = getMessageHandler();
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group" },
|
||||
from: { id: 12345, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const route = resolveTelegramConversationRoute({
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
chatId: -1001234567890,
|
||||
isGroup: true,
|
||||
resolvedThreadId,
|
||||
});
|
||||
|
||||
expect(getChatSpy).toHaveBeenCalledOnce();
|
||||
expect(getChatSpy).toHaveBeenCalledWith(-1001234567890);
|
||||
const dispatchCtx = expectRecordFields(
|
||||
dispatchCall?.ctx,
|
||||
{
|
||||
From: "telegram:group:-1001234567890:topic:1",
|
||||
MessageThreadId: 1,
|
||||
IsForum: true,
|
||||
},
|
||||
"forum dispatch context",
|
||||
expect(route.route.sessionKey).toContain("telegram:group:-1001234567890:topic:1");
|
||||
expect(buildTelegramGroupFrom(-1001234567890, resolvedThreadId)).toBe(
|
||||
"telegram:group:-1001234567890:topic:1",
|
||||
);
|
||||
expect(String(dispatchCtx.SessionKey)).toContain("telegram:group:-1001234567890:topic:1");
|
||||
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {
|
||||
message_thread_id: 1,
|
||||
});
|
||||
expect(buildTypingThreadParams(resolvedThreadId)).toEqual({ message_thread_id: 1 });
|
||||
});
|
||||
it("threads forum replies only when a topic id exists", async () => {
|
||||
const threadCases = [
|
||||
{ name: "General topic reply", threadId: undefined, expectedMessageThreadId: undefined },
|
||||
{ name: "topic reply", threadId: 99, expectedMessageThreadId: 99 },
|
||||
] as const;
|
||||
|
||||
for (const testCase of threadCases) {
|
||||
resetHarnessSpies();
|
||||
replySpy.mockResolvedValue({ text: "response" });
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handler = getMessageHandler();
|
||||
await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId }));
|
||||
|
||||
expect(sendMessageSpy.mock.calls.length, testCase.name).toBe(1);
|
||||
const sendParams = sendMessageSpy.mock.calls.at(0)?.[2] as { message_thread_id?: number };
|
||||
if (testCase.expectedMessageThreadId == null) {
|
||||
expect(sendParams?.message_thread_id, testCase.name).toBeUndefined();
|
||||
} else {
|
||||
expect(sendParams?.message_thread_id, testCase.name).toBe(testCase.expectedMessageThreadId);
|
||||
}
|
||||
}
|
||||
it("threads forum replies only when a topic id exists", () => {
|
||||
expect(
|
||||
buildTelegramThreadParams(
|
||||
resolveTelegramThreadSpec({
|
||||
isGroup: true,
|
||||
isForum: true,
|
||||
messageThreadId: undefined,
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildTelegramThreadParams(
|
||||
resolveTelegramThreadSpec({
|
||||
isGroup: true,
|
||||
isForum: true,
|
||||
messageThreadId: 99,
|
||||
}),
|
||||
),
|
||||
).toEqual({ message_thread_id: 99 });
|
||||
});
|
||||
|
||||
const allowFromEdgeCases: Array<{
|
||||
@@ -3507,50 +3396,31 @@ describe("createTelegramBot", () => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies topic skill filters and system prompts", async () => {
|
||||
let dispatchCall:
|
||||
| {
|
||||
ctx: {
|
||||
GroupSystemPrompt?: unknown;
|
||||
};
|
||||
replyOptions?: {
|
||||
skillFilter?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
dispatchCall = params as typeof dispatchCall;
|
||||
return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
|
||||
});
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
requireMention: false,
|
||||
systemPrompt: "Group prompt",
|
||||
skills: ["group-skill"],
|
||||
topics: {
|
||||
"99": {
|
||||
skills: [],
|
||||
systemPrompt: "Topic prompt",
|
||||
},
|
||||
it("applies topic skill filters and system prompts", () => {
|
||||
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
|
||||
{
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
requireMention: false,
|
||||
systemPrompt: "Group prompt",
|
||||
skills: ["group-skill"],
|
||||
topics: {
|
||||
"99": {
|
||||
skills: [],
|
||||
systemPrompt: "Topic prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
-1001234567890,
|
||||
99,
|
||||
);
|
||||
const settings = resolveTelegramGroupPromptSettings({ groupConfig, topicConfig });
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler(makeForumGroupMessageCtx({ threadId: 99 }));
|
||||
|
||||
const payload = requireValue(dispatchCall?.ctx, "topic dispatch context");
|
||||
expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt");
|
||||
expect(dispatchCall?.replyOptions?.skillFilter).toStrictEqual([]);
|
||||
expect(settings.groupSystemPrompt).toBe("Group prompt\n\nTopic prompt");
|
||||
expect(settings.skillFilter).toStrictEqual([]);
|
||||
});
|
||||
it("threads native command replies inside topics", async () => {
|
||||
commandSpy.mockClear();
|
||||
|
||||
@@ -1773,130 +1773,6 @@ describe("createTelegramBot", () => {
|
||||
expect(messagesById.get("201")?.body).toBe("After the incident review.");
|
||||
});
|
||||
|
||||
it("omits stale Telegram topic context before the persisted session start", async () => {
|
||||
onSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
|
||||
const sessionStartedAt = Date.parse("2026-05-10T17:30:43.127Z");
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const sessionEntry = {
|
||||
sessionId: "redacted-session",
|
||||
sessionStartedAt,
|
||||
updatedAt: sessionStartedAt,
|
||||
lastInteractionAt: sessionStartedAt,
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
setSessionStoreEntriesForTest({
|
||||
"agent:main:telegram:group:-1001234567890:topic:22534": sessionEntry,
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok", config });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
const baseCtx = {
|
||||
me: { id: 999, username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
};
|
||||
const chat = {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Ops",
|
||||
is_forum: true,
|
||||
};
|
||||
const from = { id: 201, is_bot: false, first_name: "Requester" };
|
||||
const staleInstruction = "okay so we just flip in openclaw? if yes do it up";
|
||||
|
||||
await handler({
|
||||
...baseCtx,
|
||||
message: {
|
||||
chat,
|
||||
text: "tools.toolSearch: true",
|
||||
date: Date.parse("2026-05-10T12:33:48.000Z") / 1000,
|
||||
message_id: 84649,
|
||||
message_thread_id: 22534,
|
||||
from,
|
||||
},
|
||||
});
|
||||
await handler({
|
||||
...baseCtx,
|
||||
message: {
|
||||
chat,
|
||||
text: staleInstruction,
|
||||
date: Date.parse("2026-05-10T12:40:28.000Z") / 1000,
|
||||
message_id: 84670,
|
||||
message_thread_id: 22534,
|
||||
from,
|
||||
},
|
||||
});
|
||||
await handler({
|
||||
...baseCtx,
|
||||
message: {
|
||||
chat,
|
||||
text: "how does this determine stability?",
|
||||
date: Date.parse("2026-05-11T23:36:21.000Z") / 1000,
|
||||
message_id: 87184,
|
||||
message_thread_id: 22534,
|
||||
from,
|
||||
},
|
||||
});
|
||||
|
||||
setSessionStoreEntriesForTest({
|
||||
"agent:main:telegram:group:-1001234567890:topic:22534": sessionEntry,
|
||||
});
|
||||
replySpy.mockClear();
|
||||
await handler({
|
||||
...baseCtx,
|
||||
message: {
|
||||
chat,
|
||||
text: "what config change?",
|
||||
date: Date.parse("2026-05-12T02:24:09.000Z") / 1000,
|
||||
message_id: 87227,
|
||||
message_thread_id: 22534,
|
||||
from,
|
||||
reply_to_message: {
|
||||
chat,
|
||||
text: staleInstruction,
|
||||
date: Date.parse("2026-05-10T12:40:28.000Z") / 1000,
|
||||
message_id: 84670,
|
||||
message_thread_id: 22534,
|
||||
from,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = requireRecord(
|
||||
mockArg(replySpy as unknown as MockCallSource, 0, 0, "reply payload"),
|
||||
"reply payload",
|
||||
);
|
||||
const [conversationContext] = requireArray(
|
||||
payload.UntrustedStructuredContext,
|
||||
"structured context",
|
||||
);
|
||||
const contextRecord = requireRecord(conversationContext, "conversation context");
|
||||
const contextPayload = requireRecord(contextRecord.payload, "conversation context payload");
|
||||
const messages = requireArray(contextPayload.messages, "conversation context messages").map(
|
||||
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
|
||||
);
|
||||
const messagesById = new Map(messages.map((message) => [message.message_id, message]));
|
||||
expect(messagesById.get("87184")?.body).toBe("how does this determine stability?");
|
||||
expect(messagesById.has("84649")).toBe(false);
|
||||
expect(messagesById.has("84670")).toBe(false);
|
||||
expect(messages.map((message) => message.body)).not.toContain(staleInstruction);
|
||||
expect(messages.map((message) => message.body)).not.toContain("tools.toolSearch: true");
|
||||
});
|
||||
|
||||
it("updates cached bot messages from Telegram edit updates", async () => {
|
||||
onSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
@@ -2718,49 +2594,6 @@ describe("createTelegramBot", () => {
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("inherits group allowlist + requireMention in topics", async () => {
|
||||
onSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
requireMention: false,
|
||||
allowFrom: ["123456789"],
|
||||
topics: {
|
||||
"99": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
is_forum: true,
|
||||
},
|
||||
from: { id: 123456789, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers topic allowFrom over group allowFrom", async () => {
|
||||
onSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as legacySessionSurfaceApi from "./legacy-session-surface-api.js";
|
||||
import * as legacyStateMigrationsApi from "./legacy-state-migrations-api.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
import * as setupPluginApi from "./setup-plugin-api.js";
|
||||
|
||||
vi.mock("baileys", () => {
|
||||
throw new Error("setup plugin load must not load Baileys");
|
||||
@@ -8,19 +12,38 @@ vi.mock("./src/setup-finalize.js", () => {
|
||||
throw new Error("setup status load must not load finalize");
|
||||
});
|
||||
|
||||
describe("whatsapp setup entry", () => {
|
||||
it("loads the setup plugin without installing or importing runtime dependencies", async () => {
|
||||
const { default: setupEntry } = await import("./setup-entry.js");
|
||||
const setupEntryLoadOptions = {
|
||||
createLoaderForTest: (() => (specifier: string) => {
|
||||
if (/[\\/]setup-plugin-api\.[jt]s$/u.test(specifier)) {
|
||||
return setupPluginApi;
|
||||
}
|
||||
if (/[\\/]legacy-state-migrations-api\.[jt]s$/u.test(specifier)) {
|
||||
return legacyStateMigrationsApi;
|
||||
}
|
||||
if (/[\\/]legacy-session-surface-api\.[jt]s$/u.test(specifier)) {
|
||||
return legacySessionSurfaceApi;
|
||||
}
|
||||
throw new Error(`unexpected setup entry module load: ${specifier}`);
|
||||
}) as never,
|
||||
};
|
||||
|
||||
describe("whatsapp setup entry", () => {
|
||||
it("loads setup entry metadata without importing runtime dependencies", () => {
|
||||
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
|
||||
expect(setupEntry.features).toEqual({
|
||||
legacySessionSurfaces: true,
|
||||
legacyStateMigrations: true,
|
||||
});
|
||||
});
|
||||
|
||||
const whatsappSetupPlugin = setupEntry.loadSetupPlugin();
|
||||
it("loads the setup plugin without installing runtime dependencies", () => {
|
||||
const whatsappSetupPlugin = setupEntry.loadSetupPlugin(setupEntryLoadOptions);
|
||||
expect(whatsappSetupPlugin.id).toBe("whatsapp");
|
||||
const detectLegacyStateMigrations = setupEntry.loadLegacyStateMigrationDetector?.();
|
||||
});
|
||||
|
||||
it("loads legacy setup helpers without importing runtime dependencies", () => {
|
||||
const detectLegacyStateMigrations =
|
||||
setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions);
|
||||
if (!detectLegacyStateMigrations) {
|
||||
throw new Error("expected WhatsApp legacy state migration detector");
|
||||
}
|
||||
@@ -32,7 +55,7 @@ describe("whatsapp setup entry", () => {
|
||||
stateDir: "/tmp/openclaw-state",
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
const legacySessionSurface = setupEntry.loadLegacySessionSurface?.();
|
||||
const legacySessionSurface = setupEntry.loadLegacySessionSurface?.(setupEntryLoadOptions);
|
||||
if (!legacySessionSurface) {
|
||||
throw new Error("expected WhatsApp legacy session surface");
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
makeSessionStore,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import { buildMentionConfig } from "./auto-reply/mentions.js";
|
||||
import { createEchoTracker } from "./auto-reply/monitor/echo.js";
|
||||
import { awaitBackgroundTasks } from "./auto-reply/monitor/last-route.js";
|
||||
import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
|
||||
|
||||
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks;
|
||||
let buildMentionConfig: typeof import("./auto-reply/mentions.js").buildMentionConfig;
|
||||
let createEchoTracker: typeof import("./auto-reply/monitor/echo.js").createEchoTracker;
|
||||
let createWebOnMessageHandler: typeof import("./auto-reply/monitor/on-message.js").createWebOnMessageHandler;
|
||||
|
||||
vi.mock("./auto-reply/monitor/last-route.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auto-reply/monitor/last-route.js")>(
|
||||
@@ -100,13 +100,8 @@ function buildInboundMessage(params: {
|
||||
describe("web auto-reply last-route", () => {
|
||||
installWebAutoReplyUnitTestHooks();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeEach(() => {
|
||||
updateLastRouteInBackgroundMock.mockClear();
|
||||
({ awaitBackgroundTasks } = await import("./auto-reply/monitor/last-route.js"));
|
||||
({ buildMentionConfig } = await import("./auto-reply/mentions.js"));
|
||||
({ createEchoTracker } = await import("./auto-reply/monitor/echo.js"));
|
||||
({ createWebOnMessageHandler } = await import("./auto-reply/monitor/on-message.js"));
|
||||
});
|
||||
|
||||
it("updates last-route for direct chats without senderE164", async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { type ResolvedWhatsAppAccount } from "./accounts.js";
|
||||
import { readWebAuthState } from "./auth-store.js";
|
||||
import { resolveWhatsAppGroupIntroHint } from "./group-intro.js";
|
||||
import {
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
@@ -10,6 +9,11 @@ import { whatsappSetupAdapter } from "./setup-core.js";
|
||||
import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js";
|
||||
import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js";
|
||||
|
||||
async function isWhatsAppAuthConfigured(account: ResolvedWhatsAppAccount): Promise<boolean> {
|
||||
const { readWebAuthState } = await import("./auth-store.js");
|
||||
return (await readWebAuthState(account.authDir)) === "linked";
|
||||
}
|
||||
|
||||
export const whatsappSetupPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
...createWhatsAppPluginBase({
|
||||
groups: {
|
||||
@@ -19,7 +23,7 @@ export const whatsappSetupPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
setupWizard: whatsappSetupWizardProxy,
|
||||
setup: whatsappSetupAdapter,
|
||||
isConfigured: async (account) => (await readWebAuthState(account.authDir)) === "linked",
|
||||
isConfigured: isWhatsAppAuthConfigured,
|
||||
}),
|
||||
lifecycle: {
|
||||
detectLegacyStateMigrations: ({ oauthDir }) =>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { expectProviderOnboardPreservesPrimary } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { ZAI_CODING_CN_BASE_URL, ZAI_GLOBAL_BASE_URL } from "./model-definitions.js";
|
||||
import { applyZaiConfig, applyZaiProviderConfig } from "./onboard.js";
|
||||
|
||||
describe("zai onboard", () => {
|
||||
let defaultCfg: ReturnType<typeof applyZaiConfig>;
|
||||
let cnFlashCfg: ReturnType<typeof applyZaiConfig>;
|
||||
let cnFlashxCfg: ReturnType<typeof applyZaiConfig>;
|
||||
|
||||
beforeAll(() => {
|
||||
defaultCfg = applyZaiConfig({});
|
||||
cnFlashCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flash" });
|
||||
cnFlashxCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flashx" });
|
||||
});
|
||||
|
||||
it("adds zai provider with correct settings", () => {
|
||||
const cfg = applyZaiConfig({});
|
||||
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL);
|
||||
expect(cfg.models?.providers?.zai?.api).toBe("openai-completions");
|
||||
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);
|
||||
expect(defaultCfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL);
|
||||
expect(defaultCfg.models?.providers?.zai?.api).toBe("openai-completions");
|
||||
const ids = defaultCfg.models?.providers?.zai?.models?.map((m) => m.id);
|
||||
expect(ids).toEqual([
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
@@ -28,8 +37,10 @@ describe("zai onboard", () => {
|
||||
});
|
||||
|
||||
it("supports CN endpoint for supported coding models", () => {
|
||||
for (const modelId of ["glm-4.7-flash", "glm-4.7-flashx"] as const) {
|
||||
const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId });
|
||||
for (const [modelId, cfg] of [
|
||||
["glm-4.7-flash", cnFlashCfg],
|
||||
["glm-4.7-flashx", cnFlashxCfg],
|
||||
] as const) {
|
||||
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { runDirectImportSmoke } from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as runtime from "./runtime-api.js";
|
||||
|
||||
describe("zalo runtime api", () => {
|
||||
it("loads the narrow runtime api without reentering setup surfaces", async () => {
|
||||
const stdout = await runDirectImportSmoke(
|
||||
`const runtime = await import("./extensions/zalo/runtime-api.ts");
|
||||
process.stdout.write(JSON.stringify({
|
||||
hasZaloPlugin: Object.hasOwn(runtime, "zaloPlugin"),
|
||||
hasZaloSetupWizard: Object.hasOwn(runtime, "zaloSetupWizard"),
|
||||
type: typeof runtime.setZaloRuntime,
|
||||
}));`,
|
||||
);
|
||||
|
||||
expect(stdout).toBe('{"hasZaloPlugin":false,"hasZaloSetupWizard":false,"type":"function"}');
|
||||
}, 45_000);
|
||||
it("loads the narrow runtime api without reentering setup surfaces", () => {
|
||||
expect(Object.hasOwn(runtime, "zaloPlugin")).toBe(false);
|
||||
expect(Object.hasOwn(runtime, "zaloSetupWizard")).toBe(false);
|
||||
expect(typeof runtime.setZaloRuntime).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,6 +251,9 @@ function compareFiles(beforeFiles = [], afterFiles = []) {
|
||||
}
|
||||
|
||||
function runKey(run) {
|
||||
if (typeof run.label === "string" && run.label.trim().length > 0) {
|
||||
return normalizeConfigLabel(run.label);
|
||||
}
|
||||
return normalizeConfigLabel(run.config);
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ function runVitestJsonReport(params) {
|
||||
"--reporter=json",
|
||||
"--outputFile",
|
||||
params.reportPath,
|
||||
...params.forwardedArgs,
|
||||
...params.vitestArgs,
|
||||
];
|
||||
const startedAt = process.hrtime.bigint();
|
||||
@@ -200,6 +201,7 @@ function runVitestJsonReport(params) {
|
||||
return {
|
||||
config: params.config,
|
||||
elapsedMs,
|
||||
label: params.label,
|
||||
logPath: params.logPath,
|
||||
maxRssBytes: params.rss ? parseMaxRssBytes(output) : null,
|
||||
reportPath: params.reportPath,
|
||||
@@ -231,20 +233,50 @@ export function resolveReportArtifactDirs(outputPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfigs(args) {
|
||||
function withUniqueLabels(plans) {
|
||||
const totals = new Map();
|
||||
for (const plan of plans) {
|
||||
totals.set(plan.label, (totals.get(plan.label) ?? 0) + 1);
|
||||
}
|
||||
const seen = new Map();
|
||||
return plans.map((plan) => {
|
||||
const total = totals.get(plan.label) ?? 0;
|
||||
if (total <= 1) {
|
||||
return plan;
|
||||
}
|
||||
const index = (seen.get(plan.label) ?? 0) + 1;
|
||||
seen.set(plan.label, index);
|
||||
return {
|
||||
...plan,
|
||||
label: `${plan.label}-${index}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRunPlans(args) {
|
||||
if (args.reports.length > 0) {
|
||||
return [];
|
||||
}
|
||||
if (args.fullSuite) {
|
||||
return buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
return withUniqueLabels(
|
||||
buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => ({
|
||||
config: plan.config,
|
||||
forwardedArgs: plan.forwardedArgs ?? [],
|
||||
label: normalizeConfigLabel(plan.config),
|
||||
})),
|
||||
);
|
||||
}
|
||||
return args.configs.length > 0 ? args.configs : ["test/vitest/vitest.unit.config.ts"];
|
||||
const configs = args.configs.length > 0 ? args.configs : ["test/vitest/vitest.unit.config.ts"];
|
||||
return configs.map((config) => ({
|
||||
config,
|
||||
forwardedArgs: [],
|
||||
label: normalizeConfigLabel(config),
|
||||
}));
|
||||
}
|
||||
|
||||
function printRunLine(run) {
|
||||
const label = normalizeConfigLabel(run.config);
|
||||
console.log(
|
||||
`[test-group-report] ${label} status=${run.status} wall=${formatMs(run.elapsedMs)} rss=${formatBytesAsMb(run.maxRssBytes)} report=${run.reportPath}`,
|
||||
`[test-group-report] ${run.label} status=${run.status} wall=${formatMs(run.elapsedMs)} rss=${formatBytesAsMb(run.maxRssBytes)} report=${run.reportPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +312,7 @@ async function main() {
|
||||
|
||||
const { reportDir, logDir } = resolveReportArtifactDirs(output);
|
||||
const runEntries = [];
|
||||
const configs = resolveConfigs(args);
|
||||
const runPlans = resolveRunPlans(args);
|
||||
let failed = false;
|
||||
let exitCode = 0;
|
||||
|
||||
@@ -291,10 +323,12 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
const slug = sanitizePathSegment(normalizeConfigLabel(config));
|
||||
for (const plan of runPlans) {
|
||||
const slug = sanitizePathSegment(plan.label);
|
||||
const run = runVitestJsonReport({
|
||||
config,
|
||||
config: plan.config,
|
||||
forwardedArgs: plan.forwardedArgs,
|
||||
label: plan.label,
|
||||
logPath: path.join(logDir, `${slug}.log`),
|
||||
reportPath: path.join(reportDir, `${slug}.json`),
|
||||
rss: args.rss,
|
||||
@@ -321,7 +355,7 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
runEntries.push({ config, reportPath: run.reportPath, run });
|
||||
runEntries.push({ config: plan.label, reportPath: run.reportPath, run });
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
|
||||
@@ -424,9 +424,37 @@ const GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS = [
|
||||
"src/agents/system-prompt.test.ts",
|
||||
...GROUP_VISIBLE_REPLY_TEST_TARGETS,
|
||||
];
|
||||
const CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS = [
|
||||
...["directory", "plugin", "surfaces-only", "threading"].flatMap((suite) =>
|
||||
"abcdefgh"
|
||||
.split("")
|
||||
.map(
|
||||
(shard) =>
|
||||
`src/channels/plugins/contracts/${suite}.registry-backed-shard-${shard}.contract.test.ts`,
|
||||
),
|
||||
),
|
||||
];
|
||||
const TEST_HELPER_NORMALIZE_TEXT_TARGETS = [
|
||||
"src/auto-reply/reply/commands-status.test.ts",
|
||||
"src/auto-reply/status.test.ts",
|
||||
"src/tui/components/chat-log.test.ts",
|
||||
];
|
||||
const SOURCE_TEST_TARGETS = new Map([
|
||||
...PRECISE_SOURCE_TEST_TARGETS,
|
||||
["src/test-utils/openclaw-test-state.ts", ["src/test-utils/openclaw-test-state.test.ts"]],
|
||||
[
|
||||
"src/channels/plugins/contracts/test-helpers/manifest.ts",
|
||||
[
|
||||
...CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS,
|
||||
"src/channels/plugins/contracts/registry.contract.test.ts",
|
||||
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts",
|
||||
CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS,
|
||||
],
|
||||
["test/helpers/normalize-text.ts", TEST_HELPER_NORMALIZE_TEXT_TARGETS],
|
||||
[
|
||||
"src/plugin-sdk/test-helpers/directory-ids.ts",
|
||||
[
|
||||
@@ -452,6 +480,19 @@ const SOURCE_TEST_TARGETS = new Map([
|
||||
["extensions/google-meet/src/cli.ts", ["extensions/google-meet/src/cli.test.ts"]],
|
||||
["extensions/google-meet/src/create.ts", ["extensions/google-meet/index.test.ts"]],
|
||||
["extensions/google-meet/src/oauth.ts", ["extensions/google-meet/src/oauth.test.ts"]],
|
||||
[
|
||||
"extensions/discord/src/monitor/message-handler.ts",
|
||||
[
|
||||
"extensions/discord/src/channel-actions.contract.test.ts",
|
||||
"extensions/discord/src/channel.message-adapter.test.ts",
|
||||
"extensions/discord/src/channel.test.ts",
|
||||
"extensions/discord/src/durable-delivery.test.ts",
|
||||
"extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts",
|
||||
"extensions/discord/src/monitor/message-handler.queue.test.ts",
|
||||
"extensions/discord/src/monitor/provider.skill-dedupe.test.ts",
|
||||
"extensions/discord/src/monitor/provider.test.ts",
|
||||
],
|
||||
],
|
||||
["src/commands/doctor-memory-search.ts", ["src/commands/doctor-memory-search.test.ts"]],
|
||||
[
|
||||
"src/commitments/model-selection.runtime.ts",
|
||||
|
||||
@@ -1,113 +1,31 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../config/runtime-snapshot.js";
|
||||
import { resetFacadeRuntimeStateForTest } from "../plugin-sdk/facade-runtime.js";
|
||||
import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js";
|
||||
import { writePersistedInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-records.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const tempDirs: string[] = [];
|
||||
const facadeRuntimeMocks = vi.hoisted(() => ({
|
||||
loadBundledPluginPublicSurfaceModuleSync: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeExternalAnthropicVertexPlugin(rootDir: string): void {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/anthropic-vertex-provider",
|
||||
version: "0.0.0",
|
||||
type: "module",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
runtimeExtensions: ["./dist/index.js"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "anthropic-vertex",
|
||||
providers: ["anthropic-vertex"],
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(distDir, "api.js"),
|
||||
[
|
||||
"export function createAnthropicVertexStreamFnForModel(model, env) {",
|
||||
" return async () => ({ marker: 'external-vertex', baseUrl: model.baseUrl, envMarker: env.OPENCLAW_TEST_MARKER });",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(distDir, "index.js"), "export default {};\n", "utf8");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetFacadeRuntimeStateForTest();
|
||||
setBundledPluginsDirOverrideForTest(undefined);
|
||||
if (originalBundledPluginsDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
|
||||
}
|
||||
if (originalDisableBundledPlugins === undefined) {
|
||||
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
|
||||
} else {
|
||||
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins;
|
||||
}
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
vi.mock("../plugin-sdk/facade-runtime.js", () => ({
|
||||
loadBundledPluginPublicSurfaceModuleSync:
|
||||
facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync,
|
||||
}));
|
||||
|
||||
describe("anthropic-vertex stream facade", () => {
|
||||
it("loads the stream facade from an installed external provider when bundled surfaces are absent", async () => {
|
||||
const bundledDir = makeTempDir("openclaw-empty-bundled-vertex-");
|
||||
const stateDir = makeTempDir("openclaw-state-vertex-");
|
||||
const pluginRoot = makeTempDir("openclaw-external-vertex-");
|
||||
writeExternalAnthropicVertexPlugin(pluginRoot);
|
||||
writePersistedInstalledPluginIndexInstallRecordsSync(
|
||||
{
|
||||
"anthropic-vertex": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/anthropic-vertex-provider",
|
||||
installPath: pluginRoot,
|
||||
resolvedName: "@openclaw/anthropic-vertex-provider",
|
||||
resolvedVersion: "0.0.0",
|
||||
},
|
||||
},
|
||||
{ stateDir },
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync.mockReset();
|
||||
});
|
||||
|
||||
it("loads the stream facade through the plugin public surface", async () => {
|
||||
const createStream = vi.fn(
|
||||
(model: { baseUrl?: string }, env: NodeJS.ProcessEnv) => async () => ({
|
||||
marker: "external-vertex",
|
||||
baseUrl: model.baseUrl,
|
||||
envMarker: env.OPENCLAW_TEST_MARKER,
|
||||
}),
|
||||
);
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
setBundledPluginsDirOverrideForTest(bundledDir);
|
||||
setRuntimeConfigSnapshot({});
|
||||
facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
|
||||
createAnthropicVertexStreamFnForModel: createStream,
|
||||
});
|
||||
|
||||
const { createAnthropicVertexStreamFnForModel } = await import("./anthropic-vertex-stream.js");
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
@@ -115,6 +33,14 @@ describe("anthropic-vertex stream facade", () => {
|
||||
{ OPENCLAW_TEST_MARKER: "registry" },
|
||||
);
|
||||
|
||||
expect(facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
|
||||
dirName: "anthropic-vertex",
|
||||
artifactBasename: "api.js",
|
||||
});
|
||||
expect(createStream).toHaveBeenCalledWith(
|
||||
{ baseUrl: "https://us-central1-aiplatform.googleapis.com" },
|
||||
{ OPENCLAW_TEST_MARKER: "registry" },
|
||||
);
|
||||
await expect(streamFn({} as never, {} as never, {} as never)).resolves.toEqual({
|
||||
marker: "external-vertex",
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com",
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveVisibleModelCatalog } from "./model-catalog-visibility.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.types.js";
|
||||
import { createProviderAuthChecker } from "./model-provider-auth.js";
|
||||
|
||||
vi.mock("./model-provider-auth.js", () => ({
|
||||
createProviderAuthChecker: vi.fn(),
|
||||
}));
|
||||
|
||||
const createProviderAuthCheckerMock = vi.mocked(createProviderAuthChecker);
|
||||
|
||||
function firstAuthCheckerOptions(): unknown {
|
||||
const call = createProviderAuthCheckerMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected provider auth checker to be created");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
describe("resolveVisibleModelCatalog", () => {
|
||||
beforeEach(() => {
|
||||
createProviderAuthCheckerMock.mockReset();
|
||||
});
|
||||
|
||||
it("can use static auth checks for gateway read-only model lists", () => {
|
||||
const authChecker = vi.fn((provider: string) => provider === "openai");
|
||||
createProviderAuthCheckerMock.mockReturnValue(authChecker);
|
||||
const catalog: ModelCatalogEntry[] = [
|
||||
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
|
||||
{ provider: "openai", id: "gpt-test", name: "GPT Test" },
|
||||
@@ -37,17 +17,9 @@ describe("resolveVisibleModelCatalog", () => {
|
||||
catalog,
|
||||
defaultProvider: "openai",
|
||||
runtimeAuthDiscovery: false,
|
||||
providerAuthChecker: authChecker,
|
||||
});
|
||||
|
||||
expect(createProviderAuthCheckerMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstAuthCheckerOptions()).toEqual({
|
||||
cfg,
|
||||
workspaceDir: undefined,
|
||||
agentDir: undefined,
|
||||
env: undefined,
|
||||
allowPluginSyntheticAuth: false,
|
||||
discoverExternalCliAuth: false,
|
||||
});
|
||||
expect(authChecker).toHaveBeenNthCalledWith(1, "anthropic");
|
||||
expect(authChecker).toHaveBeenNthCalledWith(2, "openai");
|
||||
expect(authChecker).toHaveBeenCalledTimes(2);
|
||||
@@ -56,7 +28,6 @@ describe("resolveVisibleModelCatalog", () => {
|
||||
|
||||
it("limits visible catalog to provider wildcard entries after default discovery", () => {
|
||||
const authChecker = vi.fn((provider: string) => provider !== "blocked");
|
||||
createProviderAuthCheckerMock.mockReturnValue(authChecker);
|
||||
const catalog: ModelCatalogEntry[] = [
|
||||
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
|
||||
{ provider: "openai-codex", id: "gpt-codex-test", name: "GPT Codex Test" },
|
||||
@@ -81,17 +52,9 @@ describe("resolveVisibleModelCatalog", () => {
|
||||
catalog,
|
||||
defaultProvider: "anthropic",
|
||||
runtimeAuthDiscovery: true,
|
||||
providerAuthChecker: authChecker,
|
||||
});
|
||||
|
||||
expect(createProviderAuthCheckerMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstAuthCheckerOptions()).toEqual({
|
||||
cfg,
|
||||
workspaceDir: undefined,
|
||||
agentDir: undefined,
|
||||
env: undefined,
|
||||
allowPluginSyntheticAuth: true,
|
||||
discoverExternalCliAuth: true,
|
||||
});
|
||||
expect(authChecker).toHaveBeenNthCalledWith(1, "anthropic");
|
||||
expect(authChecker).toHaveBeenNthCalledWith(2, "openai-codex");
|
||||
expect(authChecker).toHaveBeenNthCalledWith(3, "vllm");
|
||||
@@ -105,7 +68,6 @@ describe("resolveVisibleModelCatalog", () => {
|
||||
|
||||
it("does not broaden visibility when selected providers have no catalog rows", () => {
|
||||
const authChecker = vi.fn(() => true);
|
||||
createProviderAuthCheckerMock.mockReturnValue(authChecker);
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
@@ -122,17 +84,9 @@ describe("resolveVisibleModelCatalog", () => {
|
||||
catalog: [{ provider: "anthropic", id: "claude-test", name: "Claude Test" }],
|
||||
defaultProvider: "anthropic",
|
||||
runtimeAuthDiscovery: true,
|
||||
providerAuthChecker: authChecker,
|
||||
});
|
||||
|
||||
expect(createProviderAuthCheckerMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstAuthCheckerOptions()).toEqual({
|
||||
cfg,
|
||||
workspaceDir: undefined,
|
||||
agentDir: undefined,
|
||||
env: undefined,
|
||||
allowPluginSyntheticAuth: true,
|
||||
discoverExternalCliAuth: true,
|
||||
});
|
||||
expect(authChecker).toHaveBeenCalledWith("anthropic");
|
||||
expect(authChecker).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
@@ -37,6 +37,7 @@ export function resolveVisibleModelCatalog(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
view?: ModelCatalogVisibilityView;
|
||||
runtimeAuthDiscovery?: boolean;
|
||||
providerAuthChecker?: (provider: string) => boolean;
|
||||
}): ModelCatalogEntry[] {
|
||||
if (params.view === "all") {
|
||||
return params.catalog;
|
||||
@@ -46,14 +47,16 @@ export function resolveVisibleModelCatalog(params: {
|
||||
const configuredCatalog = sortModelCatalogEntries(
|
||||
buildConfiguredModelCatalog({ cfg: params.cfg }),
|
||||
);
|
||||
const hasAuth = createProviderAuthChecker({
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
env: params.env,
|
||||
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
|
||||
discoverExternalCliAuth: params.runtimeAuthDiscovery,
|
||||
});
|
||||
const hasAuth =
|
||||
params.providerAuthChecker ??
|
||||
createProviderAuthChecker({
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
env: params.env,
|
||||
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
|
||||
discoverExternalCliAuth: params.runtimeAuthDiscovery,
|
||||
});
|
||||
const authBackedCatalog = params.catalog.filter((entry) => hasAuth(entry.provider));
|
||||
return sortModelCatalogEntries(
|
||||
dedupeModelCatalogEntries([...configuredCatalog, ...authBackedCatalog]),
|
||||
|
||||
@@ -850,6 +850,7 @@ export async function runWithModelFallback<T>(
|
||||
onError?: ModelFallbackErrorHandler;
|
||||
onFallbackStep?: ModelFallbackStepHandler;
|
||||
classifyResult?: ModelFallbackResultClassifier<T>;
|
||||
skipAuthProfileRuntime?: boolean;
|
||||
} & ModelManifestNormalizationContext,
|
||||
): Promise<ModelFallbackRunResult<T>> {
|
||||
const candidates = resolveFallbackCandidates({
|
||||
@@ -860,7 +861,7 @@ export async function runWithModelFallback<T>(
|
||||
manifestPlugins: params.manifestPlugins,
|
||||
});
|
||||
const authRuntime =
|
||||
params.cfg && hasAnyAuthProfileStoreSource(params.agentDir)
|
||||
!params.skipAuthProfileRuntime && params.cfg && hasAnyAuthProfileStoreSource(params.agentDir)
|
||||
? await loadModelFallbackAuthRuntime()
|
||||
: null;
|
||||
const authStore = authRuntime
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createContractRunResult,
|
||||
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js";
|
||||
@@ -13,6 +13,16 @@ vi.mock("./auth-profiles/source-check.js", () => ({
|
||||
}));
|
||||
|
||||
describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
|
||||
beforeAll(async () => {
|
||||
await runWithModelFallback({
|
||||
cfg: {} as OpenClawConfig,
|
||||
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
|
||||
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
|
||||
run: vi.fn().mockResolvedValue(createContractRunResult({ meta: { durationMs: 1 } })),
|
||||
skipAuthProfileRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
const fallbackClassificationCases = [
|
||||
["empty", "empty_result"],
|
||||
["reasoning-only", "reasoning_only_result"],
|
||||
@@ -64,6 +74,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
|
||||
model,
|
||||
result,
|
||||
}),
|
||||
skipAuthProfileRuntime: true,
|
||||
});
|
||||
|
||||
expect(result.result).toBe(fallback);
|
||||
@@ -178,6 +189,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
|
||||
hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply,
|
||||
hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput,
|
||||
}),
|
||||
skipAuthProfileRuntime: true,
|
||||
});
|
||||
|
||||
expect(result.result).toBe(contractCase.result);
|
||||
|
||||
@@ -132,7 +132,14 @@ function expectInteractiveApprovalButtons(
|
||||
result: Record<string, unknown>,
|
||||
expectedButtons: readonly Record<string, unknown>[],
|
||||
) {
|
||||
expect(requireNestedRecord(result, "interactive payload", ["interactive"])).toEqual({
|
||||
const interactive = result.interactive;
|
||||
if (interactive === undefined) {
|
||||
expect(
|
||||
requireNestedRecord(result, "exec approval payload", ["channelData", "execApproval"]),
|
||||
).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
expect(requireRecord(interactive, "interactive payload")).toEqual({
|
||||
blocks: [{ type: "buttons", buttons: expectedButtons }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
const log = createSubsystemLogger("provider-local-service");
|
||||
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 2_000;
|
||||
const PROBE_INTERVAL_MS = 1_000;
|
||||
const PROBE_INTERVAL_MS = 250;
|
||||
|
||||
const MODEL_PROVIDER_LOCAL_SERVICE_SYMBOL = Symbol.for("openclaw.modelProviderLocalService");
|
||||
|
||||
|
||||
@@ -461,6 +461,7 @@ describe("prepareSimpleCompletionModel", () => {
|
||||
provider: "ollama",
|
||||
modelId: "llama3.2:latest",
|
||||
skipPiDiscovery: true,
|
||||
modelResolver: hoisted.resolveModelAsyncMock,
|
||||
});
|
||||
|
||||
expect(result).not.toHaveProperty("error");
|
||||
@@ -494,6 +495,7 @@ describe("prepareSimpleCompletionModel", () => {
|
||||
modelId: "mistral-medium-3-5",
|
||||
allowBundledStaticCatalogFallback: true,
|
||||
skipPiDiscovery: true,
|
||||
modelResolver: hoisted.resolveModelAsyncMock,
|
||||
});
|
||||
|
||||
expect(result).not.toHaveProperty("error");
|
||||
@@ -522,7 +524,7 @@ describe("prepareSimpleCompletionModelForAgent", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
hoisted.resolveModelMock.mockReturnValueOnce({
|
||||
hoisted.resolveModelAsyncMock.mockResolvedValueOnce({
|
||||
model: {
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4-mini",
|
||||
@@ -536,17 +538,22 @@ describe("prepareSimpleCompletionModelForAgent", () => {
|
||||
const result = await prepareSimpleCompletionModelForAgent({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
skipPiDiscovery: true,
|
||||
modelResolver: hoisted.resolveModelAsyncMock,
|
||||
});
|
||||
|
||||
expectPreparedModelResult(result);
|
||||
expect(result.selection.provider).toBe("openai");
|
||||
expect(result.selection.modelId).toBe("gpt-5.4-mini");
|
||||
expect(result.selection.runtimeProvider).toBe("openai-codex");
|
||||
expect(hoisted.resolveModelMock).toHaveBeenCalledWith(
|
||||
expect(hoisted.resolveModelAsyncMock).toHaveBeenCalledWith(
|
||||
"openai-codex",
|
||||
"gpt-5.4-mini",
|
||||
expect.any(String),
|
||||
cfg,
|
||||
{
|
||||
skipPiDiscovery: true,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(callArg(hoisted.getApiKeyForModelMock) as { model?: { provider?: string } }).model?.provider,
|
||||
|
||||
@@ -198,14 +198,21 @@ export async function prepareSimpleCompletionModel(params: {
|
||||
allowMissingApiKeyModes?: ReadonlyArray<AllowedMissingApiKeyMode>;
|
||||
allowBundledStaticCatalogFallback?: boolean;
|
||||
skipPiDiscovery?: boolean;
|
||||
modelResolver?: typeof resolveModelAsync;
|
||||
}): Promise<PreparedSimpleCompletionModel> {
|
||||
const resolved = params.skipPiDiscovery
|
||||
? await resolveModelAsync(params.provider, params.modelId, params.agentDir, params.cfg, {
|
||||
...(params.allowBundledStaticCatalogFallback !== undefined
|
||||
? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback }
|
||||
: {}),
|
||||
skipPiDiscovery: true,
|
||||
})
|
||||
? await (params.modelResolver ?? resolveModelAsync)(
|
||||
params.provider,
|
||||
params.modelId,
|
||||
params.agentDir,
|
||||
params.cfg,
|
||||
{
|
||||
...(params.allowBundledStaticCatalogFallback !== undefined
|
||||
? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback }
|
||||
: {}),
|
||||
skipPiDiscovery: true,
|
||||
},
|
||||
)
|
||||
: resolveModel(params.provider, params.modelId, params.agentDir, params.cfg);
|
||||
if (!resolved.model) {
|
||||
return {
|
||||
@@ -282,6 +289,7 @@ export async function prepareSimpleCompletionModelForAgent(params: {
|
||||
allowMissingApiKeyModes?: ReadonlyArray<AllowedMissingApiKeyMode>;
|
||||
allowBundledStaticCatalogFallback?: boolean;
|
||||
skipPiDiscovery?: boolean;
|
||||
modelResolver?: typeof resolveModelAsync;
|
||||
}): Promise<PreparedSimpleCompletionModelForAgent> {
|
||||
const selection = resolveSimpleCompletionSelectionForAgent({
|
||||
cfg: params.cfg,
|
||||
@@ -305,6 +313,7 @@ export async function prepareSimpleCompletionModelForAgent(params: {
|
||||
? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback }
|
||||
: {}),
|
||||
skipPiDiscovery: params.skipPiDiscovery,
|
||||
modelResolver: params.modelResolver,
|
||||
});
|
||||
if ("error" in prepared) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { MAX_VIDEO_BYTES } from "../../media/constants.js";
|
||||
import * as mediaStore from "../../media/store.js";
|
||||
@@ -186,6 +186,15 @@ function resetVideoGenerateMocks() {
|
||||
}
|
||||
|
||||
describe("createVideoGenerateTool", () => {
|
||||
let emptyConfigTool: ReturnType<typeof createVideoGenerateTool>;
|
||||
|
||||
beforeAll(() => {
|
||||
resetVideoGenerateMocks();
|
||||
emptyConfigTool = createVideoGenerateTool({ config: asConfig({}) });
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetVideoGenerateMocks();
|
||||
for (const envVar of GENERATION_PROVIDER_ENV_VARS) {
|
||||
@@ -200,7 +209,7 @@ describe("createVideoGenerateTool", () => {
|
||||
it("returns null when no video-generation config or auth-backed provider is available", () => {
|
||||
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([]);
|
||||
|
||||
expect(createVideoGenerateTool({ config: asConfig({}) })).toBeNull();
|
||||
expect(emptyConfigTool).toBeNull();
|
||||
});
|
||||
|
||||
it("registers when video-generation config is present", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withFastReplyConfig } from "./reply/get-reply-fast-path.js";
|
||||
import { loadGetReplyModuleForTest } from "./reply/get-reply.test-loader.js";
|
||||
@@ -172,8 +172,11 @@ function createContinueDirectivesResult() {
|
||||
}
|
||||
|
||||
describe("block streaming", () => {
|
||||
beforeEach(async () => {
|
||||
beforeAll(async () => {
|
||||
await loadFreshGetReplyModuleForTest();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
mocks.resolveReplyDirectives.mockReset();
|
||||
mocks.handleInlineActions.mockReset();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
@@ -98,6 +98,15 @@ const textSurfaceModelsTestPlugins = (["discord", "whatsapp"] as const).map((id)
|
||||
source: "test",
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||
]);
|
||||
await buildModelsProviderData({
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as OpenClawConfig);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
modelCatalogMocks.loadModelCatalog.mockReset();
|
||||
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
|
||||
@@ -274,7 +283,42 @@ describe("handleModelsCommand", () => {
|
||||
expect(result?.reply?.text).not.toMatch(/^- codex-cli \(/m);
|
||||
});
|
||||
|
||||
it("sources CLI runtime provider model lists from the catalog, not user agents.defaults.models", async () => {
|
||||
it("sources CLI runtime provider model lists from the catalog", async () => {
|
||||
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
|
||||
{ provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ provider: "claude-cli", id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ provider: "claude-cli", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
{ provider: "claude-cli", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "claude-cli", id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
|
||||
]);
|
||||
modelProviderAuthMocks.authenticatedProviders = new Set(["claude-cli"]);
|
||||
|
||||
const data = await buildModelsProviderData({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
// User only declared 2 of claude-cli's 6 supported models.
|
||||
// For claude-cli this narrowing must be ignored.
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
expect([...(data.byProvider.get("claude-cli") ?? [])].toSorted()).toEqual([
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-6",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps non-CLI configured provider model lists scoped to user config", async () => {
|
||||
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
|
||||
{ provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
@@ -283,57 +327,23 @@ describe("handleModelsCommand", () => {
|
||||
{ provider: "claude-cli", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "claude-cli", id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
// A non-CLI configured provider — its narrowing IS respected.
|
||||
{ provider: "minimax", id: "abab-7", name: "Abab 7" },
|
||||
{ provider: "minimax", id: "abab-6.5", name: "Abab 6.5" },
|
||||
]);
|
||||
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "claude-cli", "minimax"]);
|
||||
|
||||
const result = await handleModelsCommand(
|
||||
buildParams("/models claude-cli", {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
// User only declared 2 of claude-cli's 6 supported models, plus 1
|
||||
// of minimax's 2. For claude-cli this narrowing must be ignored;
|
||||
// for minimax it must still gate.
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"minimax/abab-7": {},
|
||||
},
|
||||
const minimaxData = await buildModelsProviderData({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"minimax/abab-7": {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.reply?.text).toContain("- claude-cli/claude-opus-4-7");
|
||||
expect(result?.reply?.text).toContain("- claude-cli/claude-sonnet-4-6");
|
||||
expect(result?.reply?.text).toContain("- claude-cli/claude-opus-4-6");
|
||||
expect(result?.reply?.text).toContain("- claude-cli/claude-opus-4-5");
|
||||
expect(result?.reply?.text).toContain("- claude-cli/claude-sonnet-4-5");
|
||||
expect(result?.reply?.text).toContain("- claude-cli/claude-haiku-4-5");
|
||||
expect(result?.reply?.text).toContain("of 6");
|
||||
|
||||
// For non-CLI configured providers (e.g. Minimax / LM Studio / custom
|
||||
// OpenAI-compatible endpoints), user config is still the source of truth.
|
||||
const minimaxResult = await handleModelsCommand(
|
||||
buildParams("/models minimax", {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"minimax/abab-7": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
expect(minimaxResult?.reply?.text).toContain("- minimax/abab-7");
|
||||
expect(minimaxResult?.reply?.text).not.toContain("- minimax/abab-6.5");
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
expect([...(minimaxData.byProvider.get("minimax") ?? [])]).toEqual(["abab-7"]);
|
||||
});
|
||||
|
||||
it("does not synthesize claude-cli models when the catalog has no claude-cli entries", async () => {
|
||||
@@ -607,22 +617,13 @@ describe("handleModelsCommand", () => {
|
||||
},
|
||||
} satisfies Partial<OpenClawConfig>;
|
||||
|
||||
const defaultProviderResult = await handleModelsCommand(
|
||||
buildParams("/models openai-codex", cfg),
|
||||
true,
|
||||
);
|
||||
const deepseekResult = await handleModelsCommand(buildParams("/models deepseek", cfg), true);
|
||||
const data = await buildModelsProviderData(cfg as OpenClawConfig);
|
||||
|
||||
expect(defaultProviderResult?.reply?.text).toContain(
|
||||
"Models (openai-codex) — showing 1-1 of 1 (page 1/1)",
|
||||
);
|
||||
expect(defaultProviderResult?.reply?.text).toContain("- openai-codex/gpt-5.4");
|
||||
expect(defaultProviderResult?.reply?.text).not.toContain("openai-codex/deepseek-v4");
|
||||
expect(deepseekResult?.reply?.text).toContain(
|
||||
"Models (deepseek) — showing 1-2 of 2 (page 1/1)",
|
||||
);
|
||||
expect(deepseekResult?.reply?.text).toContain("- deepseek/deepseek-v4-flash");
|
||||
expect(deepseekResult?.reply?.text).toContain("- deepseek/deepseek-v4-pro");
|
||||
expect([...(data.byProvider.get("openai-codex") ?? [])]).toEqual(["gpt-5.4"]);
|
||||
expect([...(data.byProvider.get("deepseek") ?? [])].toSorted()).toEqual([
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps /models list <provider> as an alias", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
vi.mock("../../agents/fast-mode.js", () => ({
|
||||
@@ -57,6 +57,20 @@ async function buildKiraStatusReply(cfg: OpenClawConfig) {
|
||||
}
|
||||
|
||||
describe("buildStatusReply", () => {
|
||||
beforeAll(async () => {
|
||||
await buildKiraStatusReply({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
});
|
||||
|
||||
it("shows per-agent thinkingDefault in the status card", async () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
completionRequiresMessageToolDelivery,
|
||||
resolveCompletionChatType,
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
} from "./completion-delivery-policy.js";
|
||||
|
||||
describe("completion delivery policy", () => {
|
||||
beforeAll(() => {
|
||||
resolveCompletionChatType({ requesterSessionKey: "agent:main:whatsapp:warmup@g.us" });
|
||||
resolveCompletionChatType({
|
||||
requesterSessionKey: "agent:main:discord:guild-warmup:channel-warmup",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "canonical group key",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
createReplyRuntimeMocks,
|
||||
@@ -17,10 +17,13 @@ const { withTempHome } = createTempHomeHarness({ prefix: "openclaw-getreply-fast
|
||||
installReplyRuntimeMocks(agentMocks);
|
||||
|
||||
describe("getReplyFromConfig fast-path runtime", () => {
|
||||
beforeAll(async () => {
|
||||
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
resetReplyRuntimeMocks(agentMocks);
|
||||
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expectChannelPluginContract } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe, it } from "vitest";
|
||||
import { beforeAll, describe, it } from "vitest";
|
||||
import { getBundledChannelPluginAsync } from "./bundled-channel-plugin-loader.js";
|
||||
import { channelPluginSurfaceKeys } from "./manifest.js";
|
||||
import { getPluginContractRegistryShardRefs } from "./registry-plugin.js";
|
||||
@@ -60,10 +60,18 @@ export function installDirectoryContractRegistryShard(params: ContractShardParam
|
||||
installEmptyShardSuite("directory contract registry shard");
|
||||
return;
|
||||
}
|
||||
const pluginCache = new Map<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
|
||||
beforeAll(async () => {
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
pluginCache.set(entry.id, await getBundledChannelPluginAsync(entry.id));
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (const entry of entries) {
|
||||
describe(`${entry.id} directory contract`, () => {
|
||||
it("exposes the base directory contract", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(entry.id);
|
||||
const plugin = pluginCache.get(entry.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type SessionBindingContractEntry = {
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
preload?: () => Promise<void> | void;
|
||||
beforeEach?: () => Promise<void> | void;
|
||||
};
|
||||
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
|
||||
@@ -243,6 +244,9 @@ const sessionBindingContractEntries: Record<
|
||||
Omit<SessionBindingContractEntry, "id">
|
||||
> = {
|
||||
discord: {
|
||||
preload: async () => {
|
||||
await getContractApi<DiscordContractApi>("discord");
|
||||
},
|
||||
beforeEach: prepareDiscordSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
@@ -304,6 +308,9 @@ const sessionBindingContractEntries: Record<
|
||||
},
|
||||
},
|
||||
feishu: {
|
||||
preload: async () => {
|
||||
await getContractApi<FeishuContractApi>("feishu");
|
||||
},
|
||||
beforeEach: prepareFeishuSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
@@ -365,6 +372,9 @@ const sessionBindingContractEntries: Record<
|
||||
},
|
||||
},
|
||||
imessage: {
|
||||
preload: async () => {
|
||||
await getContractApi<IMessageContractApi>("imessage");
|
||||
},
|
||||
beforeEach: prepareIMessageSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
@@ -428,6 +438,9 @@ const sessionBindingContractEntries: Record<
|
||||
},
|
||||
},
|
||||
matrix: {
|
||||
preload: async () => {
|
||||
await getContractApi<MatrixContractApi>("matrix");
|
||||
},
|
||||
beforeEach: prepareMatrixSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
@@ -479,6 +492,9 @@ const sessionBindingContractEntries: Record<
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
preload: async () => {
|
||||
await getContractApi<TelegramContractApi>("telegram");
|
||||
},
|
||||
beforeEach: prepareTelegramSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../../../config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
@@ -58,6 +58,10 @@ export function describeSessionBindingRegistryBackedContract(id: string) {
|
||||
}
|
||||
|
||||
describe(`${entry.id} session binding contract`, () => {
|
||||
beforeAll(async () => {
|
||||
await entry.preload?.();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
|
||||
|
||||
describe("legacy config migrate validation", () => {
|
||||
it("returns valid migrated config for legacy group chat routing drift", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
let groupChatRoutingResult: ReturnType<typeof migrateLegacyConfig>;
|
||||
let partialValidationResult: ReturnType<typeof migrateLegacyConfig>;
|
||||
|
||||
beforeAll(() => {
|
||||
groupChatRoutingResult = migrateLegacyConfig({
|
||||
routing: {
|
||||
allowFrom: ["+15550001111"],
|
||||
groupChat: {
|
||||
@@ -17,7 +20,27 @@ describe("legacy config migrate validation", () => {
|
||||
telegram: {},
|
||||
},
|
||||
});
|
||||
partialValidationResult = migrateLegacyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
llm: { idleTimeoutSeconds: 120 },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
brave: {
|
||||
enabled: true,
|
||||
config: { webSearch: { mode: "definitely-invalid" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: { web: { search: { provider: "brave" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns valid migrated config for legacy group chat routing drift", () => {
|
||||
const res = groupChatRoutingResult;
|
||||
expect(res.partiallyValid).toBeUndefined();
|
||||
const migratedConfig = res.config as Record<string, unknown> | null;
|
||||
expect(migratedConfig?.routing).toBeUndefined();
|
||||
@@ -42,23 +65,7 @@ describe("legacy config migrate validation", () => {
|
||||
});
|
||||
|
||||
it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
llm: { idleTimeoutSeconds: 120 },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
brave: {
|
||||
enabled: true,
|
||||
config: { webSearch: { mode: "definitely-invalid" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: { web: { search: { provider: "brave" } } },
|
||||
});
|
||||
const res = partialValidationResult;
|
||||
|
||||
expect(res.partiallyValid).toBe(true);
|
||||
expect(res.changes).toStrictEqual([
|
||||
|
||||
@@ -38,6 +38,21 @@ function installThinkingTestProviders() {
|
||||
setActivePluginRegistry(registry);
|
||||
}
|
||||
|
||||
function mockDeterministicModelCatalog() {
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([
|
||||
{
|
||||
id: "gpt-4.1-mini",
|
||||
name: "GPT-4.1 Mini",
|
||||
provider: "openai",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn model overrides", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
@@ -58,23 +73,10 @@ describe("runCronIsolatedAgentTurn model overrides", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies model overrides with correct precedence", async () => {
|
||||
it("applies direct cron model overrides", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const deterministicCatalog = [
|
||||
{
|
||||
id: "gpt-4.1-mini",
|
||||
name: "GPT-4.1 Mini",
|
||||
provider: "openai",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
},
|
||||
];
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue(deterministicCatalog);
|
||||
|
||||
let res = (
|
||||
mockDeterministicModelCatalog();
|
||||
const res = (
|
||||
await runCronTurn(home, {
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
@@ -89,16 +91,26 @@ describe("runCronIsolatedAgentTurn model overrides", () => {
|
||||
model: "gpt-4.1-mini",
|
||||
});
|
||||
directModel.assert();
|
||||
});
|
||||
});
|
||||
|
||||
res = (await runTurnWithStoredModelOverride(home, DEFAULT_AGENT_TURN_PAYLOAD)).res;
|
||||
it("uses stored model overrides when cron payload omits a model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
mockDeterministicModelCatalog();
|
||||
const res = (await runTurnWithStoredModelOverride(home, DEFAULT_AGENT_TURN_PAYLOAD)).res;
|
||||
expect(res.status).toBe("ok");
|
||||
const storedOverride = expectEmbeddedProviderModel({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
});
|
||||
storedOverride.assert();
|
||||
});
|
||||
});
|
||||
|
||||
res = (
|
||||
it("lets explicit cron model override stored session overrides", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
mockDeterministicModelCatalog();
|
||||
const res = (
|
||||
await runTurnWithStoredModelOverride(home, {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
|
||||
@@ -142,6 +142,81 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("gateway restart deferral preflight", () => {
|
||||
it("defers channel hot reload until active embedded work drains", async () => {
|
||||
const startChannel = vi.fn(async () => {});
|
||||
const stopChannel = vi.fn(async () => {});
|
||||
const { applyHotReload } = createGatewayReloadHandlers({
|
||||
deps: {} as never,
|
||||
broadcast: vi.fn(),
|
||||
getState: () => ({
|
||||
hooksConfig: {} as never,
|
||||
hookClientIpConfig: {} as never,
|
||||
heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
|
||||
cronState: {
|
||||
cron: { start: vi.fn(async () => {}), stop: vi.fn() },
|
||||
storePath: "/tmp/cron.json",
|
||||
cronEnabled: false,
|
||||
} as never,
|
||||
channelHealthMonitor: null,
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
startChannel,
|
||||
stopChannel,
|
||||
reloadPlugins: vi.fn(
|
||||
async (): Promise<GatewayPluginReloadResult> => ({
|
||||
restartChannels: new Set(),
|
||||
activeChannels: new Set(),
|
||||
}),
|
||||
),
|
||||
logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
logChannels: { info: vi.fn(), error: vi.fn() },
|
||||
logCron: { error: vi.fn() },
|
||||
logReload: { info: vi.fn(), warn: vi.fn() },
|
||||
createHealthMonitor: () => null,
|
||||
});
|
||||
hoisted.activeEmbeddedRunCount.value = 1;
|
||||
vi.useFakeTimers();
|
||||
const reloadPromise = applyHotReload(
|
||||
{
|
||||
changedPaths: ["channels.discord.token"],
|
||||
restartGateway: false,
|
||||
restartReasons: [],
|
||||
hotReasons: ["channels.discord.token"],
|
||||
reloadHooks: false,
|
||||
restartGmailWatcher: false,
|
||||
restartCron: false,
|
||||
restartHeartbeat: false,
|
||||
restartHealthMonitor: false,
|
||||
reloadPlugins: false,
|
||||
restartChannels: new Set(["discord"]),
|
||||
disposeMcpRuntimes: false,
|
||||
noopPaths: [],
|
||||
},
|
||||
{
|
||||
gateway: { reload: { deferralTimeoutMs: 60_000 } },
|
||||
channels: { discord: { token: "token" } },
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(stopChannel).not.toHaveBeenCalled();
|
||||
expect(startChannel).not.toHaveBeenCalled();
|
||||
|
||||
hoisted.activeEmbeddedRunCount.value = 0;
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await reloadPromise;
|
||||
} finally {
|
||||
hoisted.activeEmbeddedRunCount.value = 0;
|
||||
await vi.advanceTimersByTimeAsync(500).catch(() => {});
|
||||
vi.useRealTimers();
|
||||
await reloadPromise.catch(() => {});
|
||||
}
|
||||
|
||||
expect(stopChannel).toHaveBeenCalledWith("discord");
|
||||
expect(startChannel).toHaveBeenCalledWith("discord");
|
||||
});
|
||||
|
||||
it("logs active task run ids before waiting and when forcing after timeout", async () => {
|
||||
const restartTesting = (await import("../infra/restart.js")).__testing;
|
||||
restartTesting.resetSigusr1State();
|
||||
|
||||
@@ -513,7 +513,25 @@ describe("gateway server cron", () => {
|
||||
expect(wrappedPayload?.sessionTarget).toBe("main");
|
||||
expect(wrappedPayload?.wakeMode).toBe("now");
|
||||
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
|
||||
} finally {
|
||||
await cleanupCronTestRun({
|
||||
cronState,
|
||||
prevSkipCron,
|
||||
clearSessionConfig: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handles cron patch merge and validation semantics", { timeout: 45_000 }, async () => {
|
||||
const { prevSkipCron } = await setupCronTestRun({
|
||||
tempPrefix: "openclaw-gw-cron-patch-",
|
||||
sessionConfig: { mainKey: "primary" },
|
||||
cronEnabled: false,
|
||||
});
|
||||
|
||||
const cronState = await createDirectCronState();
|
||||
|
||||
try {
|
||||
const patchJobId = await addMainSystemEventCronJobDirect({
|
||||
cronState,
|
||||
name: "patch test",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
@@ -186,15 +186,25 @@ async function issueMixedRolePairingScopedDevice(
|
||||
}
|
||||
|
||||
describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
|
||||
let ownershipGuardServer: Awaited<ReturnType<typeof startServer>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
ownershipGuardServer = await startServer("secret");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await ownershipGuardServer.server.close();
|
||||
ownershipGuardServer.envSnapshot.restore();
|
||||
});
|
||||
|
||||
test("rejects a device-token caller rotating or revoking another device's token", async () => {
|
||||
const started = await startServer("secret");
|
||||
const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-a");
|
||||
const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-b");
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
port: ownershipGuardServer.port,
|
||||
identityPath: deviceA.identityPath,
|
||||
deviceToken: deviceA.pairingToken,
|
||||
});
|
||||
@@ -221,8 +231,6 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
|
||||
expect(pairedBAfterRevoke?.tokens?.operator?.revokedAtMs).toBeUndefined();
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
approveNodePairing,
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
listNodePairing,
|
||||
requestNodePairing,
|
||||
} from "../infra/node-pairing.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
loadDeviceIdentity,
|
||||
openTrackedWs,
|
||||
pairDeviceIdentity,
|
||||
@@ -23,6 +23,21 @@ import {
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pair-authz-" });
|
||||
|
||||
async function makeNodePairingStateDir(): Promise<string> {
|
||||
return await tempDirs.make("case");
|
||||
}
|
||||
|
||||
function requireApprovedPairing(
|
||||
result: Awaited<ReturnType<typeof approveNodePairing>>,
|
||||
): Exclude<typeof result, null | { status: "forbidden"; missingScope: string }> {
|
||||
if (!result || "status" in result) {
|
||||
throw new Error(`Expected approved node pairing, got ${JSON.stringify(result)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function connectNodeClient(params: {
|
||||
port: number;
|
||||
deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"];
|
||||
@@ -45,61 +60,14 @@ async function connectNodeClient(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPairingApprovalRejected(params: {
|
||||
started: Awaited<ReturnType<typeof startServerWithClient>>;
|
||||
nodeId: string;
|
||||
approverName: string;
|
||||
tokenScopes: string[];
|
||||
connectedScopes: string[];
|
||||
requestCommands?: string[];
|
||||
expectedMessage: string;
|
||||
}) {
|
||||
const { started } = params;
|
||||
const approver = await issueOperatorToken({
|
||||
name: params.approverName,
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: params.tokenScopes,
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
const request = await requestNodePairing({
|
||||
nodeId: params.nodeId,
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
...(params.requestCommands ? { commands: params.requestCommands } : {}),
|
||||
});
|
||||
|
||||
pairingWs = await openTrackedWs(started.port);
|
||||
await connectOk(pairingWs, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: approver.token,
|
||||
deviceIdentityPath: approver.identityPath,
|
||||
scopes: params.connectedScopes,
|
||||
});
|
||||
|
||||
const approve = await rpcReq(pairingWs, "node.pair.approve", {
|
||||
requestId: request.request.requestId,
|
||||
});
|
||||
expect(approve.ok).toBe(false);
|
||||
expect(approve.error?.message).toBe(params.expectedMessage);
|
||||
|
||||
await expect(getPairedNode(params.nodeId)).resolves.toBeNull();
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRePairingRequest(params: {
|
||||
started: Awaited<ReturnType<typeof startServerWithClient>>;
|
||||
pairedName: string;
|
||||
initialCommands?: string[];
|
||||
reconnectCommands: string[];
|
||||
approvalScopes: string[];
|
||||
expectedVisibleCommands: string[];
|
||||
}) {
|
||||
const started = await startServerWithClient("secret");
|
||||
const pairedNode = await pairDeviceIdentity({
|
||||
name: params.pairedName,
|
||||
role: "node",
|
||||
@@ -112,12 +80,12 @@ async function expectRePairingRequest(params: {
|
||||
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
try {
|
||||
controlWs = await openTrackedWs(started.port);
|
||||
controlWs = await openTrackedWs(params.started.port);
|
||||
await connectOk(controlWs, { token: "secret" });
|
||||
|
||||
if (params.initialCommands) {
|
||||
firstClient = await connectNodeClient({
|
||||
port: started.port,
|
||||
port: params.started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: params.initialCommands,
|
||||
});
|
||||
@@ -135,7 +103,7 @@ async function expectRePairingRequest(params: {
|
||||
});
|
||||
|
||||
nodeClient = await connectNodeClient({
|
||||
port: started.port,
|
||||
port: params.started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: params.reconnectCommands,
|
||||
});
|
||||
@@ -174,95 +142,127 @@ async function expectRePairingRequest(params: {
|
||||
controlWs?.close();
|
||||
await firstClient?.stopAndWait();
|
||||
await nodeClient?.stopAndWait();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway node pairing authorization", () => {
|
||||
test("enforces node pairing approval scopes", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
await expectPairingApprovalRejected({
|
||||
started,
|
||||
nodeId: "node-approve-reject-admin",
|
||||
approverName: "node-pair-approve-pairing-only",
|
||||
tokenScopes: ["operator.pairing"],
|
||||
connectedScopes: ["operator.pairing"],
|
||||
requestCommands: ["system.run"],
|
||||
expectedMessage: "missing scope: operator.admin",
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await tempDirs.setup();
|
||||
});
|
||||
|
||||
await expectPairingApprovalRejected({
|
||||
started,
|
||||
nodeId: "node-approve-reject-pairing",
|
||||
approverName: "node-pair-approve-attacker",
|
||||
tokenScopes: ["operator.write"],
|
||||
connectedScopes: ["operator.write"],
|
||||
requestCommands: ["system.run"],
|
||||
expectedMessage: "missing scope: operator.pairing",
|
||||
});
|
||||
afterAll(async () => {
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
const approver = await issueOperatorToken({
|
||||
name: "node-pair-approve-commandless",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.pairing"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
describe("approval scopes", () => {
|
||||
test("rejects node pairing approval without admin scope", async () => {
|
||||
const baseDir = await makeNodePairingStateDir();
|
||||
const request = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-approve-reject-admin",
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
commands: ["system.run"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
const request = await requestNodePairing({
|
||||
nodeId: "node-approve-target",
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
await expect(
|
||||
approveNodePairing(
|
||||
request.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.admin",
|
||||
});
|
||||
await expect(getPairedNode("node-approve-reject-admin", baseDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
pairingWs = await openTrackedWs(started.port);
|
||||
await connectOk(pairingWs, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: approver.token,
|
||||
deviceIdentityPath: approver.identityPath,
|
||||
scopes: ["operator.pairing"],
|
||||
test("rejects node pairing approval without pairing scope", async () => {
|
||||
const baseDir = await makeNodePairingStateDir();
|
||||
const request = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-approve-reject-pairing",
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
commands: ["system.run"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveNodePairing(
|
||||
request.request.requestId,
|
||||
{ callerScopes: ["operator.write"] },
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.pairing",
|
||||
});
|
||||
await expect(getPairedNode("node-approve-reject-pairing", baseDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
const approve = await rpcReq<{
|
||||
requestId?: string;
|
||||
node?: { nodeId?: string };
|
||||
}>(pairingWs, "node.pair.approve", {
|
||||
requestId: request.request.requestId,
|
||||
});
|
||||
expect(approve.ok).toBe(true);
|
||||
expect(approve.payload?.requestId).toBe(request.request.requestId);
|
||||
expect(approve.payload?.node?.nodeId).toBe("node-approve-target");
|
||||
test("approves commandless node pairing with pairing scope", async () => {
|
||||
const baseDir = await makeNodePairingStateDir();
|
||||
const request = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-approve-target",
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
const pairedNode = await getPairedNode("node-approve-target");
|
||||
const approved = requireApprovedPairing(
|
||||
await approveNodePairing(
|
||||
request.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
);
|
||||
expect(approved.requestId).toBe(request.request.requestId);
|
||||
expect(approved.node.nodeId).toBe("node-approve-target");
|
||||
|
||||
const pairedNode = await getPairedNode("node-approve-target", baseDir);
|
||||
expect(pairedNode?.nodeId).toBe("node-approve-target");
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("paired node reconnects", () => {
|
||||
let started: Awaited<ReturnType<typeof startServerWithClient>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
started = await startServerWithClient("secret");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("requests re-pairing when a paired node reconnects with upgraded commands", async () => {
|
||||
await expectRePairingRequest({
|
||||
pairedName: "node-command-pin",
|
||||
initialCommands: ["screen.snapshot"],
|
||||
reconnectCommands: ["screen.snapshot", "system.run"],
|
||||
approvalScopes: ["operator.pairing", "operator.write"],
|
||||
expectedVisibleCommands: ["screen.snapshot"],
|
||||
});
|
||||
});
|
||||
|
||||
test("requests re-pairing when a commandless paired node reconnects with system.run", async () => {
|
||||
await expectRePairingRequest({
|
||||
pairedName: "node-command-empty",
|
||||
reconnectCommands: ["screen.snapshot", "system.run"],
|
||||
approvalScopes: ["operator.pairing"],
|
||||
expectedVisibleCommands: [],
|
||||
test("requests re-pairing when a paired node reconnects with upgraded commands", async () => {
|
||||
await expectRePairingRequest({
|
||||
started,
|
||||
pairedName: "node-command-pin",
|
||||
initialCommands: ["screen.snapshot"],
|
||||
reconnectCommands: ["screen.snapshot", "system.run"],
|
||||
approvalScopes: ["operator.pairing", "operator.write"],
|
||||
expectedVisibleCommands: ["screen.snapshot"],
|
||||
});
|
||||
});
|
||||
|
||||
test("requests re-pairing when a commandless paired node reconnects with system.run", async () => {
|
||||
await expectRePairingRequest({
|
||||
started,
|
||||
pairedName: "node-command-empty",
|
||||
reconnectCommands: ["screen.snapshot", "system.run"],
|
||||
approvalScopes: ["operator.pairing"],
|
||||
expectedVisibleCommands: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,57 +466,6 @@ describe("gateway hot reload", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("defers channel hot reload until active work drains", async () => {
|
||||
await withNonMinimalGatewayServer(async () => {
|
||||
const onHotReload = hoisted.getOnHotReload();
|
||||
expect(onHotReload).toBeTypeOf("function");
|
||||
|
||||
hoisted.providerManager.stopChannel.mockClear();
|
||||
hoisted.providerManager.startChannel.mockClear();
|
||||
hoisted.activeEmbeddedRunCount.value = 1;
|
||||
embeddedRunMock.activeIds.add("reload-active");
|
||||
vi.useFakeTimers();
|
||||
const reloadPromise = onHotReload?.(
|
||||
{
|
||||
changedPaths: ["channels.discord.token"],
|
||||
restartGateway: false,
|
||||
restartReasons: [],
|
||||
hotReasons: ["channels.discord.token"],
|
||||
reloadHooks: false,
|
||||
restartGmailWatcher: false,
|
||||
restartCron: false,
|
||||
restartHeartbeat: false,
|
||||
restartChannels: new Set(["discord"]),
|
||||
noopPaths: [],
|
||||
},
|
||||
{
|
||||
gateway: { reload: { deferralTimeoutMs: 60_000 } },
|
||||
channels: { discord: { token: "token" } },
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(hoisted.providerManager.stopChannel).not.toHaveBeenCalled();
|
||||
expect(hoisted.providerManager.startChannel).not.toHaveBeenCalled();
|
||||
|
||||
hoisted.activeEmbeddedRunCount.value = 0;
|
||||
embeddedRunMock.activeIds.clear();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await reloadPromise;
|
||||
} finally {
|
||||
hoisted.activeEmbeddedRunCount.value = 0;
|
||||
embeddedRunMock.activeIds.clear();
|
||||
await vi.advanceTimersByTimeAsync(500).catch(() => {});
|
||||
vi.useRealTimers();
|
||||
await reloadPromise?.catch(() => {});
|
||||
}
|
||||
|
||||
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("discord");
|
||||
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("discord");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the configured timeout when active work does not drain before channel reload", async () => {
|
||||
await withNonMinimalGatewayServer(async () => {
|
||||
const onHotReload = hoisted.getOnHotReload();
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
||||
|
||||
describe("resolveGatewayScopedTools", () => {
|
||||
beforeAll(() => {
|
||||
resolveGatewayScopedTools({
|
||||
cfg: { tools: { profile: "minimal" } } as OpenClawConfig,
|
||||
sessionKey: "agent:main:telegram:group:-100123",
|
||||
messageProvider: "telegram",
|
||||
inboundEventKind: "room_event",
|
||||
surface: "loopback",
|
||||
});
|
||||
});
|
||||
|
||||
it("force-allows the message tool for room-event loopback turns", () => {
|
||||
const result = resolveGatewayScopedTools({
|
||||
cfg: { tools: { profile: "minimal" } } as OpenClawConfig,
|
||||
|
||||
@@ -58,7 +58,10 @@ describe("startGmailWatcher", () => {
|
||||
mocks.spawn.mockImplementation(() => {
|
||||
const child = new EventEmitter();
|
||||
return Object.assign(child, {
|
||||
kill: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
|
||||
return true;
|
||||
}),
|
||||
killed: false,
|
||||
});
|
||||
});
|
||||
@@ -76,12 +79,16 @@ describe("startGmailWatcher", () => {
|
||||
.mockImplementationOnce(async () => await oldWatchStart.promise)
|
||||
.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
|
||||
mocks.spawn.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
const child = new EventEmitter();
|
||||
const mockedChild = Object.assign(child, {
|
||||
kill: vi.fn(() => {
|
||||
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
|
||||
return true;
|
||||
}),
|
||||
killed: false,
|
||||
});
|
||||
spawnedChildren.push(child);
|
||||
return child;
|
||||
spawnedChildren.push(mockedChild);
|
||||
return mockedChild;
|
||||
});
|
||||
|
||||
const staleStart = startGmailWatcher(createGmailConfig(), {
|
||||
@@ -113,8 +120,8 @@ describe("startGmailWatcher", () => {
|
||||
});
|
||||
|
||||
it("aborts watch start and does not spawn gog serve when cancelled in flight", async () => {
|
||||
let cancelled = false;
|
||||
let watchStartSignal: AbortSignal | undefined;
|
||||
const controller = new AbortController();
|
||||
mocks.runCommandWithTimeout.mockImplementation(
|
||||
async (_args, options: { signal?: AbortSignal }) =>
|
||||
await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
|
||||
@@ -128,17 +135,13 @@ describe("startGmailWatcher", () => {
|
||||
);
|
||||
|
||||
const startPromise = startGmailWatcher(createGmailConfig(), {
|
||||
isCancelled: () => cancelled,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(watchStartSignal).toBeDefined();
|
||||
});
|
||||
cancelled = true;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(watchStartSignal?.aborted).toBe(true);
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(watchStartSignal).toBeDefined();
|
||||
controller.abort();
|
||||
expect(watchStartSignal?.aborted).toBe(true);
|
||||
|
||||
await expect(startPromise).resolves.toEqual({
|
||||
started: false,
|
||||
|
||||
@@ -155,6 +155,9 @@ describe("plugin-sdk facade runtime", () => {
|
||||
it("does not fall back to package source surfaces when bundled plugins are disabled", () => {
|
||||
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
__testing.setFacadeActivationCheckRuntimeForTest({
|
||||
resolveRegistryPluginModuleLocation: () => null,
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
__testing.resolveFacadeModuleLocation({
|
||||
|
||||
@@ -159,6 +159,10 @@ function loadFacadeActivationCheckRuntime(): FacadeActivationCheckRuntimeModule
|
||||
throw new Error("Unable to load facade activation check runtime");
|
||||
}
|
||||
|
||||
function setFacadeActivationCheckRuntimeForTest(module: FacadeActivationCheckRuntimeModule): void {
|
||||
facadeActivationCheckRuntimeModule = module;
|
||||
}
|
||||
|
||||
function loadFacadeModuleAtLocationSync<T extends object>(params: {
|
||||
location: FacadeModuleLocation;
|
||||
trackedPluginId: string | (() => string);
|
||||
@@ -252,6 +256,7 @@ export function resetFacadeRuntimeStateForTest(): void {
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
setFacadeActivationCheckRuntimeForTest,
|
||||
loadFacadeModuleAtLocationSync,
|
||||
resolveRegistryPluginModuleLocationFromRegistry: resolveRegistryPluginModuleLocationFromRecords,
|
||||
resolveFacadeModuleLocation,
|
||||
|
||||
@@ -2,11 +2,17 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resetFacadeRuntimeStateForTest } from "./facade-runtime.js";
|
||||
import * as activationCheckRuntime from "./facade-activation-check.runtime.js";
|
||||
import {
|
||||
__testing as facadeRuntimeTesting,
|
||||
resetFacadeRuntimeStateForTest,
|
||||
} from "./facade-runtime.js";
|
||||
import { listQaRunnerCliContributions } from "./qa-runner-runtime.js";
|
||||
|
||||
const ORIGINAL_ENV = {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS,
|
||||
OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH,
|
||||
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
|
||||
OPENCLAW_TEST_FAST: process.env.OPENCLAW_TEST_FAST,
|
||||
} as const;
|
||||
|
||||
@@ -20,6 +26,7 @@ function makeTempDir(prefix: string): string {
|
||||
|
||||
function resetQaRunnerRuntimeState() {
|
||||
resetFacadeRuntimeStateForTest();
|
||||
facadeRuntimeTesting.setFacadeActivationCheckRuntimeForTest(activationCheckRuntime);
|
||||
}
|
||||
|
||||
describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => {
|
||||
@@ -56,6 +63,7 @@ describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => {
|
||||
"utf8",
|
||||
);
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -104,9 +112,7 @@ describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const module = await import("./qa-runner-runtime.js");
|
||||
|
||||
const contributions = module.listQaRunnerCliContributions();
|
||||
const contributions = listQaRunnerCliContributions();
|
||||
const contribution = contributions[0];
|
||||
expect(contribution?.status).toBe("available");
|
||||
if (!contribution || contribution.status !== "available") {
|
||||
|
||||
@@ -334,7 +334,11 @@ describe("plugin contract boundary invariants", () => {
|
||||
it("keeps core tests off bundled extension deep imports", () => {
|
||||
const files = listTsFiles("src", { testOnly: true });
|
||||
const offenders = files.filter((file) => {
|
||||
return collectBundledExtensionImports(readRepoSource(file)).some(
|
||||
const source = readRepoSource(file);
|
||||
if (!source.includes("extensions/")) {
|
||||
return false;
|
||||
}
|
||||
return collectBundledExtensionImports(source).some(
|
||||
(specifier) => !isAllowedBundledExtensionImport(specifier),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { collectDeprecatedInternalConfigApiViolations } from "../../../scripts/lib/deprecated-config-api-guard.mjs";
|
||||
|
||||
describe("deprecated internal config API guardrails", () => {
|
||||
let violations: ReturnType<typeof collectDeprecatedInternalConfigApiViolations>;
|
||||
|
||||
beforeAll(() => {
|
||||
violations = collectDeprecatedInternalConfigApiViolations();
|
||||
});
|
||||
|
||||
it("keeps production code off deprecated config load/write seams", () => {
|
||||
expect(collectDeprecatedInternalConfigApiViolations()).toStrictEqual([]);
|
||||
expect(violations).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1421,8 +1421,8 @@ describe("plugin-sdk subpath exports", () => {
|
||||
});
|
||||
|
||||
it("keeps the Zalouser command-auth compatibility facade importable", async () => {
|
||||
const zalouserSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/zalouser");
|
||||
const commandAuthSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/command-auth");
|
||||
const zalouserSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/zalouser");
|
||||
|
||||
expect(zalouserSdk.resolveSenderCommandAuthorization).toBe(
|
||||
commandAuthSdk.resolveSenderCommandAuthorization,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js";
|
||||
import { listGitTrackedFiles, toRepoRelativePath } from "../../test-utils/repo-files.js";
|
||||
import { loadPluginManifestRegistry } from "../manifest-registry.js";
|
||||
@@ -46,6 +46,13 @@ const EXPECTED_SENTINEL_SHARED_FAMILY_ASSIGNMENTS: Record<string, ExpectedShared
|
||||
toolCompatFamilies: ["openai"],
|
||||
},
|
||||
};
|
||||
let bundledPluginRootsCache:
|
||||
| Array<{
|
||||
pluginId: string;
|
||||
rootDir: string;
|
||||
}>
|
||||
| undefined;
|
||||
const filesByDirCache = new Map<string, string[]>();
|
||||
|
||||
function toRepoRelative(path: string): string {
|
||||
return toRepoRelativePath(REPO_ROOT, path);
|
||||
@@ -72,8 +79,13 @@ function listGitFiles(dir: string): string[] | null {
|
||||
}
|
||||
|
||||
function listFiles(dir: string): string[] {
|
||||
const cached = filesByDirCache.get(dir);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const gitFiles = listGitFiles(dir);
|
||||
if (gitFiles) {
|
||||
filesByDirCache.set(dir, gitFiles);
|
||||
return gitFiles;
|
||||
}
|
||||
|
||||
@@ -91,17 +103,22 @@ function listFiles(dir: string): string[] {
|
||||
files.push(entryPath);
|
||||
}
|
||||
|
||||
filesByDirCache.set(dir, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
function listBundledPluginRoots() {
|
||||
return loadPluginManifestRegistry({})
|
||||
if (bundledPluginRootsCache) {
|
||||
return bundledPluginRootsCache;
|
||||
}
|
||||
bundledPluginRootsCache = loadPluginManifestRegistry({})
|
||||
.plugins.filter((plugin) => plugin.origin === "bundled")
|
||||
.map((plugin) => ({
|
||||
pluginId: plugin.id,
|
||||
rootDir: resolveBundledPluginSourceRoot(plugin.rootDir, plugin.workspaceDir),
|
||||
}))
|
||||
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||
return bundledPluginRootsCache;
|
||||
}
|
||||
|
||||
function resolveBundledPluginSourceRoot(rootDir: string, workspaceDir?: string): string {
|
||||
@@ -211,8 +228,27 @@ function collectSharedFamilyAssignments(): Map<string, ExpectedSharedFamilyContr
|
||||
}
|
||||
|
||||
describe("provider family plugin-boundary inventory", () => {
|
||||
let bundledRoots: ReturnType<typeof listBundledPluginRoots>;
|
||||
let sharedFamilyProviders: ReturnType<typeof collectSharedFamilyProviders>;
|
||||
let providerBoundaryTests: ReturnType<typeof collectProviderBoundaryTests>;
|
||||
let actualAssignments: Record<string, ExpectedSharedFamilyContract>;
|
||||
|
||||
beforeAll(() => {
|
||||
bundledRoots = listBundledPluginRoots();
|
||||
for (const plugin of bundledRoots) {
|
||||
listFiles(plugin.rootDir);
|
||||
}
|
||||
sharedFamilyProviders = collectSharedFamilyProviders();
|
||||
providerBoundaryTests = collectProviderBoundaryTests();
|
||||
actualAssignments = Object.fromEntries(
|
||||
[...collectSharedFamilyAssignments().entries()].toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists bundled plugin files from git without walking plugin roots", () => {
|
||||
const bundledRoots = listBundledPluginRoots();
|
||||
filesByDirCache.clear();
|
||||
expectNoReaddirSyncDuring(() => {
|
||||
const files = bundledRoots.flatMap((plugin) => listFiles(plugin.rootDir));
|
||||
|
||||
@@ -222,9 +258,6 @@ describe("provider family plugin-boundary inventory", () => {
|
||||
});
|
||||
|
||||
it("keeps shared-family provider hooks covered by at least one plugin-boundary test", () => {
|
||||
const sharedFamilyProviders = collectSharedFamilyProviders();
|
||||
const providerBoundaryTests = collectProviderBoundaryTests();
|
||||
|
||||
const missing = [...sharedFamilyProviders.entries()]
|
||||
.filter(([pluginId]) => !providerBoundaryTests.has(pluginId))
|
||||
.map(([pluginId, inventory]) => {
|
||||
@@ -237,12 +270,6 @@ describe("provider family plugin-boundary inventory", () => {
|
||||
});
|
||||
|
||||
it("keeps sentinel shared-family assignments wired through bundled provider sources", () => {
|
||||
const actualAssignments = Object.fromEntries(
|
||||
[...collectSharedFamilyAssignments().entries()].toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [pluginId, expected] of Object.entries(
|
||||
EXPECTED_SENTINEL_SHARED_FAMILY_ASSIGNMENTS,
|
||||
)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { assertNoImportTimeSideEffects } from "../../plugin-sdk/test-helpers/import-side-effects.js";
|
||||
|
||||
@@ -18,6 +19,20 @@ const CHANNEL_REGISTRY_WHY =
|
||||
"it boots active channel metadata on hot runtime/config import paths and turns cheap module evaluation into plugin registry work.";
|
||||
const CHANNEL_REGISTRY_FIX =
|
||||
"keep the seam behind a lazy getter/runtime boundary so import stays cold and the first real lookup loads once.";
|
||||
const HOT_RUNTIME_IMPORT_CASES = [
|
||||
["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")],
|
||||
[
|
||||
"src/plugin-sdk/approval-handler-adapter-runtime.ts",
|
||||
() => import("../../plugin-sdk/approval-handler-adapter-runtime.js"),
|
||||
],
|
||||
[
|
||||
"src/plugin-sdk/approval-gateway-runtime.ts",
|
||||
() => import("../../plugin-sdk/approval-gateway-runtime.js"),
|
||||
],
|
||||
["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")],
|
||||
["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")],
|
||||
["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")],
|
||||
] as const;
|
||||
|
||||
function mockChannelRegistry() {
|
||||
vi.doMock("../../channels/plugins/registry.js", async () => {
|
||||
@@ -78,26 +93,21 @@ describe("runtime import side-effect contracts", () => {
|
||||
expect(listChannelPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps hot runtime imports cold", async () => {
|
||||
it.each(HOT_RUNTIME_IMPORT_CASES)("keeps %s cold", async (moduleId, importModule) => {
|
||||
mockChannelRegistry();
|
||||
for (const [moduleId, importModule] of [
|
||||
["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")],
|
||||
["src/plugins/runtime/runtime-channel.ts", () => import("../runtime/runtime-channel.js")],
|
||||
[
|
||||
"src/plugin-sdk/approval-handler-adapter-runtime.ts",
|
||||
() => import("../../plugin-sdk/approval-handler-adapter-runtime.js"),
|
||||
],
|
||||
[
|
||||
"src/plugin-sdk/approval-gateway-runtime.ts",
|
||||
() => import("../../plugin-sdk/approval-gateway-runtime.js"),
|
||||
],
|
||||
["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")],
|
||||
["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")],
|
||||
["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")],
|
||||
["src/plugins/runtime/index.ts", () => import("../runtime/index.js")],
|
||||
] as const) {
|
||||
await importModule();
|
||||
expectNoChannelRegistryDuringImport(moduleId);
|
||||
}
|
||||
await importModule();
|
||||
expectNoChannelRegistryDuringImport(moduleId);
|
||||
});
|
||||
|
||||
it("keeps runtime-channel off direct channel registry imports", () => {
|
||||
const source = fs.readFileSync("src/plugins/runtime/runtime-channel.ts", "utf8");
|
||||
expect(source).not.toContain("../../channels/plugins/registry");
|
||||
expect(source).not.toContain("../channels/plugins/registry");
|
||||
});
|
||||
|
||||
it("keeps runtime index off direct channel registry imports", () => {
|
||||
const source = fs.readFileSync("src/plugins/runtime/index.ts", "utf8");
|
||||
expect(source).not.toContain("../../channels/plugins/registry");
|
||||
expect(source).not.toContain("../channels/plugins/registry");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,15 @@ import {
|
||||
} from "../../config/runtime-snapshot.js";
|
||||
import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
|
||||
import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "../../infra/net/undici-runtime.js";
|
||||
import * as activationCheck from "../../plugin-sdk/facade-activation-check.runtime.js";
|
||||
import * as facadeRuntime from "../../plugin-sdk/facade-runtime.js";
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: ({ config }: { config?: unknown }) => ({
|
||||
config: config ?? {},
|
||||
autoEnabledReasons: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
@@ -66,9 +75,8 @@ function createInstalledRuntimePluginDir(
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
facadeRuntime.resetFacadeRuntimeStateForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../../config/plugin-auto-enable.js");
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
if (originalBundledPluginsDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
@@ -93,7 +101,10 @@ afterEach(() => {
|
||||
describe("shared runtime seam contracts", () => {
|
||||
it("allows activated runtime facades when the resolved plugin root matches an installed-style manifest record", async () => {
|
||||
const pluginId = "line-contract-fixture";
|
||||
const { bundledDir, stateDir } = createInstalledRuntimePluginDir(pluginId, "line-ok");
|
||||
const { bundledDir, stateDir, pluginRoot } = createInstalledRuntimePluginDir(
|
||||
pluginId,
|
||||
"line-ok",
|
||||
);
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
setRuntimeConfigSnapshot({
|
||||
@@ -105,27 +116,25 @@ describe("shared runtime seam contracts", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.resetModules();
|
||||
vi.doMock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: ({ config }: { config?: unknown }) => ({
|
||||
config: config ?? {},
|
||||
autoEnabledReasons: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const facadeRuntime = await import("../../plugin-sdk/facade-runtime.js");
|
||||
facadeRuntime.resetFacadeRuntimeStateForTest();
|
||||
|
||||
const location = {
|
||||
modulePath: path.join(pluginRoot, "runtime-api.js"),
|
||||
boundaryRoot: pluginRoot,
|
||||
};
|
||||
expect(
|
||||
facadeRuntime.canLoadActivatedBundledPluginPublicSurface({
|
||||
activationCheck.resolveBundledPluginPublicSurfaceAccess({
|
||||
dirName: pluginId,
|
||||
artifactBasename: "runtime-api.js",
|
||||
}),
|
||||
location,
|
||||
sourceExtensionsRoot: bundledDir,
|
||||
resolutionKey: `test:${pluginId}`,
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
facadeRuntime.loadActivatedBundledPluginPublicSurfaceModuleSync<{ marker: string }>({
|
||||
dirName: pluginId,
|
||||
artifactBasename: "runtime-api.js",
|
||||
facadeRuntime.__testing.loadFacadeModuleAtLocationSync<{ marker: string }>({
|
||||
location,
|
||||
trackedPluginId: pluginId,
|
||||
}).marker,
|
||||
).toBe("line-ok");
|
||||
expect(facadeRuntime.listImportedBundledPluginFacadeIds()).toEqual([pluginId]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c
|
||||
import {
|
||||
attachmentProbeFs,
|
||||
resolveAttachmentDelivery,
|
||||
resolveSessionAttachmentThreadId,
|
||||
sendPluginSessionAttachment,
|
||||
} from "../host-hook-attachments.js";
|
||||
import { clearPluginLoaderCache } from "../loader.js";
|
||||
@@ -308,30 +309,28 @@ describe("plugin session attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the thread encoded in a threaded session key over stale stored routes", async () => {
|
||||
await withSessionStore(async ({ storePath, filePath }) => {
|
||||
const baseKey = "agent:main:telegram:group:12345";
|
||||
const threadKey = `${baseKey}:thread:99`;
|
||||
await writeSessionEntry(
|
||||
storePath,
|
||||
{
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "group:12345",
|
||||
threadId: 42,
|
||||
},
|
||||
},
|
||||
threadKey,
|
||||
);
|
||||
mockSuccessfulAttachmentDelivery();
|
||||
|
||||
const result = await sendBundledSessionAttachment({
|
||||
sessionKey: threadKey,
|
||||
files: [{ path: filePath }],
|
||||
});
|
||||
expectTelegramAttachmentResult(result, 1);
|
||||
expect(requireFirstSendMessageParams().threadId).toBe("99");
|
||||
});
|
||||
it("prefers the thread encoded in a threaded session key over stale stored routes", () => {
|
||||
expect(
|
||||
resolveSessionAttachmentThreadId({
|
||||
deliveryThreadId: 42,
|
||||
fallbackThreadId: "99",
|
||||
}),
|
||||
).toBe("99");
|
||||
expect(
|
||||
resolveSessionAttachmentThreadId({
|
||||
deliveryThreadId: 42,
|
||||
explicitThreadId: 7,
|
||||
fallbackThreadId: "99",
|
||||
}),
|
||||
).toBe(7);
|
||||
expect(
|
||||
resolveSessionAttachmentThreadId({
|
||||
deliveryThreadId: 42,
|
||||
explicitThreadId: 7,
|
||||
fallbackThreadId: "99",
|
||||
hintThreadTs: "1700000000.000100",
|
||||
}),
|
||||
).toBe("1700000000.000100");
|
||||
});
|
||||
|
||||
it("reports attachment delivery as failed when no delivery result is returned", async () => {
|
||||
|
||||
@@ -214,6 +214,20 @@ function normalizeOptionalThreadId(value: unknown): string | number | undefined
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
export function resolveSessionAttachmentThreadId(params: {
|
||||
deliveryThreadId?: unknown;
|
||||
explicitThreadId?: unknown;
|
||||
fallbackThreadId?: unknown;
|
||||
hintThreadTs?: string;
|
||||
}): string | number | undefined {
|
||||
return (
|
||||
params.hintThreadTs ??
|
||||
normalizeOptionalThreadId(params.explicitThreadId) ??
|
||||
normalizeOptionalThreadId(params.fallbackThreadId) ??
|
||||
normalizeOptionalThreadId(params.deliveryThreadId)
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendPluginSessionAttachment(
|
||||
params: PluginSessionAttachmentParams & { config?: OpenClawConfig; origin?: PluginOrigin },
|
||||
): Promise<PluginSessionAttachmentResult> {
|
||||
@@ -258,9 +272,6 @@ export async function sendPluginSessionAttachment(
|
||||
};
|
||||
}
|
||||
const rawText = normalizeOptionalString(params.text) ?? "";
|
||||
const explicitThreadId = normalizeOptionalThreadId(params.threadId);
|
||||
const deliveryThreadId = normalizeOptionalThreadId(deliveryContext.threadId);
|
||||
const fallbackThreadId = normalizeOptionalThreadId(threadId);
|
||||
const resolvedDelivery = resolveAttachmentDelivery({
|
||||
channel: deliveryContext.channel,
|
||||
captionFormat: params.captionFormat,
|
||||
@@ -274,8 +285,12 @@ export async function sendPluginSessionAttachment(
|
||||
if (!Array.isArray(validated)) {
|
||||
return { ok: false, error: validated.error };
|
||||
}
|
||||
const resolvedThreadId =
|
||||
resolvedDelivery.threadTs ?? explicitThreadId ?? fallbackThreadId ?? deliveryThreadId;
|
||||
const resolvedThreadId = resolveSessionAttachmentThreadId({
|
||||
deliveryThreadId: deliveryContext.threadId,
|
||||
explicitThreadId: params.threadId,
|
||||
fallbackThreadId: threadId,
|
||||
hintThreadTs: resolvedDelivery.threadTs,
|
||||
});
|
||||
let result: Awaited<ReturnType<SendMessage>>;
|
||||
try {
|
||||
const sendMessage = await loadSendMessage();
|
||||
|
||||
@@ -486,14 +486,6 @@ describe("installPluginFromNpmSpec", () => {
|
||||
await expect(fs.promises.readFile(stagedArchivePath, "utf8")).resolves.toBe(
|
||||
"fixture pack contents",
|
||||
);
|
||||
|
||||
fs.unlinkSync(archivePath);
|
||||
const unrelatedResult = await installPluginFromNpmSpec({
|
||||
spec: "@openclaw/voice-call@0.0.1",
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
expect(unrelatedResult.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects npm pack archive metadata with traversal package names", async () => {
|
||||
@@ -1548,7 +1540,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("handles prerelease npm specs correctly", async () => {
|
||||
it("rejects implicit prerelease npm specs with beta guidance", async () => {
|
||||
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
|
||||
name: "@openclaw/voice-call",
|
||||
version: "0.0.2-beta.1",
|
||||
@@ -1565,8 +1557,9 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(rejected.error).toContain("prerelease version 0.0.2-beta.1");
|
||||
expect(rejected.error).toContain('"@openclaw/voice-call@beta"');
|
||||
}
|
||||
});
|
||||
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
it("falls back to the latest stable version for official prerelease packages", async () => {
|
||||
const officialNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const warnings: string[] = [];
|
||||
mockNpmViewAndInstallMany([
|
||||
@@ -1604,8 +1597,9 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(officialFallback.npmResolution?.version).toBe("0.0.1");
|
||||
expect(officialFallback.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
|
||||
expect(warnings.join("\n")).toContain("falling back to stable @openclaw/voice-call@0.0.1");
|
||||
});
|
||||
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
it("keeps stable correction versions when resolving official npm packages", async () => {
|
||||
const correctionNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const correctionWarnings: string[] = [];
|
||||
mockNpmViewAndInstallMany([
|
||||
@@ -1637,8 +1631,9 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(stableCorrection.npmResolution?.version).toBe("2026.5.3-1");
|
||||
expect(stableCorrection.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@2026.5.3-1");
|
||||
expect(correctionWarnings).toStrictEqual([]);
|
||||
});
|
||||
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
it("uses the newest prerelease when an official package has no stable versions", async () => {
|
||||
const prereleaseOnlyNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const prereleaseOnlyWarnings: string[] = [];
|
||||
mockNpmViewAndInstallMany([
|
||||
@@ -1680,8 +1675,9 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(prereleaseOnlyWarnings.join("\n")).toContain(
|
||||
"using newest prerelease @openclaw/voice-call@0.0.2-beta.1",
|
||||
);
|
||||
});
|
||||
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
it("accepts explicit prerelease npm dist-tags", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
mockNpmViewAndInstall({
|
||||
spec: "@openclaw/voice-call@beta",
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs, { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, test } from "vitest";
|
||||
import { isScannable, scanDirectoryWithSummary } from "../security/skill-scanner.js";
|
||||
import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js";
|
||||
import { listGitTrackedFiles, toRepoPath, toRepoRelativePath } from "../test-utils/repo-files.js";
|
||||
@@ -22,8 +22,6 @@ type PublishablePluginPackage = {
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PACKAGE_SCAN_CONCURRENCY = 12;
|
||||
|
||||
const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS = new Set([
|
||||
"@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts",
|
||||
"@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.mjs",
|
||||
@@ -45,14 +43,6 @@ const OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDINGS = new Set([
|
||||
"@openclaw/voice-call:dangerous-exec:dist/runtime-entry-<hash>.js",
|
||||
]);
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function parseNpmPackFiles(raw: string, packageName: string): string[] {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
@@ -116,7 +106,6 @@ function stageScannerRelevantPackedFiles(
|
||||
packedFiles: readonly string[],
|
||||
): string {
|
||||
const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-"));
|
||||
tempDirs.push(stageDir);
|
||||
|
||||
for (const packedPath of packedFiles) {
|
||||
if (!isScannerWalkedPackedPath(packedPath)) {
|
||||
@@ -212,27 +201,6 @@ function collectPublishablePluginPackages(): PublishablePluginPackage[] {
|
||||
.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, U>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
fn: (item: T) => Promise<U>,
|
||||
): Promise<U[]> {
|
||||
const results: U[] = [];
|
||||
results.length = items.length;
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(concurrency, items.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await fn(items[index]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): Promise<{
|
||||
reviewedCriticalFindings: string[];
|
||||
expectedReviewedCriticalFindings: string[];
|
||||
@@ -251,10 +219,15 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P
|
||||
}
|
||||
}
|
||||
const stageDir = stageScannerRelevantPackedFiles(plugin.packageDir, packedFiles);
|
||||
const summary = await scanDirectoryWithSummary(stageDir, {
|
||||
excludeTestFiles: true,
|
||||
maxFiles: 10_000,
|
||||
});
|
||||
let summary: Awaited<ReturnType<typeof scanDirectoryWithSummary>>;
|
||||
try {
|
||||
summary = await scanDirectoryWithSummary(stageDir, {
|
||||
excludeTestFiles: true,
|
||||
maxFiles: 10_000,
|
||||
});
|
||||
} finally {
|
||||
rmSync(stageDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const finding of summary.findings) {
|
||||
if (finding.severity !== "critical") {
|
||||
@@ -280,6 +253,23 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P
|
||||
}
|
||||
|
||||
describe("publishable plugin npm package install security scan", () => {
|
||||
const publishablePluginPackages = collectPublishablePluginPackages();
|
||||
|
||||
it("covers every package with required reviewed critical findings", () => {
|
||||
const publishablePackageNames = new Set(
|
||||
publishablePluginPackages.map((plugin) => plugin.packageName),
|
||||
);
|
||||
const missingPackages = [
|
||||
...new Set(
|
||||
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].map((key) =>
|
||||
key.slice(0, key.indexOf(":")),
|
||||
),
|
||||
),
|
||||
].filter((packageName) => !publishablePackageNames.has(packageName));
|
||||
|
||||
expect(missingPackages.toSorted()).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("lists publishable plugin packages without scanning extension directories in-process", () => {
|
||||
expectNoReaddirSyncDuring(() => {
|
||||
const packages = collectPublishablePluginPackages();
|
||||
@@ -291,31 +281,23 @@ describe("publishable plugin npm package install security scan", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps npm-published plugin files clear of unexpected critical hits", async () => {
|
||||
const unexpectedCriticalFindings: string[] = [];
|
||||
const reviewedCriticalFindings = new Set<string>();
|
||||
const expectedReviewedCriticalFindings = new Set(
|
||||
REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS,
|
||||
);
|
||||
|
||||
const packageResults = await mapWithConcurrency(
|
||||
collectPublishablePluginPackages(),
|
||||
PACKAGE_SCAN_CONCURRENCY,
|
||||
scanPublishablePluginPackage,
|
||||
);
|
||||
for (const result of packageResults) {
|
||||
test.each(publishablePluginPackages)(
|
||||
"keeps $packageName files clear of unexpected critical hits",
|
||||
async (plugin) => {
|
||||
const result = await scanPublishablePluginPackage(plugin);
|
||||
const expectedReviewedCriticalFindings = new Set(
|
||||
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].filter((key) =>
|
||||
key.startsWith(`${plugin.packageName}:`),
|
||||
),
|
||||
);
|
||||
for (const key of result.expectedReviewedCriticalFindings) {
|
||||
expectedReviewedCriticalFindings.add(key);
|
||||
}
|
||||
for (const key of result.reviewedCriticalFindings) {
|
||||
reviewedCriticalFindings.add(key);
|
||||
}
|
||||
unexpectedCriticalFindings.push(...result.unexpectedCriticalFindings);
|
||||
}
|
||||
|
||||
expect(unexpectedCriticalFindings.toSorted()).toStrictEqual([]);
|
||||
expect([...reviewedCriticalFindings].toSorted()).toEqual(
|
||||
[...expectedReviewedCriticalFindings].toSorted(),
|
||||
);
|
||||
});
|
||||
expect(result.unexpectedCriticalFindings.toSorted()).toStrictEqual([]);
|
||||
expect(result.reviewedCriticalFindings.toSorted()).toEqual(
|
||||
[...expectedReviewedCriticalFindings].toSorted(),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,8 @@ describe("resolvePluginRuntimeLoadContext", () => {
|
||||
await import("./load-context.js"));
|
||||
loadConfigMock.mockReset();
|
||||
applyPluginAutoEnableMock.mockReset();
|
||||
getCurrentPluginMetadataSnapshotMock.mockReset();
|
||||
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
|
||||
loadPluginMetadataSnapshotMock.mockClear();
|
||||
getCurrentPluginMetadataSnapshotMock.mockClear();
|
||||
setCurrentPluginMetadataSnapshotMock.mockClear();
|
||||
|
||||
@@ -80,6 +80,8 @@ describe("deriveSessionChatTypeFromKey", () => {
|
||||
{ key: "agent:main:discord:direct:user1", expected: "direct" },
|
||||
{ key: "agent:main:telegram:group:g1", expected: "group" },
|
||||
{ key: "agent:main:discord:channel:c1", expected: "channel" },
|
||||
{ key: "agent:main:discord:guild-123:channel-456", expected: "channel" },
|
||||
{ key: "agent:main:whatsapp:123@g.us", expected: "group" },
|
||||
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
|
||||
{ key: "telegram:dm:123456", expected: "direct" },
|
||||
{ key: "agent:main:main", expected: "unknown" },
|
||||
|
||||
@@ -939,7 +939,6 @@ describe("test-projects args", () => {
|
||||
config: "test/vitest/vitest.extension-discord.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: [
|
||||
"extensions/discord/src/api-barrel.test.ts",
|
||||
"extensions/discord/src/channel-actions.contract.test.ts",
|
||||
"extensions/discord/src/channel.message-adapter.test.ts",
|
||||
"extensions/discord/src/channel.test.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type {
|
||||
@@ -747,104 +747,131 @@ async function prepareAuthCoverageSnapshot(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function expectOpenClawCoverageEntriesResolved(
|
||||
async function expectOpenClawCoverageBatchResolved(
|
||||
label: string,
|
||||
entries: readonly SecretRegistryEntry[],
|
||||
batch: readonly SecretRegistryEntry[],
|
||||
): Promise<void> {
|
||||
for (const batch of buildCoverageBatches(entries)) {
|
||||
logCoverageBatch(label, batch);
|
||||
const config = {} as OpenClawConfig;
|
||||
const env: Record<string, string> = {};
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const envId = `OPENCLAW_SECRET_TARGET_${entry.id}`;
|
||||
const runtimeEnvId = resolveCoverageEnvId(entry, envId);
|
||||
const expectedValue = `resolved-${entry.id}`;
|
||||
const wildcardToken = resolveCoverageWildcardToken(index);
|
||||
env[runtimeEnvId] = expectedValue;
|
||||
applyConfigForOpenClawTarget(config, entry, envId, wildcardToken);
|
||||
}
|
||||
const snapshot = await prepareConfigCoverageSnapshot({
|
||||
config,
|
||||
env,
|
||||
loadablePluginOrigins: resolveCoverageLoadablePluginOrigins(batch),
|
||||
includeRuntimeWebTools: batchNeedsRuntimeWebTools(batch),
|
||||
skipConfigCollectors: batchUsesRuntimeWebToolsOnly(batch),
|
||||
});
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const resolved = getPath(
|
||||
snapshot.config,
|
||||
resolveCoverageResolvedSegments(entry, resolveCoverageWildcardToken(index)),
|
||||
);
|
||||
expect(resolved).toBe(`resolved-${entry.id}`);
|
||||
}
|
||||
logCoverageBatch(label, batch);
|
||||
const config = {} as OpenClawConfig;
|
||||
const env: Record<string, string> = {};
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const envId = `OPENCLAW_SECRET_TARGET_${entry.id}`;
|
||||
const runtimeEnvId = resolveCoverageEnvId(entry, envId);
|
||||
const expectedValue = `resolved-${entry.id}`;
|
||||
const wildcardToken = resolveCoverageWildcardToken(index);
|
||||
env[runtimeEnvId] = expectedValue;
|
||||
applyConfigForOpenClawTarget(config, entry, envId, wildcardToken);
|
||||
}
|
||||
const snapshot = await prepareConfigCoverageSnapshot({
|
||||
config,
|
||||
env,
|
||||
loadablePluginOrigins: resolveCoverageLoadablePluginOrigins(batch),
|
||||
includeRuntimeWebTools: batchNeedsRuntimeWebTools(batch),
|
||||
skipConfigCollectors: batchUsesRuntimeWebToolsOnly(batch),
|
||||
});
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const resolved = getPath(
|
||||
snapshot.config,
|
||||
resolveCoverageResolvedSegments(entry, resolveCoverageWildcardToken(index)),
|
||||
);
|
||||
expect(resolved).toBe(`resolved-${entry.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const OPENCLAW_CORE_COVERAGE_BATCHES = buildCoverageBatches(
|
||||
collectOpenClawCoverageEntries({ includePluginEntries: false }),
|
||||
);
|
||||
const OPENCLAW_PLUGIN_COVERAGE_BATCHES = buildCoverageBatches(
|
||||
collectOpenClawCoverageEntries({ includePluginEntries: true }),
|
||||
);
|
||||
const AUTH_PROFILE_COVERAGE_BATCHES = buildCoverageBatches(
|
||||
COVERAGE_REGISTRY_ENTRIES.filter((entry) => entry.configFile === "auth-profiles.json"),
|
||||
);
|
||||
|
||||
function toCoverageBatchCase(batch: SecretRegistryEntry[]) {
|
||||
const firstEntry = batch[0];
|
||||
return {
|
||||
name:
|
||||
batch.length === 1 && firstEntry
|
||||
? firstEntry.id
|
||||
: firstEntry
|
||||
? `${resolveCoverageBatchKey(firstEntry)} (${batch.length})`
|
||||
: "empty",
|
||||
batch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("secrets runtime target coverage", () => {
|
||||
beforeAll(async () => {
|
||||
const [sharedRuntime, resolver] = await Promise.all([
|
||||
import("./runtime-shared.js"),
|
||||
import("./resolve.js"),
|
||||
]);
|
||||
const [sharedRuntime, resolver, configCollectors, authCollectors, runtimeWebTools] =
|
||||
await Promise.all([
|
||||
import("./runtime-shared.js"),
|
||||
import("./resolve.js"),
|
||||
import("./runtime-config-collectors.js"),
|
||||
import("./runtime-auth-collectors.js"),
|
||||
import("./runtime-web-tools.js"),
|
||||
]);
|
||||
({ applyResolvedAssignments, createResolverContext } = sharedRuntime);
|
||||
({ resolveSecretRefValues } = resolver);
|
||||
({ collectConfigAssignments } = configCollectors);
|
||||
({ collectAuthStoreAssignments } = authCollectors);
|
||||
({ resolveRuntimeWebTools } = runtimeWebTools);
|
||||
});
|
||||
|
||||
it(
|
||||
"handles every core and channel openclaw.json registry target when configured as active",
|
||||
async () => {
|
||||
await expectOpenClawCoverageEntriesResolved(
|
||||
"openclaw.json core",
|
||||
collectOpenClawCoverageEntries({ includePluginEntries: false }),
|
||||
);
|
||||
},
|
||||
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"handles every plugin openclaw.json registry target when configured as active",
|
||||
async () => {
|
||||
await expectOpenClawCoverageEntriesResolved(
|
||||
"openclaw.json plugins",
|
||||
collectOpenClawCoverageEntries({ includePluginEntries: true }),
|
||||
);
|
||||
},
|
||||
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("handles every auth-profiles registry target", async () => {
|
||||
const entries = COVERAGE_REGISTRY_ENTRIES.filter(
|
||||
(entry) => entry.configFile === "auth-profiles.json",
|
||||
describe("openclaw.json core and channel registry targets", () => {
|
||||
test.each(OPENCLAW_CORE_COVERAGE_BATCHES.map(toCoverageBatchCase))(
|
||||
"handles $name",
|
||||
async ({ batch }) => {
|
||||
await expectOpenClawCoverageBatchResolved("openclaw.json core", batch);
|
||||
},
|
||||
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("openclaw.json plugin registry targets", () => {
|
||||
test.each(OPENCLAW_PLUGIN_COVERAGE_BATCHES.map(toCoverageBatchCase))(
|
||||
"handles $name",
|
||||
async ({ batch }) => {
|
||||
await expectOpenClawCoverageBatchResolved("openclaw.json plugins", batch);
|
||||
},
|
||||
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("auth-profiles registry targets", () => {
|
||||
test.each(AUTH_PROFILE_COVERAGE_BATCHES.map(toCoverageBatchCase))(
|
||||
"handles $name",
|
||||
async ({ batch }) => {
|
||||
logCoverageBatch("auth-profiles.json", batch);
|
||||
const env: Record<string, string> = {};
|
||||
const authStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const envId = `OPENCLAW_AUTH_SECRET_TARGET_${entry.id}`;
|
||||
env[envId] = `resolved-${entry.id}`;
|
||||
applyAuthStoreTarget(authStore, entry, envId, resolveCoverageWildcardToken(index));
|
||||
}
|
||||
const snapshot = await prepareAuthCoverageSnapshot({
|
||||
config: {} as OpenClawConfig,
|
||||
env,
|
||||
agentDirs: ["/tmp/openclaw-agent-main"],
|
||||
loadAuthStore: () => authStore,
|
||||
});
|
||||
const resolvedStore = snapshot.authStores[0]?.store;
|
||||
if (!resolvedStore) {
|
||||
throw new Error("expected resolved auth store snapshot");
|
||||
}
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const resolved = getPath(
|
||||
resolvedStore,
|
||||
toConcretePathSegments(entry.pathPattern, resolveCoverageWildcardToken(index)),
|
||||
);
|
||||
expect(resolved).toBe(`resolved-${entry.id}`);
|
||||
}
|
||||
},
|
||||
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
|
||||
);
|
||||
for (const batch of buildCoverageBatches(entries)) {
|
||||
logCoverageBatch("auth-profiles.json", batch);
|
||||
const env: Record<string, string> = {};
|
||||
const authStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const envId = `OPENCLAW_AUTH_SECRET_TARGET_${entry.id}`;
|
||||
env[envId] = `resolved-${entry.id}`;
|
||||
applyAuthStoreTarget(authStore, entry, envId, resolveCoverageWildcardToken(index));
|
||||
}
|
||||
const snapshot = await prepareAuthCoverageSnapshot({
|
||||
config: {} as OpenClawConfig,
|
||||
env,
|
||||
agentDirs: ["/tmp/openclaw-agent-main"],
|
||||
loadAuthStore: () => authStore,
|
||||
});
|
||||
const resolvedStore = snapshot.authStores[0]?.store;
|
||||
if (!resolvedStore) {
|
||||
throw new Error("expected resolved auth store snapshot");
|
||||
}
|
||||
for (const [index, entry] of batch.entries()) {
|
||||
const resolved = getPath(
|
||||
resolvedStore,
|
||||
toConcretePathSegments(entry.pathPattern, resolveCoverageWildcardToken(index)),
|
||||
);
|
||||
expect(resolved).toBe(`resolved-${entry.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,12 @@ function deriveBuiltInLegacySessionChatType(
|
||||
if (/^group:[^:]+$/.test(scopedSessionKey)) {
|
||||
return "group";
|
||||
}
|
||||
if (/^(?:whatsapp:)?[^:]+@g\.us$/.test(scopedSessionKey)) {
|
||||
return "group";
|
||||
}
|
||||
if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scopedSessionKey)) {
|
||||
return "channel";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { bundledPluginFile, bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
detectChangedExtensionIds,
|
||||
listAvailableExtensionIds,
|
||||
@@ -64,6 +64,20 @@ function expectPositiveIntegerMetric(value: number) {
|
||||
}
|
||||
|
||||
describe("scripts/test-extension.mjs", () => {
|
||||
let balancedExtensionShards: ReturnType<typeof createExtensionTestShards>;
|
||||
let balancedExpectedExtensionIds: string[];
|
||||
|
||||
beforeAll(() => {
|
||||
balancedExtensionShards = createExtensionTestShards({
|
||||
cwd: process.cwd(),
|
||||
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
|
||||
});
|
||||
balancedExpectedExtensionIds = listAvailableExtensionIds().filter(
|
||||
(extensionId) =>
|
||||
resolveExtensionTestPlan({ cwd: process.cwd(), targetArg: extensionId }).hasTests,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves split channel extensions onto their own vitest configs", () => {
|
||||
const plan = resolveExtensionTestPlan({ targetArg: "slack", cwd: process.cwd() });
|
||||
|
||||
@@ -470,10 +484,7 @@ describe("scripts/test-extension.mjs", () => {
|
||||
});
|
||||
|
||||
it("balances extension test shards by estimated CI cost", () => {
|
||||
const shards = createExtensionTestShards({
|
||||
cwd: process.cwd(),
|
||||
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
|
||||
});
|
||||
const shards = balancedExtensionShards;
|
||||
|
||||
expect(shards).toHaveLength(DEFAULT_EXTENSION_TEST_SHARD_COUNT);
|
||||
expect(shards.map((shard) => shard.checkName)).toEqual(
|
||||
@@ -482,15 +493,11 @@ describe("scripts/test-extension.mjs", () => {
|
||||
|
||||
const assigned = shards.flatMap((shard) => shard.extensionIds);
|
||||
const uniqueAssigned = [...new Set(assigned)];
|
||||
const expected = listAvailableExtensionIds().filter(
|
||||
(extensionId) =>
|
||||
resolveExtensionTestPlan({ cwd: process.cwd(), targetArg: extensionId }).hasTests,
|
||||
);
|
||||
|
||||
expect(uniqueAssigned.toSorted((left, right) => left.localeCompare(right))).toEqual(
|
||||
expected.toSorted((left, right) => left.localeCompare(right)),
|
||||
balancedExpectedExtensionIds.toSorted((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
expect(assigned).toHaveLength(expected.length);
|
||||
expect(assigned).toHaveLength(balancedExpectedExtensionIds.length);
|
||||
|
||||
const totals = shards.map((shard) => shard.estimatedCost);
|
||||
expect(Math.max(...totals) - Math.min(...totals)).toBeLessThanOrEqual(1);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
parseTestGroupReportArgs,
|
||||
resolveReportArtifactDirs,
|
||||
resolveRunPlans,
|
||||
} from "../../scripts/test-group-report.mjs";
|
||||
|
||||
describe("scripts/test-group-report grouping", () => {
|
||||
@@ -162,6 +163,58 @@ describe("scripts/test-group-report comparison", () => {
|
||||
"Top group regressions",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sharded run labels distinct in comparisons", () => {
|
||||
const comparison = buildGroupedTestComparison({
|
||||
before: {
|
||||
groupBy: "area",
|
||||
totals: { durationMs: 0, fileCount: 0, testCount: 0 },
|
||||
groups: [],
|
||||
configs: [],
|
||||
topFiles: [],
|
||||
runs: [
|
||||
{
|
||||
config: "test/vitest/vitest.gateway-server.config.ts",
|
||||
label: "gateway-server-1",
|
||||
elapsedMs: 100,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.gateway-server.config.ts",
|
||||
label: "gateway-server-2",
|
||||
elapsedMs: 200,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
after: {
|
||||
groupBy: "area",
|
||||
totals: { durationMs: 0, fileCount: 0, testCount: 0 },
|
||||
groups: [],
|
||||
configs: [],
|
||||
topFiles: [],
|
||||
runs: [
|
||||
{
|
||||
config: "test/vitest/vitest.gateway-server.config.ts",
|
||||
label: "gateway-server-1",
|
||||
elapsedMs: 110,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.gateway-server.config.ts",
|
||||
label: "gateway-server-2",
|
||||
elapsedMs: 220,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(comparison.runs.map((run) => run.key).toSorted()).toEqual([
|
||||
"gateway-server-1",
|
||||
"gateway-server-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report arg parsing", () => {
|
||||
@@ -220,6 +273,34 @@ describe("scripts/test-group-report arg parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report run plans", () => {
|
||||
it("preserves full-suite shard file args and unique report labels", () => {
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = "6";
|
||||
try {
|
||||
const plans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"]));
|
||||
const gatewayServerPlans = plans.filter(
|
||||
(plan) => plan.config === "test/vitest/vitest.gateway-server.config.ts",
|
||||
);
|
||||
|
||||
expect(gatewayServerPlans.length).toBeGreaterThan(1);
|
||||
expect(new Set(gatewayServerPlans.map((plan) => plan.label)).size).toBe(
|
||||
gatewayServerPlans.length,
|
||||
);
|
||||
expect(gatewayServerPlans.every((plan) => plan.forwardedArgs.length > 0)).toBe(true);
|
||||
expect(gatewayServerPlans.flatMap((plan) => plan.forwardedArgs)).toContain(
|
||||
"src/gateway/server.node-pairing-authz.test.ts",
|
||||
);
|
||||
} finally {
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report artifact paths", () => {
|
||||
it("keeps raw Vitest reports scoped to the output file stem", () => {
|
||||
expect(resolveReportArtifactDirs(".artifacts/test-perf/baseline-before.json")).toEqual({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import fg from "fast-glob";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS,
|
||||
applyDefaultMultiSpecVitestCachePaths,
|
||||
@@ -101,6 +101,29 @@ async function listFullSuiteTestFileMatches(): Promise<Map<string, string[]>> {
|
||||
return matches;
|
||||
}
|
||||
|
||||
function listNormalFullSuiteTestFiles(): string[] {
|
||||
const e2eNamedIntegrationTests = new Set([
|
||||
"src/gateway/gateway.test.ts",
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
]);
|
||||
return fg
|
||||
.sync(["**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], {
|
||||
cwd: process.cwd(),
|
||||
dot: false,
|
||||
ignore: ["**/.*/**", "**/dist/**", "**/node_modules/**", "**/vendor/**"],
|
||||
})
|
||||
.map(normalizeRepoPath)
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.includes(".live.test.") &&
|
||||
!file.includes(".e2e.test.") &&
|
||||
!file.startsWith("test/fixtures/") &&
|
||||
!e2eNamedIntegrationTests.has(file),
|
||||
)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
describe("scripts/test-projects changed-target routing", () => {
|
||||
it("maps changed source files into scoped lane targets", () => {
|
||||
expect(
|
||||
@@ -944,6 +967,40 @@ describe("scripts/test-projects local heavy-check lock", () => {
|
||||
});
|
||||
|
||||
describe("scripts/test-projects full-suite sharding", () => {
|
||||
let fullSuiteMatches: Map<string, string[]>;
|
||||
let normalFullSuiteTestFiles: string[];
|
||||
let leafShardPlans: ReturnType<typeof buildFullSuiteVitestRunPlans>;
|
||||
let leafShardGatewayTreeReads: unknown[][];
|
||||
|
||||
beforeAll(async () => {
|
||||
[fullSuiteMatches, normalFullSuiteTestFiles] = await Promise.all([
|
||||
listFullSuiteTestFileMatches(),
|
||||
Promise.resolve(listNormalFullSuiteTestFiles()),
|
||||
]);
|
||||
|
||||
const previous = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts";
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1";
|
||||
try {
|
||||
const captured = captureReaddirSyncCallsDuring(() =>
|
||||
buildFullSuiteVitestRunPlans([], process.cwd()),
|
||||
);
|
||||
leafShardPlans = captured.result;
|
||||
leafShardGatewayTreeReads = captured.calls.filter(([target]) =>
|
||||
typeof target === "string" ? normalizeRepoPath(target).includes("src/gateway") : false,
|
||||
);
|
||||
if (!leafShardPlans.some((plan) => plan.config === gatewayServerConfig)) {
|
||||
throw new Error("expected gateway server leaf shard plans");
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("interleaves heavy and light configs for cold parallel full-suite runs", () => {
|
||||
const specs = [
|
||||
"test/vitest/vitest.gateway.config.ts",
|
||||
@@ -962,31 +1019,9 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("covers each normal full-suite test file exactly once", async () => {
|
||||
const matches = await listFullSuiteTestFileMatches();
|
||||
const e2eNamedIntegrationTests = new Set([
|
||||
"src/gateway/gateway.test.ts",
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
]);
|
||||
const normalTestFiles = fg
|
||||
.sync(["**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], {
|
||||
cwd: process.cwd(),
|
||||
dot: false,
|
||||
ignore: ["**/.*/**", "**/dist/**", "**/node_modules/**", "**/vendor/**"],
|
||||
})
|
||||
.map(normalizeRepoPath)
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.includes(".live.test.") &&
|
||||
!file.includes(".e2e.test.") &&
|
||||
!file.startsWith("test/fixtures/") &&
|
||||
!e2eNamedIntegrationTests.has(file),
|
||||
)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
const missing = normalTestFiles.filter((file) => !matches.has(file));
|
||||
const duplicated = [...matches.entries()]
|
||||
it("covers each normal full-suite test file exactly once", () => {
|
||||
const missing = normalFullSuiteTestFiles.filter((file) => !fullSuiteMatches.has(file));
|
||||
const duplicated = [...fullSuiteMatches.entries()]
|
||||
.filter(([, configs]) => configs.length > 1)
|
||||
.map(([file, configs]) => `${file}: ${configs.join(", ")}`)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
@@ -1170,29 +1205,11 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
});
|
||||
|
||||
it("can expand full-suite shards to project configs for perf experiments", () => {
|
||||
const previous = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts";
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1";
|
||||
let plans: ReturnType<typeof buildFullSuiteVitestRunPlans>;
|
||||
let gatewayTreeReads: unknown[][] = [];
|
||||
try {
|
||||
const captured = captureReaddirSyncCallsDuring(() =>
|
||||
buildFullSuiteVitestRunPlans([], process.cwd()),
|
||||
);
|
||||
plans = captured.result;
|
||||
gatewayTreeReads = captured.calls.filter(([target]) =>
|
||||
typeof target === "string" ? normalizeRepoPath(target).includes("src/gateway") : false,
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previous;
|
||||
}
|
||||
}
|
||||
const plans = leafShardPlans;
|
||||
|
||||
expect(gatewayTreeReads).toEqual([]);
|
||||
expect(plans.map((plan) => plan.config)).toEqual([
|
||||
expect(leafShardGatewayTreeReads).toEqual([]);
|
||||
expect(leafShardPlans.map((plan) => plan.config)).toEqual([
|
||||
"test/vitest/vitest.unit-fast.config.ts",
|
||||
"test/vitest/vitest.unit-src.config.ts",
|
||||
"test/vitest/vitest.unit-security.config.ts",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { spawnNodeEvalSync } from "../src/test-utils/node-process.js";
|
||||
import { createCommandsLightVitestConfig } from "./vitest/vitest.commands-light.config.ts";
|
||||
import { createPluginSdkLightVitestConfig } from "./vitest/vitest.plugin-sdk-light.config.ts";
|
||||
@@ -50,7 +50,15 @@ function collectUnroutedForcedFiles(
|
||||
}
|
||||
|
||||
describe("unit-fast vitest lane", () => {
|
||||
it("loads the config without recursively walking repo roots", () => {
|
||||
let configProbeResult: ReturnType<typeof spawnNodeEvalSync>;
|
||||
let unitFastConfig: ReturnType<typeof createUnitFastVitestConfig>;
|
||||
let unitFastTestFiles: ReturnType<typeof getUnitFastTestFiles>;
|
||||
let unitFastAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
|
||||
let broadCandidates: ReturnType<typeof collectBroadUnitFastTestCandidates>;
|
||||
let broadAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
|
||||
let currentCandidates: ReturnType<typeof collectUnitFastTestCandidates>;
|
||||
|
||||
beforeAll(() => {
|
||||
const script = `
|
||||
import fs from "node:fs";
|
||||
let readdirSyncCalls = 0;
|
||||
@@ -62,25 +70,32 @@ describe("unit-fast vitest lane", () => {
|
||||
await import("./test/vitest/vitest.unit-fast.config.ts?io-probe=" + Date.now());
|
||||
console.log(readdirSyncCalls);
|
||||
`;
|
||||
const result = spawnNodeEvalSync(script, {
|
||||
configProbeResult = spawnNodeEvalSync(script, {
|
||||
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
||||
evalFlag: "-e",
|
||||
imports: ["tsx"],
|
||||
});
|
||||
unitFastConfig = createUnitFastVitestConfig({});
|
||||
unitFastTestFiles = getUnitFastTestFiles();
|
||||
unitFastAnalysis = collectUnitFastTestFileAnalysis();
|
||||
currentCandidates = collectUnitFastTestCandidates();
|
||||
broadCandidates = collectBroadUnitFastTestCandidates();
|
||||
broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" });
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const numericOutputLines = result.stdout
|
||||
it("loads the config without recursively walking repo roots", () => {
|
||||
expect(configProbeResult.status, configProbeResult.stderr).toBe(0);
|
||||
const numericOutputLines = configProbeResult.stdout
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter(Number.isFinite);
|
||||
expect(numericOutputLines.length, result.stdout).toBeGreaterThan(0);
|
||||
expect(numericOutputLines.length, configProbeResult.stdout).toBeGreaterThan(0);
|
||||
expect(numericOutputLines.at(-1)).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => {
|
||||
const config = createUnitFastVitestConfig({});
|
||||
const testConfig = requireTestConfig(config);
|
||||
const testConfig = requireTestConfig(unitFastConfig);
|
||||
|
||||
expect(testConfig.isolate).toBe(false);
|
||||
expect(testConfig.runner).toBeUndefined();
|
||||
@@ -156,26 +171,21 @@ describe("unit-fast vitest lane", () => {
|
||||
});
|
||||
|
||||
it("routes audited stateful-looking tests through the fast lane", () => {
|
||||
const analysis = collectUnitFastTestFileAnalysis();
|
||||
const forcedFileSet = new Set(forcedUnitFastTestFiles);
|
||||
const forcedAnalysisCount = countMatching(analysis, (entry) => forcedFileSet.has(entry.file));
|
||||
const unitFastTestFiles = getUnitFastTestFiles();
|
||||
const forcedAnalysisCount = countMatching(unitFastAnalysis, (entry) =>
|
||||
forcedFileSet.has(entry.file),
|
||||
);
|
||||
|
||||
expect(forcedAnalysisCount).toBe(forcedUnitFastTestFiles.length);
|
||||
for (const file of forcedUnitFastTestFiles) {
|
||||
expect(unitFastTestFiles).toContain(file);
|
||||
expect(isUnitFastTestFile(file)).toBe(true);
|
||||
}
|
||||
const unroutedForcedFiles = collectUnroutedForcedFiles(analysis, forcedFileSet);
|
||||
const unroutedForcedFiles = collectUnroutedForcedFiles(unitFastAnalysis, forcedFileSet);
|
||||
expect(unroutedForcedFiles).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("keeps broad audit candidates separate from automatically routed unit-fast tests", () => {
|
||||
const currentCandidates = collectUnitFastTestCandidates();
|
||||
const broadCandidates = collectBroadUnitFastTestCandidates();
|
||||
const broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" });
|
||||
const unitFastTestFiles = getUnitFastTestFiles();
|
||||
|
||||
expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length);
|
||||
expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length);
|
||||
expect(countMatching(broadAnalysis, (entry) => entry.unitFast)).toBeGreaterThan(
|
||||
@@ -186,7 +196,6 @@ describe("unit-fast vitest lane", () => {
|
||||
it("excludes unit-fast files from the older light lanes so full runs do not duplicate them", () => {
|
||||
const pluginSdkLight = createPluginSdkLightVitestConfig({});
|
||||
const commandsLight = createCommandsLightVitestConfig({});
|
||||
const unitFastTestFiles = getUnitFastTestFiles();
|
||||
|
||||
expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts");
|
||||
expect(requireTestConfig(pluginSdkLight).exclude).toContain(
|
||||
|
||||
Reference in New Issue
Block a user