mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 04:16:25 +00:00
perf(test): optimize heavy suites and stabilize lock timing
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("session store lock (Promise chain mutex)", () => {
|
||||
const entry = store[key] as Record<string, unknown>;
|
||||
// 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string | undefined>; logPath: string }) => Promise<void>,
|
||||
@@ -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";
|
||||
|
||||
@@ -25,6 +25,8 @@ installGatewayTestHooks({ scope: "suite" });
|
||||
let server: Awaited<ReturnType<typeof startServerWithClient>>["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<void>((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<void>((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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
sessionKey?: string;
|
||||
}) => {
|
||||
const body: Record<string, unknown> = { 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<ReturnType<typeof startGatewayServer>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -150,6 +150,11 @@ export async function getTailnetHostname(exec: typeof runExec = runExec, detecte
|
||||
let cachedTailscaleBinary: string | null = null;
|
||||
|
||||
export async function getTailscaleBinary(): Promise<string> {
|
||||
const forcedBinary = process.env.OPENCLAW_TEST_TAILSCALE_BINARY?.trim();
|
||||
if (forcedBinary) {
|
||||
cachedTailscaleBinary = forcedBinary;
|
||||
return forcedBinary;
|
||||
}
|
||||
if (cachedTailscaleBinary) {
|
||||
return cachedTailscaleBinary;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof optimizeImageToPng>> | null = null;
|
||||
let jpegOptimized: Awaited<ReturnType<typeof optimizeImageToJpeg>> | null = null;
|
||||
|
||||
Reference in New Issue
Block a user