import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isChannelSurfaceTestFile } from "../vitest.channel-paths.mjs"; import { isAcpxExtensionRoot } from "../vitest.extension-acpx-paths.mjs"; import { isBlueBubblesExtensionRoot } from "../vitest.extension-bluebubbles-paths.mjs"; import { isDiffsExtensionRoot } from "../vitest.extension-diffs-paths.mjs"; import { isFeishuExtensionRoot } from "../vitest.extension-feishu-paths.mjs"; import { isMatrixExtensionRoot } from "../vitest.extension-matrix-paths.mjs"; import { isMemoryExtensionRoot } from "../vitest.extension-memory-paths.mjs"; import { isMessagingExtensionRoot } from "../vitest.extension-messaging-paths.mjs"; import { isMsTeamsExtensionRoot } from "../vitest.extension-msteams-paths.mjs"; import { isProviderExtensionRoot } from "../vitest.extension-provider-paths.mjs"; import { isTelegramExtensionRoot } from "../vitest.extension-telegram-paths.mjs"; import { isBoundaryTestFile, isBundledPluginDependentUnitTestFile } from "../vitest.unit-paths.mjs"; const DEFAULT_VITEST_CONFIG = "vitest.unit.config.ts"; const AGENTS_VITEST_CONFIG = "vitest.agents.config.ts"; const ACP_VITEST_CONFIG = "vitest.acp.config.ts"; const AUTO_REPLY_VITEST_CONFIG = "vitest.auto-reply.config.ts"; const BOUNDARY_VITEST_CONFIG = "vitest.boundary.config.ts"; const BUNDLED_VITEST_CONFIG = "vitest.bundled.config.ts"; const CHANNEL_VITEST_CONFIG = "vitest.channels.config.ts"; const CLI_VITEST_CONFIG = "vitest.cli.config.ts"; const COMMANDS_VITEST_CONFIG = "vitest.commands.config.ts"; const CONTRACTS_VITEST_CONFIG = "vitest.contracts.config.ts"; const CRON_VITEST_CONFIG = "vitest.cron.config.ts"; const DAEMON_VITEST_CONFIG = "vitest.daemon.config.ts"; const E2E_VITEST_CONFIG = "vitest.e2e.config.ts"; const EXTENSION_ACPX_VITEST_CONFIG = "vitest.extension-acpx.config.ts"; const EXTENSION_BLUEBUBBLES_VITEST_CONFIG = "vitest.extension-bluebubbles.config.ts"; const EXTENSION_CHANNELS_VITEST_CONFIG = "vitest.extension-channels.config.ts"; const EXTENSION_DIFFS_VITEST_CONFIG = "vitest.extension-diffs.config.ts"; const EXTENSION_FEISHU_VITEST_CONFIG = "vitest.extension-feishu.config.ts"; const EXTENSION_MATRIX_VITEST_CONFIG = "vitest.extension-matrix.config.ts"; const EXTENSION_MEMORY_VITEST_CONFIG = "vitest.extension-memory.config.ts"; const EXTENSION_MSTEAMS_VITEST_CONFIG = "vitest.extension-msteams.config.ts"; const EXTENSION_MESSAGING_VITEST_CONFIG = "vitest.extension-messaging.config.ts"; const EXTENSION_PROVIDERS_VITEST_CONFIG = "vitest.extension-providers.config.ts"; const EXTENSION_TELEGRAM_VITEST_CONFIG = "vitest.extension-telegram.config.ts"; const EXTENSIONS_VITEST_CONFIG = "vitest.extensions.config.ts"; const GATEWAY_VITEST_CONFIG = "vitest.gateway.config.ts"; const HOOKS_VITEST_CONFIG = "vitest.hooks.config.ts"; const INFRA_VITEST_CONFIG = "vitest.infra.config.ts"; const MEDIA_VITEST_CONFIG = "vitest.media.config.ts"; const MEDIA_UNDERSTANDING_VITEST_CONFIG = "vitest.media-understanding.config.ts"; const PLUGIN_SDK_VITEST_CONFIG = "vitest.plugin-sdk.config.ts"; const PLUGINS_VITEST_CONFIG = "vitest.plugins.config.ts"; const RUNTIME_CONFIG_VITEST_CONFIG = "vitest.runtime-config.config.ts"; const SECRETS_VITEST_CONFIG = "vitest.secrets.config.ts"; const SHARED_CORE_VITEST_CONFIG = "vitest.shared-core.config.ts"; const TOOLING_VITEST_CONFIG = "vitest.tooling.config.ts"; const TUI_VITEST_CONFIG = "vitest.tui.config.ts"; const UI_VITEST_CONFIG = "vitest.ui.config.ts"; const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE"; function normalizePathPattern(value) { return value.replaceAll("\\", "/"); } function isExistingPathTarget(arg, cwd) { return fs.existsSync(path.resolve(cwd, arg)); } function isExistingFileTarget(arg, cwd) { try { return fs.statSync(path.resolve(cwd, arg)).isFile(); } catch { return false; } } function isGlobTarget(arg) { return /[*?[\]{}]/u.test(arg); } function isFileLikeTarget(arg) { return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg); } function isPathLikeTargetArg(arg, cwd) { if (!arg || arg === "--" || arg.startsWith("-")) { return false; } return isExistingPathTarget(arg, cwd) || isGlobTarget(arg) || isFileLikeTarget(arg); } function toRepoRelativeTarget(arg, cwd) { if (isGlobTarget(arg)) { return normalizePathPattern(arg.replace(/^\.\//u, "")); } const absolute = path.resolve(cwd, arg); return normalizePathPattern(path.relative(cwd, absolute)); } function toScopedIncludePattern(arg, cwd) { const relative = toRepoRelativeTarget(arg, cwd); if (isGlobTarget(relative) || isFileLikeTarget(relative)) { return relative; } if (isExistingFileTarget(arg, cwd)) { const directory = normalizePathPattern(path.posix.dirname(relative)); return directory === "." ? "**/*.test.ts" : `${directory}/**/*.test.ts`; } return `${relative.replace(/\/+$/u, "")}/**/*.test.ts`; } function classifyTarget(arg, cwd) { const relative = toRepoRelativeTarget(arg, cwd); if (relative.endsWith(".e2e.test.ts")) { return "e2e"; } if (relative.startsWith("extensions/")) { const extensionRoot = relative.split("/").slice(0, 2).join("/"); if (isChannelSurfaceTestFile(relative)) { return "extensionChannel"; } if (isAcpxExtensionRoot(extensionRoot)) { return "extensionAcpx"; } if (isDiffsExtensionRoot(extensionRoot)) { return "extensionDiffs"; } if (isBlueBubblesExtensionRoot(extensionRoot)) { return "extensionBlueBubbles"; } if (isFeishuExtensionRoot(extensionRoot)) { return "extensionFeishu"; } if (isTelegramExtensionRoot(extensionRoot)) { return "extensionTelegram"; } if (isMatrixExtensionRoot(extensionRoot)) { return "extensionMatrix"; } if (isMemoryExtensionRoot(extensionRoot)) { return "extensionMemory"; } if (isMsTeamsExtensionRoot(extensionRoot)) { return "extensionMsTeams"; } if (isMessagingExtensionRoot(extensionRoot)) { return "extensionMessaging"; } return isProviderExtensionRoot(extensionRoot) ? "extensionProvider" : "extension"; } if (isChannelSurfaceTestFile(relative)) { return "channel"; } if (isBoundaryTestFile(relative)) { return "boundary"; } if ( relative.startsWith("test/") || relative.startsWith("src/scripts/") || relative.startsWith("src/plugins/contracts/") || relative.startsWith("src/channels/plugins/contracts/") || relative === "src/config/doc-baseline.integration.test.ts" || relative === "src/config/schema.base.generated.test.ts" || relative === "src/config/schema.help.quality.test.ts" ) { return relative.startsWith("src/plugins/contracts/") || relative.startsWith("src/channels/plugins/contracts/") ? "contracts" : "tooling"; } if (isBundledPluginDependentUnitTestFile(relative)) { return "bundled"; } if (relative.startsWith("src/channels/")) { return "channel"; } if (relative.startsWith("src/gateway/")) { return "gateway"; } if (relative.startsWith("src/hooks/")) { return "hooks"; } if (relative.startsWith("src/infra/")) { return "infra"; } if (relative.startsWith("src/config/")) { return "runtimeConfig"; } if (relative.startsWith("src/cron/")) { return "cron"; } if (relative.startsWith("src/daemon/")) { return "daemon"; } if (relative.startsWith("src/media-understanding/")) { return "mediaUnderstanding"; } if (relative.startsWith("src/media/")) { return "media"; } if (relative.startsWith("src/plugin-sdk/")) { return "pluginSdk"; } if (relative.startsWith("src/secrets/")) { return "secrets"; } if (relative.startsWith("src/shared/")) { return "sharedCore"; } if (relative.startsWith("src/tui/")) { return "tui"; } if (relative.startsWith("src/acp/")) { return "acp"; } if (relative.startsWith("src/cli/")) { return "cli"; } if (relative.startsWith("src/commands/")) { return "command"; } if (relative.startsWith("src/auto-reply/")) { return "autoReply"; } if (relative.startsWith("src/agents/")) { return "agent"; } if (relative.startsWith("src/plugins/")) { return "plugin"; } if (relative.startsWith("ui/src/ui/")) { return "ui"; } return "default"; } function createVitestArgs(params) { return [ "exec", "vitest", ...(params.watchMode ? [] : ["run"]), "--config", params.config, ...params.forwardedArgs, ]; } export function parseTestProjectsArgs(args, cwd = process.cwd()) { const forwardedArgs = []; const targetArgs = []; let watchMode = false; for (const arg of args) { if (arg === "--") { continue; } if (arg === "--watch") { watchMode = true; continue; } if (isPathLikeTargetArg(arg, cwd)) { targetArgs.push(arg); } forwardedArgs.push(arg); } return { forwardedArgs, targetArgs, watchMode }; } export function buildVitestRunPlans(args, cwd = process.cwd()) { const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd); if (targetArgs.length === 0) { return [ { config: DEFAULT_VITEST_CONFIG, forwardedArgs, includePatterns: null, watchMode, }, ]; } const groupedTargets = new Map(); for (const targetArg of targetArgs) { const kind = classifyTarget(targetArg, cwd); const current = groupedTargets.get(kind) ?? []; current.push(targetArg); groupedTargets.set(kind, current); } if (watchMode && groupedTargets.size > 1) { throw new Error( "watch mode with mixed test suites is not supported; target one suite at a time or use a dedicated suite command", ); } const nonTargetArgs = forwardedArgs.filter((arg) => !targetArgs.includes(arg)); const orderedKinds = [ "default", "boundary", "tooling", "contracts", "bundled", "gateway", "hooks", "infra", "runtimeConfig", "cron", "daemon", "media", "pluginSdk", "secrets", "sharedCore", "tui", "mediaUnderstanding", "acp", "cli", "command", "autoReply", "agent", "plugin", "ui", "e2e", "extensionAcpx", "extensionDiffs", "extensionBlueBubbles", "extensionFeishu", "extensionChannel", "extensionTelegram", "extensionMatrix", "extensionMemory", "extensionMsTeams", "extensionMessaging", "extensionProvider", "channel", "extension", ]; const plans = []; for (const kind of orderedKinds) { const grouped = groupedTargets.get(kind); if (!grouped || grouped.length === 0) { continue; } const config = kind === "boundary" ? BOUNDARY_VITEST_CONFIG : kind === "tooling" ? TOOLING_VITEST_CONFIG : kind === "contracts" ? CONTRACTS_VITEST_CONFIG : kind === "bundled" ? BUNDLED_VITEST_CONFIG : kind === "gateway" ? GATEWAY_VITEST_CONFIG : kind === "hooks" ? HOOKS_VITEST_CONFIG : kind === "infra" ? INFRA_VITEST_CONFIG : kind === "runtimeConfig" ? RUNTIME_CONFIG_VITEST_CONFIG : kind === "cron" ? CRON_VITEST_CONFIG : kind === "daemon" ? DAEMON_VITEST_CONFIG : kind === "media" ? MEDIA_VITEST_CONFIG : kind === "pluginSdk" ? PLUGIN_SDK_VITEST_CONFIG : kind === "secrets" ? SECRETS_VITEST_CONFIG : kind === "sharedCore" ? SHARED_CORE_VITEST_CONFIG : kind === "tui" ? TUI_VITEST_CONFIG : kind === "mediaUnderstanding" ? MEDIA_UNDERSTANDING_VITEST_CONFIG : kind === "acp" ? ACP_VITEST_CONFIG : kind === "cli" ? CLI_VITEST_CONFIG : kind === "command" ? COMMANDS_VITEST_CONFIG : kind === "autoReply" ? AUTO_REPLY_VITEST_CONFIG : kind === "agent" ? AGENTS_VITEST_CONFIG : kind === "plugin" ? PLUGINS_VITEST_CONFIG : kind === "ui" ? UI_VITEST_CONFIG : kind === "e2e" ? E2E_VITEST_CONFIG : kind === "extensionAcpx" ? EXTENSION_ACPX_VITEST_CONFIG : kind === "extensionDiffs" ? EXTENSION_DIFFS_VITEST_CONFIG : kind === "extensionBlueBubbles" ? EXTENSION_BLUEBUBBLES_VITEST_CONFIG : kind === "extensionFeishu" ? EXTENSION_FEISHU_VITEST_CONFIG : kind === "extensionChannel" ? EXTENSION_CHANNELS_VITEST_CONFIG : kind === "extensionTelegram" ? EXTENSION_TELEGRAM_VITEST_CONFIG : kind === "extensionMatrix" ? EXTENSION_MATRIX_VITEST_CONFIG : kind === "extensionMemory" ? EXTENSION_MEMORY_VITEST_CONFIG : kind === "extensionMsTeams" ? EXTENSION_MSTEAMS_VITEST_CONFIG : kind === "extensionMessaging" ? EXTENSION_MESSAGING_VITEST_CONFIG : kind === "extensionProvider" ? EXTENSION_PROVIDERS_VITEST_CONFIG : kind === "channel" ? CHANNEL_VITEST_CONFIG : kind === "extension" ? EXTENSIONS_VITEST_CONFIG : DEFAULT_VITEST_CONFIG; const includePatterns = kind === "default" || kind === "e2e" ? null : grouped.map((targetArg) => toScopedIncludePattern(targetArg, cwd)); const scopedTargetArgs = kind === "default" || kind === "e2e" ? grouped : []; plans.push({ config, forwardedArgs: [...nonTargetArgs, ...scopedTargetArgs], includePatterns, watchMode, }); } return plans; } export function createVitestRunSpecs(args, params = {}) { const cwd = params.cwd ?? process.cwd(); const plans = buildVitestRunPlans(args, cwd); return plans.map((plan, index) => { const includeFilePath = plan.includePatterns ? path.join( params.tempDir ?? os.tmpdir(), `openclaw-vitest-include-${process.pid}-${Date.now()}-${index}.json`, ) : null; return { config: plan.config, env: includeFilePath ? { ...(params.baseEnv ?? process.env), [INCLUDE_FILE_ENV_KEY]: includeFilePath, } : (params.baseEnv ?? process.env), includeFilePath, includePatterns: plan.includePatterns, pnpmArgs: createVitestArgs(plan), watchMode: plan.watchMode, }; }); } export function writeVitestIncludeFile(filePath, includePatterns) { fs.writeFileSync(filePath, `${JSON.stringify(includePatterns, null, 2)}\n`); } export function buildVitestArgs(args, cwd = process.cwd()) { const [plan] = buildVitestRunPlans(args, cwd); if (!plan) { return createVitestArgs({ config: DEFAULT_VITEST_CONFIG, forwardedArgs: [], watchMode: false, }); } return createVitestArgs(plan); }