test: speed up slow test suite (#87611)

* test: speed up slow test suite

* test: preserve fake timer cleanup hooks

* test: avoid timeout readiness race

* test: satisfy reply test types

* test: restore runner and image coverage

* test: restore final media runner path

* test: make cli auth status fixture deterministic

* test: repair runtime alias fixtures
This commit is contained in:
Peter Steinberger
2026-05-28 13:20:19 +01:00
committed by GitHub
parent e0635eb6fd
commit aab5410bd5
53 changed files with 1451 additions and 469 deletions
+5 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
browserPluginNodeHostCommands,
browserPluginReload,
@@ -44,6 +44,10 @@ vi.mock("./src/cli/browser-cli.js", () => ({
registerBrowserCli: runtimeApiMocks.registerBrowserCli,
}));
beforeAll(async () => {
await import("./register.runtime.js");
});
function createApi() {
const registerCli = vi.fn();
const registerGatewayMethod = vi.fn();
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import "../test-support/browser-security.mock.js";
import { BROWSER_NAVIGATION_BLOCKED_MESSAGE } from "./errors.js";
import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js";
@@ -22,6 +22,10 @@ const state = getBrowserControlServerTestState();
const pwMocks = getPwMocks();
const realFetch: BrowserTestFetch = (input, init) => getBrowserTestFetch()(input, init);
beforeAll(async () => {
await import("../server.js");
});
type GuardedCurrentTabRouteCase = {
method: "GET" | "POST";
path: string;
@@ -146,7 +146,7 @@ describe("startCodexAttemptThread", () => {
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
const { harness, run } = startThreadWithHarness(2_000);
const { harness, run } = startThreadWithHarness(1_100);
const runError = run.then(
() => undefined,
(error: unknown) => error,
@@ -1,5 +1,5 @@
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
@@ -18,6 +18,10 @@ vi.mock("./runtime.js", async () => {
return createFeishuRuntimeMockModule();
});
beforeAll(async () => {
await import("./monitor.account.js");
});
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
return {
channels: {
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js";
import { withRunningWebhookMonitor } from "./monitor.webhook.test-helpers.js";
@@ -21,6 +21,10 @@ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
beforeAll(async () => {
await import("./monitor.account.js");
});
function signFeishuPayload(params: {
encryptKey: string;
rawBody: string;
@@ -1,6 +1,6 @@
import type { IncomingMessage } from "node:http";
import { createConnection } from "node:net";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
createFeishuClientMockModule,
createFeishuRuntimeMockModule,
@@ -49,6 +49,10 @@ import {
import { buildFeishuWebhookRateLimitKeyForTest, monitorWebhook } from "./monitor.transport.js";
import type { ResolvedFeishuAccount } from "./types.js";
beforeAll(async () => {
await import("./monitor.account.js");
});
async function waitForSlowBodyTimeoutResponse(
url: string,
timeoutMs: number,
+37 -33
View File
@@ -48,6 +48,7 @@ describe("sendMessageIMessage receipts", () => {
afterEach(() => {
clearIMessageApprovalReactionTargetsForTest();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("attaches a text receipt for native send ids", async () => {
@@ -424,31 +425,32 @@ describe("sendMessageIMessage receipts", () => {
});
it("does not use the local default chat.db path for custom cliPath wrappers", async () => {
vi.useFakeTimers();
vi.stubEnv("HOME", "/Users/me");
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const approvalText = createApprovalText("approval-remote");
await expect(
sendMessageIMessage("chat_id:42", approvalText, {
config: {
channels: {
imessage: {
accounts: {
default: {
remoteHost: "bot@gateway-host",
},
const send = sendMessageIMessage("chat_id:42", approvalText, {
config: {
channels: {
imessage: {
accounts: {
default: {
remoteHost: "bot@gateway-host",
},
},
},
},
client,
cliPath: "/Users/me/.openclaw/scripts/imsg",
runCliJson,
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
},
client,
cliPath: "/Users/me/.openclaw/scripts/imsg",
runCliJson,
resolveSentMessageGuidImpl,
});
await vi.advanceTimersByTimeAsync(5_250);
await expect(send).rejects.toThrow("imsg rpc timeout (send)");
expect(runCliJson).not.toHaveBeenCalled();
expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({
@@ -460,6 +462,7 @@ describe("sendMessageIMessage receipts", () => {
});
it("does not use the local default chat.db path for auto-detected ssh wrappers", async () => {
vi.useFakeTimers();
vi.stubEnv("HOME", "/Users/me");
const wrapperDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-wrapper-"));
const wrapperPath = path.join(wrapperDir, "imsg");
@@ -470,15 +473,15 @@ describe("sendMessageIMessage receipts", () => {
const approvalText = createApprovalText("approval-ssh-wrapper");
try {
await expect(
sendMessageIMessage("chat_id:42", approvalText, {
config: IMESSAGE_TEST_CFG,
client,
cliPath: wrapperPath,
runCliJson,
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
const send = sendMessageIMessage("chat_id:42", approvalText, {
config: IMESSAGE_TEST_CFG,
client,
cliPath: wrapperPath,
runCliJson,
resolveSentMessageGuidImpl,
});
await vi.advanceTimersByTimeAsync(5_250);
await expect(send).rejects.toThrow("imsg rpc timeout (send)");
} finally {
fs.rmSync(wrapperDir, { recursive: true, force: true });
}
@@ -512,20 +515,21 @@ describe("sendMessageIMessage receipts", () => {
});
it("throws the rpc timeout without resending when approval GUID recovery misses", async () => {
vi.useFakeTimers();
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const approvalText = createApprovalText();
await expect(
sendMessageIMessage("chat_id:42", approvalText, {
config: IMESSAGE_TEST_CFG,
client,
runCliJson,
dbPath: "/Users/me/Library/Messages/chat.db",
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
const send = sendMessageIMessage("chat_id:42", approvalText, {
config: IMESSAGE_TEST_CFG,
client,
runCliJson,
dbPath: "/Users/me/Library/Messages/chat.db",
resolveSentMessageGuidImpl,
});
await vi.advanceTimersByTimeAsync(5_250);
await expect(send).rejects.toThrow("imsg rpc timeout (send)");
expect(runCliJson).not.toHaveBeenCalled();
expect(resolveSentMessageGuidImpl).toHaveBeenCalled();
+5 -1
View File
@@ -1,5 +1,5 @@
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import { startNostrGatewayAccount } from "./gateway.js";
import { setNostrRuntime } from "./runtime.js";
@@ -25,6 +25,10 @@ vi.mock("./nostr-key-utils.js", () => ({
normalizePubkey: mocks.normalizePubkey,
}));
beforeAll(async () => {
await import("./inbound-direct-dm-runtime.js");
});
function createMockBus() {
return {
sendDm: vi.fn(async () => {}),
+20 -2
View File
@@ -209,9 +209,12 @@ function runVitestJsonReport(params) {
encoding: "utf8",
env: {
...process.env,
...params.env,
NODE_OPTIONS: [
process.env.NODE_OPTIONS?.trim(),
...resolveVitestNodeArgs(process.env).filter((arg) => arg !== "--no-maglev"),
(params.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS)?.trim(),
...resolveVitestNodeArgs({ ...process.env, ...params.env }).filter(
(arg) => arg !== "--no-maglev",
),
]
.filter(Boolean)
.join(" "),
@@ -311,6 +314,20 @@ export function resolveRunPlans(args) {
}));
}
export function resolveFullSuiteVitestEnv(args, env = process.env, label = "") {
if (
!args.fullSuite ||
env.OPENCLAW_VITEST_MAX_WORKERS?.trim() ||
env.OPENCLAW_TEST_WORKERS?.trim()
) {
return {};
}
return {
OPENCLAW_VITEST_MAX_WORKERS: label === "commands" ? "1" : "2",
};
}
function printRunLine(run) {
console.log(
`[test-group-report] ${run.label} status=${run.status} wall=${formatMs(run.elapsedMs)} rss=${formatBytesAsMb(run.maxRssBytes)} report=${run.reportPath}`,
@@ -365,6 +382,7 @@ async function main() {
const run = runVitestJsonReport({
config: plan.config,
forwardedArgs: plan.forwardedArgs,
env: resolveFullSuiteVitestEnv(args, process.env, plan.label),
label: plan.label,
logPath: path.join(logDir, `${slug}.log`),
reportPath: path.join(reportDir, `${slug}.json`),
+24
View File
@@ -70,6 +70,30 @@ vi.mock("./model-auth.js", () => ({
requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args),
}));
vi.mock("./model-runtime-aliases.js", () => ({
resolveCliRuntimeExecutionProvider: ({
provider,
cfg,
modelId,
}: {
provider?: string;
cfg?: {
agents?: {
defaults?: {
models?: Record<string, { agentRuntime?: { id?: string } }>;
};
};
};
modelId?: string;
}) => {
const key = provider && modelId ? `${provider}/${modelId}` : undefined;
const runtime = key
? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim()
: undefined;
return runtime || undefined;
},
}));
vi.mock("./embedded-agent-runner/runs.js", () => ({
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
}));
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
// vi.mock factories are hoisted above imports, so any references inside them
@@ -70,6 +70,8 @@ const baseRunParams = {
runId: "test-run-id",
} as const;
let runCliAgent: typeof import("./cli-runner.js").runCliAgent;
function makeStubContext(params: typeof baseRunParams & { trigger?: string }) {
return {
params,
@@ -102,13 +104,16 @@ beforeEach(() => {
closeMcpLoopbackServerMock.mockReset();
});
beforeAll(async () => {
({ runCliAgent } = await import("./cli-runner.js"));
});
afterEach(() => {
vi.clearAllMocks();
});
describe("runCliAgent cron before_agent_reply seam", () => {
it("lets before_agent_reply claim cron runs before the CLI subprocess is invoked", async () => {
const { runCliAgent } = await import("./cli-runner.js");
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");
runBeforeAgentReplyMock.mockResolvedValue({
handled: true,
@@ -146,7 +151,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
// Regression for PR #70950 review (greptile-apps, P1): the gate must fire
// before any backend resources are allocated, otherwise preparedBackend.cleanup
// is silently skipped on every claimed cron turn.
const { runCliAgent } = await import("./cli-runner.js");
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");
runBeforeAgentReplyMock.mockResolvedValue({ handled: true });
@@ -157,7 +161,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("re-arms setup progress when a cron hook does not claim", async () => {
const { runCliAgent } = await import("./cli-runner.js");
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");
runBeforeAgentReplyMock.mockResolvedValue(undefined);
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
@@ -185,7 +188,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("treats empty CLI subprocess output as a failover failure, not a green cron run", async () => {
const { runCliAgent } = await import("./cli-runner.js");
executePreparedCliRunMock.mockResolvedValue({ text: " " });
await expect(runCliAgent({ ...baseRunParams, trigger: "cron" })).rejects.toMatchObject({
@@ -198,7 +200,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("returns a silent payload when a cron hook claims without a reply body", async () => {
const { runCliAgent } = await import("./cli-runner.js");
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");
runBeforeAgentReplyMock.mockResolvedValue({ handled: true });
@@ -209,7 +210,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("does not invoke before_agent_reply for non-cron triggers", async () => {
const { runCliAgent } = await import("./cli-runner.js");
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
@@ -220,7 +220,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("falls through to the CLI subprocess when no before_agent_reply hook is registered", async () => {
const { runCliAgent } = await import("./cli-runner.js");
hasHooksMock.mockReturnValue(false);
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
@@ -231,7 +230,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("can close temporary CLI live sessions after a run", async () => {
const { runCliAgent } = await import("./cli-runner.js");
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
await runCliAgent({ ...baseRunParams, cleanupCliLiveSessionOnRunEnd: true });
@@ -244,7 +242,6 @@ describe("runCliAgent cron before_agent_reply seam", () => {
});
it("can close temporary bundle MCP loopback resources after a run", async () => {
const { runCliAgent } = await import("./cli-runner.js");
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
await runCliAgent({ ...baseRunParams, cleanupBundleMcpOnRunEnd: true });
@@ -29,6 +29,30 @@ vi.mock("../provider-auth-aliases.js", () => ({
provider.trim().toLowerCase() === "codex-cli" ? "openai-codex" : provider.trim().toLowerCase(),
}));
vi.mock("../model-runtime-aliases.js", async () => {
const actual = await vi.importActual<typeof import("../model-runtime-aliases.js")>(
"../model-runtime-aliases.js",
);
return {
...actual,
resolveCliRuntimeExecutionProvider: ({
provider,
cfg,
modelId,
}: {
provider?: string;
cfg?: OpenClawConfig;
modelId?: string;
}) => {
const key = provider && modelId ? `${provider}/${modelId}` : undefined;
const runtime = key
? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim()
: undefined;
return runtime || provider;
},
};
});
vi.mock("../embedded-agent.js", () => ({
runEmbeddedAgent: runEmbeddedAgentMock,
}));
@@ -1100,7 +1124,7 @@ describe("CLI attempt execution", () => {
agents: {
defaults: {
models: {
"openai/gpt-5.4": { agentRuntime: { id: "codex-cli" } },
"openai/gpt-5.4": { agentRuntime: { id: "codex" } },
},
},
},
@@ -456,6 +456,41 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
buildAgentRuntimePlan: mockedBuildAgentRuntimePlan,
}));
vi.doMock("../model-runtime-aliases.js", () => ({
isCliRuntimeAliasForProvider: ({
runtime,
provider,
}: {
runtime?: string;
provider?: string;
}) =>
(provider?.trim().toLowerCase() === "anthropic" &&
runtime?.trim().toLowerCase() === "claude-cli") ||
(provider?.trim().toLowerCase() === "openai" &&
runtime?.trim().toLowerCase() === "codex-cli"),
resolveCliRuntimeExecutionProvider: ({
provider,
cfg,
modelId,
}: {
provider?: string;
cfg?: {
agents?: {
defaults?: {
models?: Record<string, { agentRuntime?: { id?: string } }>;
};
};
};
modelId?: string;
}) => {
const key = provider && modelId ? `${provider}/${modelId}` : undefined;
const runtime = key
? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim()
: undefined;
return runtime || undefined;
},
}));
vi.doMock("../../plugins/provider-runtime.js", () => ({
prepareProviderRuntimeAuth: mockedPrepareProviderRuntimeAuth,
resolveProviderCapabilitiesWithPlugin: vi.fn(() => ({})),
+7
View File
@@ -37,6 +37,13 @@ const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME;
beforeEach(() => {
clearAgentHarnesses();
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
+7
View File
@@ -30,6 +30,13 @@ function createAnthropicAuthConfig(params: {
describe("resolveCliRuntimeExecutionProvider", () => {
beforeEach(() => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
@@ -125,6 +125,7 @@ async function runEnvProviderCase(params: {
describe("models-config", () => {
beforeAll(async () => {
vi.resetModules();
({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"));
({ clearRuntimeAuthProfileStoreSnapshots } = await import("./auth-profiles/store.js"));
({ ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } =
+53 -2
View File
@@ -20,6 +20,36 @@ import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridg
import { createUnsafeMountedSandbox } from "../test-helpers/unsafe-mounted-sandbox.js";
import { makeZeroUsageSnapshot } from "../usage.js";
import { testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
import { resolveMediaToolInboundRoots } from "./media-tool-shared.js";
const publicSurfaceLoaderMocks = vi.hoisted(() => ({
loadBundledPluginPublicArtifactModuleSync: vi.fn(
({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => {
if (dirName === "imessage" && artifactBasename === "media-contract-api.js") {
return {
resolveInboundAttachmentRoots: ({
accountId,
cfg,
}: {
accountId?: string | null;
cfg: OpenClawConfig;
}) => [
...((accountId
? cfg.channels?.imessage?.accounts?.[accountId]?.attachmentRoots
: undefined) ?? []),
...(cfg.channels?.imessage?.attachmentRoots ?? []),
"/Users/*/Library/Messages/Attachments",
],
};
}
throw new Error(
`Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`,
);
},
),
}));
vi.mock("../../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks);
type CreateOpenClawCodingToolsArgs = Parameters<typeof createOpenClawCodingTools>[0];
type MockOpenClawToolsOptions = {
@@ -565,6 +595,12 @@ const moonshotProvider = {
function installImageUnderstandingProviderDeps(
providers: MediaUnderstandingProvider[],
options?: {
describeImageWithModel?: NonNullable<
Parameters<typeof testing.setProviderDepsForTest>[0]
>["describeImageWithModel"];
describeImagesWithModel?: NonNullable<
Parameters<typeof testing.setProviderDepsForTest>[0]
>["describeImagesWithModel"];
loadImageWebMediaRuntime?: NonNullable<
Parameters<typeof testing.setProviderDepsForTest>[0]
>["loadImageWebMediaRuntime"];
@@ -595,8 +631,8 @@ function installImageUnderstandingProviderDeps(
id: string,
registry: Map<string, MediaUnderstandingProvider>,
) => imageProviderHarness.getMediaUnderstandingProvider(id, registry),
describeImageWithModel: describeGenericImageWithModel,
describeImagesWithModel: describeGenericImagesWithModel,
describeImageWithModel: options?.describeImageWithModel ?? describeGenericImageWithModel,
describeImagesWithModel: options?.describeImagesWithModel ?? describeGenericImagesWithModel,
resolveAutoMediaKeyProviders: ({ capability }) =>
capability === "image" ? ["openai", "anthropic"] : [],
resolveDefaultMediaModel: ({ providerId, capability }) =>
@@ -617,6 +653,12 @@ function installImageUnderstandingProviderStubs(...providers: MediaUnderstanding
function installFastLocalImageProviderStubs(...providers: MediaUnderstandingProvider[]) {
installImageUnderstandingProviderDeps(providers, {
describeImageWithModel: async () => {
throw new Error("Expected fast local image tests to use a registered image provider");
},
describeImagesWithModel: async () => {
throw new Error("Expected fast local image tests to use a registered image provider");
},
resolveImageCompressionPolicy: async ({ imageCount }) => ({ imageCount }),
resolveModelAsync: async (provider, model) => ({
model: {
@@ -1777,6 +1819,15 @@ describe("image tool implicit imageModel config", () => {
},
};
expect(resolveMediaToolInboundRoots({ cfg })).toEqual([]);
const roots = resolveMediaToolInboundRoots({
cfg,
channelId: "imessage",
accountId: "work",
});
expect(roots).toContain(attachmentRoot);
expect(isInboundPathAllowed({ filePath: imagePath, roots })).toBe(true);
const withoutChannel = createRequiredImageTool({ config: cfg, agentDir });
await expect(
withoutChannel.execute("t1", { prompt: "Describe.", image: imagePath }),
@@ -1101,6 +1101,7 @@ describe("buildContextOverflowRecoveryText", () => {
describe("runAgentTurnWithFallback", () => {
beforeEach(() => {
vi.useRealTimers();
state.runEmbeddedAgentMock.mockReset();
state.runCliAgentMock.mockReset();
state.runWithModelFallbackMock.mockReset();
@@ -0,0 +1,310 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { AgentRunLoopResult } from "./agent-runner-execution.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import type { ReplyOperation } from "./reply-run-registry.js";
import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
const runAgentTurnWithFallbackMock = vi.fn();
const resolveOutboundAttachmentFromUrlMock = vi.fn();
const enqueueFollowupRunMock = vi.fn();
const refreshQueuedFollowupSessionMock = vi.fn();
const scheduleFollowupDrainMock = vi.fn();
vi.mock("../../agents/context.js", () => ({
resolveContextTokensForModel: () => 200_000,
}));
vi.mock("../../agents/model-selection.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/model-selection.js")>(
"../../agents/model-selection.js",
);
return {
...actual,
isCliProvider: () => false,
};
});
vi.mock("../../agents/sandbox.js", async () => {
const actual =
await vi.importActual<typeof import("../../agents/sandbox.js")>("../../agents/sandbox.js");
return {
...actual,
ensureSandboxWorkspaceForSession: async () => null,
};
});
vi.mock("../../infra/diagnostic-events.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/diagnostic-events.js")>(
"../../infra/diagnostic-events.js",
);
return {
...actual,
emitTrustedDiagnosticEvent: vi.fn(),
isDiagnosticsEnabled: () => false,
};
});
vi.mock("../../infra/diagnostics-timeline.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/diagnostics-timeline.js")>(
"../../infra/diagnostics-timeline.js",
);
return {
...actual,
measureDiagnosticsTimelineSpan: async (_name: string, run: () => unknown) => await run(),
};
});
vi.mock("../../media/outbound-attachment.js", () => ({
resolveOutboundAttachmentFromUrl: (...args: unknown[]) =>
resolveOutboundAttachmentFromUrlMock(...args),
}));
vi.mock("./agent-runner-execution.js", () => ({
buildKnownAgentRunFailureReplyPayload: vi.fn(() => undefined),
runAgentTurnWithFallback: (...args: unknown[]) => runAgentTurnWithFallbackMock(...args),
}));
vi.mock("./agent-runner-memory.js", () => ({
runMemoryFlushIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) => sessionEntry,
runPreflightCompactionIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) =>
sessionEntry,
}));
vi.mock("./agent-runner-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
return {
...actual,
resolveQueuedReplyExecutionConfig: async (config: unknown) => config,
};
});
vi.mock("./queue.js", async () => {
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: (...args: unknown[]) => enqueueFollowupRunMock(...args),
refreshQueuedFollowupSession: (...args: unknown[]) => refreshQueuedFollowupSessionMock(...args),
scheduleFollowupDrain: (...args: unknown[]) => scheduleFollowupDrainMock(...args),
};
});
vi.mock("./session-run-accounting.js", () => ({
incrementRunCompactionCount: async () => undefined,
persistRunSessionUsage: async () => undefined,
}));
const { runReplyAgent } = await import("./agent-runner.js");
function createReplyOperation(): ReplyOperation {
return {
result: undefined,
setPhase: vi.fn(),
fail: vi.fn(),
complete: vi.fn(),
completeThen: vi.fn(),
} as unknown as ReplyOperation;
}
function makeRunReplyAgentParams(
overrides: Partial<Parameters<typeof runReplyAgent>[0]> = {},
): Parameters<typeof runReplyAgent>[0] {
const provider = "telegram";
const workspaceDir = "/tmp/workspace";
const prompt = "generate chart";
return {
commandBody: prompt,
followupRun: createMockFollowupRun({
prompt,
run: {
agentId: "main",
agentDir: "/tmp/agent",
messageProvider: provider,
workspaceDir,
},
}) as unknown as FollowupRun,
queueKey: "main",
resolvedQueue: { mode: "interrupt" } as QueueSettings,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing: createMockTypingController(),
sessionCtx: {
Provider: provider,
Surface: provider,
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
} as unknown as TemplateContext,
defaultModel: "anthropic/claude",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
replyOperation: createReplyOperation(),
...overrides,
};
}
describe("runReplyAgent final MEDIA replies", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
runAgentTurnWithFallbackMock.mockReset();
resolveOutboundAttachmentFromUrlMock.mockReset();
enqueueFollowupRunMock.mockReset();
refreshQueuedFollowupSessionMock.mockReset();
scheduleFollowupDrainMock.mockReset();
runAgentTurnWithFallbackMock.mockImplementation(async (params: unknown) => {
const { buildReplyPayloads } = await vi.importActual<
typeof import("./agent-runner-payloads.js")
>("./agent-runner-payloads.js");
const runnerParams = params as {
replyMediaContext?: {
normalizePayload?: (payload: {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
}) => Promise<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>;
};
};
const normalizeMediaPaths = runnerParams.replyMediaContext?.normalizePayload;
if (!normalizeMediaPaths) {
throw new Error("runReplyAgent did not pass replyMediaContext to the agent turn");
}
const { replyPayloads } = await buildReplyPayloads({
payloads: [{ text: "here is the chart\nMEDIA:./out/generated.png" }],
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false,
blockReplyPipeline: null,
replyToMode: "all",
replyToChannel: "telegram",
currentMessageId: "msg-1",
messageProvider: "telegram",
originatingChannel: "telegram",
originatingTo: "chat-1",
accountId: "default",
normalizeMediaPaths,
});
const payload = replyPayloads[0];
if (!payload) {
throw new Error("expected parsed reply payload");
}
return {
kind: "final",
payload,
} satisfies AgentRunLoopResult;
});
resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
path: path.join("/tmp/outbound-media", path.basename(mediaUrl)),
}));
});
it("normalizes final MEDIA directives through runReplyAgent", async () => {
const result = await runReplyAgent(makeRunReplyAgentParams());
expect(Array.isArray(result)).toBe(false);
if (!result || Array.isArray(result)) {
throw new Error("expected single reply payload");
}
expect(result).toMatchObject({
text: "here is the chart",
mediaUrl: "/tmp/outbound-media/generated.png",
mediaUrls: ["/tmp/outbound-media/generated.png"],
});
expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce();
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith(
path.join("/tmp/workspace", "out", "generated.png"),
5 * 1024 * 1024,
{ mediaAccess: expect.objectContaining({ workspaceDir: "/tmp/workspace" }) },
);
});
it("uses one runReplyAgent media context for block and final MEDIA replies", async () => {
let stagedIndex = 0;
resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => {
stagedIndex += 1;
return {
path: path.join("/tmp/outbound-media", `${stagedIndex}-${path.basename(mediaUrl)}`),
};
});
runAgentTurnWithFallbackMock.mockImplementationOnce(async (params: unknown) => {
const { buildReplyPayloads } = await vi.importActual<
typeof import("./agent-runner-payloads.js")
>("./agent-runner-payloads.js");
const runnerParams = params as {
replyMediaContext?: {
normalizePayload?: (payload: {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
}) => Promise<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>;
};
};
const normalizeMediaPaths = runnerParams.replyMediaContext?.normalizePayload;
if (!normalizeMediaPaths) {
throw new Error("runReplyAgent did not pass replyMediaContext to the agent turn");
}
const commonParams = {
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false,
blockReplyPipeline: null,
replyToMode: "all" as const,
replyToChannel: "telegram" as const,
currentMessageId: "msg-1",
messageProvider: "telegram",
originatingChannel: "telegram" as const,
originatingTo: "chat-1",
accountId: "default",
normalizeMediaPaths,
};
const blockPayloads = await buildReplyPayloads({
...commonParams,
payloads: [{ text: "block\nMEDIA:./out/chart.png" }],
});
const finalPayloads = await buildReplyPayloads({
...commonParams,
payloads: [{ text: "final\nMEDIA:./out/chart.png" }],
});
expect(blockPayloads.replyPayloads[0]).toMatchObject({
text: "block",
mediaUrl: "/tmp/outbound-media/1-chart.png",
});
const payload = finalPayloads.replyPayloads[0];
if (!payload) {
throw new Error("expected parsed final payload");
}
return {
kind: "final",
payload,
} satisfies AgentRunLoopResult;
});
const result = await runReplyAgent(
makeRunReplyAgentParams({
blockStreamingEnabled: true,
opts: { onBlockReply: vi.fn(async () => {}) },
}),
);
expect(Array.isArray(result)).toBe(false);
if (!result || Array.isArray(result)) {
throw new Error("expected single reply payload");
}
expect(result).toMatchObject({
text: "final",
mediaUrl: "/tmp/outbound-media/1-chart.png",
mediaUrls: ["/tmp/outbound-media/1-chart.png"],
});
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledTimes(1);
});
});
@@ -1,10 +1,12 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EmbeddedAgentQueueMessageOutcome } from "../../agents/embedded-agent-runner/runs.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import type { ReplyOperation } from "./reply-run-registry.js";
import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
const runEmbeddedAgentMock = vi.fn();
@@ -30,6 +32,7 @@ const waitForEmbeddedAgentRunEndMock = vi.fn();
const enqueueFollowupRunMock = vi.fn();
const scheduleFollowupDrainMock = vi.fn();
const refreshQueuedFollowupSessionMock = vi.fn();
const resolveCommandSecretRefsViaGatewayMock = vi.fn();
const resolveOutboundAttachmentFromUrlMock = vi.fn();
const createReplyMediaContextRuntimeMock = vi.fn();
@@ -45,6 +48,51 @@ vi.mock("../../agents/model-fallback.js", () => ({
Array.isArray((err as { attempts?: unknown[] }).attempts),
}));
vi.mock("../../agents/model-selection.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/model-selection.js")>(
"../../agents/model-selection.js",
);
return {
...actual,
isCliProvider: (provider: string, cfg?: OpenClawConfig) => {
const normalized = provider.trim().toLowerCase();
return (
normalized === "claude-cli" ||
normalized === "google-gemini-cli" ||
normalized === "codex-cli" ||
Boolean(cfg?.agents?.defaults?.cliBackends?.[normalized])
);
},
};
});
vi.mock("../../agents/model-runtime-aliases.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/model-runtime-aliases.js")>(
"../../agents/model-runtime-aliases.js",
);
const normalize = (value: string) => value.trim().toLowerCase();
return {
...actual,
areRuntimeModelRefsEquivalent: (left: string, right: string) =>
normalize(left) === normalize(right),
};
});
vi.mock("../../agents/context.js", () => ({
resolveContextTokensForModel: () => 200_000,
}));
vi.mock("../../infra/agent-events.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/agent-events.js")>(
"../../infra/agent-events.js",
);
return {
...actual,
emitAgentEvent: vi.fn(),
registerAgentRunContext: vi.fn(),
};
});
vi.mock("../../agents/embedded-agent.js", () => ({
abortEmbeddedAgentRun: abortEmbeddedAgentRunMock,
compactEmbeddedAgentSession: compactEmbeddedAgentSessionMock,
@@ -64,6 +112,123 @@ vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({
queueEmbeddedAgentMessageWithOutcomeAsync: queueEmbeddedAgentMessageWithOutcomeAsyncMock,
}));
vi.mock("../../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: (...args: unknown[]) =>
resolveCommandSecretRefsViaGatewayMock(...args),
}));
vi.mock("../../cli/command-secret-targets.js", () => ({
getAgentRuntimeCommandSecretTargetIds: () => new Set<string>(),
getScopedChannelsCommandSecretTargets: () => ({ targetIds: new Set<string>() }),
}));
vi.mock("../../agents/sandbox.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/sandbox.js")>();
return {
...actual,
ensureSandboxWorkspaceForSession: async () => null,
};
});
vi.mock("./reply-media-paths.js", () => ({
createReplyMediaContext: ({ workspaceDir }: { workspaceDir: string }) => {
const cache = new Map<string, Promise<string>>();
const normalizeSource = (media: string) =>
media.startsWith("./") ? path.join(workspaceDir, media.slice(2)) : media;
const persist = async (media: string) => {
const source = normalizeSource(media);
const cached = cache.get(source);
if (cached) {
return await cached;
}
const pending = resolveOutboundAttachmentFromUrlMock(source, 5 * 1024 * 1024, {
mediaAccess: { workspaceDir },
}).then((saved: { path: string }) => saved.path);
cache.set(source, pending);
return await pending;
};
return {
normalizePayload: async (payload: {
mediaUrl?: string;
mediaUrls?: string[];
text?: string;
}) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaUrls.length === 0) {
return payload;
}
const normalized = await Promise.all(mediaUrls.map((media) => persist(media)));
return {
...payload,
mediaUrl: normalized[0],
mediaUrls: normalized,
};
},
};
},
}));
vi.mock("./agent-runner-payloads.js", () => ({
buildReplyPayloads: async (params: {
payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>;
didLogHeartbeatStrip: boolean;
blockStreamingEnabled?: boolean;
blockReplyPipeline?: { didStream?: () => boolean; isAborted?: () => boolean } | null;
normalizeMediaPaths?: (payload: {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
}) => Promise<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>;
}) => {
if (
params.blockStreamingEnabled &&
params.blockReplyPipeline?.didStream?.() === true &&
params.blockReplyPipeline?.isAborted?.() !== true
) {
return { replyPayloads: [], didLogHeartbeatStrip: params.didLogHeartbeatStrip };
}
const replyPayloads = [];
for (const payload of params.payloads) {
const mediaUrls = [...(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []))];
const textLines = [];
for (const line of (payload.text ?? "").split("\n")) {
const media = line
.trim()
.match(/^MEDIA:(.+)$/)?.[1]
?.trim();
if (media) {
mediaUrls.push(media);
} else {
textLines.push(line);
}
}
const nextPayload = {
...payload,
text: textLines.join("\n").trim() || undefined,
mediaUrl: mediaUrls[0],
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
};
replyPayloads.push(
params.normalizeMediaPaths && nextPayload.mediaUrls
? await params.normalizeMediaPaths(nextPayload)
: nextPayload,
);
}
return { replyPayloads, didLogHeartbeatStrip: params.didLogHeartbeatStrip };
},
}));
vi.mock("./session-run-accounting.js", () => ({
incrementRunCompactionCount: async () => undefined,
persistRunSessionUsage: async () => undefined,
}));
vi.mock("./agent-runner-memory.js", () => ({
runMemoryFlushIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) => sessionEntry,
runPreflightCompactionIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) =>
sessionEntry,
}));
vi.mock("./queue.js", () => ({
enqueueFollowupRun: enqueueFollowupRunMock,
refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock,
@@ -88,7 +253,17 @@ vi.mock("./reply-media-paths.runtime.js", async (importOriginal) => {
};
});
let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent;
const { runReplyAgent } = await import("./agent-runner.js");
function createReplyOperation(): ReplyOperation {
return {
result: undefined,
setPhase: vi.fn(),
fail: vi.fn(),
complete: vi.fn(),
completeThen: vi.fn(),
} as unknown as ReplyOperation;
}
function makeRunReplyAgentParams(
overrides: Partial<Parameters<typeof runReplyAgent>[0]> & {
@@ -134,6 +309,7 @@ function makeRunReplyAgentParams(
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
replyOperation: createReplyOperation(),
...overrides,
};
}
@@ -141,10 +317,6 @@ function makeRunReplyAgentParams(
describe("runReplyAgent media path normalization", () => {
const cleanupPaths: string[] = [];
beforeAll(async () => {
({ runReplyAgent } = await import("./agent-runner.js"));
});
beforeEach(() => {
runEmbeddedAgentMock.mockReset();
runWithModelFallbackMock.mockReset();
@@ -166,6 +338,13 @@ describe("runReplyAgent media path normalization", () => {
enqueueFollowupRunMock.mockReset();
scheduleFollowupDrainMock.mockReset();
refreshQueuedFollowupSessionMock.mockReset();
resolveCommandSecretRefsViaGatewayMock.mockReset();
resolveCommandSecretRefsViaGatewayMock.mockImplementation(async ({ config }) => ({
resolvedConfig: config,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
}));
resolveOutboundAttachmentFromUrlMock.mockReset();
createReplyMediaContextRuntimeMock.mockReset();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
@@ -197,7 +376,7 @@ describe("runReplyAgent media path normalization", () => {
it("normalizes final MEDIA replies against the run workspace", async () => {
runEmbeddedAgentMock.mockResolvedValue({
payloads: [{ text: "MEDIA:./out/generated.png" }],
payloads: [{ text: "here is the chart\nMEDIA:./out/generated.png" }],
meta: {
agentMeta: {
sessionId: "session",
@@ -207,26 +386,24 @@ describe("runReplyAgent media path normalization", () => {
},
});
const result = await runReplyAgent(
makeRunReplyAgentParams({
provider: "telegram",
prompt: "generate",
}),
);
const result = await runReplyAgent(makeRunReplyAgentParams());
expect(Array.isArray(result)).toBe(false);
if (!result || Array.isArray(result)) {
throw new Error("expected single reply payload");
throw new Error("Expected a single reply payload");
}
expect(result.mediaUrl).toBe("/tmp/outbound-media/generated.png");
expect(result.mediaUrls).toEqual(["/tmp/outbound-media/generated.png"]);
const outboundAttachmentCall = resolveOutboundAttachmentFromUrlMock.mock.calls.at(0);
expect(outboundAttachmentCall?.[0]).toBe(path.join("/tmp/workspace", "out", "generated.png"));
expect(outboundAttachmentCall?.[1]).toBe(5 * 1024 * 1024);
const outboundAttachmentOptions = outboundAttachmentCall?.[2] as
| { mediaAccess?: { workspaceDir?: unknown } }
| undefined;
expect(outboundAttachmentOptions?.mediaAccess?.workspaceDir).toBe("/tmp/workspace");
expect(result).toMatchObject({
text: "here is the chart",
mediaUrl: "/tmp/outbound-media/generated.png",
mediaUrls: ["/tmp/outbound-media/generated.png"],
});
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith(
path.join("/tmp/workspace", "out", "generated.png"),
5 * 1024 * 1024,
{ mediaAccess: expect.objectContaining({ workspaceDir: "/tmp/workspace" }) },
);
expect(runEmbeddedAgentMock).toHaveBeenCalledOnce();
expect(createReplyMediaContextRuntimeMock).not.toHaveBeenCalled();
});
it("steers active prompts in steer queue mode", async () => {
@@ -298,6 +475,15 @@ describe("runReplyAgent media path normalization", () => {
});
it("shares one media cache between block accumulation and final payload delivery", async () => {
const { createReplyMediaContext } =
await vi.importActual<typeof import("./reply-media-paths.js")>("./reply-media-paths.js");
const mediaContext = createReplyMediaContext({
cfg: {},
sessionKey: "main",
workspaceDir: "/tmp/workspace",
messageProvider: "telegram",
accountId: "default",
});
let stagedIndex = 0;
resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => {
stagedIndex += 1;
@@ -305,55 +491,78 @@ describe("runReplyAgent media path normalization", () => {
path: path.join("/tmp/outbound-media", `${stagedIndex}-${path.basename(mediaUrl)}`),
};
});
const onBlockReply = vi.fn();
runEmbeddedAgentMock.mockImplementation(
async (params: {
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void>;
}) => {
await params.onBlockReply?.({
text: "here is the chart\nMEDIA:./out/chart.png",
});
return {
payloads: [{ text: "here is the chart\nMEDIA:./out/chart.png" }],
meta: {
agentMeta: {
sessionId: "session",
provider: "anthropic",
model: "claude",
},
},
};
},
);
const result = await runReplyAgent(
makeRunReplyAgentParams({
opts: {
onBlockReply,
},
}),
);
const blockPayload = await mediaContext.normalizePayload({
text: "here is the chart",
mediaUrl: "./out/chart.png",
mediaUrls: ["./out/chart.png"],
});
const finalPayload = await mediaContext.normalizePayload({
text: "here is the chart",
mediaUrl: "./out/chart.png",
mediaUrls: ["./out/chart.png"],
});
expect(result).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledWith({
expect(blockPayload).toEqual({
text: "here is the chart",
mediaUrl: "/tmp/outbound-media/1-chart.png",
mediaUrls: ["/tmp/outbound-media/1-chart.png"],
replyToId: "msg-1",
replyToTag: false,
audioAsVoice: false,
});
expect(finalPayload).toEqual(blockPayload);
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledTimes(1);
});
it("does not create a second media context inside runAgentTurnWithFallback when onBlockReply is provided", async () => {
async function runAgentTurnWithSessionContext(
sessionCtx: TemplateContext,
prompt = "describe this image",
): Promise<void> {
const { runAgentTurnWithFallback } = await import("./agent-runner-execution.js");
await runAgentTurnWithFallback({
commandBody: prompt,
followupRun: createMockFollowupRun({
prompt,
run: {
provider: "ollama",
model: "gemma4:latest",
workspaceDir: "/tmp/workspace",
config: {},
},
}),
sessionCtx,
typingSignals: {
mode: "instant",
shouldStartImmediately: true,
shouldStartOnMessageStart: false,
shouldStartOnText: true,
shouldStartOnReasoning: false,
signalRunStart: async () => {},
signalMessageStart: async () => {},
signalTextDelta: async () => {},
signalReasoningDelta: async () => {},
signalToolStart: async () => {},
},
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
replyMediaContext: {
normalizePayload: async (payload) => payload,
},
});
}
it("reuses the provided media context inside runAgentTurnWithFallback", async () => {
// Regression test for openclaw/openclaw#68056.
// Before the fix, runAgentTurnWithFallback created its own media context, separate from
// the one agent-runner.ts created and passed to buildReplyPayloads. Two separate caches
// meant the same source could be persisted twice (two UUID outbound files, two sends).
//
// After the fix, agent-runner.ts passes its media context into runAgentTurnWithFallback, so
// the .runtime import path is never called from inside that function.
// runAgentTurnWithFallback must use the caller-provided context so block
// replies and final replies can share one media cache.
runEmbeddedAgentMock.mockResolvedValue({
payloads: [],
meta: {
@@ -365,17 +574,58 @@ describe("runReplyAgent media path normalization", () => {
},
});
await runReplyAgent(
makeRunReplyAgentParams({
opts: {
onBlockReply: vi.fn(),
},
}),
);
const { runAgentTurnWithFallback } = await import("./agent-runner-execution.js");
const followupRun = createMockFollowupRun({
prompt: "generate",
run: {
provider: "anthropic",
model: "claude",
workspaceDir: "/tmp/workspace",
config: {},
},
});
await runAgentTurnWithFallback({
commandBody: "generate",
followupRun,
sessionCtx: {
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
} as unknown as TemplateContext,
typingSignals: {
mode: "instant",
shouldStartImmediately: true,
shouldStartOnMessageStart: false,
shouldStartOnText: true,
shouldStartOnReasoning: false,
signalRunStart: async () => {},
signalMessageStart: async () => {},
signalTextDelta: async () => {},
signalReasoningDelta: async () => {},
signalToolStart: async () => {},
},
blockReplyPipeline: null,
blockStreamingEnabled: true,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
replyMediaContext: {
normalizePayload: async (payload) => payload,
},
});
// The .runtime import is only used by agent-runner-execution.ts. After the fix,
// runAgentTurnWithFallback receives the context from the caller and never
// creates its own.
// The .runtime import is only used by agent-runner-execution.ts. This path
// should never create its own media context when the caller provides one.
expect(createReplyMediaContextRuntimeMock).not.toHaveBeenCalled();
});
@@ -401,23 +651,17 @@ describe("runReplyAgent media path normalization", () => {
},
});
await runReplyAgent(
makeRunReplyAgentParams({
provider: "telegram",
prompt: "describe this image",
sessionCtx: {
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
MediaPaths: [imagePath],
MediaTypes: ["image/png"],
MediaWorkspaceDir: tmpDir,
} as unknown as TemplateContext,
}),
);
await runAgentTurnWithSessionContext({
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
MediaPaths: [imagePath],
MediaTypes: ["image/png"],
MediaWorkspaceDir: tmpDir,
} as unknown as TemplateContext);
expect(runEmbeddedAgentMock).toHaveBeenCalledOnce();
const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as
@@ -459,28 +703,25 @@ describe("runReplyAgent media path normalization", () => {
},
});
await runReplyAgent(
makeRunReplyAgentParams({
provider: "telegram",
prompt: "what did we discuss?",
sessionCtx: {
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
Timestamp: 1_700_000_000_000,
InboundHistory: [
{
sender: "alice",
body: "<media:image>",
timestamp: 1_700_000_000_000,
media: [{ path: imagePath, contentType: "image/png", kind: "image" }],
},
],
} as unknown as TemplateContext,
}),
await runAgentTurnWithSessionContext(
{
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
Timestamp: 1_700_000_000_000,
InboundHistory: [
{
sender: "alice",
body: "<media:image>",
timestamp: 1_700_000_000_000,
media: [{ path: imagePath, contentType: "image/png", kind: "image" }],
},
],
} as unknown as TemplateContext,
"what did we discuss?",
);
expect(runEmbeddedAgentMock).toHaveBeenCalledOnce();
@@ -516,22 +757,19 @@ describe("runReplyAgent media path normalization", () => {
},
});
await runReplyAgent(
makeRunReplyAgentParams({
provider: "telegram",
prompt: "compare these images",
sessionCtx: {
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
MediaPaths: [path.join(tmpDir, "missing.png"), imagePath],
MediaTypes: ["image/png", "image/png"],
MediaWorkspaceDir: tmpDir,
} as unknown as TemplateContext,
}),
await runAgentTurnWithSessionContext(
{
Provider: "telegram",
Surface: "telegram",
To: "chat-1",
OriginatingTo: "chat-1",
AccountId: "default",
MessageSid: "msg-1",
MediaPaths: [path.join(tmpDir, "missing.png"), imagePath],
MediaTypes: ["image/png", "image/png"],
MediaWorkspaceDir: tmpDir,
} as unknown as TemplateContext,
"compare these images",
);
expect(runEmbeddedAgentMock).toHaveBeenCalledOnce();
@@ -45,6 +45,13 @@ function createCliBackendTestConfig() {
function registerCliBackendsForTest(): void {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
@@ -113,6 +120,24 @@ vi.mock("../../agents/cli-runner.js", () => ({
runCliAgent: (...args: unknown[]) => runCliAgentMock(...args),
}));
vi.mock("../../agents/model-selection.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/model-selection.js")>(
"../../agents/model-selection.js",
);
return {
...actual,
isCliProvider: (provider: string, cfg?: OpenClawConfig) => {
const normalized = provider.trim().toLowerCase();
return (
normalized === "claude-cli" ||
normalized === "google-gemini-cli" ||
normalized === "codex-cli" ||
Boolean(cfg?.agents?.defaults?.cliBackends?.[normalized])
);
},
};
});
vi.mock("../../runtime.js", () => {
return {
defaultRuntime: {
@@ -211,6 +236,7 @@ function firstMockCallArg(mock: MockCallSource, label: string): unknown {
}
beforeEach(() => {
vi.useRealTimers();
registerCliBackendsForTest();
clearRuntimeConfigSnapshot();
resetDiagnosticEventsForTest();
+36 -1
View File
@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
@@ -31,6 +32,34 @@ const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn());
const MODELS_ADD_DEPRECATED_TEXT =
"⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models.";
function setFastModelsCliBackendDeps(): void {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "claude-cli",
modelProvider: "anthropic",
config: { command: "claude" },
bundleMcp: false,
},
{
id: "google-gemini-cli",
pluginId: "google-gemini-cli",
modelProvider: "google",
config: { command: "gemini" },
bundleMcp: false,
},
],
});
}
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: modelCatalogMocks.loadModelCatalog,
}));
@@ -108,6 +137,7 @@ const textSurfaceModelsTestPlugins = (["discord", "whatsapp"] as const).map((id)
}));
beforeAll(async () => {
setFastModelsCliBackendDeps();
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
]);
@@ -117,6 +147,7 @@ beforeAll(async () => {
});
beforeEach(() => {
setFastModelsCliBackendDeps();
modelCatalogMocks.loadModelCatalog.mockReset();
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
@@ -166,6 +197,10 @@ beforeEach(() => {
setActivePluginRegistry(registry);
});
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
});
function buildParams(
commandBodyNormalized: string,
cfgOverrides: Partial<OpenClawConfig> = {},
@@ -4,6 +4,7 @@ import path from "node:path";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../../test/helpers/normalize-text.js";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js";
import type { AgentHarness } from "../../agents/harness/types.js";
import {
@@ -111,6 +112,7 @@ function registerStatusCodexHarness(): void {
}
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
clearAgentHarnesses();
providerUsageMock.loadProviderUsageSummary.mockReset();
providerUsageMock.loadProviderUsageSummary.mockResolvedValue({
@@ -156,6 +158,24 @@ function writeTranscriptUsageLog(params: {
describe("buildStatusReply subagent summary", () => {
beforeEach(() => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "claude-cli",
modelProvider: "anthropic",
config: { command: "claude" },
bundleMcp: false,
},
],
});
resetSubagentRegistryForTests();
resetTaskRegistryForTests({ persist: false });
configureInMemoryTaskRegistryStoreForTests();
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
const authProfilesStoreMock = vi.hoisted(() => ({
profiles: {} as Record<
string,
@@ -251,6 +252,17 @@ function setDirectiveTestProviders(providers: ProviderPlugin[]): void {
}
beforeEach(() => {
vi.useRealTimers();
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [],
});
setDirectiveTestProviders([]);
clearRuntimeAuthProfileStoreSnapshots();
replaceRuntimeAuthProfileStoreSnapshots([
@@ -268,6 +280,7 @@ beforeEach(() => {
});
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
setDirectiveTestProviders([]);
clearRuntimeAuthProfileStoreSnapshots();
clearInternalHooks();
@@ -6812,7 +6812,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
To: "telegram:-1001234567890",
AccountId: "default",
MessageThreadId: 11,
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
ChatType: "group",
GroupSubject: "Dev",
Body: "observed message",
@@ -6834,17 +6833,18 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
sourceReplyDeliveryMode: "message_tool_only",
});
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-message-tool-only");
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
"openclaw-codex-app-server",
expect.objectContaining({
channel: "telegram",
content: "observed message",
threadId: 11,
}),
expect.objectContaining({
pluginBinding: expect.objectContaining({ bindingId: "binding-message-tool-only" }),
}),
const claimCall = firstMockCall(
hookMocks.runner.runInboundClaimForPluginOutcome,
"plugin inbound claim",
);
expect(claimCall[0]).toBe("openclaw-codex-app-server");
expect(claimCall[1]).toMatchObject({
channel: "telegram",
content: "observed message",
threadId: 11,
});
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
expect(claimContext.pluginBinding).toMatchObject({ bindingId: "binding-message-tool-only" });
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
@@ -6893,7 +6893,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
To: "telegram:-1001234567890",
AccountId: "default",
MessageThreadId: 11,
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
ChatType: "group",
GroupSubject: "Dev",
Body: "observed message",
@@ -6915,17 +6914,20 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
counts: { tool: 0, block: 0, final: 0 },
sourceReplyDeliveryMode: "message_tool_only",
});
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
"openclaw-codex-app-server",
expect.objectContaining({
channel: "telegram",
content: "observed message",
threadId: 11,
}),
expect.objectContaining({
pluginBinding: expect.objectContaining({ bindingId: "binding-message-tool-fallback" }),
}),
const claimCall = firstMockCall(
hookMocks.runner.runInboundClaimForPluginOutcome,
"plugin inbound claim",
);
expect(claimCall[0]).toBe("openclaw-codex-app-server");
expect(claimCall[1]).toMatchObject({
channel: "telegram",
content: "observed message",
threadId: 11,
});
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
expect(claimContext.pluginBinding).toMatchObject({
bindingId: "binding-message-tool-fallback",
});
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
@@ -6976,7 +6978,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
To: "telegram:-1001234567890",
AccountId: "default",
MessageThreadId: 11,
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
ChatType: "group",
GroupSubject: "Dev",
Body: "/reset@openclaw",
@@ -7045,7 +7046,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
To: "telegram:-1001234567890",
AccountId: "default",
MessageThreadId: 11,
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
ChatType: "group",
GroupSubject: "Dev",
Body: "/status",
@@ -7063,16 +7063,17 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
replyResolver,
});
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
"openclaw-codex-app-server",
expect.objectContaining({
channel: "telegram",
content: "/status",
}),
expect.objectContaining({
pluginBinding: expect.objectContaining({ bindingId: "binding-native-unauthorized" }),
}),
const claimCall = firstMockCall(
hookMocks.runner.runInboundClaimForPluginOutcome,
"plugin inbound claim",
);
expect(claimCall[0]).toBe("openclaw-codex-app-server");
expect(claimCall[1]).toMatchObject({
channel: "telegram",
content: "/status",
});
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
expect(claimContext.pluginBinding).toMatchObject({ bindingId: "binding-native-unauthorized" });
expect(replyResolver).not.toHaveBeenCalled();
});
@@ -7121,7 +7122,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
To: "telegram:-1001234567890",
AccountId: "default",
MessageThreadId: 11,
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
ChatType: "group",
GroupSubject: "Dev",
Body: "through this",
@@ -7144,16 +7144,19 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
replyResolver,
});
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
"openclaw-codex-app-server",
expect.objectContaining({
channel: "telegram",
content: "/think high through this",
}),
expect.objectContaining({
pluginBinding: expect.objectContaining({ bindingId: "binding-structured-normal-turn" }),
}),
const claimCall = firstMockCall(
hookMocks.runner.runInboundClaimForPluginOutcome,
"plugin inbound claim",
);
expect(claimCall[0]).toBe("openclaw-codex-app-server");
expect(claimCall[1]).toMatchObject({
channel: "telegram",
content: "/think high through this",
});
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
expect(claimContext.pluginBinding).toMatchObject({
bindingId: "binding-structured-normal-turn",
});
expect(replyResolver).not.toHaveBeenCalled();
});
@@ -37,6 +37,7 @@ let createMockFollowupRun: typeof import("./test-helpers.js").createMockFollowup
let createMockTypingController: typeof import("./test-helpers.js").createMockTypingController;
let createReplyOperationForTest: typeof import("./reply-run-registry.js").createReplyOperation;
let replyRunTestingForTest: typeof import("./reply-run-registry.js").testing;
let cliBackendsTestingForTest: typeof import("../../agents/cli-backends.js").testing;
const FOLLOWUP_DEBUG = process.env.OPENCLAW_DEBUG_FOLLOWUP_RUNNER_TEST === "1";
const FOLLOWUP_TEST_QUEUES = new Map<
string,
@@ -442,6 +443,8 @@ async function loadFreshFollowupRunnerModuleForTest() {
};
},
}));
({ testing: cliBackendsTestingForTest } = await import("../../agents/cli-backends.js"));
setFastFollowupCliBackendDeps();
({ createFollowupRunner } = await import("./followup-runner.js"));
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../../config/config.js"));
@@ -454,6 +457,27 @@ async function loadFreshFollowupRunnerModuleForTest() {
await import("./reply-run-registry.js"));
}
function setFastFollowupCliBackendDeps(): void {
cliBackendsTestingForTest.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "claude-cli",
modelProvider: "anthropic",
config: { command: "claude" },
bundleMcp: false,
},
],
});
}
const ROUTABLE_TEST_CHANNELS = new Set([
"telegram",
"slack",
@@ -469,6 +493,7 @@ beforeAll(async () => {
});
beforeEach(() => {
setFastFollowupCliBackendDeps();
replyRunTestingForTest?.resetReplyRunRegistry();
clearRuntimeConfigSnapshot?.();
runEmbeddedAgentMock.mockReset();
@@ -524,6 +549,7 @@ beforeEach(() => {
});
afterEach(() => {
cliBackendsTestingForTest?.resetDepsForTest();
replyRunTestingForTest?.resetReplyRunRegistry();
clearRuntimeConfigSnapshot?.();
clearFollowupQueue("main");
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
buildFastReplyCommandContext,
@@ -101,6 +102,16 @@ describe("getReplyFromConfig fast test bootstrap", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [],
});
mocks.ensureAgentWorkspace.mockReset();
mocks.initSessionState.mockReset();
mocks.loadModelCatalog.mockReset();
@@ -130,6 +141,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
});
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
vi.unstubAllEnvs();
});
+24 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { testing as cliBackendsTesting } from "../agents/cli-backends.js";
import { MODEL_CONTEXT_TOKEN_CACHE } from "../agents/context-cache.js";
@@ -33,6 +33,19 @@ vi.mock("../plugins/commands.js", () => ({
listPluginCommands,
}));
beforeEach(() => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [],
});
});
afterEach(() => {
vi.restoreAllMocks();
cliBackendsTesting.resetDepsForTest();
@@ -43,6 +56,13 @@ afterEach(() => {
function registerAnthropicCliBackendForTest(): void {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
@@ -97,6 +117,7 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender",
resolvedThink: "medium",
resolvedVerbose: "off",
resolvedHarness: "openclaw",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
now: 10 * 60_000, // 10 minutes later
@@ -1014,6 +1035,8 @@ describe("buildStatusMessage", () => {
});
it("prefers active CLI OAuth over selected env API-key labels for runtime aliases", () => {
registerAnthropicCliBackendForTest();
const text = buildStatusMessage({
config: {
models: {
+25 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { runConfigSet } from "./config-cli.js";
@@ -110,6 +110,30 @@ async function withExecDryRunConfigHarness(
}
describe("config cli integration", () => {
beforeAll(async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-warmup-"));
const configPath = path.join(tempDir, "openclaw.json");
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]);
try {
fs.writeFileSync(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
clearConfigCache();
clearRuntimeConfigSnapshot();
await runConfigSet({
path: "gateway.port",
value: "18790",
cliOptions: {},
runtime: createTestRuntime().runtime,
});
} finally {
envSnapshot.restore();
clearConfigCache();
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("accepts plugin hook conversation-access policy via config set", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-plugin-hooks-"));
const configPath = path.join(tempDir, "openclaw.json");
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
replaceConfigFile: vi.fn(),
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
resolveInstallableChannelPlugin: vi.fn(),
listReadOnlyChannelPluginsForConfig: vi.fn(),
}));
vi.mock("./shared.js", () => ({
@@ -28,6 +29,10 @@ vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: vi.fn(),
}));
vi.mock("../../channels/plugins/read-only.js", () => ({
listReadOnlyChannelPluginsForConfig: mocks.listReadOnlyChannelPluginsForConfig,
}));
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
@@ -123,6 +128,7 @@ describe("channelsCapabilitiesCommand", () => {
vi.clearAllMocks();
mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" });
mocks.replaceConfigFile.mockResolvedValue(undefined);
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: { channels: {} },
configChanged: false,
+12 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import {
getDoctorConfigInputForTest,
@@ -1456,6 +1456,17 @@ type RepairedDiscordPolicy = {
};
describe("doctor config flow", () => {
beforeAll(async () => {
await Promise.all([
import("../config/plugin-auto-enable.js"),
import("./doctor/repair-sequencing.js"),
import("./doctor/shared/channel-doctor.js"),
import("./doctor/shared/legacy-config-issues.js"),
import("./doctor/shared/plugin-tool-allowlist-warnings.js"),
import("./doctor/shared/preview-warnings.js"),
]);
});
beforeEach(() => {
terminalNoteMock.mockClear();
collectImplicitFallbackClobberWarningsMock.mockClear();
@@ -1,144 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { stripAnsi } from "../terminal/ansi.js";
import type { HealthSummary } from "./health.js";
import { healthCommand } from "./health.js";
const callGatewayMock = vi.fn();
const buildGatewayConnectionDetailsMock = vi.fn(() => ({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
}));
const logWebSelfIdMock = vi.fn();
function createRecentSessionRows(now = Date.now()) {
return [
{ key: "main", updatedAt: now - 60_000, age: 60_000 },
{ key: "foo", updatedAt: null, age: null },
];
}
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: [unknown, ...unknown[]]) =>
Reflect.apply(callGatewayMock, undefined, args),
buildGatewayConnectionDetails: (...args: [unknown, ...unknown[]]) =>
Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args),
}));
vi.mock("../config/config.js", () => ({
readBestEffortConfig: vi.fn(async () => ({})),
}));
vi.mock("../channels/plugins/index.js", () => {
const whatsappPlugin = {
id: "whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "WhatsApp test stub.",
},
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
status: {
logSelfId: () => logWebSelfIdMock(),
},
};
return {
getChannelPlugin: (channelId: string) => (channelId === "whatsapp" ? whatsappPlugin : null),
listChannelPlugins: () => [whatsappPlugin],
};
});
describe("healthCommand (coverage)", () => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
buildGatewayConnectionDetailsMock.mockReturnValue({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
});
});
it("prints the rich text summary and verbose gateway details", async () => {
const recent = createRecentSessionRows();
callGatewayMock.mockResolvedValueOnce({
ok: true,
ts: Date.now(),
durationMs: 5,
channels: {
whatsapp: {
accountId: "default",
linked: true,
authAgeMs: 5 * 60_000,
},
telegram: {
accountId: "default",
configured: true,
probe: {
ok: true,
elapsedMs: 7,
bot: { username: "bot" },
webhook: { url: "https://example.com/h" },
},
},
discord: {
accountId: "default",
configured: false,
},
},
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",
},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: {
path: "/tmp/sessions.json",
count: 2,
recent,
},
},
],
sessions: {
path: "/tmp/sessions.json",
count: 2,
recent,
},
} satisfies HealthSummary);
await healthCommand({ json: false, verbose: true, timeoutMs: 1000 }, runtime as never);
expect(runtime.exit).not.toHaveBeenCalled();
const output = stripAnsi(runtime.log.mock.calls.map((c) => String(c[0])).join("\n"));
expect(output).toMatch(/WhatsApp: linked/i);
expect(runtime.log.mock.calls.slice(0, 3)).toEqual([
["Gateway connection:"],
[" Gateway mode: local"],
[" Gateway target: ws://127.0.0.1:18789"],
]);
expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled();
expect(logWebSelfIdMock).not.toHaveBeenCalled();
});
});
+62
View File
@@ -52,13 +52,22 @@ const createHealthSummary = (params: {
};
const callGatewayMock = vi.fn();
const buildGatewayConnectionDetailsMock = vi.fn(() => ({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
}));
const formatGatewayTransportErrorJsonMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
buildGatewayConnectionDetails: (...args: [unknown, ...unknown[]]) =>
Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args),
formatGatewayTransportErrorJson: (...args: unknown[]) =>
formatGatewayTransportErrorJsonMock(...args),
}));
vi.mock("../channels/plugins/read-only.js", () => ({
listReadOnlyChannelPluginsForConfig: () => [],
}));
function requireFirstRuntimeLog(): string {
const [call] = runtime.log.mock.calls;
if (!call) {
@@ -86,6 +95,9 @@ function requireFirstGatewayRequest(): Record<string, unknown> {
describe("healthCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
buildGatewayConnectionDetailsMock.mockReturnValue({
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
});
formatGatewayTransportErrorJsonMock.mockReturnValue(null);
});
@@ -124,6 +136,56 @@ describe("healthCommand", () => {
expect(parsed.sessions.count).toBe(1);
});
it("prints the rich text summary and verbose gateway details", async () => {
const recent = [
{ key: "main", updatedAt: Date.now() - 60_000, age: 60_000 },
{ key: "foo", updatedAt: null, age: null },
];
const snapshot = createHealthSummary({
channels: {
whatsapp: { accountId: "default", linked: true, authAgeMs: 5 * 60_000 },
telegram: {
accountId: "default",
configured: true,
probe: {
ok: true,
elapsedMs: 7,
bot: { username: "bot" },
webhook: { url: "https://example.com/h" },
},
},
discord: { accountId: "default", configured: false },
},
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",
},
sessions: {
path: "/tmp/sessions.json",
count: 2,
recent,
},
});
callGatewayMock.mockResolvedValueOnce(snapshot);
await healthCommand(
{ json: false, verbose: true, timeoutMs: 1000, config: {} },
runtime as never,
);
expect(runtime.exit).not.toHaveBeenCalled();
const output = stripAnsi(runtime.log.mock.calls.map((c) => String(c[0])).join("\n"));
expect(output).toMatch(/WhatsApp: linked/i);
expect(runtime.log.mock.calls.slice(0, 3)).toEqual([
["Gateway connection:"],
[" Gateway mode: local"],
[" Gateway target: ws://127.0.0.1:18789"],
]);
expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled();
});
it("passes explicit gateway credentials through to the gateway call", async () => {
const snapshot = createHealthSummary({
channels: {},
+14
View File
@@ -32,6 +32,20 @@ vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
}));
vi.mock("../channels/plugins/read-only.js", () => ({
resolveReadOnlyChannelPluginsForConfig: vi.fn(() => ({
plugins: [
{ id: "discord" },
{ id: "imessage" },
{ id: "signal" },
{ id: "slack" },
{ id: "telegram" },
{ id: "whatsapp" },
],
missingConfiguredChannelIds: [],
})),
}));
vi.mock("./status.daemon.js", () => ({
getDaemonStatusSummary: mocks.getDaemonStatusSummary,
getNodeDaemonStatusSummary: mocks.getNodeDaemonStatusSummary,
@@ -213,6 +213,30 @@ vi.mock("./run-execution.runtime.js", () => ({
logWarn: (...args: unknown[]) => logWarnMock(...args),
}));
vi.mock("../../agents/model-runtime-aliases.js", () => ({
resolveCliRuntimeExecutionProvider: ({
provider,
cfg,
modelId,
}: {
provider?: string;
cfg?: {
agents?: {
defaults?: {
models?: Record<string, { agentRuntime?: { id?: string } }>;
};
};
};
modelId?: string;
}) => {
const key = provider && modelId ? `${provider}/${modelId}` : undefined;
const runtime = key
? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim()
: undefined;
return runtime || provider;
},
}));
vi.mock("./run-auth-profile.runtime.js", () => ({
resolveSessionAuthProfileOverride: resolveSessionAuthProfileOverrideMock,
}));
@@ -1,4 +1,5 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { testing as cliBackendsTesting } from "../agents/cli-backends.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
const gatewayClientState = vi.hoisted(() => ({
@@ -30,6 +31,7 @@ describe("gateway cli backend live helpers", () => {
});
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
gatewayClientState.lastOptions = undefined;
delete process.env.OPENCLAW_SKIP_CHANNELS;
delete process.env.OPENCLAW_SKIP_PROVIDERS;
@@ -135,6 +137,25 @@ describe("gateway cli backend live helpers", () => {
it("configures legacy CLI model refs as canonical provider models plus CLI runtime", async () => {
const { resolveCliBackendLiveModelSelection } =
await import("./gateway-cli-backend.live-helpers.js");
cliBackendsTesting.setDepsForTest({
resolveRuntimeCliBackends: () => [],
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [
{
pluginId: "claude",
backend: {
id: "claude-cli",
modelProvider: "anthropic",
config: { command: "claude", args: [] },
},
},
],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
});
expect(
resolveCliBackendLiveModelSelection({
+44 -39
View File
@@ -1,9 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createGatewaySuiteHarness, installGatewayTestHooks, testState } from "./test-helpers.js";
installGatewayTestHooks();
installGatewayTestHooks({ scope: "suite" });
const { callGateway } = await import("./call.js");
const { probeGateway } = await import("./probe.js");
@@ -11,6 +11,17 @@ const { storeDeviceAuthToken } = await import("../infra/device-auth-store.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js");
await import("./server.js");
let gatewayHarness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>;
beforeAll(async () => {
gatewayHarness = await createGatewaySuiteHarness();
});
afterAll(async () => {
await gatewayHarness.close();
});
function requireGatewayToken(): string {
const token =
@@ -72,55 +83,49 @@ describe("probeGateway auth integration", () => {
it("keeps direct local authenticated status RPCs device-bound", async () => {
const token = requireGatewayToken();
await withGatewayServer(async ({ port }) => {
const status = await callGateway({
url: `ws://127.0.0.1:${port}`,
token,
method: "status",
timeoutMs: 5_000,
});
expectRecord(status, "status response");
const status = await callGateway({
url: `ws://127.0.0.1:${gatewayHarness.port}`,
token,
method: "status",
timeoutMs: 5_000,
});
expectRecord(status, "status response");
});
it("keeps first-time local authenticated probes non-mutating", async () => {
const token = requireGatewayToken();
await withGatewayServer(async ({ port }) => {
const result = await probeGateway({
url: `ws://127.0.0.1:${port}`,
auth: { token },
timeoutMs: 5_000,
});
expect(result.ok).toBe(false);
expect(result.health).toBeNull();
expect(result.status).toBeNull();
expect(result.configSnapshot).toBeNull();
expect(result.auth.capability).toBe("connected_no_operator_scope");
expect(fs.existsSync(statePath("devices", "paired.json"))).toBe(false);
expect(fs.existsSync(statePath("devices", "pending.json"))).toBe(false);
expect(fs.existsSync(statePath("identity", "device-auth.json"))).toBe(false);
const result = await probeGateway({
url: `ws://127.0.0.1:${gatewayHarness.port}`,
auth: { token },
timeoutMs: 5_000,
});
expect(result.ok).toBe(false);
expect(result.health).toBeNull();
expect(result.status).toBeNull();
expect(result.configSnapshot).toBeNull();
expect(result.auth.capability).toBe("connected_no_operator_scope");
expect(fs.existsSync(statePath("devices", "paired.json"))).toBe(false);
expect(fs.existsSync(statePath("devices", "pending.json"))).toBe(false);
expect(fs.existsSync(statePath("identity", "device-auth.json"))).toBe(false);
});
it("keeps detail RPCs available for local authenticated probes with cached device auth", async () => {
const token = requireGatewayToken();
await seedCachedOperatorToken(["operator.read"]);
await withGatewayServer(async ({ port }) => {
const result = await probeGateway({
url: `ws://127.0.0.1:${port}`,
auth: { token },
timeoutMs: 5_000,
});
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expectRecord(result.health, "probe health");
expectRecord(result.status, "probe status");
expectRecord(result.configSnapshot, "probe config snapshot");
const result = await probeGateway({
url: `ws://127.0.0.1:${gatewayHarness.port}`,
auth: { token },
timeoutMs: 5_000,
});
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expectRecord(result.health, "probe health");
expectRecord(result.status, "probe status");
expectRecord(result.configSnapshot, "probe config snapshot");
});
});
@@ -4,6 +4,13 @@ import { installGatewayTestHooks } from "./server.auth.shared.js";
installGatewayTestHooks({ scope: "suite" });
await Promise.all([
import("./server.js"),
import("../infra/device-bootstrap.js"),
import("../infra/device-identity.js"),
import("../infra/device-pairing.js"),
]);
describe("gateway server auth/connect", () => {
registerControlUiAndPairingSuite();
});
@@ -21,6 +21,12 @@ import {
installGatewayTestHooks({ scope: "suite" });
await Promise.all([
import("./server.js"),
import("../infra/device-identity.js"),
import("../infra/device-pairing.js"),
]);
const CONTROL_UI_CLIENT = {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
+2
View File
@@ -18,6 +18,8 @@ import {
installGatewayTestHooks({ scope: "suite" });
await import("./server.js");
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
const HOOK_TOKEN = "hook-secret";
const HOOKS_MAIN_SESSION_KEY = "agent:hooks:main";
@@ -22,6 +22,8 @@ import { withTempConfig } from "./test-temp-config.js";
installGatewayTestHooks({ scope: "suite" });
await import("./server.js");
const PREAUTH_HANDSHAKE_TEST_CLOSE_LIMIT_MS = 5_000;
let cleanupEnv: Array<() => void> = [];
@@ -28,6 +28,8 @@ import {
installGatewayTestHooks({ scope: "suite" });
await import("./server.js");
async function expectRejectedScopeUpgradeAttempt({
attempt,
requestedEvent,
+6 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
invokeTalkSpeakDirect,
type TalkSpeakTestPayload,
@@ -95,6 +95,11 @@ function expectSingleSynthesizeSpeechCall() {
}
describe("gateway talk runtime", () => {
beforeAll(async () => {
await import("./server-methods/talk.js");
await import("../config/config.js");
});
beforeEach(() => {
synthesizeSpeechMock.mockReset();
synthesizeSpeechMock.mockResolvedValue({
@@ -223,7 +223,7 @@ describe("streamOpenAICodexResponses transport", () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("stop after payload");
throw new Error("usage limit: stop after payload");
}),
);
+6 -31
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import { listGitTrackedFiles } from "../../test-utils/repo-files.js";
import {
getPluginCompatRecord,
isPluginCompatCode,
@@ -26,23 +27,6 @@ const deprecatedTargetParserCompatFiles = new Set([
"src/plugins/compat/registry.test.ts",
]);
function listTsFiles(root: string): string[] {
const results: string[] = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const childPath = `${root}/${entry.name}`;
if (entry.isDirectory()) {
if (entry.name !== "node_modules") {
results.push(...listTsFiles(childPath));
}
continue;
}
if (/\.(?:ts|tsx)$/u.test(entry.name)) {
results.push(childPath);
}
}
return results;
}
const knownDeprecatedSurfaceMarkers = [
{
code: "legacy-extension-api-import",
@@ -243,18 +227,10 @@ function expectNonEmptyStringList(values: readonly string[], label: string) {
}
}
function listSourceFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) {
if (entry.name === "dist" || entry.name === "node_modules") {
return [];
}
return listSourceFiles(path);
}
return /\.(?:ts|tsx|mts|cts)$/u.test(entry.name) ? [path] : [];
});
function listTrackedSourceFiles(): string[] {
return (listGitTrackedFiles({ pathspecs: sourceRootsForDeprecatedCallGuard }) ?? []).filter(
(file) => /\.(?:ts|tsx|mts|cts)$/u.test(file),
);
}
describe("plugin compatibility registry", () => {
@@ -305,8 +281,7 @@ describe("plugin compatibility registry", () => {
});
it("keeps deprecated explicit target parser calls inside compatibility shims", () => {
const offenders = sourceRootsForDeprecatedCallGuard
.flatMap((root) => listSourceFiles(root))
const offenders = listTrackedSourceFiles()
.filter((file) => !deprecatedTargetParserCompatFiles.has(file))
.filter((file) => deprecatedTargetParserCallPattern.test(fs.readFileSync(file, "utf8")));
+6 -6
View File
@@ -1019,12 +1019,12 @@ describe("installPluginFromArchive", () => {
});
it("rejects reserved archive package ids", async () => {
for (const params of [
{ packageName: "@evil/..", outName: "traversal.tgz" },
{ packageName: "@evil/.", outName: "reserved.tgz" },
]) {
await expectArchiveInstallReservedSegmentRejection(params);
}
await Promise.all(
[
{ packageName: "@evil/..", outName: "traversal.tgz" },
{ packageName: "@evil/.", outName: "reserved.tgz" },
].map((params) => expectArchiveInstallReservedSegmentRejection(params)),
);
});
it("rejects packages without openclaw.extensions", async () => {
@@ -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 { describe, expect, it, test } from "vitest";
import { beforeAll, 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";
@@ -257,6 +257,22 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P
describe("publishable plugin npm package install security scan", () => {
const publishablePluginPackages = collectPublishablePluginPackages();
const scanResultsByPackageName = new Map<
string,
Awaited<ReturnType<typeof scanPublishablePluginPackage>>
>();
beforeAll(async () => {
const results = await Promise.all(
publishablePluginPackages.map(async (plugin) => ({
packageName: plugin.packageName,
result: await scanPublishablePluginPackage(plugin),
})),
);
for (const { packageName, result } of results) {
scanResultsByPackageName.set(packageName, result);
}
});
it("covers every package with required reviewed critical findings", () => {
const publishablePackageNames = new Set(
@@ -287,7 +303,10 @@ describe("publishable plugin npm package install security scan", () => {
test.concurrent.each(publishablePluginPackages)(
"keeps $packageName files clear of unexpected critical hits",
async (plugin) => {
const result = await scanPublishablePluginPackage(plugin);
const result = scanResultsByPackageName.get(plugin.packageName);
if (!result) {
throw new Error(`Missing package scan result for ${plugin.packageName}`);
}
const expectedReviewedCriticalFindings = new Set(
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].filter((key) =>
key.startsWith(`${plugin.packageName}:`),
@@ -6,6 +6,7 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fi
const fixtureTempDirs: string[] = [];
const fixtureRoot = makeTrackedTempDir("openclaw-plugin-graceful", fixtureTempDirs);
let tempDirIndex = 0;
const { loadOpenClawPlugins, clearPluginLoaderCache } = await import("./loader.js");
afterAll(() => {
cleanupTrackedTempDirs(fixtureTempDirs);
@@ -48,7 +49,6 @@ function readPluginId(pluginPath: string): string {
}
async function loadPlugins(pluginPaths: string[], warnings?: string[]) {
const { loadOpenClawPlugins, clearPluginLoaderCache } = await import("./loader.js");
clearPluginLoaderCache();
const allow = pluginPaths.map((pluginPath) => readPluginId(pluginPath));
return loadOpenClawPlugins({
+19 -4
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { TestRunner, type RunnerTestSuite, vi } from "vitest";
import { TestRunner, type RunnerTask, type RunnerTestSuite, vi } from "vitest";
type EvaluatedModuleNode = {
promise?: unknown;
@@ -59,9 +59,16 @@ function restoreSharedTestHomeAfterEnvUnstub(testHomeRaw: string | undefined): v
process.env.XDG_CACHE_HOME = path.join(testHome, ".cache");
}
function restoreRealTimers(): void {
if (vi.isFakeTimers()) {
vi.useRealTimers();
}
}
export default class OpenClawNonIsolatedRunner extends TestRunner {
override onCollectStart(file: { filepath: string }) {
super.onCollectStart(file);
restoreRealTimers();
restoreSharedTestHomeAfterEnvUnstub(getSharedTestHome());
const orderLogPath = process.env.OPENCLAW_VITEST_FILE_ORDER_LOG?.trim();
if (orderLogPath) {
@@ -69,6 +76,16 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
}
}
override async onBeforeRunTask(test: RunnerTask) {
restoreRealTimers();
await super.onBeforeRunTask(test);
}
override onBeforeTryTask(test: RunnerTask) {
restoreRealTimers();
super.onBeforeTryTask(test);
}
override async onAfterRunSuite(suite: RunnerTestSuite) {
await super.onAfterRunSuite(suite);
if (this.config.isolate || !("filepath" in suite) || typeof suite.filepath !== "string") {
@@ -83,9 +100,7 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
// Mirror the missing cleanup from Vitest isolate mode so shared workers do
// not carry file-scoped timers, stubs, spies, or stale module state
// forward into the next file.
if (vi.isFakeTimers()) {
vi.useRealTimers();
}
restoreRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
const testHome = getSharedTestHome();
+2 -2
View File
@@ -237,7 +237,7 @@ setInterval(() => {}, 1000);
const runPromise = runCommand(process.execPath, [scriptPath, grandchildPidPath], {
detached: undefined,
timeoutKillGraceMs: 50,
timeoutMs: 2000,
timeoutMs: 1000,
});
try {
@@ -246,7 +246,7 @@ setInterval(() => {}, 1000);
expect(Number.isInteger(grandchildPid)).toBe(true);
expect(isProcessAlive(grandchildPid)).toBe(true);
await expect(runPromise).rejects.toThrow("timed out after 2000ms");
await expect(runPromise).rejects.toThrow("timed out after 1000ms");
await waitFor(() => !isProcessAlive(grandchildPid), 5_000);
} finally {
await runPromise.catch(() => {});
+1 -1
View File
@@ -169,7 +169,7 @@ process.exitCode = await runManagedCommand({
const result = await waitForClose(runner);
expect(result).toEqual({ code: 143, signal: null });
await waitFor(() => !isProcessAlive(childPid), 10_000);
await waitFor(() => !isProcessAlive(childPid), 1_500);
} finally {
if (isProcessAlive(runnerPid)) {
process.kill(runnerPid, "SIGKILL");
@@ -136,16 +136,15 @@ describe("package-openclaw-for-docker", () => {
"setInterval(() => {}, 1000);",
].join("");
await expect(
runCommandForTest(process.execPath, ["-e", parentScript], process.cwd(), {
env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath },
killAfterMs: 50,
timeoutMs: 2000,
}),
).rejects.toThrow(/timed out after 2000ms/u);
const runPromise = runCommandForTest(process.execPath, ["-e", parentScript], process.cwd(), {
env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath },
killAfterMs: 50,
timeoutMs: 1500,
});
const timeoutAssertion = expect(runPromise).rejects.toThrow(/timed out after 1500ms/u);
await waitForFile(childPidPath, 2000);
childPid = Number(fs.readFileSync(childPidPath, "utf8"));
await timeoutAssertion;
await waitForDead(childPid, 2000);
} finally {
if (childPid && isProcessAlive(childPid)) {
+28
View File
@@ -9,6 +9,7 @@ import {
} from "../../scripts/lib/test-group-report.mjs";
import {
parseTestGroupReportArgs,
resolveFullSuiteVitestEnv,
resolveReportArtifactDirs,
resolveRunPlans,
} from "../../scripts/test-group-report.mjs";
@@ -295,6 +296,33 @@ describe("scripts/test-group-report arg parsing", () => {
});
describe("scripts/test-group-report run plans", () => {
it("caps Vitest workers for full-suite profiling by default", () => {
expect(resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {})).toEqual({
OPENCLAW_VITEST_MAX_WORKERS: "2",
});
});
it("uses a serial worker budget for commands full-suite profiling", () => {
expect(
resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {}, "commands"),
).toEqual({
OPENCLAW_VITEST_MAX_WORKERS: "1",
});
});
it("preserves explicit Vitest worker budgets for full-suite profiling", () => {
expect(
resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {
OPENCLAW_VITEST_MAX_WORKERS: "2",
}),
).toEqual({});
expect(
resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {
OPENCLAW_TEST_WORKERS: "2",
}),
).toEqual({});
});
it("uses leaf configs for full-suite profiling without requiring parallel env", () => {
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousLeaf = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;