test: split vitest setup for projects

This commit is contained in:
Peter Steinberger
2026-04-03 12:26:49 +01:00
parent 1dd88c6288
commit f4393791eb
14 changed files with 429 additions and 309 deletions

View File

@@ -24,6 +24,7 @@ Most days:
- Full gate (expected before push): `pnpm build && pnpm check && pnpm test`
- Faster local full-suite run on a roomy machine: `pnpm test:max`
- Direct Vitest watch loop (modern projects config): `pnpm test:watch`
When you touch tests or want extra confidence:
@@ -44,8 +45,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
- Config: `scripts/test-parallel.mjs` (runs `vitest.unit.config.ts`, `vitest.extensions.config.ts`, `vitest.gateway.config.ts`)
- Files: `src/**/*.test.ts`, bundled plugin `**/*.test.ts`
- Config: native Vitest `projects` via `vitest.projects.config.ts` (`unit` + `boundary`)
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
- Scope:
- Pure unit tests
- In-process integration tests (gateway auth, routing, tooling, parsing, config)
@@ -54,8 +55,13 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Runs in CI
- No real keys required
- Should be fast and stable
- Scheduler note:
- `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files.
- Projects note:
- `pnpm test`, `pnpm test:projects`, and `pnpm test:watch` all use the same native Vitest `projects` config now.
- The wrapper CLI shape is preserved, so `pnpm test -- src/foo.test.ts -t bar` still works.
- Planner note:
- Planner-backed lanes are explicit now: `pnpm test:planner`, `pnpm test:max`, `pnpm test:serial`, `pnpm test:bundled`, `pnpm test:extensions`, and `pnpm test:channels`.
- CI shards, pre-push mirrors, and other lane-tuned flows keep using the planner-backed scripts.
- The planner still keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files.
- Extension-only local runs now also use a checked-in extensions timing snapshot plus a slightly coarser shared batch target on high-memory hosts, so the shared extensions lane avoids spawning an extra batch when two measured shared runs are enough.
- High-memory local extension shared batches also run with a slightly higher worker cap than before, which shortened the two remaining shared extension batches without changing the isolated extension lanes.
- High-memory local channel runs now reuse the checked-in channel timing snapshot to split the shared channels lane into a few measured batches instead of one long shared worker.
@@ -83,16 +89,18 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
sufficient substitute for those integration paths.
- Pool note:
- Base Vitest config still defaults to `forks`.
- Unit, channel, extension, and gateway wrapper lanes all default to `forks`.
- Unit and boundary projects stay on `forks`.
- Channel, extension, and gateway planner lanes also stay on `forks`.
- Unit, channel, and extension configs default to `isolate: false` for faster file startup.
- `pnpm test` also passes `--isolate=false` at the wrapper level.
- Opt back into Vitest file isolation with `OPENCLAW_TEST_ISOLATE=1 pnpm test`.
- `pnpm test` inherits the isolation defaults from `vitest.projects.config.ts`.
- Opt back into unit-file isolation with `OPENCLAW_TEST_ISOLATE=1 pnpm test`.
- `OPENCLAW_TEST_NO_ISOLATE=0` or `OPENCLAW_TEST_NO_ISOLATE=false` also force isolated runs.
- Fast-local iteration note:
- `pnpm test:changed` runs the wrapper with `--changed origin/main`.
- `pnpm test:changed:max` keeps the same changed-file filter but uses the wrapper's aggressive local planner profile.
- `pnpm test:changed` runs the native projects config with `--changed origin/main`.
- `pnpm test:changed:max` keeps the same changed-file filter but uses the planner's aggressive local profile.
- `pnpm test:max` exposes that same planner profile for a full local run.
- On supported local Node versions, including Node 25, the normal profile can use top-level lane parallelism. `pnpm test:max` still pushes the planner harder when you want a more aggressive local run.
- `pnpm test:planner` remains available when you want the old planner-backed local run shape.
- On supported local Node versions, including Node 25, the normal planner profile can use top-level lane parallelism. `pnpm test:max` still pushes the planner harder when you want a more aggressive local run.
- The base Vitest config marks the wrapper manifests/config files as `forceRerunTriggers` so changed-mode reruns stay correct when scheduler inputs change.
- The wrapper keeps `OPENCLAW_VITEST_FS_MODULE_CACHE` enabled on supported hosts, but assigns a lane-local `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` so concurrent Vitest processes do not race on one shared experimental cache directory.
- Set `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/abs/path` if you want one explicit cache location for direct single-run profiling.

View File

@@ -1043,7 +1043,7 @@
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",
"stage:bundled-plugin-runtime-deps": "node scripts/stage-bundled-plugin-runtime-deps.mjs",
"start": "node scripts/run-node.mjs",
"test": "node scripts/test-parallel.mjs",
"test": "node scripts/test-projects.mjs",
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
"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",
@@ -1101,6 +1101,8 @@
"test:perf:update-memory-hotspots:extensions": "node scripts/test-update-memory-hotspots.mjs --config vitest.extensions.config.ts --out test/fixtures/test-memory-hotspots.extensions.json --lane extensions --lane-prefix extensions-batch- --min-delta-kb 1048576 --limit 20",
"test:perf:update-timings": "node scripts/test-update-timings.mjs",
"test:perf:update-timings:extensions": "node scripts/test-update-timings.mjs --config vitest.extensions.config.ts",
"test:planner": "node scripts/test-parallel.mjs",
"test:projects": "node scripts/test-projects.mjs",
"test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
"test:serial": "node scripts/test-parallel.mjs --profile serial",
"test:startup:bench": "node --import tsx scripts/bench-cli-startup.ts",
@@ -1111,7 +1113,7 @@
"test:startup:memory": "node scripts/check-cli-startup-memory.mjs",
"test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test",
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "vitest",
"test:watch": "node scripts/test-projects.mjs --watch",
"ts-topology": "node --import tsx scripts/ts-topology.ts",
"tsgo": "node scripts/run-tsgo.mjs",
"tui": "node scripts/run-node.mjs tui",

View File

@@ -0,0 +1,319 @@
import { afterAll, afterEach, beforeAll } from "vitest";
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../src/config/config.js";
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
import type { PluginRegistry } from "../src/plugins/registry.js";
import { installSharedTestSetup } from "./setup.shared.js";
const testEnv = installSharedTestSetup();
const [
{ resetContextWindowCacheForTest },
{ resetModelsJsonReadyCacheForTest },
{ drainSessionWriteLockStateForTest, resetSessionWriteLockStateForTest },
{ createTopLevelChannelReplyToModeResolver },
{ createTestRegistry },
{ cleanupSessionStateForTest },
] = await Promise.all([
import("../src/agents/context.js"),
import("../src/agents/models-config.js"),
import("../src/agents/session-write-lock.js"),
import("../src/channels/plugins/threading-helpers.js"),
import("../src/test-utils/channel-plugins.js"),
import("../src/test-utils/session-state-cleanup.js"),
]);
const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
const WORKER_RUNTIME_STATE = Symbol.for("openclaw.testSetupRuntimeState");
type RegistryState = {
registry: PluginRegistry | null;
httpRouteRegistry: PluginRegistry | null;
httpRouteRegistryPinned: boolean;
key: string | null;
version: number;
};
type WorkerRuntimeState = {
defaultPluginRegistry: PluginRegistry | null;
materializedDefaultPluginRegistry: PluginRegistry | null;
};
const globalRegistryState = (() => {
const globalState = globalThis as typeof globalThis & {
[REGISTRY_STATE]?: RegistryState;
};
if (!globalState[REGISTRY_STATE]) {
globalState[REGISTRY_STATE] = {
registry: null,
httpRouteRegistry: null,
httpRouteRegistryPinned: false,
key: null,
version: 0,
};
}
return globalState[REGISTRY_STATE];
})();
const workerRuntimeState = (() => {
const globalState = globalThis as typeof globalThis & {
[WORKER_RUNTIME_STATE]?: WorkerRuntimeState;
};
if (!globalState[WORKER_RUNTIME_STATE]) {
globalState[WORKER_RUNTIME_STATE] = {
defaultPluginRegistry: null,
materializedDefaultPluginRegistry: null,
};
}
return globalState[WORKER_RUNTIME_STATE];
})();
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
return deps?.[id] as ((...args: unknown[]) => Promise<unknown>) | undefined;
};
function resolveSlackStubReplyToMode(params: {
cfg: OpenClawConfig;
chatType?: string | null;
}): "off" | "first" | "all" {
const entry = (
params.cfg.channels as
| Record<
string,
{
replyToMode?: "off" | "first" | "all";
replyToModeByChatType?: Partial<
Record<"direct" | "group" | "channel", "off" | "first" | "all">
>;
dm?: { replyToMode?: "off" | "first" | "all" };
}
>
| undefined
)?.slack;
const normalizedChatType = params.chatType?.trim().toLowerCase();
if (
normalizedChatType === "direct" ||
normalizedChatType === "group" ||
normalizedChatType === "channel"
) {
const byChatType = entry?.replyToModeByChatType?.[normalizedChatType];
if (byChatType) {
return byChatType;
}
if (normalizedChatType === "direct" && entry?.dm?.replyToMode) {
return entry.dm.replyToMode;
}
}
return entry?.replyToMode ?? "off";
}
const createStubOutbound = (
id: ChannelId,
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
): ChannelOutboundAdapter => ({
deliveryMode,
sendText: async ({ deps, to, text }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = (await send(to, text, { verbose: false } as any)) as {
messageId: string;
};
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = (await send(to, text, { verbose: false, mediaUrl } as any)) as {
messageId: string;
};
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
});
const createStubPlugin = (params: {
id: ChannelId;
label?: string;
aliases?: string[];
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
preferSessionLookupForAnnounceTarget?: boolean;
resolveReplyToMode?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all";
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label ?? String(params.id),
selectionLabel: params.label ?? String(params.id),
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
aliases: params.aliases,
preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget,
},
capabilities: { chatTypes: ["direct", "group"] },
threading: params.resolveReplyToMode
? {
resolveReplyToMode: params.resolveReplyToMode,
}
: undefined,
config: {
listAccountIds: (cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return [];
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const ids = accounts ? Object.keys(accounts).filter(Boolean) : [];
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return {};
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const match = accountId ? accounts?.[accountId] : undefined;
return (match && typeof match === "object") || typeof match === "string" ? match : entry;
},
isConfigured: async (_account, cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
return Boolean(channels?.[params.id]);
},
},
outbound: createStubOutbound(params.id, params.deliveryMode),
});
const createDefaultRegistry = () =>
createTestRegistry([
{
pluginId: "discord",
plugin: createStubPlugin({
id: "discord",
label: "Discord",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"),
}),
source: "test",
},
{
pluginId: "slack",
plugin: createStubPlugin({
id: "slack",
label: "Slack",
resolveReplyToMode: ({ cfg, chatType }) => resolveSlackStubReplyToMode({ cfg, chatType }),
}),
source: "test",
},
{
pluginId: "telegram",
plugin: {
...createStubPlugin({
id: "telegram",
label: "Telegram",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"),
}),
status: {
buildChannelSummary: async () => ({
configured: false,
tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none",
}),
},
},
source: "test",
},
{
pluginId: "whatsapp",
plugin: createStubPlugin({
id: "whatsapp",
label: "WhatsApp",
deliveryMode: "gateway",
preferSessionLookupForAnnounceTarget: true,
}),
source: "test",
},
{
pluginId: "signal",
plugin: createStubPlugin({ id: "signal", label: "Signal" }),
source: "test",
},
{
pluginId: "imessage",
plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }),
source: "test",
},
]);
function getDefaultPluginRegistry(): PluginRegistry {
workerRuntimeState.materializedDefaultPluginRegistry ??= createDefaultRegistry();
return workerRuntimeState.materializedDefaultPluginRegistry;
}
function resolveDefaultPluginRegistryProxy(): PluginRegistry {
workerRuntimeState.defaultPluginRegistry ??= new Proxy({} as PluginRegistry, {
defineProperty(_target, property, attributes) {
return Reflect.defineProperty(getDefaultPluginRegistry() as object, property, attributes);
},
deleteProperty(_target, property) {
return Reflect.deleteProperty(getDefaultPluginRegistry() as object, property);
},
get(_target, property, receiver) {
return Reflect.get(getDefaultPluginRegistry() as object, property, receiver);
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(getDefaultPluginRegistry() as object, property);
},
has(_target, property) {
return Reflect.has(getDefaultPluginRegistry() as object, property);
},
ownKeys() {
return Reflect.ownKeys(getDefaultPluginRegistry() as object);
},
set(_target, property, value, receiver) {
return Reflect.set(getDefaultPluginRegistry() as object, property, value, receiver);
},
});
return workerRuntimeState.defaultPluginRegistry;
}
function installDefaultPluginRegistry(): void {
const defaultRegistry = resolveDefaultPluginRegistryProxy();
globalRegistryState.registry = defaultRegistry;
if (!globalRegistryState.httpRouteRegistryPinned) {
globalRegistryState.httpRouteRegistry = defaultRegistry;
}
}
beforeAll(() => {
installDefaultPluginRegistry();
});
afterEach(async () => {
await cleanupSessionStateForTest();
resetContextWindowCacheForTest();
resetModelsJsonReadyCacheForTest();
resetSessionWriteLockStateForTest();
if (globalRegistryState.registry !== resolveDefaultPluginRegistryProxy()) {
installDefaultPluginRegistry();
globalRegistryState.key = null;
globalRegistryState.version += 1;
}
});
afterAll(async () => {
await cleanupSessionStateForTest();
await drainSessionWriteLockStateForTest();
testEnv.cleanup();
});

View File

@@ -1,293 +1 @@
import { afterAll, afterEach, beforeAll } from "vitest";
import { resetContextWindowCacheForTest } from "../src/agents/context.js";
import { resetModelsJsonReadyCacheForTest } from "../src/agents/models-config.js";
import {
drainSessionWriteLockStateForTest,
resetSessionWriteLockStateForTest,
} from "../src/agents/session-write-lock.js";
import { createTopLevelChannelReplyToModeResolver } from "../src/channels/plugins/threading-helpers.js";
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../src/config/config.js";
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
import type { PluginRegistry } from "../src/plugins/registry.js";
import { createTestRegistry } from "../src/test-utils/channel-plugins.js";
import { cleanupSessionStateForTest } from "../src/test-utils/session-state-cleanup.js";
import { installSharedTestSetup } from "./setup.shared.js";
const testEnv = installSharedTestSetup({ loadProfileEnv: true });
const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
type RegistryState = {
registry: PluginRegistry | null;
httpRouteRegistry: PluginRegistry | null;
httpRouteRegistryPinned: boolean;
key: string | null;
version: number;
};
const globalRegistryState = (() => {
const globalState = globalThis as typeof globalThis & {
[REGISTRY_STATE]?: RegistryState;
};
if (!globalState[REGISTRY_STATE]) {
globalState[REGISTRY_STATE] = {
registry: null,
httpRouteRegistry: null,
httpRouteRegistryPinned: false,
key: null,
version: 0,
};
}
return globalState[REGISTRY_STATE];
})();
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
return deps?.[id] as ((...args: unknown[]) => Promise<unknown>) | undefined;
};
function resolveSlackStubReplyToMode(params: {
cfg: OpenClawConfig;
chatType?: string | null;
}): "off" | "first" | "all" {
const entry = (
params.cfg.channels as
| Record<
string,
{
replyToMode?: "off" | "first" | "all";
replyToModeByChatType?: Partial<
Record<"direct" | "group" | "channel", "off" | "first" | "all">
>;
dm?: { replyToMode?: "off" | "first" | "all" };
}
>
| undefined
)?.slack;
const normalizedChatType = params.chatType?.trim().toLowerCase();
if (
normalizedChatType === "direct" ||
normalizedChatType === "group" ||
normalizedChatType === "channel"
) {
const byChatType = entry?.replyToModeByChatType?.[normalizedChatType];
if (byChatType) {
return byChatType;
}
if (normalizedChatType === "direct" && entry?.dm?.replyToMode) {
return entry.dm.replyToMode;
}
}
return entry?.replyToMode ?? "off";
}
const createStubOutbound = (
id: ChannelId,
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
): ChannelOutboundAdapter => ({
deliveryMode,
sendText: async ({ deps, to, text }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = (await send(to, text, { verbose: false } as any)) as {
messageId: string;
};
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = (await send(to, text, { verbose: false, mediaUrl } as any)) as {
messageId: string;
};
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
});
const createStubPlugin = (params: {
id: ChannelId;
label?: string;
aliases?: string[];
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
preferSessionLookupForAnnounceTarget?: boolean;
resolveReplyToMode?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all";
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label ?? String(params.id),
selectionLabel: params.label ?? String(params.id),
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
aliases: params.aliases,
preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget,
},
capabilities: { chatTypes: ["direct", "group"] },
threading: params.resolveReplyToMode
? {
resolveReplyToMode: params.resolveReplyToMode,
}
: undefined,
config: {
listAccountIds: (cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return [];
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const ids = accounts ? Object.keys(accounts).filter(Boolean) : [];
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return {};
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const match = accountId ? accounts?.[accountId] : undefined;
return (match && typeof match === "object") || typeof match === "string" ? match : entry;
},
isConfigured: async (_account, cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
return Boolean(channels?.[params.id]);
},
},
outbound: createStubOutbound(params.id, params.deliveryMode),
});
const createDefaultRegistry = () =>
createTestRegistry([
{
pluginId: "discord",
plugin: createStubPlugin({
id: "discord",
label: "Discord",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"),
}),
source: "test",
},
{
pluginId: "slack",
plugin: createStubPlugin({
id: "slack",
label: "Slack",
resolveReplyToMode: ({ cfg, chatType }) => resolveSlackStubReplyToMode({ cfg, chatType }),
}),
source: "test",
},
{
pluginId: "telegram",
plugin: {
...createStubPlugin({
id: "telegram",
label: "Telegram",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"),
}),
status: {
buildChannelSummary: async () => ({
configured: false,
tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none",
}),
},
},
source: "test",
},
{
pluginId: "whatsapp",
plugin: createStubPlugin({
id: "whatsapp",
label: "WhatsApp",
deliveryMode: "gateway",
preferSessionLookupForAnnounceTarget: true,
}),
source: "test",
},
{
pluginId: "signal",
plugin: createStubPlugin({ id: "signal", label: "Signal" }),
source: "test",
},
{
pluginId: "imessage",
plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }),
source: "test",
},
]);
let materializedDefaultPluginRegistry: PluginRegistry | null = null;
function getDefaultPluginRegistry(): PluginRegistry {
materializedDefaultPluginRegistry ??= createDefaultRegistry();
return materializedDefaultPluginRegistry;
}
// Most unit suites never touch the plugin registry. Keep the default test registry
// behind a lazy proxy so those files avoid allocating channel fixtures up front.
const DEFAULT_PLUGIN_REGISTRY = new Proxy({} as PluginRegistry, {
defineProperty(_target, property, attributes) {
return Reflect.defineProperty(getDefaultPluginRegistry() as object, property, attributes);
},
deleteProperty(_target, property) {
return Reflect.deleteProperty(getDefaultPluginRegistry() as object, property);
},
get(_target, property, receiver) {
return Reflect.get(getDefaultPluginRegistry() as object, property, receiver);
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(getDefaultPluginRegistry() as object, property);
},
has(_target, property) {
return Reflect.has(getDefaultPluginRegistry() as object, property);
},
ownKeys() {
return Reflect.ownKeys(getDefaultPluginRegistry() as object);
},
set(_target, property, value, receiver) {
return Reflect.set(getDefaultPluginRegistry() as object, property, value, receiver);
},
});
function installDefaultPluginRegistry(): void {
globalRegistryState.registry = DEFAULT_PLUGIN_REGISTRY;
if (!globalRegistryState.httpRouteRegistryPinned) {
globalRegistryState.httpRouteRegistry = DEFAULT_PLUGIN_REGISTRY;
}
}
beforeAll(() => {
installDefaultPluginRegistry();
});
afterEach(async () => {
await cleanupSessionStateForTest();
resetContextWindowCacheForTest();
resetModelsJsonReadyCacheForTest();
resetSessionWriteLockStateForTest();
if (globalRegistryState.registry !== DEFAULT_PLUGIN_REGISTRY) {
installDefaultPluginRegistry();
globalRegistryState.key = null;
globalRegistryState.version += 1;
}
});
afterAll(async () => {
await cleanupSessionStateForTest();
await drainSessionWriteLockStateForTest();
testEnv.cleanup();
});
import "./setup.shared.js";

View File

@@ -355,7 +355,7 @@ export function installTestEnv(options?: { loadProfileEnv?: boolean }): {
const realHome = process.env.HOME ?? os.homedir();
const liveEnvSnapshot = { ...process.env };
if (options?.loadProfileEnv ?? true) {
if (options?.loadProfileEnv ?? live) {
loadProfileEnv(realHome);
}

View File

@@ -218,4 +218,8 @@ describe("base vitest config", () => {
it("excludes fixture trees from test collection", () => {
expect(baseConfig.test?.exclude).toContain("test/fixtures/**");
});
it("keeps the base setup file minimal", () => {
expect(baseConfig.test?.setupFiles).toEqual(["test/setup.ts"]);
});
});

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import projectsConfig from "../vitest.projects.config.ts";
describe("projects vitest config", () => {
it("defines named unit and boundary projects", () => {
expect(projectsConfig.test?.projects).toHaveLength(2);
expect(projectsConfig.test?.projects?.map((project) => project.test?.name)).toEqual([
"unit",
"boundary",
]);
});
});

View File

@@ -27,6 +27,7 @@ describe("createScopedVitestConfig", () => {
const config = createScopedVitestConfig(["src/example.test.ts"], { env: {} });
expect(config.test?.isolate).toBe(false);
expect(config.test?.runner).toBe("./test/non-isolated-runner.ts");
expect(config.test?.setupFiles).toEqual(["test/setup.ts", "test/setup-openclaw-runtime.ts"]);
});
it("passes through a scoped root dir when provided", () => {

View File

@@ -71,4 +71,12 @@ describe("unit vitest config", () => {
const unitConfig = createUnitVitestConfig({});
expect(unitConfig.test?.isolate).toBe(false);
});
it("adds the OpenClaw runtime setup hooks on top of the base setup", () => {
const unitConfig = createUnitVitestConfig({});
expect(unitConfig.test?.setupFiles).toEqual([
"test/setup.ts",
"test/setup-openclaw-runtime.ts",
]);
});
});

View File

@@ -15,7 +15,8 @@ const e2eWorkers =
: defaultWorkers;
const verboseE2E = process.env.OPENCLAW_E2E_VERBOSE === "1";
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
const baseTest =
(baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {};
const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.e2e.test.ts");
export default defineConfig({
@@ -26,6 +27,7 @@ export default defineConfig({
pool: "forks",
maxWorkers: e2eWorkers,
silent: !verboseE2E,
setupFiles: [...new Set([...(baseTest.setupFiles ?? []), "test/setup-openclaw-runtime.ts"])],
include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts", BUNDLED_PLUGIN_E2E_TEST_GLOB],
exclude,
},

View File

@@ -3,7 +3,8 @@ import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./scripts/lib/bundled-plugin-path
import baseConfig from "./vitest.config.ts";
const base = baseConfig as unknown as Record<string, unknown>;
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
const baseTest =
(baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {};
const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.live.test.ts");
export default defineConfig({
@@ -14,6 +15,7 @@ export default defineConfig({
// Vitest's buffered per-test console capture.
disableConsoleIntercept: true,
maxWorkers: 1,
setupFiles: [...new Set([...(baseTest.setupFiles ?? []), "test/setup-openclaw-runtime.ts"])],
include: ["src/**/*.live.test.ts", BUNDLED_PLUGIN_LIVE_TEST_GLOB],
exclude,
},

49
vitest.projects.config.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineConfig } from "vitest/config";
import { createBoundaryVitestConfig } from "./vitest.boundary.config.ts";
import baseConfig from "./vitest.config.ts";
import { createUnitVitestConfig } from "./vitest.unit.config.ts";
const base = baseConfig as unknown as Record<string, unknown>;
const baseTest =
(
baseConfig as {
test?: {
include?: string[];
exclude?: string[];
setupFiles?: string[];
};
}
).test ?? {};
const unitTest = createUnitVitestConfig({}).test ?? {};
const boundaryTest = createBoundaryVitestConfig({}).test ?? {};
export default defineConfig({
...base,
test: {
...baseTest,
projects: [
{
extends: true,
test: {
name: "unit",
include: unitTest.include,
exclude: unitTest.exclude,
isolate: unitTest.isolate,
runner: unitTest.runner,
setupFiles: unitTest.setupFiles,
},
},
{
extends: true,
test: {
name: "boundary",
include: boundaryTest.include,
exclude: boundaryTest.exclude,
isolate: boundaryTest.isolate,
runner: boundaryTest.runner,
setupFiles: boundaryTest.setupFiles,
},
},
],
},
});

View File

@@ -57,6 +57,7 @@ export function createScopedVitestConfig(
exclude?: string[];
pool?: "threads" | "forks";
passWithNoTests?: boolean;
setupFiles?: string[];
};
}
).test ?? {};
@@ -73,6 +74,7 @@ export function createScopedVitestConfig(
...baseTest,
isolate,
runner: "./test/non-isolated-runner.ts",
setupFiles: [...new Set([...(baseTest.setupFiles ?? []), "test/setup-openclaw-runtime.ts"])],
...(scopedDir ? { dir: scopedDir } : {}),
include: relativizeScopedPatterns(include, scopedDir),
exclude,

View File

@@ -8,7 +8,9 @@ import {
} from "./vitest.unit-paths.mjs";
const base = baseConfig as unknown as Record<string, unknown>;
const baseTest = (baseConfig as { test?: { include?: string[]; exclude?: string[] } }).test ?? {};
const baseTest =
(baseConfig as { test?: { include?: string[]; exclude?: string[]; setupFiles?: string[] } })
.test ?? {};
const exclude = baseTest.exclude ?? [];
export function loadIncludePatternsFromEnv(
@@ -36,6 +38,7 @@ export function createUnitVitestConfigWithOptions(
...baseTest,
isolate: resolveVitestIsolation(env),
runner: "./test/non-isolated-runner.ts",
setupFiles: [...new Set([...(baseTest.setupFiles ?? []), "test/setup-openclaw-runtime.ts"])],
include:
loadIncludePatternsFromEnv(env) ?? options.includePatterns ?? unitTestIncludePatterns,
exclude: [