fix: restore ci gates

This commit is contained in:
Peter Steinberger
2026-03-23 14:32:10 +00:00
parent 4e849ac127
commit 3725b38335
20 changed files with 388 additions and 71 deletions
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runDuckDuckGoSearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runDuckDuckGoSearch } = vi.hoisted(() => ({
runDuckDuckGoSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./ddg-client.js", () => ({
runDuckDuckGoSearch,
}));
describe("duckduckgo web search provider", () => {
beforeEach(() => {
vi.resetModules();
runDuckDuckGoSearch.mockReset();
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes keyless metadata and enables the plugin in config", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlScrape = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runFirecrawlScrape } = vi.hoisted(() => ({
runFirecrawlScrape: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./firecrawl-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
}));
describe("firecrawl scrape tool", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlScrape.mockReset();
runFirecrawlScrape.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("maps scrape params and defaults extract mode to markdown", async () => {
const { createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js");
const tool = createFirecrawlScrapeTool({
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runFirecrawlSearch } = vi.hoisted(() => ({
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./firecrawl-client.js", () => ({
runFirecrawlSearch,
}));
describe("firecrawl web search provider", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlSearch.mockReset();
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes selection metadata and enables the plugin in config", async () => {
const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js");
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runFirecrawlSearch } = vi.hoisted(() => ({
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./firecrawl-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
}));
describe("firecrawl search tool", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlSearch.mockReset();
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("normalizes optional search parameters before invoking Firecrawl", async () => {
const { createFirecrawlSearchTool } = await import("./firecrawl-search-tool.js");
const tool = createFirecrawlSearchTool({
+16 -10
View File
@@ -1,6 +1,6 @@
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LineAccountConfig } from "./types.js";
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
@@ -209,18 +209,24 @@ async function startInflightReplayDuplicate(params: {
}
describe("handleLineWebhookEvents", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
buildLineMessageContextMock.mockReset();
buildLineMessageContextMock.mockImplementation(async () => ({
ctxPayload: { From: "line:group:group-1" },
replyToken: "reply-token",
route: { agentId: "default" },
isGroup: true,
accountId: "default",
}));
buildLinePostbackContextMock.mockReset();
buildLinePostbackContextMock.mockImplementation(async () => null as unknown);
readAllowFromStoreMock.mockReset();
readAllowFromStoreMock.mockImplementation(async () => [] as string[]);
upsertPairingRequestMock.mockReset();
upsertPairingRequestMock.mockImplementation(async () => ({ code: "CODE", created: true }));
({ handleLineWebhookEvents, createLineWebhookReplayCache } = await import("./bot-handlers.js"));
});
beforeEach(() => {
buildLineMessageContextMock.mockReset().mockResolvedValue(buildDefaultLineMessageContext());
buildLinePostbackContextMock.mockReset().mockResolvedValue(null as unknown);
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "CODE", created: true });
});
it("blocks group messages when groupPolicy is disabled", async () => {
const processMessage = vi.fn();
const event = {
+6 -3
View File
@@ -29,7 +29,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: () => {},
}));
import { downloadLineMedia } from "./download.js";
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
for (const part of parts) {
@@ -38,8 +38,11 @@ async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
}
describe("downloadLineMedia", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(async () => {
vi.restoreAllMocks();
vi.resetModules();
getMessageContentMock.mockReset();
({ downloadLineMedia } = await import("./download.js"));
});
it("does not derive temp file path from external messageId", async () => {
+12 -8
View File
@@ -11,6 +11,8 @@ const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = v
unregisterHttpMock: vi.fn(),
}));
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
vi.mock("./bot.js", () => ({
createLineBot: createLineBotMock,
}));
@@ -74,14 +76,19 @@ vi.mock("./template-messages.js", () => ({
}));
describe("monitorLineProvider lifecycle", () => {
beforeEach(() => {
createLineBotMock.mockClear();
unregisterHttpMock.mockClear();
registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
beforeEach(async () => {
vi.resetModules();
createLineBotMock.mockReset();
createLineBotMock.mockReturnValue({
account: { accountId: "default" },
handleWebhook: vi.fn(),
});
unregisterHttpMock.mockReset();
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
({ monitorLineProvider } = await import("./monitor.js"));
});
it("waits for abort before resolving", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
let resolved = false;
@@ -108,7 +115,6 @@ describe("monitorLineProvider lifecycle", () => {
});
it("stops immediately when signal is already aborted", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
abort.abort();
@@ -124,8 +130,6 @@ describe("monitorLineProvider lifecycle", () => {
});
it("returns immediately without abort signal and stop is idempotent", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
+8 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
@@ -19,7 +19,13 @@ afterEach(() => {
});
describe("probeLineBot", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
({ probeLineBot } = await import("./probe.js"));
});
+13 -7
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
@@ -74,28 +74,34 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
let sendModule: typeof import("./send.js");
describe("LINE send helpers", () => {
beforeAll(async () => {
sendModule = await import("./send.js");
});
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockClear();
MessagingApiClientMock.mockReset();
loadConfigMock.mockReset();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
loadConfigMock.mockReturnValue({});
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
sendModule = await import("./send.js");
});
afterEach(() => {
@@ -1,12 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./tavily-client.js", () => ({
const { runTavilyExtract } = vi.hoisted(() => ({
runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })),
}));
import { runTavilyExtract } from "./tavily-client.js";
import { createTavilyExtractTool } from "./tavily-extract-tool.js";
vi.mock("./tavily-client.js", () => ({
runTavilyExtract,
}));
let createTavilyExtractTool: typeof import("./tavily-extract-tool.js").createTavilyExtractTool;
function fakeApi(): OpenClawPluginApi {
return {
@@ -15,8 +18,11 @@ function fakeApi(): OpenClawPluginApi {
}
describe("tavily_extract", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(async () => {
vi.resetModules();
runTavilyExtract.mockReset();
runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params }));
({ createTavilyExtractTool } = await import("./tavily-extract-tool.js"));
});
it("rejects chunks_per_source without query", async () => {
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runTavilySearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runTavilySearch } = vi.hoisted(() => ({
runTavilySearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./tavily-client.js", () => ({
runTavilySearch,
}));
describe("tavily web search provider", () => {
beforeEach(() => {
vi.resetModules();
runTavilySearch.mockReset();
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes the expected metadata and selection wiring", async () => {
const { createTavilyWebSearchProvider } = await import("./tavily-search-provider.js");
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runTavilySearch = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runTavilySearch } = vi.hoisted(() => ({
runTavilySearch: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./tavily-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./tavily-client.js", () => ({
}));
describe("tavily search tool", () => {
beforeEach(() => {
vi.resetModules();
runTavilySearch.mockReset();
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("normalizes optional parameters before invoking Tavily", async () => {
const { createTavilySearchTool } = await import("./tavily-search-tool.js");
const tool = createTavilySearchTool({
+1 -1
View File
@@ -692,7 +692,7 @@
"test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
"test:build:singleton": "node scripts/test-built-plugin-singleton.mjs",
"test:changed": "pnpm test -- --changed origin/main",
"test:channels": "vitest run --config vitest.channels.config.ts",
"test:channels": "OPENCLAW_TEST_SKIP_DEFAULT=1 OPENCLAW_TEST_INCLUDE_CHANNELS=1 node scripts/test-parallel.mjs",
"test:contracts": "pnpm test:contracts:channels && pnpm test:contracts:plugins",
"test:contracts:channels": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/channels/plugins/contracts",
"test:contracts:plugins": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/plugins/contracts",
+45 -15
View File
@@ -50,11 +50,12 @@ const cleanupTempArtifacts = () => {
};
const existingUnitConfigFiles = (entries) => existingFiles(entries).filter(isUnitConfigTestFile);
const baseThreadPinnedFiles = existingFiles(behaviorManifest.base?.threadPinned ?? []);
const channelIsolatedManifestFiles = existingFiles(behaviorManifest.channels?.isolated ?? []);
const channelIsolatedPrefixes = behaviorManifest.channels?.isolatedPrefixes ?? [];
const extensionForkIsolatedFiles = existingFiles(behaviorManifest.extensions?.isolated ?? []);
const unitForkIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.isolated);
const unitThreadPinnedFiles = existingUnitConfigFiles(behaviorManifest.unit.threadPinned);
const unitBehaviorOverrideSet = new Set([...unitForkIsolatedFiles, ...unitThreadPinnedFiles]);
const channelSingletonFiles = [];
const children = new Set();
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
@@ -91,6 +92,7 @@ const disableIsolation =
process.env.OPENCLAW_TEST_NO_ISOLATE !== "0" &&
process.env.OPENCLAW_TEST_NO_ISOLATE !== "false";
const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1";
const includeChannelsSuite = process.env.OPENCLAW_TEST_INCLUDE_CHANNELS === "1";
const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1";
const noIsolateArgs = disableIsolation ? ["--isolate=false"] : [];
const skipDefaultRuns = process.env.OPENCLAW_TEST_SKIP_DEFAULT === "1";
@@ -260,12 +262,21 @@ const allKnownTestFiles = [
...walkTestFiles(path.join("ui", "src", "ui")),
]),
];
const channelIsolatedFiles = dedupeFilesPreserveOrder([
...channelIsolatedManifestFiles,
...allKnownTestFiles.filter((file) =>
channelIsolatedPrefixes.some((prefix) => file.startsWith(prefix)),
),
]);
const channelIsolatedFileSet = new Set(channelIsolatedFiles);
const defaultUnitPool = parsePoolOverride(process.env.OPENCLAW_TEST_UNIT_DEFAULT_POOL, "threads");
const isTargetedIsolatedUnitFile = (fileFilter) =>
unitForkIsolatedFiles.includes(fileFilter) || unitMemoryIsolatedFiles.includes(fileFilter);
const inferTarget = (fileFilter) => {
const isolated =
isTargetedIsolatedUnitFile(fileFilter) || extensionForkIsolatedFiles.includes(fileFilter);
isTargetedIsolatedUnitFile(fileFilter) ||
extensionForkIsolatedFiles.includes(fileFilter) ||
channelIsolatedFileSet.has(fileFilter);
if (isUnitConfigTestFile(fileFilter)) {
return { owner: "unit", isolated };
}
@@ -362,7 +373,7 @@ const unitMemoryIsolatedFiles = dedupeFilesPreserveOrder(
);
const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]);
const unitFastExcludedFiles = [
...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelIsolatedFiles]),
];
const estimateUnitDurationMs = (file) =>
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
@@ -400,10 +411,19 @@ const extensionForkIsolatedFileSet = new Set(extensionForkIsolatedFiles);
const extensionSharedCandidateFiles = allKnownTestFiles.filter(
(file) => file.startsWith("extensions/") && !extensionForkIsolatedFileSet.has(file),
);
const channelSharedCandidateFiles = allKnownTestFiles.filter(
(file) =>
channelTestPrefixes.some((prefix) => file.startsWith(prefix)) &&
!channelIsolatedFileSet.has(file),
);
const extensionIsolatedEntries = extensionForkIsolatedFiles.map((file) => ({
name: `extensions-${path.basename(file, ".test.ts")}-isolated`,
args: ["vitest", "run", "--config", "vitest.extensions.config.ts", "--pool=forks", file],
}));
const channelIsolatedEntries = channelIsolatedFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-channels-isolated`,
args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file],
}));
const defaultUnitFastLaneCount = isCI && !isWindows ? 3 : 1;
const unitFastLaneCount = Math.max(
1,
@@ -517,18 +537,6 @@ const baseRuns = [
],
})),
...unitThreadEntries,
...channelSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-channels-isolated`,
args: [
"vitest",
"run",
"--config",
"vitest.channels.config.ts",
"--pool=forks",
...noIsolateArgs,
file,
],
})),
]
: [
{
@@ -561,6 +569,27 @@ const baseRuns = [
},
]
: []),
...(includeChannelsSuite
? [
...channelIsolatedEntries.map((entry) => ({
...entry,
args: [...entry.args.slice(0, 5), ...noIsolateArgs, ...entry.args.slice(5)],
})),
{
name: "channels",
env:
channelSharedCandidateFiles.length > 0
? {
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
"vitest-channels-include",
channelSharedCandidateFiles,
),
}
: undefined,
args: ["vitest", "run", "--config", "vitest.channels.config.ts", ...noIsolateArgs],
},
]
: []),
...(includeGatewaySuite
? [
{
@@ -684,6 +713,7 @@ const createTargetedEntry = (owner, isolated, filters) => {
"run",
"--config",
"vitest.channels.config.ts",
...(forceForks ? ["--pool=forks"] : []),
...noIsolateArgs,
...filters,
],
+25
View File
@@ -43,15 +43,40 @@ const mergeManifestEntries = (section, keys) => {
return merged;
};
const mergeManifestStrings = (section, keys) => {
const merged = [];
const seen = new Set();
for (const key of keys) {
const values = Array.isArray(section?.[key]) ? section[key] : [];
for (const value of values) {
if (typeof value !== "string") {
continue;
}
const normalizedValue = normalizeTrackedRepoPath(value);
if (normalizedValue.length === 0 || seen.has(normalizedValue)) {
continue;
}
seen.add(normalizedValue);
merged.push(normalizedValue);
}
}
return merged;
};
export function loadTestRunnerBehavior() {
const raw = tryReadJsonFile(behaviorManifestPath, {});
const unit = raw.unit ?? {};
const base = raw.base ?? {};
const channels = raw.channels ?? {};
const extensions = raw.extensions ?? {};
return {
base: {
threadPinned: mergeManifestEntries(base, ["threadPinned", "threadSingleton"]),
},
channels: {
isolated: mergeManifestEntries(channels, ["isolated"]),
isolatedPrefixes: mergeManifestStrings(channels, ["isolatedPrefixes"]),
},
extensions: {
isolated: mergeManifestEntries(extensions, ["isolated"]),
},
+105
View File
@@ -15,6 +15,67 @@
}
]
},
"channels": {
"isolatedPrefixes": ["extensions/discord/src/monitor/"],
"isolated": [
{
"file": "extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts",
"reason": "This Discord preflight suite hoists conversation-runtime ACP binding mocks; keep it in its own forked channel lane so shared workers do not reuse monitor modules with incompatible runtime stubs."
},
{
"file": "extensions/discord/src/monitor/monitor.threading-utils.test.ts",
"reason": "This Discord threading utility suite exercises gateway and presence caches plus auto-thread helpers; keep it in its own forked channel lane so shared workers cannot inherit mutated monitor module state."
},
{
"file": "extensions/discord/src/monitor/monitor.agent-components.test.ts",
"reason": "This Discord agent-components suite hoists security and conversation runtime mocks for DM component authorization; keep it in its own forked channel lane so shared workers do not inherit incompatible Discord runtime stubs."
},
{
"file": "extensions/discord/src/monitor/native-command.model-picker.test.ts",
"reason": "This Discord model-picker interaction suite relies on isolated native-command fallback state and DM channel enums; keep it in its own forked channel lane so shared workers do not reuse polluted command runtime modules."
},
{
"file": "extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts",
"reason": "This Discord native plugin-dispatch suite hoists conversation and reply runtime mocks for slash-command routing; keep it in its own forked channel lane so shared workers do not reuse mismatched native-command modules."
},
{
"file": "extensions/discord/src/monitor/agent-components.wildcard.test.ts",
"reason": "This Discord wildcard component suite is green alone but can inherit polluted component-registration parser state from the shared channel lane; keep it in its own forked lane for deterministic CI."
},
{
"file": "extensions/discord/src/send.creates-thread.test.ts",
"reason": "This Discord send/create-thread suite hoists web-media mocks and retries against rate-limit seams; keep it isolated so shared channel workers do not leak send-module state into its retry assertions."
},
{
"file": "extensions/discord/src/components.test.ts",
"reason": "This Discord component builder suite is green alone but can inherit polluted component constructor state from the shared channel lane; keep it in its own forked lane for deterministic CI."
},
{
"file": "extensions/discord/src/monitor/provider.skill-dedupe.test.ts",
"reason": "This Discord provider skill-dedupe suite depends on agent-component classes during provider import; keep it isolated so shared channel workers do not reuse partially mocked component modules."
},
{
"file": "extensions/discord/src/monitor/monitor.test.ts",
"reason": "This Discord monitor interaction suite imports agent-component classes directly and is green alone but can inherit partially mocked Discord component constructors from the shared channel lane."
},
{
"file": "extensions/whatsapp/src/inbound.test.ts",
"reason": "This WhatsApp inbound helper suite is green alone but can inherit polluted web-message parsing state from the shared channel lane; keep it in its own forked lane for deterministic CI."
},
{
"file": "src/browser/chrome.test.ts",
"reason": "This Chrome helper suite is green alone but can inherit stale fetch, websocket, or timer state from the shared channel lane; keep it isolated so its CDP timeout assertions stay deterministic."
},
{
"file": "extensions/telegram/src/bot.create-telegram-bot.test.ts",
"reason": "This Telegram bot bootstrap suite is green alone but can inherit stale command-registration spies and bot runtime state from the shared channel lane; keep it isolated for deterministic command assertions."
},
{
"file": "extensions/telegram/src/webhook.test.ts",
"reason": "This Telegram webhook suite hoists grammY bot and InputFile mocks; keep it in its own forked channel lane so shared workers do not reuse telegram runtime modules with mismatched constructors."
}
]
},
"extensions": {
"isolated": [
{
@@ -28,6 +89,50 @@
{
"file": "extensions/nextcloud-talk/src/monitor.replay.test.ts",
"reason": "The replay-handling regression is green alone but can inherit disturbed global stream/Response state from the shared extensions lane, so keep it in its own forked lane for deterministic CI."
},
{
"file": "extensions/duckduckgo/src/ddg-search-provider.test.ts",
"reason": "This provider test hoists a client mock and imports the provider lazily; keep it in its own forked lane so shared extension workers do not reuse a previously cached module under test."
},
{
"file": "extensions/firecrawl/src/firecrawl-scrape-tool.test.ts",
"reason": "This scrape-tool suite hoists client mocks and imports the tool lazily; keep it in its own forked lane so shared extension workers cannot bypass the mocked client via cached modules."
},
{
"file": "extensions/firecrawl/src/firecrawl-search-provider.test.ts",
"reason": "This provider suite hoists the Firecrawl client mock and imports lazily; keep it isolated so shared extension workers do not reuse stale provider modules."
},
{
"file": "extensions/firecrawl/src/firecrawl-search-tool.test.ts",
"reason": "This tool suite hoists the Firecrawl client mock and imports lazily; keep it isolated so shared extension workers cannot leak cached implementations across files."
},
{
"file": "extensions/line/src/download.test.ts",
"reason": "This LINE media-download suite depends on hoisted SDK mocks; run it in its own forked lane so shared extension workers do not fall back to the real SDK client."
},
{
"file": "extensions/line/src/monitor.lifecycle.test.ts",
"reason": "This LINE lifecycle suite hoists runtime and route-registration mocks; keep it isolated so shared extension workers do not reuse cached monitor modules with real dependencies."
},
{
"file": "extensions/line/src/probe.test.ts",
"reason": "This LINE probe suite depends on hoisted SDK mocks; keep it isolated so shared extension workers do not reuse cached probe modules with the real SDK client."
},
{
"file": "extensions/line/src/send.test.ts",
"reason": "This LINE send suite hoists SDK and account/token mocks; keep it isolated so shared extension workers do not reuse cached send modules that bypass those mocks."
},
{
"file": "extensions/tavily/src/tavily-extract-tool.test.ts",
"reason": "This extract-tool suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not reuse cached tool modules and hit real API-key resolution."
},
{
"file": "extensions/tavily/src/tavily-search-provider.test.ts",
"reason": "This provider suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not reuse stale provider modules."
},
{
"file": "extensions/tavily/src/tavily-search-tool.test.ts",
"reason": "This search-tool suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not bypass the mocked client via cached modules."
}
]
},
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { loadTestRunnerBehavior } from "../scripts/test-runner-manifest.mjs";
describe("loadTestRunnerBehavior", () => {
it("loads channel isolated entries from the behavior manifest", () => {
const behavior = loadTestRunnerBehavior();
const files = behavior.channels.isolated.map((entry) => entry.file);
expect(files).toContain(
"extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts",
);
});
it("loads channel isolated prefixes from the behavior manifest", () => {
const behavior = loadTestRunnerBehavior();
expect(behavior.channels.isolatedPrefixes).toContain("extensions/discord/src/monitor/");
});
});
+28
View File
@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createChannelsVitestConfig } from "../vitest.channels.config.ts";
import { createExtensionsVitestConfig } from "../vitest.extensions.config.ts";
@@ -20,6 +23,7 @@ describe("createScopedVitestConfig", () => {
it("applies non-isolated mode by default", () => {
const config = createScopedVitestConfig(["src/example.test.ts"], { env: {} });
expect(config.test?.isolate).toBe(false);
expect(config.test?.runner).toBe("./test/non-isolated-runner.ts");
});
it("passes through a scoped root dir when provided", () => {
@@ -52,6 +56,30 @@ describe("scoped vitest configs", () => {
expect(defaultChannelsConfig.test?.isolate).toBe(false);
});
it("loads channel include overrides from OPENCLAW_VITEST_INCLUDE_FILE", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-channels-"));
try {
const includeFile = path.join(tempDir, "include.json");
fs.writeFileSync(
includeFile,
JSON.stringify([
"extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts",
]),
"utf8",
);
const config = createChannelsVitestConfig({
OPENCLAW_VITEST_INCLUDE_FILE: includeFile,
});
expect(config.test?.include).toEqual([
"extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts",
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("defaults extension tests to non-isolated mode", () => {
expect(defaultExtensionsConfig.test?.isolate).toBe(false);
});
+21 -1
View File
@@ -1,11 +1,31 @@
import fs from "node:fs";
import { channelTestInclude } from "./vitest.channel-paths.mjs";
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
function loadPatternListFile(filePath: string, label: string): string[] {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
if (!Array.isArray(parsed)) {
throw new TypeError(`${label} must point to a JSON array: ${filePath}`);
}
return parsed.filter((value): value is string => typeof value === "string" && value.length > 0);
}
export function loadIncludePatternsFromEnv(
env: Record<string, string | undefined> = process.env,
): string[] | null {
const includeFile = env.OPENCLAW_VITEST_INCLUDE_FILE?.trim();
if (!includeFile) {
return null;
}
return loadPatternListFile(includeFile, "OPENCLAW_VITEST_INCLUDE_FILE");
}
export function createChannelsVitestConfig(env?: Record<string, string | undefined>) {
return createScopedVitestConfig(channelTestInclude, {
return createScopedVitestConfig(loadIncludePatternsFromEnv(env) ?? channelTestInclude, {
env,
pool: "threads",
exclude: ["src/gateway/**"],
passWithNoTests: true,
});
}
+3 -1
View File
@@ -64,12 +64,14 @@ export function createScopedVitestConfig(
[...(baseTest.exclude ?? []), ...(options?.exclude ?? [])],
scopedDir,
);
const isolate = resolveVitestIsolation(options?.env);
return defineConfig({
...base,
test: {
...baseTest,
isolate: resolveVitestIsolation(options?.env),
isolate,
runner: "./test/non-isolated-runner.ts",
...(scopedDir ? { dir: scopedDir } : {}),
include: relativizeScopedPatterns(include, scopedDir),
exclude,