fix: stabilize main test gates

This commit is contained in:
Peter Steinberger
2026-04-10 11:42:48 +01:00
parent ef1694575d
commit 444cdd055d
13 changed files with 197 additions and 58 deletions

View File

@@ -72,10 +72,22 @@ const QQBotAccountSchema = z
})
.passthrough();
const QQBotNamedAccountSchema = QQBotAccountSchema.superRefine((value, ctx) => {
for (const key of ["tts", "stt"] as const) {
if (key in value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `channels.qqbot.accounts entries do not support ${key} overrides`,
});
}
}
});
export const QQBotConfigSchema = QQBotAccountSchema.extend({
tts: QQBotTtsSchema,
stt: QQBotSttSchema,
accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(),
accounts: z.object({}).catchall(QQBotNamedAccountSchema).optional(),
defaultAccount: z.string().optional(),
}).passthrough();
export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema);

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { MockInstance } from "vitest";
export function createWhatsAppPollFixture() {
const cfg = { marker: "resolved-cfg" } as OpenClawConfig;
@@ -14,3 +15,29 @@ export function createWhatsAppPollFixture() {
accountId: "work",
};
}
export function expectWhatsAppPollSent(
sendPollWhatsApp: MockInstance,
params: {
cfg: OpenClawConfig;
poll: { question: string; options: string[]; maxSelections: number };
to?: string;
accountId?: string;
},
) {
const expected = [
params.to ?? "+1555",
params.poll,
{
verbose: false,
accountId: params.accountId ?? "work",
cfg: params.cfg,
},
];
const actual = sendPollWhatsApp.mock.calls.at(-1);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected WhatsApp poll send ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}

View File

@@ -589,7 +589,7 @@ describe("spawnAcpDirect", () => {
agentSessionKey: "agent:main:matrix:channel:!room:example",
agentChannel: "matrix",
agentAccountId: "default",
agentTo: "room:!room:example",
agentTo: "channel:!room:example",
},
);
expect(result.status, JSON.stringify(result)).toBe("accepted");

View File

