diff --git a/extensions/browser/src/browser/server.agent-contract-core.test.ts b/extensions/browser/src/browser/server.agent-contract-core.test.ts index 029895df9268..68941c9d1888 100644 --- a/extensions/browser/src/browser/server.agent-contract-core.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-core.test.ts @@ -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, ); diff --git a/extensions/discord/src/api-barrel.test.ts b/extensions/discord/src/api-barrel.test.ts index 0b8d2e0f90f5..fcd4d15d7980 100644 --- a/extensions/discord/src/api-barrel.test.ts +++ b/extensions/discord/src/api-barrel.test.ts @@ -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 { 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); } }); }); diff --git a/extensions/discord/src/voice/audio.test.ts b/extensions/discord/src/voice/audio.test.ts index ac1f59f2f9a0..0bde6ac7ead1 100644 --- a/extensions/discord/src/voice/audio.test.ts +++ b/extensions/discord/src/voice/audio.test.ts @@ -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; + } + } }); }); diff --git a/extensions/irc/runtime-api.test.ts b/extensions/irc/runtime-api.test.ts index 340063d50859..71c7d000cc53 100644 --- a/extensions/irc/runtime-api.test.ts +++ b/extensions/irc/runtime-api.test.ts @@ -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); + }); }); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index c45fa6e5d52e..c85bef9dbe50 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -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, diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index aa140e187369..6eb3aa7ec906 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -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" }); diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts index a81c34587b8a..5d4125726732 100644 --- a/extensions/mattermost/src/channel.message-adapter.test.ts +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -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); diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 7c2f87dd6b51..2c96d83f3a94 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -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(), diff --git a/extensions/slack/src/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts index 0e61e0e1a998..451b504eeac3 100644 --- a/extensions/slack/src/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -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<{ diff --git a/extensions/slack/src/monitor/message-handler/prepare-routing.ts b/extensions/slack/src/monitor/message-handler/prepare-routing.ts index a0e1b3a268bb..27d6d181cb3f 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-routing.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-routing.ts @@ -291,3 +291,7 @@ export function resolveSlackRoutingContext(params: { historyKey, }; } + +export const __testing = { + normalizeSlackRouteBindingConfig, +}; diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index f73c345870df..99a0a42f8e09 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -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( diff --git a/extensions/synthetic/onboard.test.ts b/extensions/synthetic/onboard.test.ts index 5b4ebb78d580..9c1ba64b8456 100644 --- a/extensions/synthetic/onboard.test.ts +++ b/extensions/synthetic/onboard.test.ts @@ -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; + let mergedProvider: ReturnType; + + 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\//, "")); }); diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index b035253f7dd6..deb7ee3aa11d 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -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). diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 76226a63bbdf..c6a602a846f6 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -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; @@ -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((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; - - 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) => Promise; - - 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; - message: Record; - me?: Record; - }> = [ - { - 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; - 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[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) => Promise; - - 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(); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 2e557754b4dd..8d274d279d51 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -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) => Promise; - 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) => Promise; - - 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(); diff --git a/extensions/whatsapp/setup-entry.test.ts b/extensions/whatsapp/setup-entry.test.ts index 9ab702aff2cf..04d733cfc72b 100644 --- a/extensions/whatsapp/setup-entry.test.ts +++ b/extensions/whatsapp/setup-entry.test.ts @@ -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"); } diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index 8c1623adc765..408f163d5b15 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -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( @@ -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 () => { diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 07adefa03263..deb83d35057e 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -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 { + const { readWebAuthState } = await import("./auth-store.js"); + return (await readWebAuthState(account.authDir)) === "linked"; +} + export const whatsappSetupPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ groups: { @@ -19,7 +23,7 @@ export const whatsappSetupPlugin: ChannelPlugin = { }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, - isConfigured: async (account) => (await readWebAuthState(account.authDir)) === "linked", + isConfigured: isWhatsAppAuthConfigured, }), lifecycle: { detectLegacyStateMigrations: ({ oauthDir }) => diff --git a/extensions/zai/onboard.test.ts b/extensions/zai/onboard.test.ts index f2b36ba18448..399f1a8f173c 100644 --- a/extensions/zai/onboard.test.ts +++ b/extensions/zai/onboard.test.ts @@ -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; + let cnFlashCfg: ReturnType; + let cnFlashxCfg: ReturnType; + + 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}`); } diff --git a/extensions/zalo/runtime-api.test.ts b/extensions/zalo/runtime-api.test.ts index accd0858c8d8..a9967fe6c2ce 100644 --- a/extensions/zalo/runtime-api.test.ts +++ b/extensions/zalo/runtime-api.test.ts @@ -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"); + }); }); diff --git a/scripts/lib/test-group-report.mjs b/scripts/lib/test-group-report.mjs index 9428f586e436..46c59f6fbd61 100644 --- a/scripts/lib/test-group-report.mjs +++ b/scripts/lib/test-group-report.mjs @@ -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); } diff --git a/scripts/test-group-report.mjs b/scripts/test-group-report.mjs index a8c1ba8d4095..d7479c8df4ac 100644 --- a/scripts/test-group-report.mjs +++ b/scripts/test-group-report.mjs @@ -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) { diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 15b9c4ffaea6..ceda7ee8a22b 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts index 195708bd6dbe..1ef248e84b45 100644 --- a/src/agents/anthropic-vertex-stream.test.ts +++ b/src/agents/anthropic-vertex-stream.test.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", diff --git a/src/agents/model-catalog-visibility.test.ts b/src/agents/model-catalog-visibility.test.ts index bbbbf9784ba1..1ae04f998aca 100644 --- a/src/agents/model-catalog-visibility.test.ts +++ b/src/agents/model-catalog-visibility.test.ts @@ -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([]); diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index 17e495b2b6ad..2aa245493c97 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -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]), diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 102acf2b83b9..54b081519481 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -850,6 +850,7 @@ export async function runWithModelFallback( onError?: ModelFallbackErrorHandler; onFallbackStep?: ModelFallbackStepHandler; classifyResult?: ModelFallbackResultClassifier; + skipAuthProfileRuntime?: boolean; } & ModelManifestNormalizationContext, ): Promise> { const candidates = resolveFallbackCandidates({ @@ -860,7 +861,7 @@ export async function runWithModelFallback( manifestPlugins: params.manifestPlugins, }); const authRuntime = - params.cfg && hasAnyAuthProfileStoreSource(params.agentDir) + !params.skipAuthProfileRuntime && params.cfg && hasAnyAuthProfileStoreSource(params.agentDir) ? await loadModelFallbackAuthRuntime() : null; const authStore = authRuntime diff --git a/src/agents/outcome-fallback-runtime-contract.test.ts b/src/agents/outcome-fallback-runtime-contract.test.ts index d549ad755f86..3e5e3c4e7819 100644 --- a/src/agents/outcome-fallback-runtime-contract.test.ts +++ b/src/agents/outcome-fallback-runtime-contract.test.ts @@ -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); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index 9a1a73ad5ccc..610005a7a654 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -132,7 +132,14 @@ function expectInteractiveApprovalButtons( result: Record, expectedButtons: readonly Record[], ) { - 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 }], }); } diff --git a/src/agents/provider-local-service.ts b/src/agents/provider-local-service.ts index eee0ee6c3b61..68c8b0dbc2f3 100644 --- a/src/agents/provider-local-service.ts +++ b/src/agents/provider-local-service.ts @@ -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"); diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts index f9719749935c..5d212a8837f7 100644 --- a/src/agents/simple-completion-runtime.test.ts +++ b/src/agents/simple-completion-runtime.test.ts @@ -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, diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 3d66b4e141ff..92f90b316b1f 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -198,14 +198,21 @@ export async function prepareSimpleCompletionModel(params: { allowMissingApiKeyModes?: ReadonlyArray; allowBundledStaticCatalogFallback?: boolean; skipPiDiscovery?: boolean; + modelResolver?: typeof resolveModelAsync; }): Promise { 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; allowBundledStaticCatalogFallback?: boolean; skipPiDiscovery?: boolean; + modelResolver?: typeof resolveModelAsync; }): Promise { 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 { diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index 55eeaa9925e0..7015c37af8ad 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -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; + + 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", () => { diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index d7c67b11499e..e95888b9c298 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -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(); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index a6a377ff9b7d..132c07369b4d 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -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; - 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 as an alias", async () => { diff --git a/src/auto-reply/reply/commands-status.thinking-default.test.ts b/src/auto-reply/reply/commands-status.thinking-default.test.ts index 2896d374f1cd..5fdb9aa7b94a 100644 --- a/src/auto-reply/reply/commands-status.thinking-default.test.ts +++ b/src/auto-reply/reply/commands-status.thinking-default.test.ts @@ -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" }, diff --git a/src/auto-reply/reply/completion-delivery-policy.test.ts b/src/auto-reply/reply/completion-delivery-policy.test.ts index 88815425e014..99f3239b2041 100644 --- a/src/auto-reply/reply/completion-delivery-policy.test.ts +++ b/src/auto-reply/reply/completion-delivery-policy.test.ts @@ -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", diff --git a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts index 1cfeee80314c..2780291aae54 100644 --- a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts @@ -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(() => { diff --git a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts index a51ba9db49f0..2c4dc9875903 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts @@ -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>>(); + 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}`); } diff --git a/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts b/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts index 8e8b52839b0b..101ba0f74a54 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts @@ -26,6 +26,7 @@ type SessionBindingContractEntry = { bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; + preload?: () => Promise | void; beforeEach?: () => Promise | void; }; const contractApiPromises = new Map>>(); @@ -243,6 +244,9 @@ const sessionBindingContractEntries: Record< Omit > = { discord: { + preload: async () => { + await getContractApi("discord"); + }, beforeEach: prepareDiscordSessionBindingContract, expectedCapabilities: { adapterAvailable: true, @@ -304,6 +308,9 @@ const sessionBindingContractEntries: Record< }, }, feishu: { + preload: async () => { + await getContractApi("feishu"); + }, beforeEach: prepareFeishuSessionBindingContract, expectedCapabilities: { adapterAvailable: true, @@ -365,6 +372,9 @@ const sessionBindingContractEntries: Record< }, }, imessage: { + preload: async () => { + await getContractApi("imessage"); + }, beforeEach: prepareIMessageSessionBindingContract, expectedCapabilities: { adapterAvailable: true, @@ -428,6 +438,9 @@ const sessionBindingContractEntries: Record< }, }, matrix: { + preload: async () => { + await getContractApi("matrix"); + }, beforeEach: prepareMatrixSessionBindingContract, expectedCapabilities: { adapterAvailable: true, @@ -479,6 +492,9 @@ const sessionBindingContractEntries: Record< }, }, telegram: { + preload: async () => { + await getContractApi("telegram"); + }, beforeEach: prepareTelegramSessionBindingContract, expectedCapabilities: { adapterAvailable: true, diff --git a/src/channels/plugins/contracts/test-helpers/session-binding-registry-backed-contract.ts b/src/channels/plugins/contracts/test-helpers/session-binding-registry-backed-contract.ts index 503499b3575c..b8f15d6c4dd8 100644 --- a/src/channels/plugins/contracts/test-helpers/session-binding-registry-backed-contract.ts +++ b/src/channels/plugins/contracts/test-helpers/session-binding-registry-backed-contract.ts @@ -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(); diff --git a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts index 9ebb361b2fc0..6db7a5dca7f7 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts @@ -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; + let partialValidationResult: ReturnType; + + 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 | 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([ diff --git a/src/cron/isolated-agent.model-overrides.test.ts b/src/cron/isolated-agent.model-overrides.test.ts index 6f0af678853e..c355a5f3823c 100644 --- a/src/cron/isolated-agent.model-overrides.test.ts +++ b/src/cron/isolated-agent.model-overrides.test.ts @@ -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, diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 64f2f9849d8d..13afe803726c 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -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 => ({ + 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(); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 70d87255f4ed..49f11a5131e1 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -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", diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index ec260495befc..fd16215f58b5 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -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>; + + 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(); } }); diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index af4b21ea112d..6fedf5a1c734 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -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 { + return await tempDirs.make("case"); +} + +function requireApprovedPairing( + result: Awaited>, +): Exclude { + 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["identity"]; @@ -45,61 +60,14 @@ async function connectNodeClient(params: { }); } -async function expectPairingApprovalRejected(params: { - started: Awaited>; - 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>; 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> | undefined; let nodeClient: Awaited> | 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>; + + 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: [], + }); }); }); }); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index e789164f6df1..fed02aeba0d0 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -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(); diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index 201123dea176..480b46042b48 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -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, diff --git a/src/hooks/gmail-watcher.test.ts b/src/hooks/gmail-watcher.test.ts index 978ed27d6c40..ab50900b4818 100644 --- a/src/hooks/gmail-watcher.test.ts +++ b/src/hooks/gmail-watcher.test.ts @@ -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, diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 0105e7e1e8ab..403b779690e2 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -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({ diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index 14aa9f792843..95d9e465b010 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -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(params: { location: FacadeModuleLocation; trackedPluginId: string | (() => string); @@ -252,6 +256,7 @@ export function resetFacadeRuntimeStateForTest(): void { } export const __testing = { + setFacadeActivationCheckRuntimeForTest, loadFacadeModuleAtLocationSync, resolveRegistryPluginModuleLocationFromRegistry: resolveRegistryPluginModuleLocationFromRecords, resolveFacadeModuleLocation, diff --git a/src/plugin-sdk/qa-runner-runtime.integration.test.ts b/src/plugin-sdk/qa-runner-runtime.integration.test.ts index bba7826658e1..969e2bcd5702 100644 --- a/src/plugin-sdk/qa-runner-runtime.integration.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.integration.test.ts @@ -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") { diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 53b0e679460f..1e5a578c76d5 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -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), ); }); diff --git a/src/plugins/contracts/deprecated-internal-config-api.test.ts b/src/plugins/contracts/deprecated-internal-config-api.test.ts index 9a9cf88b43bc..c5d91e0b5b4e 100644 --- a/src/plugins/contracts/deprecated-internal-config-api.test.ts +++ b/src/plugins/contracts/deprecated-internal-config-api.test.ts @@ -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; + + beforeAll(() => { + violations = collectDeprecatedInternalConfigApiViolations(); + }); + it("keeps production code off deprecated config load/write seams", () => { - expect(collectDeprecatedInternalConfigApiViolations()).toStrictEqual([]); + expect(violations).toStrictEqual([]); }); }); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 0be82d9115c7..8e22a17f3d48 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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, diff --git a/src/plugins/contracts/provider-family-plugin-tests.test.ts b/src/plugins/contracts/provider-family-plugin-tests.test.ts index 720d440fb2c3..4430f4f8e6cb 100644 --- a/src/plugins/contracts/provider-family-plugin-tests.test.ts +++ b/src/plugins/contracts/provider-family-plugin-tests.test.ts @@ -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 + | undefined; +const filesByDirCache = new Map(); 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 { + let bundledRoots: ReturnType; + let sharedFamilyProviders: ReturnType; + let providerBoundaryTests: ReturnType; + let actualAssignments: Record; + + 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, )) { diff --git a/src/plugins/contracts/runtime-import-side-effects.contract.test.ts b/src/plugins/contracts/runtime-import-side-effects.contract.test.ts index 1e98115e5158..745bb37bd8f7 100644 --- a/src/plugins/contracts/runtime-import-side-effects.contract.test.ts +++ b/src/plugins/contracts/runtime-import-side-effects.contract.test.ts @@ -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"); }); }); diff --git a/src/plugins/contracts/runtime-seams.contract.test.ts b/src/plugins/contracts/runtime-seams.contract.test.ts index 2c2d13d10019..fdc8f1b0fcd9 100644 --- a/src/plugins/contracts/runtime-seams.contract.test.ts +++ b/src/plugins/contracts/runtime-seams.contract.test.ts @@ -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]); diff --git a/src/plugins/contracts/session-attachments.contract.test.ts b/src/plugins/contracts/session-attachments.contract.test.ts index 44ce813aa4be..ea5a55b0c35e 100644 --- a/src/plugins/contracts/session-attachments.contract.test.ts +++ b/src/plugins/contracts/session-attachments.contract.test.ts @@ -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 () => { diff --git a/src/plugins/host-hook-attachments.ts b/src/plugins/host-hook-attachments.ts index 3f3b8535eaea..7ca1e3390a28 100644 --- a/src/plugins/host-hook-attachments.ts +++ b/src/plugins/host-hook-attachments.ts @@ -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 { @@ -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>; try { const sendMessage = await loadSendMessage(); diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 60a6f2a7b9bf..f7680f32f8fa 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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", diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index e2bb69e2e5a6..aa8c94e3c81d 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -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-.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( - items: readonly T[], - concurrency: number, - fn: (item: T) => Promise, -): Promise { - 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>; + 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(); - 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(), + ); + }, + ); }); diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 9a0499554c4c..65416fcf85a3 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -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(); diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index 1d2a078dd7e9..2e450f631356 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -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" }, diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 6170fa6c162d..84562ef91914 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -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", diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index e15ade009b48..ce9661e94dfe 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.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 { - for (const batch of buildCoverageBatches(entries)) { - logCoverageBatch(label, batch); - const config = {} as OpenClawConfig; - const env: Record = {}; - 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 = {}; + 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 = {}; + 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 = {}; - 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}`); - } - } }); }); diff --git a/src/sessions/session-chat-type-shared.ts b/src/sessions/session-chat-type-shared.ts index 8c232f1f5165..fe6c95dc072c 100644 --- a/src/sessions/session-chat-type-shared.ts +++ b/src/sessions/session-chat-type-shared.ts @@ -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; } diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index beccc8098c3a..309764814bff 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -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; + 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); diff --git a/test/scripts/test-group-report.test.ts b/test/scripts/test-group-report.test.ts index 5f583c0c2233..907e67a0e9a0 100644 --- a/test/scripts/test-group-report.test.ts +++ b/test/scripts/test-group-report.test.ts @@ -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({ diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index db2ecd544320..211f5c5c3f7b 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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> { 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; + let normalFullSuiteTestFiles: string[]; + let leafShardPlans: ReturnType; + 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; - 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", diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index e85c14577b95..f79784ceb27e 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.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; + let unitFastConfig: ReturnType; + let unitFastTestFiles: ReturnType; + let unitFastAnalysis: ReturnType; + let broadCandidates: ReturnType; + let broadAnalysis: ReturnType; + let currentCandidates: ReturnType; + + 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(