From c06a962bb6fe145eb64abe4cbe7823d26a10fce1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 20:54:31 +0100 Subject: [PATCH] test(e2e): stabilize suite --- ...iting-models-json-no-env-token.e2e.test.ts | 124 +++++++++++++++- .../run.overflow-compaction.e2e.test.ts | 1 + src/agents/pi-tools-agent-config.e2e.test.ts | 2 +- ...ills.loadworkspaceskillentries.e2e.test.ts | 2 + src/agents/subagent-announce-queue.ts | 12 ++ src/agents/subagent-registry.ts | 2 + src/agents/tools/image-tool.e2e.test.ts | 2 +- src/cli/gateway-cli.coverage.e2e.test.ts | 2 +- src/commands/onboard-auth.e2e.test.ts | 2 +- ...r.agent.gateway-server-agent-a.e2e.test.ts | 2 +- src/gateway/server.auth.e2e.test.ts | 24 ++-- .../server.models-voicewake-misc.e2e.test.ts | 3 +- src/gateway/server.reload.e2e.test.ts | 8 ++ .../server.roles-allowlist-update.e2e.test.ts | 1 + ...onnects-after-connection-close.e2e.test.ts | 135 +++++++++--------- 15 files changed, 238 insertions(+), 84 deletions(-) diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts index 05d4e62cb..b8b8f49e5 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts @@ -56,6 +56,23 @@ describe("models-config", () => { const previousSynthetic = process.env.SYNTHETIC_API_KEY; const previousVenice = process.env.VENICE_API_KEY; const previousXiaomi = process.env.XIAOMI_API_KEY; + const previousOllama = process.env.OLLAMA_API_KEY; + const previousVllm = process.env.VLLM_API_KEY; + const previousTogether = process.env.TOGETHER_API_KEY; + const previousHuggingfaceHub = process.env.HUGGINGFACE_HUB_TOKEN; + const previousHuggingfaceHf = process.env.HF_TOKEN; + const previousQianfan = process.env.QIANFAN_API_KEY; + const previousNvidia = process.env.NVIDIA_API_KEY; + const previousAwsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; + const previousAwsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + const previousAwsSessionToken = process.env.AWS_SESSION_TOKEN; + const previousAwsProfile = process.env.AWS_PROFILE; + const previousAwsRegion = process.env.AWS_REGION; + const previousAwsDefaultRegion = process.env.AWS_DEFAULT_REGION; + const previousAwsSharedCredentials = process.env.AWS_SHARED_CREDENTIALS_FILE; + const previousAwsConfigFile = process.env.AWS_CONFIG_FILE; + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; @@ -65,9 +82,29 @@ describe("models-config", () => { delete process.env.SYNTHETIC_API_KEY; delete process.env.VENICE_API_KEY; delete process.env.XIAOMI_API_KEY; + delete process.env.OLLAMA_API_KEY; + delete process.env.VLLM_API_KEY; + delete process.env.TOGETHER_API_KEY; + delete process.env.HUGGINGFACE_HUB_TOKEN; + delete process.env.HF_TOKEN; + delete process.env.QIANFAN_API_KEY; + delete process.env.NVIDIA_API_KEY; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + delete process.env.AWS_PROFILE; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_SHARED_CREDENTIALS_FILE; + delete process.env.AWS_CONFIG_FILE; + delete process.env.OPENCLAW_AGENT_DIR; + delete process.env.PI_CODING_AGENT_DIR; try { const agentDir = path.join(home, "agent-empty"); + // Avoid merging in the user's real main auth store via OPENCLAW_AGENT_DIR. + process.env.OPENCLAW_AGENT_DIR = agentDir; + process.env.PI_CODING_AGENT_DIR = agentDir; const result = await ensureOpenClawModelsJson( { models: { providers: {} }, @@ -123,6 +160,91 @@ describe("models-config", () => { } else { process.env.XIAOMI_API_KEY = previousXiaomi; } + if (previousOllama === undefined) { + delete process.env.OLLAMA_API_KEY; + } else { + process.env.OLLAMA_API_KEY = previousOllama; + } + if (previousVllm === undefined) { + delete process.env.VLLM_API_KEY; + } else { + process.env.VLLM_API_KEY = previousVllm; + } + if (previousTogether === undefined) { + delete process.env.TOGETHER_API_KEY; + } else { + process.env.TOGETHER_API_KEY = previousTogether; + } + if (previousHuggingfaceHub === undefined) { + delete process.env.HUGGINGFACE_HUB_TOKEN; + } else { + process.env.HUGGINGFACE_HUB_TOKEN = previousHuggingfaceHub; + } + if (previousHuggingfaceHf === undefined) { + delete process.env.HF_TOKEN; + } else { + process.env.HF_TOKEN = previousHuggingfaceHf; + } + if (previousQianfan === undefined) { + delete process.env.QIANFAN_API_KEY; + } else { + process.env.QIANFAN_API_KEY = previousQianfan; + } + if (previousNvidia === undefined) { + delete process.env.NVIDIA_API_KEY; + } else { + process.env.NVIDIA_API_KEY = previousNvidia; + } + if (previousAwsAccessKeyId === undefined) { + delete process.env.AWS_ACCESS_KEY_ID; + } else { + process.env.AWS_ACCESS_KEY_ID = previousAwsAccessKeyId; + } + if (previousAwsSecretAccessKey === undefined) { + delete process.env.AWS_SECRET_ACCESS_KEY; + } else { + process.env.AWS_SECRET_ACCESS_KEY = previousAwsSecretAccessKey; + } + if (previousAwsSessionToken === undefined) { + delete process.env.AWS_SESSION_TOKEN; + } else { + process.env.AWS_SESSION_TOKEN = previousAwsSessionToken; + } + if (previousAwsProfile === undefined) { + delete process.env.AWS_PROFILE; + } else { + process.env.AWS_PROFILE = previousAwsProfile; + } + if (previousAwsRegion === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = previousAwsRegion; + } + if (previousAwsDefaultRegion === undefined) { + delete process.env.AWS_DEFAULT_REGION; + } else { + process.env.AWS_DEFAULT_REGION = previousAwsDefaultRegion; + } + if (previousAwsSharedCredentials === undefined) { + delete process.env.AWS_SHARED_CREDENTIALS_FILE; + } else { + process.env.AWS_SHARED_CREDENTIALS_FILE = previousAwsSharedCredentials; + } + if (previousAwsConfigFile === undefined) { + delete process.env.AWS_CONFIG_FILE; + } else { + process.env.AWS_CONFIG_FILE = previousAwsConfigFile; + } + if (previousAgentDir === undefined) { + delete process.env.OPENCLAW_AGENT_DIR; + } else { + process.env.OPENCLAW_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } } }); }); @@ -158,7 +280,7 @@ describe("models-config", () => { } >; }; - expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1"); + expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-M2.1"); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts index 170b0c65c..12df89495 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts @@ -116,6 +116,7 @@ vi.mock("./logger.js", () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), + isEnabled: vi.fn(() => false), }, })); diff --git a/src/agents/pi-tools-agent-config.e2e.test.ts b/src/agents/pi-tools-agent-config.e2e.test.ts index 69e05c607..cbd11203a 100644 --- a/src/agents/pi-tools-agent-config.e2e.test.ts +++ b/src/agents/pi-tools-agent-config.e2e.test.ts @@ -586,7 +586,7 @@ describe("Agent-specific tool filtering", () => { const helperResult = await helperExecTool!.execute("call-helper", { command: "echo done", host: "sandbox", - yieldMs: 10, + yieldMs: 1000, }); expect(helperResult?.details.status).toBe("completed"); }); diff --git a/src/agents/skills.loadworkspaceskillentries.e2e.test.ts b/src/agents/skills.loadworkspaceskillentries.e2e.test.ts index d182b00a3..6ba7031ca 100644 --- a/src/agents/skills.loadworkspaceskillentries.e2e.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.e2e.test.ts @@ -60,6 +60,7 @@ describe("loadWorkspaceSkillEntries", () => { ), "utf-8", ); + await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); await fs.writeFile( path.join(pluginRoot, "skills", "prose", "SKILL.md"), `---\nname: prose\ndescription: test\n---\n`, @@ -99,6 +100,7 @@ describe("loadWorkspaceSkillEntries", () => { ), "utf-8", ); + await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); await fs.writeFile( path.join(pluginRoot, "skills", "prose", "SKILL.md"), `---\nname: prose\ndescription: test\n---\n`, diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index 2c3062d80..93416ff33 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -44,6 +44,18 @@ type AnnounceQueueState = { const ANNOUNCE_QUEUES = new Map(); +export function resetAnnounceQueuesForTests() { + // Test isolation: other suites may leave a draining queue behind in the worker. + // Clearing the map alone isn't enough because drain loops capture `queue` by reference. + for (const queue of ANNOUNCE_QUEUES.values()) { + queue.items.length = 0; + queue.summaryLines.length = 0; + queue.droppedCount = 0; + queue.lastEnqueuedAt = 0; + } + ANNOUNCE_QUEUES.clear(); +} + function getAnnounceQueue( key: string, settings: AnnounceQueueSettings, diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 8eadf5514..d6910d41a 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js"; import { loadSubagentRegistryFromDisk, @@ -398,6 +399,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) { export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { subagentRuns.clear(); resumedRuns.clear(); + resetAnnounceQueuesForTests(); stopSweeper(); restoreAttempted = false; if (listenerStop) { diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index e2236e73f..c979e806d 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -299,7 +299,7 @@ describe("image tool MiniMax VLM routing", () => { expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; - expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm"); + expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm"); expect(init?.method).toBe("POST"); expect(String((init?.headers as Record)?.Authorization)).toBe( "Bearer minimax-test", diff --git a/src/cli/gateway-cli.coverage.e2e.test.ts b/src/cli/gateway-cli.coverage.e2e.test.ts index b9d26804d..3edaa84b2 100644 --- a/src/cli/gateway-cli.coverage.e2e.test.ts +++ b/src/cli/gateway-cli.coverage.e2e.test.ts @@ -204,7 +204,7 @@ describe("gateway-cli coverage", () => { expect(out).toContain("- Studio openclaw.internal."); expect(out).toContain(" tailnet: studio.tailnet.ts.net"); expect(out).toContain(" host: studio.openclaw.internal"); - expect(out).toContain(" ws: ws://studio.tailnet.ts.net:18789"); + expect(out).toContain(" ws: ws://studio.openclaw.internal:18789"); }); it("validates gateway discover timeout", async () => { diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index eaa1658fa..eb6858f87 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -259,7 +259,7 @@ describe("applyMinimaxApiConfig", () => { expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key"); expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([ "old-model", - "MiniMax-M2.1", + "MiniMax-M2.5", ]); }); diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index 0b4ac7e04..2e72ec61c 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -448,7 +448,7 @@ describe("gateway server agent", () => { const spy = vi.mocked(agentCommand); const call = spy.mock.calls.at(-1)?.[0] as Record; - expect(call.sessionKey).toBe("main"); + expect(call.sessionKey).toBe("agent:main:main"); expectChannels(call, "webchat"); expect(typeof call.message).toBe("string"); expect(call.message).toContain("what is in the image?"); diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 49423a655..8064b72f6 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -117,6 +117,18 @@ describe("gateway server auth/connect", () => { ws.close(); }); + test("ignores requested scopes when device identity is omitted", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { device: null }); + expect(res.ok).toBe(true); + + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(false); + expect(health.error?.message).toContain("missing scope"); + + ws.close(); + }); + test("does not grant admin when scopes are omitted", async () => { const ws = await openWs(port); const token = @@ -144,18 +156,6 @@ describe("gateway server auth/connect", () => { signedAtMs, token: token ?? null, }); - - test("ignores requested scopes when device identity is omitted", async () => { - const ws = await openWs(port); - const res = await connectReq(ws, { device: null }); - expect(res.ok).toBe(true); - - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(false); - expect(health.error?.message).toContain("missing scope"); - - ws.close(); - }); const device = { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), diff --git a/src/gateway/server.models-voicewake-misc.e2e.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts index e1d9644a7..27ae4237a 100644 --- a/src/gateway/server.models-voicewake-misc.e2e.test.ts +++ b/src/gateway/server.models-voicewake-misc.e2e.test.ts @@ -403,8 +403,7 @@ describe("gateway server misc", () => { const plugins = updated.plugins as Record | undefined; const entries = plugins?.entries as Record | undefined; const discord = entries?.discord as Record | undefined; - // Auto-enable registers the plugin entry but keeps it disabled for explicit opt-in. - expect(discord?.enabled).toBe(false); + expect(discord?.enabled).toBe(true); expect((updated.channels as Record | undefined)?.discord).toMatchObject({ token: "token-123", }); diff --git a/src/gateway/server.reload.e2e.test.ts b/src/gateway/server.reload.e2e.test.ts index f991d07c9..7092d130e 100644 --- a/src/gateway/server.reload.e2e.test.ts +++ b/src/gateway/server.reload.e2e.test.ts @@ -170,12 +170,15 @@ installGatewayTestHooks({ scope: "suite" }); describe("gateway hot reload", () => { let prevSkipChannels: string | undefined; let prevSkipGmail: string | undefined; + let prevSkipProviders: string | undefined; beforeEach(() => { prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER; + prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; process.env.OPENCLAW_SKIP_CHANNELS = "0"; delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER; + delete process.env.OPENCLAW_SKIP_PROVIDERS; }); afterEach(() => { @@ -189,6 +192,11 @@ describe("gateway hot reload", () => { } else { process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prevSkipGmail; } + if (prevSkipProviders === undefined) { + delete process.env.OPENCLAW_SKIP_PROVIDERS; + } else { + process.env.OPENCLAW_SKIP_PROVIDERS = prevSkipProviders; + } }); it("applies hot reload actions and emits restart signal", async () => { diff --git a/src/gateway/server.roles-allowlist-update.e2e.test.ts b/src/gateway/server.roles-allowlist-update.e2e.test.ts index 9fa8b3f9e..029f2f847 100644 --- a/src/gateway/server.roles-allowlist-update.e2e.test.ts +++ b/src/gateway/server.roles-allowlist-update.e2e.test.ts @@ -207,6 +207,7 @@ describe("gateway update.run", () => { process.on("SIGUSR1", sigusr1); try { + await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); await fs.writeFile(CONFIG_PATH, JSON.stringify({ update: { channel: "beta" } }, null, 2)); const updateMock = vi.mocked(runGatewayUpdate); updateMock.mockClear(); diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.e2e.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.e2e.test.ts index 07c0365e7..90ba9e72e 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.e2e.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.e2e.test.ts @@ -70,75 +70,82 @@ describe("web auto-reply", () => { }); it("forces reconnect when watchdog closes without onClose", async () => { vi.useFakeTimers(); - const sleep = vi.fn(async () => {}); - const closeResolvers: Array<(reason: unknown) => void> = []; - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = vi.fn( - async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - let resolveClose: (reason: unknown) => void = () => {}; - const onClose = new Promise((res) => { - resolveClose = res; - closeResolvers.push(res); - }); - return { - close: vi.fn(), - onClose, - signalClose: (reason?: unknown) => resolveClose(reason), - }; - }, - ); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - const controller = new AbortController(); - const run = monitorWebChannel( - false, - listenerFactory, - true, - async () => ({ text: "ok" }), - runtime as never, - controller.signal, - { - heartbeatSeconds: 1, - reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, - sleep, - }, - ); + try { + const sleep = vi.fn(async () => {}); + const closeResolvers: Array<(reason: unknown) => void> = []; + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = vi.fn( + async (opts: { + onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + let resolveClose: (reason: unknown) => void = () => {}; + const onClose = new Promise((res) => { + resolveClose = res; + closeResolvers.push(res); + }); + return { + close: vi.fn(), + onClose, + signalClose: (reason?: unknown) => resolveClose(reason), + }; + }, + ); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const controller = new AbortController(); + const run = monitorWebChannel( + false, + listenerFactory, + true, + async () => ({ text: "ok" }), + runtime as never, + controller.signal, + { + heartbeatSeconds: 1, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + sleep, + }, + ); - await Promise.resolve(); - expect(listenerFactory).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(1); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const sendMedia = vi.fn(); - await capturedOnMessage?.({ - body: "hi", - from: "+1", - to: "+2", - id: "m1", - sendComposing, - reply, - sendMedia, - }); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const sendMedia = vi.fn(); - await vi.advanceTimersByTimeAsync(31 * 60 * 1000); - await Promise.resolve(); + // The watchdog only needs `lastMessageAt` to be set. Don't await full message + // processing here since it can schedule timers and become flaky under load. + void capturedOnMessage?.({ + body: "hi", + from: "+1", + to: "+2", + id: "m1", + sendComposing, + reply, + sendMedia, + }); - await vi.advanceTimersByTimeAsync(1); - await Promise.resolve(); - expect(listenerFactory).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(31 * 60 * 1000); + await Promise.resolve(); - controller.abort(); - closeResolvers[1]?.({ status: 499, isLoggedOut: false }); - await Promise.resolve(); - await run; + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(2); + + controller.abort(); + closeResolvers[1]?.({ status: 499, isLoggedOut: false }); + await Promise.resolve(); + await run; + } finally { + vi.useRealTimers(); + } }, 15_000); it("stops after hitting max reconnect attempts", { timeout: 60_000 }, async () => {