fix(ci): reduce slow channel test skew

This commit is contained in:
Vincent Koc 2026-03-31 19:48:17 +09:00
parent da7f016db6
commit cf3ae2612b
14 changed files with 230 additions and 31 deletions

View File

@ -5,10 +5,7 @@ export {
resolveConfiguredFromRequiredCredentialStatuses,
} from "openclaw/plugin-sdk/channel-status";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export {
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
} from "openclaw/plugin-sdk/slack-targets";
export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./targets.js";
export type { ChannelPlugin, OpenClawConfig, SlackAccountConfig } from "openclaw/plugin-sdk/slack";
export {
buildChannelConfigSchema,

View File

@ -1,6 +1,9 @@
import { normalizeSlackMessagingTarget } from "openclaw/plugin-sdk/slack";
import { describe, expect, it } from "vitest";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
import {
normalizeSlackMessagingTarget,
parseSlackTarget,
resolveSlackChannelId,
} from "./targets.js";
describe("parseSlackTarget", () => {
it("parses user mentions and prefixes", () => {

View File

@ -55,3 +55,27 @@ export function resolveSlackChannelId(raw: string): string {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
return requireTargetKind({ platform: "Slack", target, kind: "channel" });
}
export function normalizeSlackMessagingTarget(raw: string): string | undefined {
return parseSlackTarget(raw, { defaultKind: "channel" })?.normalized;
}
export function looksLikeSlackTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) {
return true;
}
if (/^(user|channel):/i.test(trimmed)) {
return true;
}
if (/^slack:/i.test(trimmed)) {
return true;
}
if (/^[@#]/.test(trimmed)) {
return true;
}
return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed);
}

View File

@ -9,6 +9,7 @@ import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runt
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { syncTelegramMenuCommands } from "./bot-native-command-menu.js";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
@ -26,6 +27,7 @@ export type TelegramBotDeps = {
loadWebMedia?: typeof loadWebMedia;
buildModelsProviderData: typeof buildModelsProviderData;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
syncTelegramMenuCommands?: typeof syncTelegramMenuCommands;
wasSentByBot: typeof wasSentByBot;
resolveExecApproval?: typeof resolveTelegramExecApproval;
createTelegramDraftStream?: typeof createTelegramDraftStream;
@ -65,6 +67,9 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
get listSkillCommandsForAgents() {
return listSkillCommandsForAgents;
},
get syncTelegramMenuCommands() {
return syncTelegramMenuCommands;
},
get wasSentByBot() {
return wasSentByBot;
},

View File

@ -1,3 +1,4 @@
import { vi } from "vitest";
import type { BuildTelegramMessageContextParams, TelegramMediaRef } from "./bot-message-context.js";
export const baseTelegramMessageContextConfig = {
@ -22,8 +23,7 @@ export async function buildTelegramMessageContextForTest(
): Promise<
Awaited<ReturnType<typeof import("./bot-message-context.js").buildTelegramMessageContext>>
> {
const { vi } = await import("vitest");
const { buildTelegramMessageContext } = await import("./bot-message-context.js");
const buildTelegramMessageContext = await loadBuildTelegramMessageContext();
return await buildTelegramMessageContext({
primaryCtx: {
message: {
@ -64,3 +64,15 @@ export async function buildTelegramMessageContextForTest(
sendChatActionHandler: { sendChatAction: vi.fn() } as never,
});
}
let buildTelegramMessageContextLoader:
| typeof import("./bot-message-context.js").buildTelegramMessageContext
| undefined;
async function loadBuildTelegramMessageContext() {
if (!buildTelegramMessageContextLoader) {
({ buildTelegramMessageContext: buildTelegramMessageContextLoader } =
await import("./bot-message-context.js"));
}
return buildTelegramMessageContextLoader;
}

View File

@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let createNativeCommandsHarness: typeof import("./bot-native-commands.test-helpers.js").createNativeCommandsHarness;
let deliverReplies: typeof import("./bot-native-commands.test-helpers.js").deliverReplies;
@ -27,7 +27,7 @@ let executePluginCommandMock: {
};
describe("registerTelegramNativeCommands (plugin auth)", () => {
beforeEach(async () => {
beforeAll(async () => {
vi.resetModules();
({
createNativeCommandsHarness,
@ -40,6 +40,9 @@ describe("registerTelegramNativeCommands (plugin auth)", () => {
getPluginCommandSpecs as unknown as typeof getPluginCommandSpecsMock;
matchPluginCommandMock = matchPluginCommand as unknown as typeof matchPluginCommandMock;
executePluginCommandMock = executePluginCommand as unknown as typeof executePluginCommandMock;
});
beforeEach(() => {
vi.clearAllMocks();
});

View File

@ -206,6 +206,7 @@ function registerAndResolveCommandHandlerBase(params: {
modelNames: new Map<string, string>(),
})),
listSkillCommandsForAgents: vi.fn(() => []),
syncTelegramMenuCommands: vi.fn(),
wasSentByBot: vi.fn(() => false),
};
registerTelegramNativeCommands({

View File

@ -129,6 +129,7 @@ export function createNativeCommandsHarness(params?: {
modelNames: new Map<string, string>(),
})),
listSkillCommandsForAgents: vi.fn(() => []),
syncTelegramMenuCommands: vi.fn(),
wasSentByBot: vi.fn(() => false),
};
const bot = {

View File

@ -56,7 +56,7 @@ import type { TelegramMessageContextOptions } from "./bot-message-context.types.
import {
buildCappedTelegramMenuCommands,
buildPluginTelegramMenuCommands,
syncTelegramMenuCommands,
syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime,
} from "./bot-native-command-menu.js";
import { TelegramUpdateKeyContext } from "./bot-updates.js";
import { TelegramBotOptions } from "./bot.js";
@ -501,6 +501,8 @@ export const registerTelegramNativeCommands = ({
`Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`,
);
}
const syncTelegramMenuCommands =
telegramDeps.syncTelegramMenuCommands ?? syncTelegramMenuCommandsRuntime;
// Telegram only limits the setMyCommands payload (menu entries).
// Keep hidden commands callable by registering handlers for the full catalog.
syncTelegramMenuCommands({

View File

@ -83,6 +83,7 @@ export function loadTestCatalog() {
const isolated =
options.unitMemoryIsolatedFiles?.includes(normalizedFile) ||
options.extensionTimedIsolatedFiles?.includes(normalizedFile) ||
options.channelTimedIsolatedFiles?.includes(normalizedFile) ||
unitForkIsolatedFileSet.has(normalizedFile) ||
extensionForkIsolatedFileSet.has(normalizedFile) ||
channelIsolatedFileSet.has(normalizedFile);
@ -92,6 +93,9 @@ export function loadTestCatalog() {
if (options.extensionTimedIsolatedFiles?.includes(normalizedFile)) {
reasons.push("extensions-timed-heavy");
}
if (options.channelTimedIsolatedFiles?.includes(normalizedFile)) {
reasons.push("channels-timed-heavy");
}
if (unitForkIsolatedFileSet.has(normalizedFile)) {
reasons.push("unit-isolated-manifest");
}

View File

@ -455,6 +455,30 @@ const resolveExtensionTimedHeavyFiles = (context) => {
});
};
const resolveChannelTimedHeavyFiles = (context) => {
const { env, runtime, catalog, channelTimingManifest } = context;
const timedHeavyChannelFileLimit = parseEnvNumber(
env,
"OPENCLAW_TEST_HEAVY_CHANNEL_FILE_LIMIT",
runtime.isCI ? 10 : 6,
);
const timedHeavyChannelMinDurationMs = parseEnvNumber(
env,
"OPENCLAW_TEST_HEAVY_CHANNEL_MIN_MS",
runtime.isCI ? 12_000 : 18_000,
);
return selectTimedHeavyFiles({
candidates: catalog.allKnownTestFiles.filter(
(file) =>
catalog.channelTestPrefixes.some((prefix) => file.startsWith(prefix)) &&
!catalog.channelIsolatedFileSet.has(file),
),
limit: timedHeavyChannelFileLimit,
minDurationMs: timedHeavyChannelMinDurationMs,
timings: channelTimingManifest,
});
};
const buildDefaultUnits = (context, request) => {
const {
env,
@ -481,6 +505,7 @@ const buildDefaultUnits = (context, request) => {
(file) => !catalog.unitBehaviorOverrideSet.has(file),
);
const extensionTimedHeavyFiles = resolveExtensionTimedHeavyFiles(context);
const channelTimedHeavyFiles = resolveChannelTimedHeavyFiles(context);
const unitSchedulingOverrideSet = new Set([
...catalog.unitBehaviorOverrideSet,
...memoryHeavyUnitFiles,
@ -489,6 +514,10 @@ const buildDefaultUnits = (context, request) => {
...catalog.extensionForkIsolatedFiles,
...extensionTimedHeavyFiles,
]);
const channelSchedulingOverrideSet = new Set([
...catalog.channelIsolatedFiles,
...channelTimedHeavyFiles,
]);
const unitFastExcludedFiles = [
...new Set([
...unitSchedulingOverrideSet,
@ -518,7 +547,7 @@ const buildDefaultUnits = (context, request) => {
const channelSharedCandidateFiles = catalog.allKnownTestFiles.filter(
(file) =>
catalog.channelTestPrefixes.some((prefix) => file.startsWith(prefix)) &&
!catalog.channelIsolatedFileSet.has(file),
!channelSchedulingOverrideSet.has(file),
);
const defaultExtensionsBatchTargetMs = executionBudget.extensionsBatchTargetMs;
const extensionsBatchTargetMs = parseEnvNumber(
@ -709,6 +738,26 @@ const buildDefaultUnits = (context, request) => {
}),
);
}
for (const file of channelTimedHeavyFiles) {
units.push(
createExecutionUnit(context, {
id: `channels-${path.basename(file, ".test.ts")}-isolated`,
surface: "channels",
isolate: true,
estimatedDurationMs: estimateChannelDurationMs(file),
args: [
"vitest",
"run",
"--config",
"vitest.channels.config.ts",
"--pool=forks",
...noIsolateArgs,
file,
],
reasons: ["channels-timed-heavy"],
}),
);
}
}
if (selectedSurfaceSet.has("contracts")) {
@ -839,7 +888,7 @@ const buildDefaultUnits = (context, request) => {
);
}
return { units, unitMemoryIsolatedFiles };
return { units, unitMemoryIsolatedFiles, channelTimedHeavyFiles };
};
const createTargetedUnit = (context, classification, filters) => {
@ -961,6 +1010,7 @@ const buildTargetedUnits = (context, request) => {
}
const unitMemoryIsolatedFiles = request.unitMemoryIsolatedFiles ?? [];
const extensionTimedIsolatedFiles = request.extensionTimedIsolatedFiles ?? [];
const channelTimedIsolatedFiles = request.channelTimedIsolatedFiles ?? [];
const estimateUnitDurationMs = (file) =>
context.unitTimingManifest.files[file]?.durationMs ??
context.unitTimingManifest.defaultDurationMs;
@ -985,6 +1035,7 @@ const buildTargetedUnits = (context, request) => {
const classification = context.catalog.classifyTestFile(normalizeRepoPath(fileFilter), {
unitMemoryIsolatedFiles,
extensionTimedIsolatedFiles,
channelTimedIsolatedFiles,
});
const key = `${classification.legacyBasePinned ? "base-pinned" : classification.surface}:${
classification.isolated ? "isolated" : "default"
@ -998,6 +1049,7 @@ const buildTargetedUnits = (context, request) => {
const classification = context.catalog.classifyTestFile(matchedFile, {
unitMemoryIsolatedFiles,
extensionTimedIsolatedFiles,
channelTimedIsolatedFiles,
});
const key = `${classification.legacyBasePinned ? "base-pinned" : classification.surface}:${
classification.isolated ? "isolated" : "default"
@ -1017,6 +1069,7 @@ const buildTargetedUnits = (context, request) => {
context.catalog.classifyTestFile(file, {
unitMemoryIsolatedFiles,
extensionTimedIsolatedFiles,
channelTimedIsolatedFiles,
}),
[file],
),
@ -1154,10 +1207,18 @@ const estimateTopLevelEntryDurationMs = (unit, context) => {
);
}
if (context.catalog.channelTestPrefixes.some((prefix) => file.startsWith(prefix))) {
return totalMs + 3_000;
return (
totalMs +
(context.channelTimingManifest.files[file]?.durationMs ??
context.channelTimingManifest.defaultDurationMs)
);
}
if (file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return totalMs + 2_000;
return (
totalMs +
(context.extensionTimingManifest.files[file]?.durationMs ??
context.extensionTimingManifest.defaultDurationMs)
);
}
return totalMs + 1_000;
}, 0);
@ -1497,9 +1558,11 @@ export function explainExecutionTarget(request, options = {}) {
(file) => !context.catalog.unitBehaviorOverrideSet.has(file),
);
const extensionTimedIsolatedFiles = resolveExtensionTimedHeavyFiles(context);
const channelTimedIsolatedFiles = resolveChannelTimedHeavyFiles(context);
const classification = context.catalog.classifyTestFile(normalizedTarget, {
unitMemoryIsolatedFiles,
extensionTimedIsolatedFiles,
channelTimedIsolatedFiles,
});
const targetedUnit = createTargetedUnit(context, classification, [normalizedTarget]);
return {
@ -1584,12 +1647,14 @@ export function buildExecutionPlan(request, options = {}) {
const defaultPlanning = buildDefaultUnits(context, { ...request, fileFilters });
const extensionTimedIsolatedFiles = resolveExtensionTimedHeavyFiles(context);
const channelTimedIsolatedFiles = resolveChannelTimedHeavyFiles(context);
let units = defaultPlanning.units;
const targetedUnits = buildTargetedUnits(context, {
...request,
fileFilters,
unitMemoryIsolatedFiles: defaultPlanning.unitMemoryIsolatedFiles,
extensionTimedIsolatedFiles,
channelTimedIsolatedFiles,
});
if (context.configuredShardCount !== null && context.shardCount > 1) {
units = expandUnitsAcrossTopLevelShards(context, units);

View File

@ -1,8 +1,38 @@
import { parseSlackTarget } from "../../../plugin-sdk/slack-targets.js";
import {
buildMessagingTarget,
ensureTargetId,
parseMentionPrefixOrAtUserTarget,
} from "../../targets.js";
export function normalizeSlackMessagingTarget(raw: string): string | undefined {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
return target?.normalized;
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
const userTarget = parseMentionPrefixOrAtUserTarget({
raw: trimmed,
mentionPattern: /^<@([A-Z0-9]+)>$/i,
prefixes: [
{ prefix: "user:", kind: "user" },
{ prefix: "channel:", kind: "channel" },
{ prefix: "slack:", kind: "user" },
],
atUserPattern: /^[A-Z0-9]+$/i,
atUserErrorMessage: "Slack DMs require a user id (use user:<id> or <@id>)",
});
if (userTarget) {
return userTarget.normalized;
}
if (trimmed.startsWith("#")) {
const candidate = trimmed.slice(1).trim();
const id = ensureTargetId({
candidate,
pattern: /^[A-Z0-9]+$/i,
errorMessage: "Slack channels require a channel id (use channel:<id>)",
});
return buildMessagingTarget("channel", id, trimmed).normalized;
}
return buildMessagingTarget("channel", trimmed, trimmed).normalized;
}
export function looksLikeSlackTargetId(raw: string): boolean {

View File

@ -1,6 +1,6 @@
{
"config": "vitest.channels.config.ts",
"generatedAt": "2026-03-24T04:15:05Z",
"generatedAt": "2026-03-31T10:45:00Z",
"defaultDurationMs": 3000,
"files": {
"extensions/telegram/src/bot.create-telegram-bot.test.ts": {
@ -64,7 +64,7 @@
"durationMs": 3200
},
"extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts": {
"durationMs": 3000
"durationMs": 18920
},
"extensions/telegram/src/webhook.test.ts": {
"durationMs": 2900
@ -82,7 +82,7 @@
"durationMs": 2300
},
"extensions/whatsapp/src/auto-reply/deliver-reply.test.ts": {
"durationMs": 2200
"durationMs": 12400
},
"extensions/slack/src/monitor/events/reactions.test.ts": {
"durationMs": 2200
@ -91,7 +91,7 @@
"durationMs": 2100
},
"extensions/telegram/src/bot-native-commands.session-meta.test.ts": {
"durationMs": 2100
"durationMs": 18000
},
"extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts": {
"durationMs": 2000
@ -100,7 +100,7 @@
"durationMs": 1900
},
"extensions/telegram/src/bot-native-commands.test.ts": {
"durationMs": 1700
"durationMs": 17590
},
"extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts": {
"durationMs": 1700
@ -130,7 +130,7 @@
"durationMs": 1500
},
"extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts": {
"durationMs": 1500
"durationMs": 13000
},
"extensions/whatsapp/src/setup-surface.test.ts": {
"durationMs": 1400
@ -163,7 +163,7 @@
"durationMs": 1000
},
"extensions/telegram/src/bot-message-context.audio-transcript.test.ts": {
"durationMs": 1000
"durationMs": 17310
},
"extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts": {
"durationMs": 999
@ -220,7 +220,7 @@
"durationMs": 701
},
"extensions/telegram/src/bot-message-context.thread-binding.test.ts": {
"durationMs": 695
"durationMs": 17700
},
"src/browser/pw-tools-core.waits-next-download-saves-it.test.ts": {
"durationMs": 677
@ -241,7 +241,7 @@
"durationMs": 570
},
"extensions/telegram/src/bot-native-commands.plugin-auth.test.ts": {
"durationMs": 563
"durationMs": 19500
},
"extensions/discord/src/monitor/agent-components.wildcard.test.ts": {
"durationMs": 547
@ -517,7 +517,7 @@
"durationMs": 11
},
"extensions/telegram/src/bot-message-context.sender-prefix.test.ts": {
"durationMs": 11
"durationMs": 19410
},
"extensions/whatsapp/src/active-listener.test.ts": {
"durationMs": 11
@ -736,7 +736,7 @@
"durationMs": 5
},
"extensions/slack/src/targets.test.ts": {
"durationMs": 5
"durationMs": 100
},
"extensions/imessage/src/monitor/loop-rate-limiter.test.ts": {
"durationMs": 5

View File

@ -152,6 +152,39 @@ describe("test planner", () => {
artifacts.cleanupTempArtifacts();
});
it("auto-isolates timed-heavy channel suites in CI", () => {
const env = {
CI: "true",
GITHUB_ACTIONS: "true",
RUNNER_OS: "Linux",
OPENCLAW_TEST_HOST_CPU_COUNT: "4",
OPENCLAW_TEST_HOST_MEMORY_GIB: "16",
};
const artifacts = createExecutionArtifacts(env);
const plan = buildExecutionPlan(
{
profile: null,
mode: "ci",
surfaces: ["channels"],
passthroughArgs: [],
},
{
env,
platform: "linux",
writeTempJsonArtifact: artifacts.writeTempJsonArtifact,
},
);
const hotspotUnit = plan.selectedUnits.find(
(unit) => unit.id === "channels-bot-native-commands.plugin-auth-isolated",
);
expect(hotspotUnit).toBeTruthy();
expect(hotspotUnit?.isolate).toBe(true);
expect(hotspotUnit?.reasons).toContain("channels-timed-heavy");
artifacts.cleanupTempArtifacts();
});
it("scales down mid-tier local concurrency under saturated load", () => {
const artifacts = createExecutionArtifacts({
RUNNER_OS: "Linux",
@ -437,6 +470,25 @@ describe("test planner", () => {
expect(explanation.reasons).toContain("extensions-timed-heavy");
});
it("explains timed-heavy channel suites as isolated", () => {
const explanation = explainExecutionTarget(
{
mode: "ci",
fileFilters: ["extensions/telegram/src/bot-native-commands.plugin-auth.test.ts"],
},
{
env: {
CI: "true",
GITHUB_ACTIONS: "true",
},
},
);
expect(explanation.surface).toBe("channels");
expect(explanation.isolate).toBe(true);
expect(explanation.reasons).toContain("channels-timed-heavy");
});
it("does not leak default-plan shard assignments into targeted units with the same id", () => {
const artifacts = createExecutionArtifacts({});
const plan = buildExecutionPlan(
@ -573,13 +625,13 @@ describe("test planner", () => {
expect(manifest.jobs.buildArtifacts.enabled).toBe(true);
expect(manifest.shardCounts.unit).toBe(4);
expect(manifest.shardCounts.channels).toBe(3);
expect(manifest.shardCounts.channels).toBe(4);
expect(manifest.shardCounts.extensionFast).toBeGreaterThanOrEqual(4);
expect(manifest.shardCounts.extensionFast).toBeLessThanOrEqual(5);
expect(manifest.shardCounts.windows).toBe(6);
expect(manifest.shardCounts.macosNode).toBe(9);
expect(manifest.shardCounts.bun).toBe(6);
expect(manifest.jobs.checks.matrix.include).toHaveLength(7);
expect(manifest.jobs.checks.matrix.include).toHaveLength(8);
expect(manifest.jobs.checksWindows.matrix.include).toHaveLength(6);
expect(manifest.jobs.bunChecks.matrix.include).toHaveLength(6);
expect(manifest.jobs.macosNode.matrix.include).toHaveLength(9);