test(e2e): stabilize suite

This commit is contained in:
Peter Steinberger
2026-02-14 20:54:31 +01:00
parent 2a3da21333
commit c06a962bb6
15 changed files with 238 additions and 84 deletions

View File

@@ -56,6 +56,23 @@ describe("models-config", () => {
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
const previousVenice = process.env.VENICE_API_KEY;
const previousXiaomi = process.env.XIAOMI_API_KEY;
const previousOllama = process.env.OLLAMA_API_KEY;
const previousVllm = process.env.VLLM_API_KEY;
const previousTogether = process.env.TOGETHER_API_KEY;
const previousHuggingfaceHub = process.env.HUGGINGFACE_HUB_TOKEN;
const previousHuggingfaceHf = process.env.HF_TOKEN;
const previousQianfan = process.env.QIANFAN_API_KEY;
const previousNvidia = process.env.NVIDIA_API_KEY;
const previousAwsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
const previousAwsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const previousAwsSessionToken = process.env.AWS_SESSION_TOKEN;
const previousAwsProfile = process.env.AWS_PROFILE;
const previousAwsRegion = process.env.AWS_REGION;
const previousAwsDefaultRegion = process.env.AWS_DEFAULT_REGION;
const previousAwsSharedCredentials = process.env.AWS_SHARED_CREDENTIALS_FILE;
const previousAwsConfigFile = process.env.AWS_CONFIG_FILE;
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
@@ -65,9 +82,29 @@ describe("models-config", () => {
delete process.env.SYNTHETIC_API_KEY;
delete process.env.VENICE_API_KEY;
delete process.env.XIAOMI_API_KEY;
delete process.env.OLLAMA_API_KEY;
delete process.env.VLLM_API_KEY;
delete process.env.TOGETHER_API_KEY;
delete process.env.HUGGINGFACE_HUB_TOKEN;
delete process.env.HF_TOKEN;
delete process.env.QIANFAN_API_KEY;
delete process.env.NVIDIA_API_KEY;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;
delete process.env.AWS_PROFILE;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
delete process.env.AWS_CONFIG_FILE;
delete process.env.OPENCLAW_AGENT_DIR;
delete process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = path.join(home, "agent-empty");
// Avoid merging in the user's real main auth store via OPENCLAW_AGENT_DIR.
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
const result = await ensureOpenClawModelsJson(
{
models: { providers: {} },
@@ -123,6 +160,91 @@ describe("models-config", () => {
} else {
process.env.XIAOMI_API_KEY = previousXiaomi;
}
if (previousOllama === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = previousOllama;
}
if (previousVllm === undefined) {
delete process.env.VLLM_API_KEY;
} else {
process.env.VLLM_API_KEY = previousVllm;
}
if (previousTogether === undefined) {
delete process.env.TOGETHER_API_KEY;
} else {
process.env.TOGETHER_API_KEY = previousTogether;
}
if (previousHuggingfaceHub === undefined) {
delete process.env.HUGGINGFACE_HUB_TOKEN;
} else {
process.env.HUGGINGFACE_HUB_TOKEN = previousHuggingfaceHub;
}
if (previousHuggingfaceHf === undefined) {
delete process.env.HF_TOKEN;
} else {
process.env.HF_TOKEN = previousHuggingfaceHf;
}
if (previousQianfan === undefined) {
delete process.env.QIANFAN_API_KEY;
} else {
process.env.QIANFAN_API_KEY = previousQianfan;
}
if (previousNvidia === undefined) {
delete process.env.NVIDIA_API_KEY;
} else {
process.env.NVIDIA_API_KEY = previousNvidia;
}
if (previousAwsAccessKeyId === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previousAwsAccessKeyId;
}
if (previousAwsSecretAccessKey === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previousAwsSecretAccessKey;
}
if (previousAwsSessionToken === undefined) {
delete process.env.AWS_SESSION_TOKEN;
} else {
process.env.AWS_SESSION_TOKEN = previousAwsSessionToken;
}
if (previousAwsProfile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previousAwsProfile;
}
if (previousAwsRegion === undefined) {
delete process.env.AWS_REGION;
} else {
process.env.AWS_REGION = previousAwsRegion;
}
if (previousAwsDefaultRegion === undefined) {
delete process.env.AWS_DEFAULT_REGION;
} else {
process.env.AWS_DEFAULT_REGION = previousAwsDefaultRegion;
}
if (previousAwsSharedCredentials === undefined) {
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
} else {
process.env.AWS_SHARED_CREDENTIALS_FILE = previousAwsSharedCredentials;
}
if (previousAwsConfigFile === undefined) {
delete process.env.AWS_CONFIG_FILE;
} else {
process.env.AWS_CONFIG_FILE = previousAwsConfigFile;
}
if (previousAgentDir === undefined) {
delete process.env.OPENCLAW_AGENT_DIR;
} else {
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
}
});
});
@@ -158,7 +280,7 @@ describe("models-config", () => {
}
>;
};
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1");

View File

@@ -116,6 +116,7 @@ vi.mock("./logger.js", () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
isEnabled: vi.fn(() => false),
},
}));