@@ -6,7 +6,6 @@ import type { CliBundleMcpMode } from "../plugins/types.js";
let createEmptyPluginRegistry: typeof import("../plugins/registry.js").createEmptyPluginRegistry;
let resetPluginRuntimeStateForTest: typeof import("../plugins/runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
let normalizeClaudeBackendConfig: typeof import("./cli-backends.js").normalizeClaudeBackendConfig;
let resolveCliBackendConfig: typeof import("./cli-backends.js").resolveCliBackendConfig;
let resolveCliBackendLiveTest: typeof import("./cli-backends.js").resolveCliBackendLiveTest;
@@ -34,7 +33,7 @@ function createBackendEntry(params: {
: params.id === "codex-cli"
? "codex-cli/gpt-5.4"
: params.id === "google-gemini-cli"
? "google-gemini-cli/gemini-3.1-pro-preview"
? "google-gemini-cli/gemini-3-flash-preview"
: undefined,
defaultImageProbe: true,
defaultMcpProbe: true,
@@ -93,6 +92,63 @@ const NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS = [
"bypassPermissions",
];
function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let hasSettingSources = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--dangerously-skip-permissions") {
continue;
}
if (arg === "--setting-sources") {
const maybeValue = args[i + 1];
if (maybeValue && !maybeValue.startsWith("-")) {
hasSettingSources = true;
normalized.push(arg, "user");
i += 1;
}
continue;
}
if (arg.startsWith("--setting-sources=")) {
hasSettingSources = true;
normalized.push("--setting-sources=user");
continue;
}
if (arg === "--permission-mode") {
const maybeValue = args[i + 1];
if (maybeValue && !maybeValue.startsWith("-")) {
hasPermissionMode = true;
normalized.push(arg, maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith("--permission-mode=")) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (!hasSettingSources) {
normalized.push("--setting-sources", "user");
}
if (!hasPermissionMode) {
normalized.push("--permission-mode", "bypassPermissions");
}
return normalized;
}
function normalizeTestClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeTestClaudeArgs(config.args),
resumeArgs: normalizeTestClaudeArgs(config.resumeArgs),
};
}
beforeAll(async () => {
vi.doUnmock("../plugins/setup-registry.js");
vi.doUnmock("../plugins/cli-backends.runtime.js");
@@ -100,8 +156,7 @@ beforeAll(async () => {
({ createEmptyPluginRegistry } = await import("../plugins/registry.js"));
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } =
await import("../plugins/runtime.js"));
({ normalizeClaudeBackendConfig, resolveCliBackendConfig, resolveCliBackendLiveTest } =
await import("./cli-backends.js"));
({ resolveCliBackendConfig, resolveCliBackendLiveTest } = await import("./cli-backends.js"));
});
afterEach(() => {
@@ -165,7 +220,7 @@ beforeEach(() => {
"CLAUDE_CODE_USE_VERTEX",
],
},
normalizeConfig: normalizeClaudeBackendConfig,
normalizeConfig: normalizeTestClaudeBackendConfig,
}),
createBackendEntry({
pluginId: "openai",

View File

@@ -99,8 +99,14 @@ describe("normalizeProviders", () => {
});
it("replaces resolved env var value with env var name to prevent plaintext persistence", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const original = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; // pragma: allowlist secret
const env = {
...process.env,
OPENAI_API_KEY: "sk-test-secret-value-12345", // pragma: allowlist secret
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_SKIP_PROVIDERS: undefined,
OPENCLAW_TEST_MINIMAL_GATEWAY: undefined,
};
const secretRefManagedProviders = new Set<string>();
try {
const providers: NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]> = {
@@ -121,15 +127,15 @@ describe("normalizeProviders", () => {
],
},
};
const normalized = normalizeProviders({ providers, agentDir, secretRefManagedProviders });
const normalized = normalizeProviders({
providers,
agentDir,
env,
secretRefManagedProviders,
});
expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY");
expect(secretRefManagedProviders.has("openai")).toBe(true);
} finally {
if (original === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = original;
}
await fs.rm(agentDir, { recursive: true, force: true });
}
});

View File

@@ -7,10 +7,12 @@ type TimeoutCallbackMock = ReturnType<typeof vi.fn<TimeoutCallback>>;
async function withFakeTimers(run: () => Promise<void>) {
vi.useFakeTimers();
vi.clearAllTimers();
try {
await run();
} finally {
await vi.runOnlyPendingTimersAsync();
vi.clearAllTimers();
vi.useRealTimers();
}
}

View File

@@ -11,6 +11,7 @@ afterEach(() => {
vi.doUnmock("../../plugins/bundled-plugin-metadata.js");
vi.doUnmock("../../plugins/discovery.js");
vi.doUnmock("../../plugins/manifest-registry.js");
vi.doUnmock("../../plugins/channel-catalog-registry.js");
vi.doUnmock("../../infra/boundary-file-read.js");
vi.doUnmock("jiti");
});

View File

@@ -42,13 +42,10 @@ vi.mock("../channels/plugins/setup-registry.js", () => ({
}));
vi.mock("../channels/registry.js", () => ({
getChatChannelMeta: (channelId: string) => ({ id: channelId, label: channelId }),
listChatChannels: () => [],
getChatChannelMeta: (channelId?: unknown) => ({
id: typeof channelId === "string" ? channelId : "unknown",
label: typeof channelId === "string" ? channelId : "Unknown",
}),
normalizeChatChannelId: (channelId?: unknown) =>
typeof channelId === "string" ? channelId.trim().toLowerCase() : undefined,
typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null,
}));
vi.mock("../commands/channel-setup/discovery.js", () => ({

View File

@@ -10,6 +10,7 @@ import {
agentCommand,
getFreePort,
installGatewayTestHooks,
startGatewayServerWithRetries,
testState,
withGatewayServer,
} from "./test-helpers.js";
@@ -22,12 +23,21 @@ let enabledPort: number;
beforeAll(async () => {
({ startGatewayServer } = await import("./server.js"));
enabledPort = await getFreePort();
enabledServer = await startServer(enabledPort);
const started = await startGatewayServerWithRetries({
port: await getFreePort(),
opts: {
host: "127.0.0.1",
auth: { mode: "none" },
controlUiEnabled: false,
openAiChatCompletionsEnabled: true,
},
});
enabledPort = started.port;
enabledServer = started.server;
});
afterAll(async () => {
await enabledServer.close({ reason: "openai http enabled suite done" });
await enabledServer?.close({ reason: "openai http enabled suite done" });
});
async function startServerWithDefaultConfig(port: number) {

View File

@@ -6,7 +6,12 @@ import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
import {
agentCommand,
getFreePort,
installGatewayTestHooks,
startGatewayServerWithRetries,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -30,12 +35,21 @@ let openResponsesTesting: {
beforeAll(async () => {
({ __testing: openResponsesTesting } = await import("./openresponses-http.js"));
enabledPort = await getFreePort();
enabledServer = await startServer(enabledPort, { openResponsesEnabled: true });
const started = await startGatewayServerWithRetries({
port: await getFreePort(),
opts: {
host: "127.0.0.1",
auth: { mode: "none" },
controlUiEnabled: false,
openResponsesEnabled: true,
},
});
enabledPort = started.port;
enabledServer = started.server;
});
afterAll(async () => {
await enabledServer.close({ reason: "openresponses enabled suite done" });
await enabledServer?.close({ reason: "openresponses enabled suite done" });
});
beforeEach(() => {

View File

@@ -373,31 +373,37 @@ describe("gateway server agent", () => {
expectAgentRoutingCall({ channel: "webchat", deliver: false });
});
test("agent routes bare /new through session reset before running greeting prompt", async () => {
await writeMainSessionEntry({ sessionId: "sess-main-before-reset" });
const spy = vi.mocked(agentCommand);
const calls = spy.mock.calls;
const callsBefore = calls.length;
const res = await rpcReq(
ws,
"agent",
{
message: "/new",
sessionKey: "main",
idempotencyKey: "idem-agent-new",
},
20_000,
);
expect(res.ok).toBe(true);
test(
"agent routes bare /new through session reset before running greeting prompt",
{
timeout: 45_000,
},
async () => {
await writeMainSessionEntry({ sessionId: "sess-main-before-reset" });
const spy = vi.mocked(agentCommand);
const calls = spy.mock.calls;
const callsBefore = calls.length;
const res = await rpcReq(
ws,
"agent",
{
message: "/new",
sessionKey: "main",
idempotencyKey: "idem-agent-new",
},
30_000,
);
expect(res.ok).toBe(true);
await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore));
const call = (calls.at(-1)?.[0] ?? {}) as Record<string, unknown>;
expect(call.message).toBeTypeOf("string");
expect(call.message).toContain("Run your Session Startup sequence");
expect(call.message).toContain("Current time:");
expect(typeof call.sessionId).toBe("string");
expect(call.sessionId).not.toBe("sess-main-before-reset");
});
await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore));
const call = (calls.at(-1)?.[0] ?? {}) as Record<string, unknown>;
expect(call.message).toBeTypeOf("string");
expect(call.message).toContain("Run your Session Startup sequence");
expect(call.message).toContain("Current time:");
expect(typeof call.sessionId).toBe("string");
expect(call.sessionId).not.toBe("sess-main-before-reset");
},
);
test("write-scoped callers cannot reset conversations via agent", async () => {
await withGatewayServer(async ({ port }) => {

View File

@@ -607,7 +607,7 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
return server;
}
async function startGatewayServerWithRetries(params: {
export async function startGatewayServerWithRetries(params: {
port: number;
opts?: GatewayServerOptions;
}): Promise<{ port: number; server: Awaited<ReturnType<typeof startGatewayServer>> }> {

View File

@@ -318,18 +318,27 @@ describe("drainPendingDeliveries for WhatsApp reconnect", () => {
}
});
const nowSpy = vi.spyOn(Date, "now");
nowSpy.mockReturnValueOnce(1_000);
await enqueueDelivery(
const blockerId = await enqueueDelivery(
{ channel: "demo-channel-a", to: "+1000", payloads: [{ text: "blocker" }] },
tmpDir,
);
nowSpy.mockReturnValueOnce(2_000);
await enqueueDelivery(
const whatsappId = await enqueueDelivery(
{ channel: "whatsapp", to: "+1555", payloads: [{ text: "hi" }], accountId: "acct1" },
tmpDir,
);
nowSpy.mockRestore();
const queueDir = path.join(tmpDir, "delivery-queue");
const blockerPath = path.join(queueDir, `${blockerId}.json`);
const whatsappPath = path.join(queueDir, `${whatsappId}.json`);
const blockerEntry = JSON.parse(fs.readFileSync(blockerPath, "utf-8")) as {
enqueuedAt: number;
};
const whatsappEntry = JSON.parse(fs.readFileSync(whatsappPath, "utf-8")) as {
enqueuedAt: number;
};
blockerEntry.enqueuedAt = 1;
whatsappEntry.enqueuedAt = 2;
fs.writeFileSync(blockerPath, JSON.stringify(blockerEntry, null, 2));
fs.writeFileSync(whatsappPath, JSON.stringify(whatsappEntry, null, 2));
const startupRecovery = recoverPendingDeliveries({
cfg: stubCfg,