mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 10:02:04 +00:00
test: split vitest setup for projects
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
319
test/setup-openclaw-runtime.ts
Normal file
319
test/setup-openclaw-runtime.ts
Normal 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();
|
||||
});
|
||||
294
test/setup.ts
294
test/setup.ts
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
12
test/vitest-projects-config.test.ts
Normal file
12
test/vitest-projects-config.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
49
vitest.projects.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user