fix: stabilize ci and serial test gate

This commit is contained in:
Peter Steinberger
2026-03-30 00:41:57 +01:00
parent 0da610a8ec
commit 193f781fad
13 changed files with 464 additions and 189 deletions

View File

@@ -33,6 +33,7 @@ vi.mock("../progress.js", () => ({
describe("exec approval transport timeout (#12098)", () => {
let callGatewayCli: typeof import("./rpc.js").callGatewayCli;
const approvalTransportFloorMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS + 10_000;
beforeAll(async () => {
({ callGatewayCli } = await import("./rpc.js"));
@@ -55,10 +56,10 @@ describe("exec approval transport timeout (#12098)", () => {
});
it("fix: overriding transportTimeoutMs gives the approval enough transport time", async () => {
const approvalTimeoutMs = 120_000;
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
// Mirror the production code: parseTimeoutMs(opts.timeout) ?? 0
const transportTimeoutMs = Math.max(parseTimeoutMs("35000") ?? 0, approvalTimeoutMs + 10_000);
expect(transportTimeoutMs).toBe(130_000);
const transportTimeoutMs = Math.max(parseTimeoutMs("35000") ?? 0, approvalTransportFloorMs);
expect(transportTimeoutMs).toBe(approvalTransportFloorMs);
await callGatewayCli(
"exec.approval.request",
@@ -70,18 +71,18 @@ describe("exec approval transport timeout (#12098)", () => {
expect(callGatewaySpy).toHaveBeenCalledTimes(1);
const callOpts = callGatewaySpy.mock.calls[0][0];
expect(callOpts.timeoutMs).toBeGreaterThanOrEqual(approvalTimeoutMs);
expect(callOpts.timeoutMs).toBe(130_000);
expect(callOpts.timeoutMs).toBe(approvalTransportFloorMs);
});
it("fix: user-specified timeout larger than approval is preserved", async () => {
const approvalTimeoutMs = 120_000;
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
const userTimeout = 200_000;
// Mirror the production code: parseTimeoutMs preserves valid large values
const transportTimeoutMs = Math.max(
parseTimeoutMs(String(userTimeout)) ?? 0,
approvalTimeoutMs + 10_000,
approvalTransportFloorMs,
);
expect(transportTimeoutMs).toBe(200_000);
expect(transportTimeoutMs).toBe(approvalTransportFloorMs);
await callGatewayCli(
"exec.approval.request",
@@ -91,15 +92,15 @@ describe("exec approval transport timeout (#12098)", () => {
);
const callOpts = callGatewaySpy.mock.calls[0][0];
expect(callOpts.timeoutMs).toBe(200_000);
expect(callOpts.timeoutMs).toBe(approvalTransportFloorMs);
});
it("fix: non-numeric timeout falls back to approval floor", async () => {
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
// parseTimeoutMs returns undefined for garbage input, ?? 0 ensures
// Math.max picks the approval floor instead of producing NaN
const transportTimeoutMs = Math.max(parseTimeoutMs("foo") ?? 0, approvalTimeoutMs + 10_000);
expect(transportTimeoutMs).toBe(approvalTimeoutMs + 10_000);
const transportTimeoutMs = Math.max(parseTimeoutMs("foo") ?? 0, approvalTransportFloorMs);
expect(transportTimeoutMs).toBe(approvalTransportFloorMs);
await callGatewayCli(
"exec.approval.request",
@@ -109,6 +110,6 @@ describe("exec approval transport timeout (#12098)", () => {
);
const callOpts = callGatewaySpy.mock.calls[0][0];
expect(callOpts.timeoutMs).toBe(approvalTimeoutMs + 10_000);
expect(callOpts.timeoutMs).toBe(approvalTransportFloorMs);
});
});

View File

@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("bundled channel config runtime", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../channels/plugins/bundled.js");
});
afterEach(() => {
vi.resetModules();
vi.doUnmock("../channels/plugins/bundled.js");

View File

@@ -1,8 +1,16 @@
import { describe, expect, it } from "vitest";
import { loadConfig } from "./config.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadConfig, resetConfigRuntimeState } from "./config.js";
import { withTempHomeConfig } from "./test-helpers.js";
describe("config compaction settings", () => {
beforeEach(() => {
resetConfigRuntimeState();
});
afterEach(() => {
resetConfigRuntimeState();
});
it("preserves memory flush config values", async () => {
await withTempHomeConfig(
{

View File

@@ -1,11 +1,19 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } 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 { loadConfig, resetConfigRuntimeState } from "./config.js";
import { withTempHome } from "./home-env.test-harness.js";
describe("config identity defaults", () => {
beforeEach(() => {
resetConfigRuntimeState();
});
afterEach(() => {
resetConfigRuntimeState();
});
const defaultIdentity = {
name: "Samantha",
theme: "helpful sloth",

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "./config.js";
import { migrateLegacyConfig, validateConfigObject } from "./config.js";
import { migrateLegacyConfig } from "./legacy-migrate.js";
import type { OpenClawConfig } from "./types.js";
import { validateConfigObject } from "./validation.js";
function getChannelConfig(config: unknown, provider: string) {
const channels = (config as { channels?: Record<string, Record<string, unknown>> } | undefined)
@@ -346,6 +347,7 @@ describe("legacy config detection", () => {
expect(res.issues[0]?.path, provider).toBe(expectedIssuePath);
}
},
180_000,
);
it.each(["telegram", "whatsapp", "signal"] as const)(

View File

@@ -1,8 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { loadConfig } from "./config.js";
import { loadConfig, resetConfigRuntimeState } from "./config.js";
import { withTempHome } from "./test-helpers.js";
async function writeConfigForTest(home: string, config: unknown): Promise<void> {
@@ -32,6 +32,14 @@ function expectAnthropicPruningDefaults(
}
describe("config pruning defaults", () => {
beforeEach(() => {
resetConfigRuntimeState();
});
afterEach(() => {
resetConfigRuntimeState();
});
it("does not enable contextPruning by default", async () => {
await withEnvAsync({ ANTHROPIC_API_KEY: "", ANTHROPIC_OAUTH_TOKEN: "" }, async () => {
await withTempHome(async (home) => {

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { withTempHome } from "./home-env.test-harness.js";
import {
getRuntimeConfigSourceSnapshot,
@@ -48,6 +48,14 @@ function resetRuntimeConfigState(): void {
}
describe("runtime config snapshot writes", () => {
beforeEach(() => {
resetRuntimeConfigState();
});
afterEach(() => {
resetRuntimeConfigState();
});
it("returns the source snapshot when runtime snapshot is active", async () => {
await withTempHome("openclaw-config-runtime-source-", async () => {
const sourceConfig = createSourceConfig();

View File

@@ -21,6 +21,7 @@ import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net
import { isRecord } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
import {
listLegacyWebSearchConfigPaths,
@@ -40,8 +41,14 @@ type AllowedValuesCollection = {
incomplete: boolean;
hasValues: boolean;
};
type JsonSchemaLike = Record<string, unknown>;
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
const bundledChannelSchemaById = new Map<string, unknown>(
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.map(
(entry) => [entry.channelId, entry.schema] as const,
),
);
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
if (!value || typeof value !== "object") {
@@ -64,6 +71,97 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
return segments.join(".");
}
function asJsonSchemaLike(value: unknown): JsonSchemaLike | null {
return value && typeof value === "object" ? (value as JsonSchemaLike) : null;
}
function lookupJsonSchemaNode(
schema: unknown,
pathSegments: readonly ConfigPathSegment[],
): JsonSchemaLike | null {
let current = asJsonSchemaLike(schema);
for (const segment of pathSegments) {
if (!current) {
return null;
}
if (typeof segment === "number") {
const items = current.items;
if (Array.isArray(items)) {
current = asJsonSchemaLike(items[segment] ?? items[0]);
continue;
}
current = asJsonSchemaLike(items);
continue;
}
const properties = asJsonSchemaLike(current.properties);
const next =
(properties && asJsonSchemaLike(properties[segment])) ||
asJsonSchemaLike(current.additionalProperties);
current = next;
}
return current;
}
function collectAllowedValuesFromJsonSchemaNode(schema: unknown): AllowedValuesCollection {
const node = asJsonSchemaLike(schema);
if (!node) {
return { values: [], incomplete: false, hasValues: false };
}
if (Object.prototype.hasOwnProperty.call(node, "const")) {
return { values: [node.const], incomplete: false, hasValues: true };
}
if (Array.isArray(node.enum)) {
return { values: node.enum, incomplete: false, hasValues: node.enum.length > 0 };
}
const type = node.type;
if (type === "boolean") {
return { values: [true, false], incomplete: false, hasValues: true };
}
if (Array.isArray(type) && type.includes("boolean")) {
return { values: [true, false], incomplete: false, hasValues: true };
}
const unionBranches = Array.isArray(node.anyOf)
? node.anyOf
: Array.isArray(node.oneOf)
? node.oneOf
: null;
if (!unionBranches) {
return { values: [], incomplete: false, hasValues: false };
}
const collected: unknown[] = [];
for (const branch of unionBranches) {
const branchCollected = collectAllowedValuesFromJsonSchemaNode(branch);
if (branchCollected.incomplete || !branchCollected.hasValues) {
return { values: [], incomplete: true, hasValues: false };
}
collected.push(...branchCollected.values);
}
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}
function collectAllowedValuesFromBundledChannelSchemaPath(
pathSegments: readonly ConfigPathSegment[],
): AllowedValuesCollection {
if (pathSegments[0] !== "channels" || typeof pathSegments[1] !== "string") {
return { values: [], incomplete: false, hasValues: false };
}
const channelSchema = bundledChannelSchemaById.get(pathSegments[1]);
if (!channelSchema) {
return { values: [], incomplete: false, hasValues: false };
}
const targetNode = lookupJsonSchemaNode(channelSchema, pathSegments.slice(2));
if (!targetNode) {
return { values: [], incomplete: false, hasValues: false };
}
return collectAllowedValuesFromJsonSchemaNode(targetNode);
}
function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
const message = typeof record.message === "string" ? record.message : "";
const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
@@ -72,10 +170,11 @@ function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): Allowe
return { values, incomplete: false, hasValues: values.length > 0 };
}
// Custom Zod issues usually come from superRefine rules, not enum schemas.
// Avoid bundled channel schema lookup here: it can pull in runtime plugin
// metadata during validation error formatting and hang on some bootstrap paths.
return { values: [], incomplete: false, hasValues: false };
// Custom Zod issues usually come from superRefine rules, but some normalized
// channel unions collapse to a generic custom issue. Use generated channel
// config metadata here so we can recover enum hints without touching runtime
// plugin registries during validation formatting.
return collectAllowedValuesFromBundledChannelSchemaPath(toConfigPathSegments(record.path));
}
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { shouldSuppressTelegramExecApprovalForwardingFallback } from "../plugin-sdk/telegram.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
@@ -43,6 +43,82 @@ function isDiscordExecApprovalClientEnabledForTest(params: {
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
}
function isTelegramExecApprovalClientEnabledForTest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const accountId = params.accountId?.trim();
const rootConfig = params.cfg.channels?.telegram?.execApprovals;
const accountConfig =
accountId && accountId !== "default"
? params.cfg.channels?.telegramAccounts?.[accountId]?.execApprovals
: undefined;
const config = accountConfig ?? rootConfig;
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
}
function shouldSuppressTelegramExecApprovalForwardingFallbackForTest(params: {
cfg: OpenClawConfig;
target: { channel: string; accountId?: string | null };
request: { request: { turnSourceChannel?: string | null; turnSourceAccountId?: string | null } };
}): boolean {
if (
params.target.channel !== "telegram" ||
params.request.request.turnSourceChannel !== "telegram"
) {
return false;
}
const accountId =
params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim();
return isTelegramExecApprovalClientEnabledForTest({ cfg: params.cfg, accountId });
}
function buildTelegramExecApprovalPendingPayloadForTest(params: {
request: { id: string };
}): ReplyPayload {
return {
text: `Telegram exec approval ${params.request.id}`,
interactive: {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Allow Once",
value: `/approve ${params.request.id} allow-once`,
style: "success",
},
{
label: "Allow Always",
value: `/approve ${params.request.id} always`,
style: "primary",
},
{
label: "Deny",
value: `/approve ${params.request.id} deny`,
style: "danger",
},
],
},
],
},
channelData: {
execApproval: {
approvalId: params.request.id,
},
telegram: {
buttons: [
[
{ text: "Allow Once", callback_data: `/approve ${params.request.id} allow-once` },
{ text: "Allow Always", callback_data: `/approve ${params.request.id} always` },
],
[{ text: "Deny", callback_data: `/approve ${params.request.id} deny` }],
],
},
},
};
}
const telegramApprovalPlugin: Pick<
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "approvals"
@@ -51,7 +127,13 @@ const telegramApprovalPlugin: Pick<
approvals: {
delivery: {
shouldSuppressForwardingFallback: (params) =>
shouldSuppressTelegramExecApprovalForwardingFallback(params),
shouldSuppressTelegramExecApprovalForwardingFallbackForTest(params),
},
render: {
exec: {
buildPendingPayload: ({ request }) =>
buildTelegramExecApprovalPendingPayloadForTest({ request }),
},
},
},
};
@@ -321,12 +403,12 @@ describe("exec approval forwarder", () => {
to: "123",
payloads: [
expect.objectContaining({
channelData: {
channelData: expect.objectContaining({
execApproval: expect.objectContaining({
approvalId: "req-1",
}),
},
interactive: {
}),
interactive: expect.objectContaining({
blocks: [
{
type: "buttons",
@@ -349,7 +431,7 @@ describe("exec approval forwarder", () => {
],
},
],
},
}),
}),
],
}),

View File

@@ -1,11 +1,6 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js";
import { hasBundledWebSearchCredential } from "./bundled-web-search-registry.js";
import {
listBundledWebSearchProviders,
resolveBundledWebSearchPluginIds,
} from "./bundled-web-search.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
function resolveManifestBundledWebSearchPluginIds() {
@@ -18,7 +13,8 @@ function resolveManifestBundledWebSearchPluginIds() {
.toSorted((left, right) => left.localeCompare(right));
}
function resolveRegistryBundledWebSearchPluginIds() {
async function resolveRegistryBundledWebSearchPluginIds() {
const { listBundledWebSearchProviders } = await import("./bundled-web-search.js");
return listBundledWebSearchProviders()
.map(({ pluginId }) => pluginId)
.filter((value, index, values) => values.indexOf(value) === index)
@@ -37,23 +33,31 @@ function expectBundledWebSearchAlignment(params: {
}
describe("bundled web search metadata", () => {
it.each([
[
"keeps bundled web search compat ids aligned with bundled manifests",
resolveBundledWebSearchPluginIds({}),
resolveManifestBundledWebSearchPluginIds(),
],
[
"keeps bundled web search fast-path ids aligned with the registry",
[...BUNDLED_WEB_SEARCH_PLUGIN_IDS],
resolveRegistryBundledWebSearchPluginIds(),
],
] as const)("%s", (_name, actual, expected) => {
expectBundledWebSearchAlignment({ actual, expected });
beforeEach(() => {
vi.resetModules();
});
it("keeps bundled web search compat ids aligned with bundled manifests", async () => {
const { resolveBundledWebSearchPluginIds } = await import("./bundled-web-search.js");
expectBundledWebSearchAlignment({
actual: resolveBundledWebSearchPluginIds({}),
expected: resolveManifestBundledWebSearchPluginIds(),
});
});
it("keeps bundled web search fast-path ids aligned with the registry", async () => {
expectBundledWebSearchAlignment({
actual: [...BUNDLED_WEB_SEARCH_PLUGIN_IDS],
expected: await resolveRegistryBundledWebSearchPluginIds(),
});
});
});
describe("hasBundledWebSearchCredential", () => {
beforeEach(() => {
vi.resetModules();
});
const baseCfg = {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
browser: { enabled: false },
@@ -98,7 +102,8 @@ describe("hasBundledWebSearchCredential", () => {
config: baseCfg,
env: { OPENROUTER_API_KEY: "sk-or-v1-test" },
},
] as const)("$name", ({ config, env }) => {
] as const)("$name", async ({ config, env }) => {
const { hasBundledWebSearchCredential } = await import("./bundled-web-search-registry.js");
expect(hasBundledWebSearchCredential({ config, env })).toBe(true);
});
});