View File

@@ -586,7 +586,7 @@ describe("Agent-specific tool filtering", () => {
const helperResult = await helperExecTool!.execute("call-helper", {
command: "echo done",
host: "sandbox",
yieldMs: 10,
yieldMs: 1000,
});
expect(helperResult?.details.status).toBe("completed");
});

View File

@@ -60,6 +60,7 @@ describe("loadWorkspaceSkillEntries", () => {
),
"utf-8",
);
await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8");
await fs.writeFile(
path.join(pluginRoot, "skills", "prose", "SKILL.md"),
`---\nname: prose\ndescription: test\n---\n`,
@@ -99,6 +100,7 @@ describe("loadWorkspaceSkillEntries", () => {
),
"utf-8",
);
await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8");
await fs.writeFile(
path.join(pluginRoot, "skills", "prose", "SKILL.md"),
`---\nname: prose\ndescription: test\n---\n`,

View File

@@ -44,6 +44,18 @@ type AnnounceQueueState = {
const ANNOUNCE_QUEUES = new Map<string, AnnounceQueueState>();
export function resetAnnounceQueuesForTests() {
// Test isolation: other suites may leave a draining queue behind in the worker.
// Clearing the map alone isn't enough because drain loops capture `queue` by reference.
for (const queue of ANNOUNCE_QUEUES.values()) {
queue.items.length = 0;
queue.summaryLines.length = 0;
queue.droppedCount = 0;
queue.lastEnqueuedAt = 0;
}
ANNOUNCE_QUEUES.clear();
}
function getAnnounceQueue(
key: string,
settings: AnnounceQueueSettings,

View File

@@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
import {
loadSubagentRegistryFromDisk,
@@ -398,6 +399,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
subagentRuns.clear();
resumedRuns.clear();
resetAnnounceQueuesForTests();
stopSweeper();
restoreAttempted = false;
if (listenerStop) {

View File

@@ -299,7 +299,7 @@ describe("image tool MiniMax VLM routing", () => {
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = fetch.mock.calls[0];
expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
expect(init?.method).toBe("POST");
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
"Bearer minimax-test",

View File

@@ -204,7 +204,7 @@ describe("gateway-cli coverage", () => {
expect(out).toContain("- Studio openclaw.internal.");
expect(out).toContain(" tailnet: studio.tailnet.ts.net");
expect(out).toContain(" host: studio.openclaw.internal");
expect(out).toContain(" ws: ws://studio.tailnet.ts.net:18789");
expect(out).toContain(" ws: ws://studio.openclaw.internal:18789");
});
it("validates gateway discover timeout", async () => {

View File

@@ -259,7 +259,7 @@ describe("applyMinimaxApiConfig", () => {
expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([
"old-model",
"MiniMax-M2.1",
"MiniMax-M2.5",
]);
});

View File

@@ -448,7 +448,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionKey).toBe("main");
expect(call.sessionKey).toBe("agent:main:main");
expectChannels(call, "webchat");
expect(typeof call.message).toBe("string");
expect(call.message).toContain("what is in the image?");

View File

@@ -117,6 +117,18 @@ describe("gateway server auth/connect", () => {
ws.close();
});
test("ignores requested scopes when device identity is omitted", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { device: null });
expect(res.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(false);
expect(health.error?.message).toContain("missing scope");
ws.close();
});
test("does not grant admin when scopes are omitted", async () => {
const ws = await openWs(port);
const token =
@@ -144,18 +156,6 @@ describe("gateway server auth/connect", () => {
signedAtMs,
token: token ?? null,
});
test("ignores requested scopes when device identity is omitted", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { device: null });
expect(res.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(false);
expect(health.error?.message).toContain("missing scope");
ws.close();
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),

View File

@@ -403,8 +403,7 @@ describe("gateway server misc", () => {
const plugins = updated.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const discord = entries?.discord as Record<string, unknown> | undefined;
// Auto-enable registers the plugin entry but keeps it disabled for explicit opt-in.
expect(discord?.enabled).toBe(false);
expect(discord?.enabled).toBe(true);
expect((updated.channels as Record<string, unknown> | undefined)?.discord).toMatchObject({
token: "token-123",
});

View File

@@ -170,12 +170,15 @@ installGatewayTestHooks({ scope: "suite" });
describe("gateway hot reload", () => {
let prevSkipChannels: string | undefined;
let prevSkipGmail: string | undefined;
let prevSkipProviders: string | undefined;
beforeEach(() => {
prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
process.env.OPENCLAW_SKIP_CHANNELS = "0";
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
delete process.env.OPENCLAW_SKIP_PROVIDERS;
});
afterEach(() => {
@@ -189,6 +192,11 @@ describe("gateway hot reload", () => {
} else {
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prevSkipGmail;
}
if (prevSkipProviders === undefined) {
delete process.env.OPENCLAW_SKIP_PROVIDERS;
} else {
process.env.OPENCLAW_SKIP_PROVIDERS = prevSkipProviders;
}
});
it("applies hot reload actions and emits restart signal", async () => {

View File

@@ -207,6 +207,7 @@ describe("gateway update.run", () => {
process.on("SIGUSR1", sigusr1);
try {
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
await fs.writeFile(CONFIG_PATH, JSON.stringify({ update: { channel: "beta" } }, null, 2));
const updateMock = vi.mocked(runGatewayUpdate);
updateMock.mockClear();

View File

@@ -70,75 +70,82 @@ describe("web auto-reply", () => {
});
it("forces reconnect when watchdog closes without onClose", async () => {
vi.useFakeTimers();
const sleep = vi.fn(async () => {});
const closeResolvers: Array<(reason: unknown) => void> = [];
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = vi.fn(
async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
let resolveClose: (reason: unknown) => void = () => {};
const onClose = new Promise<unknown>((res) => {
resolveClose = res;
closeResolvers.push(res);
});
return {
close: vi.fn(),
onClose,
signalClose: (reason?: unknown) => resolveClose(reason),
};
},
);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const controller = new AbortController();
const run = monitorWebChannel(
false,
listenerFactory,
true,
async () => ({ text: "ok" }),
runtime as never,
controller.signal,
{
heartbeatSeconds: 1,
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
sleep,
},
);
try {
const sleep = vi.fn(async () => {});
const closeResolvers: Array<(reason: unknown) => void> = [];
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = vi.fn(
async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
let resolveClose: (reason: unknown) => void = () => {};
const onClose = new Promise<unknown>((res) => {
resolveClose = res;
closeResolvers.push(res);
});
return {
close: vi.fn(),
onClose,
signalClose: (reason?: unknown) => resolveClose(reason),
};
},
);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const controller = new AbortController();
const run = monitorWebChannel(
false,
listenerFactory,
true,
async () => ({ text: "ok" }),
runtime as never,
controller.signal,
{
heartbeatSeconds: 1,
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
sleep,
},
);
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(1);
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const sendMedia = vi.fn();
await capturedOnMessage?.({
body: "hi",
from: "+1",
to: "+2",
id: "m1",
sendComposing,
reply,
sendMedia,
});
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const sendMedia = vi.fn();
await vi.advanceTimersByTimeAsync(31 * 60 * 1000);
await Promise.resolve();
// The watchdog only needs `lastMessageAt` to be set. Don't await full message
// processing here since it can schedule timers and become flaky under load.
void capturedOnMessage?.({
body: "hi",
from: "+1",
to: "+2",
id: "m1",
sendComposing,
reply,
sendMedia,
});
await vi.advanceTimersByTimeAsync(1);
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(31 * 60 * 1000);
await Promise.resolve();
controller.abort();
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
await Promise.resolve();
await run;
await vi.advanceTimersByTimeAsync(1);
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(2);
controller.abort();
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
}, 15_000);
it("stops after hitting max reconnect attempts", { timeout: 60_000 }, async () => {