From 8899f9e94a9e39098816914ffd50b849f3fc3cb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Feb 2026 13:28:23 +0000 Subject: [PATCH] perf(test): optimize heavy suites and stabilize lock timing --- scripts/test-parallel.mjs | 3 + src/cli/exec-approvals-cli.test.ts | 42 +- src/config/config.identity-defaults.test.ts | 392 ++++++------------- src/config/config.plugin-validation.test.ts | 43 +- src/config/sessions/store.lock.test.ts | 46 ++- src/cron/service.issue-regressions.test.ts | 10 +- src/daemon/launchd.test.ts | 134 ++++--- src/gateway/server.nodes.late-invoke.test.ts | 104 ++--- src/gateway/tools-invoke-http.test.ts | 314 ++++++--------- src/hooks/gmail-setup-utils.test.ts | 20 +- src/infra/tailscale.test.ts | 19 +- src/infra/tailscale.ts | 5 + src/infra/transport-ready.test.ts | 40 +- src/web/media.test.ts | 6 +- 14 files changed, 476 insertions(+), 702 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 7de2e4af641..3483b058c91 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -37,6 +37,7 @@ const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor < 24 : true; const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" || (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks); +const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const runs = [ ...(useVmForks ? [ @@ -48,6 +49,7 @@ const runs = [ "--config", "vitest.unit.config.ts", "--pool=vmForks", + ...(disableIsolation ? ["--isolate=false"] : []), ...unitIsolatedFiles.flatMap((file) => ["--exclude", file]), ], }, @@ -146,6 +148,7 @@ const WARNING_SUPPRESSION_FLAGS = [ "--disable-warning=ExperimentalWarning", "--disable-warning=DEP0040", "--disable-warning=DEP0060", + "--disable-warning=MaxListenersExceededWarning", ]; function resolveReportDir() { diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 33038496dbf..1d8a1d58dcd 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -60,49 +60,35 @@ vi.mock("../infra/exec-approvals.js", async () => { }); describe("exec approvals CLI", () => { - it("loads local approvals by default", async () => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; - callGatewayFromCli.mockClear(); - + const createProgram = async () => { const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js"); const program = new Command(); program.exitOverride(); registerExecApprovalsCli(program); + return program; + }; - await program.parseAsync(["approvals", "get"], { from: "user" }); + it("routes get command to local, gateway, and node modes", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGatewayFromCli.mockClear(); + + const localProgram = await createProgram(); + await localProgram.parseAsync(["approvals", "get"], { from: "user" }); expect(callGatewayFromCli).not.toHaveBeenCalled(); expect(runtimeErrors).toHaveLength(0); - }); - - it("loads gateway approvals when --gateway is set", async () => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; callGatewayFromCli.mockClear(); - const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js"); - const program = new Command(); - program.exitOverride(); - registerExecApprovalsCli(program); - - await program.parseAsync(["approvals", "get", "--gateway"], { from: "user" }); + const gatewayProgram = await createProgram(); + await gatewayProgram.parseAsync(["approvals", "get", "--gateway"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {}); expect(runtimeErrors).toHaveLength(0); - }); - - it("loads node approvals when --node is set", async () => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; callGatewayFromCli.mockClear(); - const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js"); - const program = new Command(); - program.exitOverride(); - registerExecApprovalsCli(program); - - await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" }); + const nodeProgram = await createProgram(); + await nodeProgram.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" }); expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), { nodeId: "node-1", diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index e63d2feb9b0..fe5286fe6f7 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; +import { loadConfig } from "./config.js"; import { withTempHome } from "./test-helpers.js"; describe("config identity defaults", () => { @@ -15,139 +16,77 @@ describe("config identity defaults", () => { process.env.HOME = previousHome; }); - it("does not derive mentionPatterns when identity is set", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - agents: { - list: [ - { - id: "main", - identity: { - name: "Samantha", - theme: "helpful sloth", - emoji: "🦥", - }, - }, - ], - }, - messages: {}, - }, - null, - 2, - ), - "utf-8", - ); + const writeAndLoadConfig = async (home: string, config: Record) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify(config, null, 2), + "utf-8", + ); + return loadConfig(); + }; - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + it("does not derive mention defaults and only sets ackReactionScope when identity is present", async () => { + await withTempHome(async (home) => { + const cfg = await writeAndLoadConfig(home, { + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + }, + }, + ], + }, + messages: {}, + }); expect(cfg.messages?.responsePrefix).toBeUndefined(); expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); - }); - }); - - it("defaults ackReactionScope without setting ackReaction", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - agents: { - list: [ - { - id: "main", - identity: { - name: "Samantha", - theme: "helpful sloth", - emoji: "🦥", - }, - }, - ], - }, - messages: {}, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - expect(cfg.messages?.ackReaction).toBeUndefined(); expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); }); }); - it("keeps ackReaction unset when identity is missing", async () => { + it("keeps ackReaction unset and does not synthesize agent/session defaults when identity is missing", async () => { await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - messages: {}, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + const cfg = await writeAndLoadConfig(home, { messages: {} }); expect(cfg.messages?.ackReaction).toBeUndefined(); expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + expect(cfg.messages?.responsePrefix).toBeUndefined(); + expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); + expect(cfg.agents?.list).toBeUndefined(); + expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); + expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); + expect(cfg.session).toBeUndefined(); }); }); it("does not override explicit values", async () => { await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - agents: { - list: [ - { - id: "main", - identity: { - name: "Samantha Sloth", - theme: "space lobster", - emoji: "🦞", - }, - groupChat: { mentionPatterns: ["@openclaw"] }, - }, - ], + const cfg = await writeAndLoadConfig(home, { + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha Sloth", + theme: "space lobster", + emoji: "🦞", + }, + groupChat: { mentionPatterns: ["@openclaw"] }, }, - messages: { - responsePrefix: "✅", - }, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + ], + }, + messages: { + responsePrefix: "✅", + }, + }); expect(cfg.messages?.responsePrefix).toBe("✅"); expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual(["@openclaw"]); @@ -156,37 +95,23 @@ describe("config identity defaults", () => { it("supports provider textChunkLimit config", async () => { await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - messages: { - messagePrefix: "[openclaw]", - responsePrefix: "🦞", - }, - channels: { - whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 }, - telegram: { enabled: true, textChunkLimit: 3333 }, - discord: { - enabled: true, - textChunkLimit: 1999, - maxLinesPerMessage: 17, - }, - signal: { enabled: true, textChunkLimit: 2222 }, - imessage: { enabled: true, textChunkLimit: 1111 }, - }, + const cfg = await writeAndLoadConfig(home, { + messages: { + messagePrefix: "[openclaw]", + responsePrefix: "🦞", + }, + channels: { + whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 }, + telegram: { enabled: true, textChunkLimit: 3333 }, + discord: { + enabled: true, + textChunkLimit: 1999, + maxLinesPerMessage: 17, }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + signal: { enabled: true, textChunkLimit: 2222 }, + imessage: { enabled: true, textChunkLimit: 1111 }, + }, + }); expect(cfg.channels?.whatsapp?.textChunkLimit).toBe(4444); expect(cfg.channels?.telegram?.textChunkLimit).toBe(3333); @@ -202,48 +127,34 @@ describe("config identity defaults", () => { it("accepts blank model provider apiKey values", async () => { await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - models: { - mode: "merge", - providers: { - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - apiKey: "", - api: "anthropic-messages", - models: [ - { - id: "MiniMax-M2.1", - name: "MiniMax M2.1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 8192, - }, - ], + const cfg = await writeAndLoadConfig(home, { + models: { + mode: "merge", + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, }, - }, + ], }, }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + }, + }); expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); }); @@ -251,100 +162,43 @@ describe("config identity defaults", () => { it("respects empty responsePrefix to disable identity defaults", async () => { await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - agents: { - list: [ - { - id: "main", - identity: { - name: "Samantha", - theme: "helpful sloth", - emoji: "🦥", - }, - }, - ], + const cfg = await writeAndLoadConfig(home, { + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + }, }, - messages: { responsePrefix: "" }, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + ], + }, + messages: { responsePrefix: "" }, + }); expect(cfg.messages?.responsePrefix).toBe(""); }); }); - it("does not synthesize agent list/session when absent", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - messages: {}, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - - expect(cfg.messages?.responsePrefix).toBeUndefined(); - expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); - expect(cfg.agents?.list).toBeUndefined(); - expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); - expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); - expect(cfg.session).toBeUndefined(); - }); - }); - it("does not derive responsePrefix from identity emoji", async () => { await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - agents: { - list: [ - { - id: "main", - identity: { - name: "OpenClaw", - theme: "space lobster", - emoji: "🦞", - }, - }, - ], + const cfg = await writeAndLoadConfig(home, { + agents: { + list: [ + { + id: "main", + identity: { + name: "OpenClaw", + theme: "space lobster", + emoji: "🦞", + }, }, - messages: {}, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + ], + }, + messages: {}, + }); expect(cfg.messages?.responsePrefix).toBeUndefined(); }); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 35e4b9a8a50..418af2fdbac 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { validateConfigObjectWithPlugins } from "./config.js"; import { withTempHome } from "./test-helpers.js"; async function writePluginFixture(params: { @@ -30,13 +31,15 @@ async function writePluginFixture(params: { } describe("config plugin validation", () => { + const validateInHome = (home: string, raw: unknown) => { + process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + return validateConfigObjectWithPlugins(raw); + }; + it("rejects missing plugin load paths", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); const missingPath = path.join(home, "missing-plugin"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [missingPath] } }, }); @@ -53,10 +56,7 @@ describe("config plugin validation", () => { it("rejects missing plugin ids in entries", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } }, }); @@ -72,10 +72,7 @@ describe("config plugin validation", () => { it("rejects missing plugin ids in allow/deny/slots", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, @@ -99,7 +96,6 @@ describe("config plugin validation", () => { it("surfaces plugin config diagnostics", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); const pluginDir = path.join(home, "bad-plugin"); await writePluginFixture({ dir: pluginDir, @@ -114,9 +110,7 @@ describe("config plugin validation", () => { }, }); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, @@ -138,10 +132,7 @@ describe("config plugin validation", () => { it("accepts known plugin ids", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { discord: { enabled: true } } }, }); @@ -151,7 +142,6 @@ describe("config plugin validation", () => { it("accepts plugin heartbeat targets", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); const pluginDir = path.join(home, "bluebubbles-plugin"); await writePluginFixture({ dir: pluginDir, @@ -160,9 +150,7 @@ describe("config plugin validation", () => { schema: { type: "object" }, }); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [pluginDir] } }, }); @@ -172,10 +160,7 @@ describe("config plugin validation", () => { it("rejects unknown heartbeat targets", async () => { await withTempHome(async (home) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - vi.resetModules(); - const { validateConfigObjectWithPlugins } = await import("./config.js"); - const res = validateConfigObjectWithPlugins({ + const res = validateInHome(home, { agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] }, }); expect(res.ok).toBe(false); diff --git a/src/config/sessions/store.lock.test.ts b/src/config/sessions/store.lock.test.ts index f8a82f7aed5..d0d447cfcfc 100644 --- a/src/config/sessions/store.lock.test.ts +++ b/src/config/sessions/store.lock.test.ts @@ -52,7 +52,7 @@ describe("session store lock (Promise chain mutex)", () => { const entry = store[key] as Record; // Simulate async work so that without proper serialization // multiple readers would see the same stale value. - await sleep(Math.random() * 20); + await sleep(Math.random() * 3); entry.counter = (entry.counter as number) + 1; entry.tag = `writer-${i}`; }), @@ -74,7 +74,7 @@ describe("session store lock (Promise chain mutex)", () => { storePath, sessionKey: key, update: async () => { - await sleep(30); + await sleep(9); return { modelOverride: "model-a" }; }, }), @@ -82,7 +82,7 @@ describe("session store lock (Promise chain mutex)", () => { storePath, sessionKey: key, update: async () => { - await sleep(10); + await sleep(3); return { thinkingLevel: "high" as const }; }, }), @@ -90,7 +90,7 @@ describe("session store lock (Promise chain mutex)", () => { storePath, sessionKey: key, update: async () => { - await sleep(20); + await sleep(6); return { systemPromptOverride: "custom" }; }, }), @@ -168,22 +168,32 @@ describe("session store lock (Promise chain mutex)", () => { const opA = updateSessionStore(pathA, async (store) => { order.push("a-start"); - await sleep(50); + await sleep(12); store.a = { ...store.a, modelOverride: "done-a" } as unknown as SessionEntry; order.push("a-end"); }); const opB = updateSessionStore(pathB, async (store) => { order.push("b-start"); - await sleep(10); + await sleep(3); store.b = { ...store.b, modelOverride: "done-b" } as unknown as SessionEntry; order.push("b-end"); }); await Promise.all([opA, opB]); - // B should finish before A because they run in parallel and B sleeps less. - expect(order.indexOf("b-end")).toBeLessThan(order.indexOf("a-end")); + // Parallel behavior: both ops start before either one finishes. + const aStart = order.indexOf("a-start"); + const bStart = order.indexOf("b-start"); + const aEnd = order.indexOf("a-end"); + const bEnd = order.indexOf("b-end"); + const firstEnd = Math.min(aEnd, bEnd); + expect(aStart).toBeGreaterThanOrEqual(0); + expect(bStart).toBeGreaterThanOrEqual(0); + expect(aEnd).toBeGreaterThanOrEqual(0); + expect(bEnd).toBeGreaterThanOrEqual(0); + expect(aStart).toBeLessThan(firstEnd); + expect(bStart).toBeLessThan(firstEnd); expect(loadSessionStore(pathA).a?.modelOverride).toBe("done-a"); expect(loadSessionStore(pathB).b?.modelOverride).toBe("done-b"); @@ -256,7 +266,7 @@ describe("session store lock (Promise chain mutex)", () => { const lockHolder = withSessionStoreLockForTest( storePath, async () => { - await sleep(80); + await sleep(40); }, { timeoutMs: 2_000 }, ); @@ -270,7 +280,7 @@ describe("session store lock (Promise chain mutex)", () => { await expect(timedOut).rejects.toThrow("timeout waiting for session store lock"); await lockHolder; - await sleep(30); + await sleep(8); expect(timedOutRan).toBe(false); }); @@ -281,12 +291,22 @@ describe("session store lock (Promise chain mutex)", () => { }); const write = updateSessionStore(storePath, async (store) => { - await sleep(60); + await sleep(18); store[key] = { ...store[key], modelOverride: "v" } as unknown as SessionEntry; }); - await sleep(10); - await expect(fs.access(`${storePath}.lock`)).resolves.toBeUndefined(); + const lockPath = `${storePath}.lock`; + let lockSeen = false; + for (let i = 0; i < 20; i += 1) { + try { + await fs.access(lockPath); + lockSeen = true; + break; + } catch { + await sleep(2); + } + } + expect(lockSeen).toBe(true); await write; const files = await fs.readdir(dir); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 83d7cab8060..2889d18f267 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -271,7 +271,7 @@ describe("Cron issue regressions", () => { }); await cron.start(); - const runAt = Date.now() + 30; + const runAt = Date.now() + 5; const job = await cron.add({ name: "timer-overlap", enabled: true, @@ -282,8 +282,8 @@ describe("Cron issue regressions", () => { delivery: { mode: "none" }, }); - for (let i = 0; i < 25 && runIsolatedAgentJob.mock.calls.length === 0; i++) { - await delay(20); + for (let i = 0; i < 30 && runIsolatedAgentJob.mock.calls.length === 0; i++) { + await delay(5); } expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); @@ -292,12 +292,12 @@ describe("Cron issue regressions", () => { expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); resolveRun?.({ status: "ok", summary: "done" }); - for (let i = 0; i < 25; i++) { + for (let i = 0; i < 30; i++) { const jobs = await cron.list({ includeDisabled: true }); if (jobs.some((j) => j.id === job.id && j.state.lastStatus === "ok")) { break; } - await delay(20); + await delay(5); } cron.stop(); diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index c3e094bfdde..beb67e7cefe 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -11,6 +11,71 @@ import { resolveLaunchAgentPlistPath, } from "./launchd.js"; +function parseLaunchctlCalls(raw: string): string[][] { + return raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split(/\s+/)); +} + +async function writeLaunchctlStub(binDir: string) { + if (process.platform === "win32") { + const stubJsPath = path.join(binDir, "launchctl.js"); + await fs.writeFile( + stubJsPath, + [ + 'import fs from "node:fs";', + "const args = process.argv.slice(2);", + "const logPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;", + "if (logPath) {", + ' fs.appendFileSync(logPath, args.join("\\t") + "\\n", "utf8");', + "}", + 'if (args[0] === "list") {', + ' const output = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT || "";', + " process.stdout.write(output);", + "}", + "process.exit(0);", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(binDir, "launchctl.cmd"), + `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, + "utf8", + ); + return; + } + + const shPath = path.join(binDir, "launchctl"); + await fs.writeFile( + shPath, + [ + "#!/bin/sh", + 'log_path="${OPENCLAW_TEST_LAUNCHCTL_LOG:-}"', + 'if [ -n "$log_path" ]; then', + ' line=""', + ' for arg in "$@"; do', + ' if [ -n "$line" ]; then', + ' line="$line $arg"', + " else", + ' line="$arg"', + " fi", + " done", + ' printf \'%s\\n\' "$line" >> "$log_path"', + "fi", + 'if [ "$1" = "list" ]; then', + " printf '%s' \"${OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT:-}\"", + "fi", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(shPath, 0o755); +} + async function withLaunchctlStub( options: { listOutput?: string }, run: (context: { env: Record; logPath: string }) => Promise, @@ -27,37 +92,7 @@ async function withLaunchctlStub( await fs.mkdir(binDir, { recursive: true }); await fs.mkdir(homeDir, { recursive: true }); - const stubJsPath = path.join(binDir, "launchctl.js"); - await fs.writeFile( - stubJsPath, - [ - 'import fs from "node:fs";', - "const args = process.argv.slice(2);", - "const logPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;", - "if (logPath) {", - ' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");', - "}", - 'if (args[0] === "list") {', - ' const output = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT || "";', - " process.stdout.write(output);", - "}", - "process.exit(0);", - "", - ].join("\n"), - "utf8", - ); - - if (process.platform === "win32") { - await fs.writeFile( - path.join(binDir, "launchctl.cmd"), - `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, - "utf8", - ); - } else { - const shPath = path.join(binDir, "launchctl"); - await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8"); - await fs.chmod(shPath, 0o755); - } + await writeLaunchctlStub(binDir); process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath; process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? ""; @@ -125,10 +160,7 @@ describe("launchd bootstrap repair", () => { const repair = await repairLaunchAgentBootstrap({ env }); expect(repair.ok).toBe(true); - const calls = (await fs.readFile(logPath, "utf8")) - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as string[]); + const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8")); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; @@ -153,32 +185,7 @@ describe("launchd install", () => { await fs.mkdir(binDir, { recursive: true }); await fs.mkdir(homeDir, { recursive: true }); - const stubJsPath = path.join(binDir, "launchctl.js"); - await fs.writeFile( - stubJsPath, - [ - 'import fs from "node:fs";', - "const logPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;", - "if (logPath) {", - ' fs.appendFileSync(logPath, JSON.stringify(process.argv.slice(2)) + "\\n", "utf8");', - "}", - "process.exit(0);", - "", - ].join("\n"), - "utf8", - ); - - if (process.platform === "win32") { - await fs.writeFile( - path.join(binDir, "launchctl.cmd"), - `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, - "utf8", - ); - } else { - const shPath = path.join(binDir, "launchctl"); - await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8"); - await fs.chmod(shPath, 0o755); - } + await writeLaunchctlStub(binDir); process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; @@ -193,10 +200,7 @@ describe("launchd install", () => { programArguments: ["node", "-e", "process.exit(0)"], }); - const calls = (await fs.readFile(logPath, "utf8")) - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as string[]); + const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8")); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; diff --git a/src/gateway/server.nodes.late-invoke.test.ts b/src/gateway/server.nodes.late-invoke.test.ts index 36a7972e38b..b965e773464 100644 --- a/src/gateway/server.nodes.late-invoke.test.ts +++ b/src/gateway/server.nodes.late-invoke.test.ts @@ -25,6 +25,8 @@ installGatewayTestHooks({ scope: "suite" }); let server: Awaited>["server"]; let ws: WebSocket; let port: number; +let nodeWs: WebSocket; +let nodeId: string; beforeAll(async () => { const token = "test-gateway-token-1234567890"; @@ -33,94 +35,60 @@ beforeAll(async () => { ws = started.ws; port = started.port; await connectOk(ws, { token }); + + nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => nodeWs.once("open", resolve)); + + const identity = loadOrCreateDeviceIdentity(); + nodeId = identity.deviceId; + await connectOk(nodeWs, { + role: "node", + client: { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "darwin", + mode: GATEWAY_CLIENT_MODES.NODE, + }, + commands: ["canvas.snapshot"], + token, + }); }); afterAll(async () => { + nodeWs.close(); ws.close(); await server.close(); }); describe("late-arriving invoke results", () => { - test("returns success for unknown invoke id (late arrival after timeout)", async () => { - // Create a node client WebSocket - const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => nodeWs.once("open", resolve)); + test("returns success for unknown invoke ids for both success and error payloads", async () => { + const cases = [ + { + id: "unknown-invoke-id-12345", + ok: true, + payloadJSON: JSON.stringify({ result: "late" }), + }, + { + id: "another-unknown-invoke-id", + ok: false, + error: { code: "FAILED", message: "test error" }, + }, + ] as const; - try { - // Connect as a node with device identity - const identity = loadOrCreateDeviceIdentity(); - const nodeId = identity.deviceId; - - await connectOk(nodeWs, { - role: "node", - client: { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - version: "1.0.0", - platform: "ios", - mode: GATEWAY_CLIENT_MODES.NODE, - }, - commands: ["canvas.snapshot"], - token: "test-gateway-token-1234567890", - }); - - // Send an invoke result with an unknown ID (simulating late arrival after timeout) + for (const params of cases) { const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>( nodeWs, "node.invoke.result", { - id: "unknown-invoke-id-12345", + ...params, nodeId, - ok: true, - payloadJSON: JSON.stringify({ result: "late" }), }, ); - // Late-arriving results return success instead of error to reduce log noise + // Late-arriving results return success instead of error to reduce log noise. expect(result.ok).toBe(true); expect(result.payload?.ok).toBe(true); expect(result.payload?.ignored).toBe(true); - } finally { - nodeWs.close(); - } - }); - - test("returns success for unknown invoke id with error payload", async () => { - // Verifies late results are accepted regardless of their ok/error status - const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => nodeWs.once("open", resolve)); - - try { - await connectOk(nodeWs, { - role: "node", - client: { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - version: "1.0.0", - platform: "darwin", - mode: GATEWAY_CLIENT_MODES.NODE, - }, - commands: [], - }); - - const identity = loadOrCreateDeviceIdentity(); - const nodeId = identity.deviceId; - - // Late invoke result with error payload - should still return success - const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>( - nodeWs, - "node.invoke.result", - { - id: "another-unknown-invoke-id", - nodeId, - ok: false, - error: { code: "FAILED", message: "test error" }, - }, - ); - - expect(result.ok).toBe(true); - expect(result.payload?.ok).toBe(true); - expect(result.payload?.ignored).toBe(true); - } finally { - nodeWs.close(); } }); }); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 8b7646fa1a4..1afaed10199 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -1,7 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js"; import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js"; @@ -22,134 +20,137 @@ const resolveGatewayToken = (): string => { return token; }; -describe("POST /tools/invoke", () => { - it("invokes a tool and returns {ok:true,result}", async () => { - // Allow the agents_list tool for main agent. - testState.agentsConfig = { - list: [ - { - id: "main", - tools: { - allow: ["agents_list"], - }, +const allowAgentsListForMain = () => { + testState.agentsConfig = { + list: [ + { + id: "main", + tools: { + allow: ["agents_list"], }, - ], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; + }, + ], + // oxlint-disable-next-line typescript/no-explicit-any + } as any; +}; - const port = await getFreePort(); - const server = await startGatewayServer(port, { +const invokeAgentsList = async (params: { + port: number; + headers?: Record; + sessionKey?: string; +}) => { + const body: Record = { tool: "agents_list", action: "json", args: {} }; + if (params.sessionKey) { + body.sessionKey = params.sessionKey; + } + return await fetch(`http://127.0.0.1:${params.port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json", ...params.headers }, + body: JSON.stringify(body), + }); +}; + +describe("POST /tools/invoke", () => { + let sharedPort = 0; + let sharedServer: Awaited>; + + beforeAll(async () => { + sharedPort = await getFreePort(); + sharedServer = await startGatewayServer(sharedPort, { bind: "loopback", }); + }); + + afterAll(async () => { + await sharedServer.close(); + }); + + it("invokes a tool and returns {ok:true,result}", async () => { + allowAgentsListForMain(); const token = resolveGatewayToken(); - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), + const res = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); expect(res.status).toBe(200); const body = await res.json(); expect(body.ok).toBe(true); expect(body).toHaveProperty("result"); - - await server.close(); }); - it("supports tools.alsoAllow as additive allowlist (profile stage)", async () => { - // No explicit tool allowlist; rely on profile + alsoAllow. + it("supports tools.alsoAllow in profile and implicit modes", async () => { testState.agentsConfig = { list: [{ id: "main" }], // oxlint-disable-next-line typescript/no-explicit-any } as any; - // minimal profile does NOT include agents_list, but alsoAllow should. const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ tools: { profile: "minimal", alsoAllow: ["agents_list"] }, // oxlint-disable-next-line typescript/no-explicit-any } as any); - - const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); const token = resolveGatewayToken(); - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), + const resProfile = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.ok).toBe(true); + expect(resProfile.status).toBe(200); + const profileBody = await resProfile.json(); + expect(profileBody.ok).toBe(true); - await server.close(); - }); - - it("supports tools.alsoAllow without allow/profile (implicit allow-all)", async () => { - testState.agentsConfig = { - list: [{ id: "main" }], + await writeConfigFile({ + tools: { alsoAllow: ["agents_list"] }, // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const { CONFIG_PATH } = await import("../config/config.js"); - await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); - await fs.writeFile( - CONFIG_PATH, - JSON.stringify({ tools: { alsoAllow: ["agents_list"] } }, null, 2), - "utf-8", - ); - - const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); - const token = resolveGatewayToken(); - - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), + } as any); + const resImplicit = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.ok).toBe(true); - - await server.close(); + expect(resImplicit.status).toBe(200); + const implicitBody = await resImplicit.json(); + expect(implicitBody.ok).toBe(true); }); - it("accepts password auth when bearer token matches", async () => { - testState.agentsConfig = { - list: [ - { - id: "main", - tools: { - allow: ["agents_list"], - }, - }, - ], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; + it("handles dedicated auth modes for password accept and token reject", async () => { + allowAgentsListForMain(); - const port = await getFreePort(); - const server = await startGatewayServer(port, { + const passwordPort = await getFreePort(); + const passwordServer = await startGatewayServer(passwordPort, { bind: "loopback", auth: { mode: "password", password: "secret" }, }); + try { + const passwordRes = await invokeAgentsList({ + port: passwordPort, + headers: { authorization: "Bearer secret" }, + sessionKey: "main", + }); + expect(passwordRes.status).toBe(200); + } finally { + await passwordServer.close(); + } - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), + const tokenPort = await getFreePort(); + const tokenServer = await startGatewayServer(tokenPort, { + bind: "loopback", + auth: { mode: "token", token: "t" }, }); - - expect(res.status).toBe(200); - - await server.close(); + try { + const tokenRes = await invokeAgentsList({ + port: tokenPort, + sessionKey: "main", + }); + expect(tokenRes.status).toBe(401); + } finally { + await tokenServer.close(); + } }); it("routes tools invoke before plugin HTTP handlers", async () => { @@ -171,72 +172,23 @@ describe("POST /tools/invoke", () => { ]; setTestPluginRegistry(registry); - testState.agentsConfig = { - list: [ - { - id: "main", - tools: { - allow: ["agents_list"], - }, - }, - ], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); + allowAgentsListForMain(); try { const token = resolveGatewayToken(); - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ - tool: "agents_list", - action: "json", - args: {}, - sessionKey: "main", - }), + const res = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); expect(res.status).toBe(200); expect(pluginHandler).not.toHaveBeenCalled(); } finally { - await server.close(); resetTestPluginRegistry(); } }); - it("rejects unauthorized when auth mode is token and header is missing", async () => { - testState.agentsConfig = { - list: [ - { - id: "main", - tools: { - allow: ["agents_list"], - }, - }, - ], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const port = await getFreePort(); - const server = await startGatewayServer(port, { - bind: "loopback", - auth: { mode: "token", token: "t" }, - }); - - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), - }); - - expect(res.status).toBe(401); - - await server.close(); - }); - - it("returns 404 when tool is not allowlisted", async () => { + it("returns 404 when denylisted or blocked by tools.profile", async () => { testState.agentsConfig = { list: [ { @@ -248,34 +200,16 @@ describe("POST /tools/invoke", () => { ], // oxlint-disable-next-line typescript/no-explicit-any } as any; - - const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); const token = resolveGatewayToken(); - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), + const denyRes = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); + expect(denyRes.status).toBe(404); - expect(res.status).toBe(404); - - await server.close(); - }); - - it("respects tools.profile allowlist", async () => { - testState.agentsConfig = { - list: [ - { - id: "main", - tools: { - allow: ["agents_list"], - }, - }, - ], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; + allowAgentsListForMain(); const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ @@ -283,19 +217,12 @@ describe("POST /tools/invoke", () => { // oxlint-disable-next-line typescript/no-explicit-any } as any); - const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); - const token = resolveGatewayToken(); - - const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ tool: "agents_list", action: "json", args: {}, sessionKey: "main" }), + const profileRes = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); - - expect(res.status).toBe(404); - - await server.close(); + expect(profileRes.status).toBe(404); }); it("uses the configured main session key when sessionKey is missing or main", async () => { @@ -319,26 +246,19 @@ describe("POST /tools/invoke", () => { } as any; testState.sessionConfig = { mainKey: "primary" }; - const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "loopback" }); - - const payload = { tool: "agents_list", action: "json", args: {} }; const token = resolveGatewayToken(); - const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify(payload), + const resDefault = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, }); expect(resDefault.status).toBe(200); - const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ ...payload, sessionKey: "main" }), + const resMain = await invokeAgentsList({ + port: sharedPort, + headers: { authorization: `Bearer ${token}` }, + sessionKey: "main", }); expect(resMain.status).toBe(200); - - await server.close(); }); }); diff --git a/src/hooks/gmail-setup-utils.test.ts b/src/hooks/gmail-setup-utils.test.ts index 1876dd8ea44..4cd8c0ed0d4 100644 --- a/src/hooks/gmail-setup-utils.test.ts +++ b/src/hooks/gmail-setup-utils.test.ts @@ -13,6 +13,10 @@ describe("resolvePythonExecutablePath", () => { itUnix( "resolves a working python path and caches the result", async () => { + vi.doMock("../process/exec.js", () => ({ + runCommandWithTimeout: vi.fn(), + })); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-python-")); const originalPath = process.env.PATH; try { @@ -23,16 +27,21 @@ describe("resolvePythonExecutablePath", () => { const shimDir = path.join(tmp, "shims"); await fs.mkdir(shimDir, { recursive: true }); const shim = path.join(shimDir, "python3"); - await fs.writeFile( - shim, - `#!/bin/sh\nif [ "$1" = "-c" ]; then\n echo "${realPython}"\n exit 0\nfi\nexit 1\n`, - "utf-8", - ); + await fs.writeFile(shim, "#!/bin/sh\nexit 0\n", "utf-8"); await fs.chmod(shim, 0o755); process.env.PATH = `${shimDir}${path.delimiter}/usr/bin`; const { resolvePythonExecutablePath } = await import("./gmail-setup-utils.js"); + const { runCommandWithTimeout } = await import("../process/exec.js"); + const runCommand = vi.mocked(runCommandWithTimeout); + runCommand.mockResolvedValue({ + stdout: `${realPython}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + }); const resolved = await resolvePythonExecutablePath(); expect(resolved).toBe(realPython); @@ -40,6 +49,7 @@ describe("resolvePythonExecutablePath", () => { process.env.PATH = "/bin"; const cached = await resolvePythonExecutablePath(); expect(cached).toBe(realPython); + expect(runCommand).toHaveBeenCalledTimes(1); } finally { process.env.PATH = originalPath; await fs.rm(tmp, { recursive: true, force: true }); diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 0e30c1f729f..ec6ab392ba4 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as tailscale from "./tailscale.js"; const { @@ -12,7 +12,18 @@ const { const tailscaleBin = expect.stringMatching(/tailscale$/i); describe("tailscale helpers", () => { + const originalForcedBinary = process.env.OPENCLAW_TEST_TAILSCALE_BINARY; + + beforeEach(() => { + process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "tailscale"; + }); + afterEach(() => { + if (originalForcedBinary === undefined) { + delete process.env.OPENCLAW_TEST_TAILSCALE_BINARY; + } else { + process.env.OPENCLAW_TEST_TAILSCALE_BINARY = originalForcedBinary; + } vi.restoreAllMocks(); }); @@ -65,7 +76,6 @@ describe("tailscale helpers", () => { it("enableTailscaleServe attempts normal first, then sudo", async () => { // 1. First attempt fails // 2. Second attempt (sudo) succeeds - vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const exec = vi .fn() .mockRejectedValueOnce(new Error("permission denied")) @@ -89,7 +99,6 @@ describe("tailscale helpers", () => { }); it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { - vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const exec = vi.fn().mockResolvedValue({ stdout: "" }); await enableTailscaleServe(3000, exec as never); @@ -103,7 +112,6 @@ describe("tailscale helpers", () => { }); it("disableTailscaleServe uses fallback", async () => { - vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const exec = vi .fn() .mockRejectedValueOnce(new Error("permission denied")) @@ -125,7 +133,6 @@ describe("tailscale helpers", () => { // 1. status (success) // 2. enable (fails) // 3. enable sudo (success) - vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const exec = vi .fn() .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status @@ -166,7 +173,6 @@ describe("tailscale helpers", () => { }); it("enableTailscaleServe skips sudo on non-permission errors", async () => { - vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const exec = vi.fn().mockRejectedValueOnce(new Error("boom")); await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom"); @@ -175,7 +181,6 @@ describe("tailscale helpers", () => { }); it("enableTailscaleServe rethrows original error if sudo fails", async () => { - vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const originalError = Object.assign(new Error("permission denied"), { stderr: "permission denied", }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index bf74306bfc0..c2244b19b98 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -150,6 +150,11 @@ export async function getTailnetHostname(exec: typeof runExec = runExec, detecte let cachedTailscaleBinary: string | null = null; export async function getTailscaleBinary(): Promise { + const forcedBinary = process.env.OPENCLAW_TEST_TAILSCALE_BINARY?.trim(); + if (forcedBinary) { + cachedTailscaleBinary = forcedBinary; + return forcedBinary; + } if (cachedTailscaleBinary) { return cachedTailscaleBinary; } diff --git a/src/infra/transport-ready.test.ts b/src/infra/transport-ready.test.ts index 3768908e001..adb2560ce16 100644 --- a/src/infra/transport-ready.test.ts +++ b/src/infra/transport-ready.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { waitForTransportReady } from "./transport-ready.js"; describe("waitForTransportReady", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("returns when the check succeeds and logs after the delay", async () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; let attempts = 0; - await waitForTransportReady({ + const readyPromise = waitForTransportReady({ label: "test transport", timeoutMs: 500, logAfterMs: 120, @@ -20,22 +28,28 @@ describe("waitForTransportReady", () => { return { ok: false, error: "not ready" }; }, }); + + for (let i = 0; i < 5; i += 1) { + await vi.advanceTimersByTimeAsync(80); + } + + await readyPromise; expect(runtime.error).toHaveBeenCalled(); }); it("throws after the timeout", async () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - await expect( - waitForTransportReady({ - label: "test transport", - timeoutMs: 200, - logAfterMs: 0, - logIntervalMs: 100, - pollIntervalMs: 50, - runtime, - check: async () => ({ ok: false, error: "still down" }), - }), - ).rejects.toThrow("test transport not ready"); + const waitPromise = waitForTransportReady({ + label: "test transport", + timeoutMs: 200, + logAfterMs: 0, + logIntervalMs: 100, + pollIntervalMs: 50, + runtime, + check: async () => ({ ok: false, error: "still down" }), + }); + await vi.advanceTimersByTimeAsync(250); + await expect(waitPromise).rejects.toThrow("test transport not ready"); expect(runtime.error).toHaveBeenCalled(); }); diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 861ca9da456..bc9c6392cac 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -51,8 +51,8 @@ describe("web media loading", () => { it("compresses large local images under the provided cap", async () => { const buffer = await sharp({ create: { - width: 1600, - height: 1600, + width: 1200, + height: 1200, channels: 3, background: "#ff0000", }, @@ -254,7 +254,7 @@ describe("web media loading", () => { }); it("falls back to JPEG when PNG alpha cannot fit under cap", async () => { - const sizes = [512, 768, 1024]; + const sizes = [320, 448, 640]; let pngBuffer: Buffer | null = null; let smallestPng: Awaited> | null = null; let jpegOptimized: Awaited> | null = null;