mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* feat(qa): drive forwarded text+photo bursts in Telegram userbot scenarios
Add a forwardBurst scenario action to the Telegram Test Server userbot
harness. The SUT bot posts a text and a photo into the QA user's DM
through the direct Test Bot API, the recorder correlates both source
messages from its own TDLib ingestion, and the QA user forwards both back
with one TDLib forwardMessages call (TDLib 1.8.67 schema). The action row
records the forwarded and source message ids; a receipt without
forward_info fails the action because the SUT forward lane would not
engage. The runner rejects forwardBurst without --dm before leasing.
The source is bot-authored because a live run showed Telegram strips the
forward origin when a user forwards their own Saved Messages posts: both
forwards arrived with forward_info null and the Gateway handled them as
plain messages.
The local Test Bot API proxy timing log gains doneAt on every row,
log-only file-download rows without paths, and count-only rows for
nonempty getUpdates responses, so a run can show getFile/download latency
and whether burst members reached the Gateway in one long-poll response.
The Gateway itself logs no getFile/download timing.
Live proof (Test Server, --source-gateway --dm, mock provider): on
current main the burst produced two agent-turn provider requests
(text-only, then photo-only) and two replies; getFile+download measured
571+175 ms. Telegram delivered the two forwards in separate getUpdates
responses 208 ms apart.
Tests: user-driver 37, user-record 13, scenario/proxy/runner/scenario-run
65 passing. telegram-run-composition is load-sensitive on the unchanged
harness too (different cases fail per run at load ~40 on the host).
* fix(ci): PR checks fail when a change touches skill node:test files
bbb074a4a8 made aggressive PR Node selection add every changed path
matching isTestFileTarget as a direct target. A PR that changed
node:test files under .agents/skills/telegram-e2e-userbot/scripts/ then
handed them to the Vitest unit config, which exited 1 with "No test
files found" and failed checks-node-changed and openclaw/ci-gate.
Aggressive mode now applies the same isRoutableChangedTarget predicate
that default mode already uses for direct changed-test selection. Across
all 18,695 tracked test files, this drops only non-Vitest files from
direct selection: 16 skill node:test files (still run once by their
Vitest owner test/scripts/telegram-e2e-userbot-skill.test.ts), 4 CodeQL
fixtures, and 140 credentialed .live.test.ts files that default mode
already excluded.
The regression test fails without the fix (it selects the skill file)
and passes with it; all 37 ci-changed-node-test-plan
selection/integration/dependency-input tests pass.
5881 lines
211 KiB
TypeScript
5881 lines
211 KiB
TypeScript
// Test-project planning helpers used by scripts/run-vitest.mts,
|
|
// scripts/test-projects.mts, and focused tests. Exports are intentionally
|
|
// granular so project selection stays testable without spawning Vitest.
|
|
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
agentVitestProjectOwners,
|
|
embeddedAgentVitestProjectOwners,
|
|
isAgentsCoreIsolatedTestFile,
|
|
isAgentsSpawnProductionBoundaryTestFile,
|
|
} from "../test/vitest/vitest.agents-paths.mjs";
|
|
import { isChannelSurfaceTestFile } from "../test/vitest/vitest.channel-paths.mjs";
|
|
import {
|
|
cliProcessTestFiles,
|
|
isCliProcessTestFile,
|
|
} from "../test/vitest/vitest.cli-process-paths.mjs";
|
|
import {
|
|
commandsLightTestFiles,
|
|
isCommandsLightTarget,
|
|
resolveCommandsLightIncludePattern,
|
|
} from "../test/vitest/vitest.commands-light-paths.mjs";
|
|
import {
|
|
channelConfigContractPatterns,
|
|
channelRegistryContractPatterns,
|
|
channelSessionContractPatterns,
|
|
channelSurfaceContractPatterns,
|
|
} from "../test/vitest/vitest.contracts-paths.mjs";
|
|
import {
|
|
DATABASE_WORKER_WATCH_OWNER_ENV_KEY,
|
|
DATABASE_WORKER_WATCH_TESTS_ENV_KEY,
|
|
databaseWorkerCoreFormerFastKinds,
|
|
databaseWorkerCoreTestFiles,
|
|
isDatabaseWorkerCoreTestFile,
|
|
} from "../test/vitest/vitest.database-worker-core-paths.mjs";
|
|
import { codexExtensionTestRoots } from "../test/vitest/vitest.extension-codex-paths.mjs";
|
|
import {
|
|
databaseWorkerExtensionTestFiles,
|
|
databaseWorkerExtensionTestRoots,
|
|
} from "../test/vitest/vitest.extension-database-workers-paths.mjs";
|
|
import { matrixExtensionTestRoots } from "../test/vitest/vitest.extension-matrix-paths.mjs";
|
|
import { telegramExtensionTestRoots } from "../test/vitest/vitest.extension-telegram-paths.mjs";
|
|
import {
|
|
gatewayDatabaseWorkerTestFiles,
|
|
gatewayPluginTestFiles,
|
|
isGatewayServerTestFile,
|
|
} from "../test/vitest/vitest.gateway-server-paths.mjs";
|
|
import { intersectIncludePatterns } from "../test/vitest/vitest.include-patterns.ts";
|
|
import { packageContractTestFiles } from "../test/vitest/vitest.package-contract-paths.mjs";
|
|
import { isSharedVitestExcludedPath } from "../test/vitest/vitest.pattern-file.ts";
|
|
import {
|
|
isPluginSdkLightTarget,
|
|
pluginSdkLightTestFiles,
|
|
resolvePluginSdkLightIncludePattern,
|
|
} from "../test/vitest/vitest.plugin-sdk-paths.mjs";
|
|
import { fullSuiteVitestShards, tuiPtyTestFiles } from "../test/vitest/vitest.test-shards.mjs";
|
|
import {
|
|
isToolingIsolatedTestFile,
|
|
toolingIsolatedTestFiles,
|
|
} from "../test/vitest/vitest.tooling-isolated-paths.mjs";
|
|
import {
|
|
isUiIsolatedTestFile,
|
|
uiIsolatedTestFiles,
|
|
} from "../test/vitest/vitest.ui-isolated-paths.mjs";
|
|
import {
|
|
isControlUiSourcePath,
|
|
isPluginControlUiPath,
|
|
isUiBrowserTestFile,
|
|
uiE2eRealGatewayTestFiles,
|
|
uiTimingTestFiles,
|
|
} from "../test/vitest/vitest.ui-paths.mjs";
|
|
import {
|
|
getUnitFastIsolatedTestFiles,
|
|
getUnitFastTestFiles,
|
|
getUnitFastTimerTestFiles,
|
|
resolveUnitFastIsolatedTestIncludePattern,
|
|
resolveUnitFastTestIncludePattern,
|
|
resolveUnitFastTimerTestIncludePattern,
|
|
} from "../test/vitest/vitest.unit-fast-paths.mjs";
|
|
import {
|
|
isBoundaryTestFile,
|
|
isBundledPluginDependentUnitTestFile,
|
|
isUnitConfigTestFile,
|
|
filterUnitConfigTestFiles,
|
|
} from "../test/vitest/vitest.unit-paths.mjs";
|
|
import {
|
|
detectChangedLanes,
|
|
listChangedPathsFromGit as listChangedPathsFromGitSource,
|
|
} from "./changed-lanes.mts";
|
|
import { parsePermissiveBooleanToken } from "./lib/arg-utils.mts";
|
|
import {
|
|
getChangedPathFacts,
|
|
isTestFileTarget,
|
|
isTestSupportFileTarget,
|
|
} from "./lib/changed-path-facts.mjs";
|
|
import {
|
|
createExtensionTestProcessTargetChunks,
|
|
listTrackedTestPlanFiles,
|
|
resolveExtensionTestConfig,
|
|
splitExtensionTestProcessTargets,
|
|
} from "./lib/extension-test-plan.mts";
|
|
import {
|
|
createGatewayServerTestTargetChunks,
|
|
splitTestTargetChunks as splitTargetChunks,
|
|
} from "./lib/gateway-server-test-plan.mts";
|
|
import { GIT_LS_FILES_MAX_BUFFER_BYTES } from "./lib/list-test-files.mts";
|
|
import { readTestSelectorSourceFacts } from "./lib/test-selector-source-facts.mts";
|
|
// CI imports planning before dependency installation; execution owners stay outside this closure.
|
|
import { resolveVitestCliEntry } from "./lib/vitest-build-prerequisites.mts";
|
|
import { resolveVitestCacheRoot, resolveVitestCacheSlotPath } from "./lib/vitest-cache-slots.mts";
|
|
import {
|
|
collectVitestFileFilters,
|
|
resolveBooleanModeFlag,
|
|
vitestOptionConsumesNextArg,
|
|
} from "./lib/vitest-cli-mode.mts";
|
|
import {
|
|
isCiLikeEnv,
|
|
resolveLocalFullSuiteProfile,
|
|
type VitestHostInfo,
|
|
} from "./lib/vitest-local-scheduling.mts";
|
|
import {
|
|
DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS,
|
|
resolveDefaultVitestNoOutputTimeoutMs,
|
|
resolveVitestNodeArgs,
|
|
} from "./lib/vitest-process-env.mts";
|
|
import {
|
|
estimateVitestTestFileSeconds,
|
|
estimateVitestToolingFileSeconds,
|
|
resolveShardTimingKey,
|
|
type VitestShardTimingSpec,
|
|
} from "./lib/vitest-shard-metadata.mts";
|
|
|
|
export { isTestFileTarget } from "./lib/changed-path-facts.mjs";
|
|
|
|
type VitestRunPlan = {
|
|
config: string;
|
|
databaseWorkerWatchOwner?: string;
|
|
databaseWorkerWatchTests?: string[];
|
|
forwardedArgs: string[];
|
|
timingTargets?: string[];
|
|
includePatterns: string[] | null;
|
|
watchMode: boolean;
|
|
};
|
|
|
|
export type VitestRunSpec = ReturnType<typeof createVitestRunSpecs>[number];
|
|
|
|
export type FailedVitestShard = {
|
|
code?: number | null;
|
|
config: string;
|
|
includePatterns?: string[] | null;
|
|
noOutputTimedOut?: boolean;
|
|
order?: number;
|
|
signal?: string | null;
|
|
};
|
|
|
|
type ChangedTestTargetOptions = {
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
broad?: boolean;
|
|
combineSiblingWithImportGraph?: boolean;
|
|
boundedOwners?: boolean;
|
|
aggressive?: { maxDirectImporters: number; maxDirectoryTests: number };
|
|
onSelection?: (selection: { rule: string; input: string; targets: string[] }) => void;
|
|
forceFullImportGraph?: boolean;
|
|
resolveAliases?: boolean;
|
|
runtimeOnly?: boolean;
|
|
includeExtensionImpact?: boolean;
|
|
watchMode?: boolean;
|
|
};
|
|
|
|
type ChangedTestTargetPlan = {
|
|
mode: "none" | "broad" | "targets";
|
|
targets: string[];
|
|
ownerTargets?: string[];
|
|
ownerAreas?: string[];
|
|
skippedBroadFallbackPaths?: string[];
|
|
};
|
|
|
|
type ImportGraphOptions = {
|
|
tooling?: boolean;
|
|
resolveAliases?: boolean;
|
|
runtimeOnly?: boolean;
|
|
direct?: boolean;
|
|
maxDepth?: number;
|
|
};
|
|
type ImportGraphAlias = { pattern: string; targets: string[] };
|
|
type VitestSpecShape = Pick<VitestRunSpec, "config" | "env"> & {
|
|
cacheAssignment?: VitestCacheAssignment;
|
|
};
|
|
export type VitestCacheAssignment =
|
|
| { kind: "scheduler"; root: string; leased?: true }
|
|
| { kind: "caller" };
|
|
type CacheAssignedSpec<T> = Omit<T, "env"> & {
|
|
env: NodeJS.ProcessEnv;
|
|
cacheAssignment?: VitestCacheAssignment;
|
|
};
|
|
type WatchableVitestSpecShape = VitestSpecShape & Pick<VitestRunSpec, "watchMode">;
|
|
type ImportGraph = {
|
|
files: readonly string[];
|
|
reverseImports: Map<string, string[]>;
|
|
testFiles: Set<string>;
|
|
};
|
|
type ImportGraphEdges = {
|
|
file: string;
|
|
specifiers: string[];
|
|
typeOnlySpecifiers: Set<string>;
|
|
imports: Set<string>;
|
|
references: Set<string>;
|
|
};
|
|
type UnmatchedExplicitTestTarget = {
|
|
target: string;
|
|
reason: "glob-matched-no-files" | "path-does-not-exist" | "target-matched-no-test-files";
|
|
includePattern?: string;
|
|
};
|
|
|
|
const DEFAULT_VITEST_CONFIG = "test/vitest/vitest.unit.config.ts";
|
|
const AGENTS_EMBEDDED_AGENT_TEST_ROOT = agentVitestProjectOwners.embedded.root;
|
|
const AGENTS_SPAWN_PRODUCTION_BOUNDARY_VITEST_CONFIG =
|
|
agentVitestProjectOwners.spawnProductionBoundary.config;
|
|
const AGENTS_CORE_ISOLATED_VITEST_CONFIG = agentVitestProjectOwners.coreIsolated.config;
|
|
const AGENTS_CORE_VITEST_CONFIG = agentVitestProjectOwners.core.config;
|
|
const AGENTS_EMBEDDED_AGENT_VITEST_CONFIG = agentVitestProjectOwners.embedded.config;
|
|
const AGENTS_EMBEDDED_AGENT_INCOMPLETE_TURN_VITEST_CONFIG =
|
|
agentVitestProjectOwners.embeddedIncompleteTurn.config;
|
|
const AGENTS_EMBEDDED_AGENT_OVERFLOW_COMPACTION_VITEST_CONFIG =
|
|
agentVitestProjectOwners.embeddedOverflowCompaction.config;
|
|
const AGENTS_EMBEDDED_AGENT_RUN_VITEST_CONFIG = agentVitestProjectOwners.embeddedRun.config;
|
|
const AGENTS_SUPPORT_VITEST_CONFIG = agentVitestProjectOwners.support.config;
|
|
const AGENTS_TOOLS_VITEST_CONFIG = agentVitestProjectOwners.tools.config;
|
|
const AGENTS_VITEST_CONFIG = agentVitestProjectOwners.all.config;
|
|
const ACP_VITEST_CONFIG = "test/vitest/vitest.acp.config.ts";
|
|
const AUTO_REPLY_CORE_VITEST_CONFIG = "test/vitest/vitest.auto-reply-core.config.ts";
|
|
const AUTO_REPLY_VITEST_CONFIG = "test/vitest/vitest.auto-reply.config.ts";
|
|
const AUTO_REPLY_REPLY_VITEST_CONFIG = "test/vitest/vitest.auto-reply-reply.config.ts";
|
|
const AUTO_REPLY_TOP_LEVEL_VITEST_CONFIG = "test/vitest/vitest.auto-reply-top-level.config.ts";
|
|
const BOUNDARY_VITEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
|
|
const BUNDLED_VITEST_CONFIG = "test/vitest/vitest.bundled.config.ts";
|
|
const CHANNEL_VITEST_CONFIG = "test/vitest/vitest.channels.config.ts";
|
|
const CLI_PROCESS_VITEST_CONFIG = "test/vitest/vitest.cli-process.config.ts";
|
|
const CLI_VITEST_CONFIG = "test/vitest/vitest.cli.config.ts";
|
|
const COMMANDS_LIGHT_VITEST_CONFIG = "test/vitest/vitest.commands-light.config.ts";
|
|
const COMMANDS_VITEST_CONFIG = "test/vitest/vitest.commands.config.ts";
|
|
const CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG =
|
|
"test/vitest/vitest.contracts-channel-config.config.ts";
|
|
const CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG =
|
|
"test/vitest/vitest.contracts-channel-registry.config.ts";
|
|
const CONTRACTS_CHANNEL_SESSION_VITEST_CONFIG =
|
|
"test/vitest/vitest.contracts-channel-session.config.ts";
|
|
const CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG =
|
|
"test/vitest/vitest.contracts-channel-surface.config.ts";
|
|
export const CONTRACTS_PLUGIN_VITEST_CONFIG = "test/vitest/vitest.contracts-plugin.config.ts";
|
|
const CRON_VITEST_CONFIG = "test/vitest/vitest.cron.config.ts";
|
|
const DAEMON_VITEST_CONFIG = "test/vitest/vitest.daemon.config.ts";
|
|
export const E2E_VITEST_CONFIG = "test/vitest/vitest.e2e.config.ts";
|
|
const EXTENSION_ACTIVE_MEMORY_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-active-memory.config.ts";
|
|
const EXTENSION_ACPX_VITEST_CONFIG = "test/vitest/vitest.extension-acpx.config.ts";
|
|
const EXTENSION_BROWSER_VITEST_CONFIG = "test/vitest/vitest.extension-browser.config.ts";
|
|
const EXTENSION_CODEX_VITEST_CONFIG = "test/vitest/vitest.extension-codex.config.ts";
|
|
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_LIGHT_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts";
|
|
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_SUPPORT_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-codex-app-server-attempt-support.config.ts";
|
|
const EXTENSION_CODEX_APP_SERVER_RUNTIME_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-codex-app-server-runtime.config.ts";
|
|
const EXTENSION_CODEX_APP_SERVER_SUPPORT_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-codex-app-server-support.config.ts";
|
|
const EXTENSION_CODEX_APP_SERVER_TOOLS_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-codex-app-server-tools.config.ts";
|
|
const EXTENSION_CODEX_SURFACE_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-codex-surface.config.ts";
|
|
const EXTENSION_DIFFS_VITEST_CONFIG = "test/vitest/vitest.extension-diffs.config.ts";
|
|
const EXTENSION_DISCORD_VITEST_CONFIG = "test/vitest/vitest.extension-discord.config.ts";
|
|
const EXTENSION_FEISHU_VITEST_CONFIG = "test/vitest/vitest.extension-feishu.config.ts";
|
|
const EXTENSION_IMESSAGE_VITEST_CONFIG = "test/vitest/vitest.extension-imessage.config.ts";
|
|
const EXTENSION_IRC_VITEST_CONFIG = "test/vitest/vitest.extension-irc.config.ts";
|
|
const EXTENSION_LINE_VITEST_CONFIG = "test/vitest/vitest.extension-line.config.ts";
|
|
const EXTENSION_MATTERMOST_VITEST_CONFIG = "test/vitest/vitest.extension-mattermost.config.ts";
|
|
const EXTENSION_MEDIA_VITEST_CONFIG = "test/vitest/vitest.extension-media.config.ts";
|
|
const EXTENSION_MATRIX_VITEST_CONFIG = "test/vitest/vitest.extension-matrix.config.ts";
|
|
const EXTENSION_MEMORY_VITEST_CONFIG = "test/vitest/vitest.extension-memory.config.ts";
|
|
const EXTENSION_MSTEAMS_VITEST_CONFIG = "test/vitest/vitest.extension-msteams.config.ts";
|
|
const EXTENSION_MESSAGING_VITEST_CONFIG = "test/vitest/vitest.extension-messaging.config.ts";
|
|
const EXTENSION_MISC_VITEST_CONFIG = "test/vitest/vitest.extension-misc.config.ts";
|
|
const EXTENSION_PROVIDER_OPENAI_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-provider-openai.config.ts";
|
|
const EXTENSION_PROVIDERS_VITEST_CONFIG = "test/vitest/vitest.extension-providers.config.ts";
|
|
const EXTENSION_QA_VITEST_CONFIG = "test/vitest/vitest.extension-qa.config.ts";
|
|
const EXTENSION_SIGNAL_VITEST_CONFIG = "test/vitest/vitest.extension-signal.config.ts";
|
|
const EXTENSION_SLACK_VITEST_CONFIG = "test/vitest/vitest.extension-slack.config.ts";
|
|
const EXTENSION_DATABASE_WORKERS_VITEST_CONFIG =
|
|
"test/vitest/vitest.extension-database-workers.config.ts";
|
|
const EXTENSION_TELEGRAM_VITEST_CONFIG = "test/vitest/vitest.extension-telegram.config.ts";
|
|
const EXTENSION_VOICE_CALL_VITEST_CONFIG = "test/vitest/vitest.extension-voice-call.config.ts";
|
|
const EXTENSION_WHATSAPP_VITEST_CONFIG = "test/vitest/vitest.extension-whatsapp.config.ts";
|
|
const EXTENSION_ZALO_VITEST_CONFIG = "test/vitest/vitest.extension-zalo.config.ts";
|
|
const EXTENSIONS_VITEST_CONFIG = "test/vitest/vitest.extensions.config.ts";
|
|
const FULL_AGENTIC_VITEST_CONFIG = "test/vitest/vitest.full-agentic.config.ts";
|
|
const FULL_EXTENSIONS_VITEST_CONFIG = "test/vitest/vitest.full-extensions.config.ts";
|
|
const GATEWAY_CLIENT_VITEST_CONFIG = "test/vitest/vitest.gateway-client.config.ts";
|
|
const GATEWAY_CORE_VITEST_CONFIG = "test/vitest/vitest.gateway-core.config.ts";
|
|
const GATEWAY_DATABASE_WORKERS_VITEST_CONFIG =
|
|
"test/vitest/vitest.gateway-database-workers.config.ts";
|
|
const GATEWAY_METHODS_VITEST_CONFIG = "test/vitest/vitest.gateway-methods.config.ts";
|
|
const GATEWAY_SERVER_VITEST_CONFIG = "test/vitest/vitest.gateway-server.config.ts";
|
|
const GATEWAY_VITEST_CONFIG = "test/vitest/vitest.gateway.config.ts";
|
|
const HOOKS_VITEST_CONFIG = "test/vitest/vitest.hooks.config.ts";
|
|
const INFRA_VITEST_CONFIG = "test/vitest/vitest.infra.config.ts";
|
|
const MEDIA_VITEST_CONFIG = "test/vitest/vitest.media.config.ts";
|
|
const MEDIA_UNDERSTANDING_VITEST_CONFIG = "test/vitest/vitest.media-understanding.config.ts";
|
|
const LOGGING_VITEST_CONFIG = "test/vitest/vitest.logging.config.ts";
|
|
const PLUGIN_SDK_LIGHT_VITEST_CONFIG = "test/vitest/vitest.plugin-sdk-light.config.ts";
|
|
const PLUGIN_SDK_VITEST_CONFIG = "test/vitest/vitest.plugin-sdk.config.ts";
|
|
const PLUGINS_VITEST_CONFIG = "test/vitest/vitest.plugins.config.ts";
|
|
const UNIT_FAST_VITEST_CONFIG = "test/vitest/vitest.unit-fast.config.ts";
|
|
const UNIT_FAST_ISOLATED_VITEST_CONFIG = "test/vitest/vitest.unit-fast-isolated.config.ts";
|
|
const UNIT_FAST_FAKE_TIMERS_VITEST_CONFIG = "test/vitest/vitest.unit-fast-fake-timers.config.ts";
|
|
const UNIT_SECURITY_VITEST_CONFIG = "test/vitest/vitest.unit-security.config.ts";
|
|
const UNIT_SRC_VITEST_CONFIG = "test/vitest/vitest.unit-src.config.ts";
|
|
const UNIT_SUPPORT_VITEST_CONFIG = "test/vitest/vitest.unit-support.config.ts";
|
|
const EXTENSION_TEST_PROCESS_ROOTS = new Map([
|
|
[EXTENSION_CODEX_VITEST_CONFIG, codexExtensionTestRoots],
|
|
[EXTENSION_MATRIX_VITEST_CONFIG, matrixExtensionTestRoots],
|
|
[EXTENSION_TELEGRAM_VITEST_CONFIG, telegramExtensionTestRoots],
|
|
[
|
|
EXTENSION_DATABASE_WORKERS_VITEST_CONFIG,
|
|
[...databaseWorkerExtensionTestRoots, ...databaseWorkerExtensionTestFiles],
|
|
],
|
|
]);
|
|
|
|
const FULL_SUITE_CONFIG_WEIGHT = new Map([
|
|
[GATEWAY_VITEST_CONFIG, 180],
|
|
[GATEWAY_SERVER_VITEST_CONFIG, 180],
|
|
[GATEWAY_CORE_VITEST_CONFIG, 179],
|
|
[GATEWAY_CLIENT_VITEST_CONFIG, 178],
|
|
[GATEWAY_METHODS_VITEST_CONFIG, 177],
|
|
[COMMANDS_VITEST_CONFIG, 175],
|
|
[AGENTS_CORE_VITEST_CONFIG, 170],
|
|
[AGENTS_EMBEDDED_AGENT_VITEST_CONFIG, 169],
|
|
[AGENTS_EMBEDDED_AGENT_INCOMPLETE_TURN_VITEST_CONFIG, 169],
|
|
[AGENTS_EMBEDDED_AGENT_OVERFLOW_COMPACTION_VITEST_CONFIG, 169],
|
|
[AGENTS_EMBEDDED_AGENT_RUN_VITEST_CONFIG, 169],
|
|
[AGENTS_SUPPORT_VITEST_CONFIG, 168],
|
|
[AGENTS_TOOLS_VITEST_CONFIG, 167],
|
|
[EXTENSION_CODEX_VITEST_CONFIG, 168],
|
|
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_LIGHT_VITEST_CONFIG, 82],
|
|
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_SUPPORT_VITEST_CONFIG, 80],
|
|
[EXTENSION_CODEX_APP_SERVER_RUNTIME_VITEST_CONFIG, 88],
|
|
[EXTENSION_CODEX_APP_SERVER_TOOLS_VITEST_CONFIG, 78],
|
|
[EXTENSION_CODEX_APP_SERVER_SUPPORT_VITEST_CONFIG, 72],
|
|
[EXTENSION_CODEX_SURFACE_VITEST_CONFIG, 68],
|
|
[EXTENSION_VOICE_CALL_VITEST_CONFIG, 169],
|
|
[EXTENSIONS_VITEST_CONFIG, 168],
|
|
[EXTENSION_PROVIDER_OPENAI_VITEST_CONFIG, 167],
|
|
["test/vitest/vitest.runtime-config.config.ts", 166],
|
|
[CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG, 85],
|
|
[CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG, 60],
|
|
[CONTRACTS_CHANNEL_SESSION_VITEST_CONFIG, 50],
|
|
[CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG, 35],
|
|
[CONTRACTS_PLUGIN_VITEST_CONFIG, 20],
|
|
[CHANNEL_VITEST_CONFIG, 164],
|
|
[UNIT_FAST_VITEST_CONFIG, 160],
|
|
[UNIT_FAST_ISOLATED_VITEST_CONFIG, 159],
|
|
[AUTO_REPLY_REPLY_VITEST_CONFIG, 155],
|
|
[INFRA_VITEST_CONFIG, 145],
|
|
["test/vitest/vitest.secrets.config.ts", 140],
|
|
[CRON_VITEST_CONFIG, 135],
|
|
["test/vitest/vitest.wizard.config.ts", 130],
|
|
[UNIT_SRC_VITEST_CONFIG, 125],
|
|
[EXTENSION_MATRIX_VITEST_CONFIG, 100],
|
|
[EXTENSION_DISCORD_VITEST_CONFIG, 98],
|
|
[EXTENSION_PROVIDERS_VITEST_CONFIG, 96],
|
|
[EXTENSION_TELEGRAM_VITEST_CONFIG, 94],
|
|
[EXTENSION_WHATSAPP_VITEST_CONFIG, 92],
|
|
[AUTO_REPLY_CORE_VITEST_CONFIG, 90],
|
|
[CLI_PROCESS_VITEST_CONFIG, 87],
|
|
[CLI_VITEST_CONFIG, 86],
|
|
[MEDIA_VITEST_CONFIG, 84],
|
|
[PLUGINS_VITEST_CONFIG, 82],
|
|
[BUNDLED_VITEST_CONFIG, 80],
|
|
[EXTENSION_SLACK_VITEST_CONFIG, 78],
|
|
[COMMANDS_LIGHT_VITEST_CONFIG, 48],
|
|
[PLUGIN_SDK_VITEST_CONFIG, 46],
|
|
[AUTO_REPLY_TOP_LEVEL_VITEST_CONFIG, 45],
|
|
[PLUGIN_SDK_LIGHT_VITEST_CONFIG, 38],
|
|
[DAEMON_VITEST_CONFIG, 36],
|
|
[BOUNDARY_VITEST_CONFIG, 34],
|
|
["test/vitest/vitest.tooling.config.ts", 32],
|
|
["test/vitest/vitest.tooling-isolated.config.ts", 1],
|
|
[UNIT_SECURITY_VITEST_CONFIG, 30],
|
|
[UNIT_SUPPORT_VITEST_CONFIG, 28],
|
|
[EXTENSION_ZALO_VITEST_CONFIG, 24],
|
|
[EXTENSION_IRC_VITEST_CONFIG, 20],
|
|
[EXTENSION_FEISHU_VITEST_CONFIG, 18],
|
|
[EXTENSION_MATTERMOST_VITEST_CONFIG, 16],
|
|
[EXTENSION_MESSAGING_VITEST_CONFIG, 14],
|
|
[EXTENSION_IMESSAGE_VITEST_CONFIG, 13],
|
|
[EXTENSION_LINE_VITEST_CONFIG, 12],
|
|
[EXTENSION_SIGNAL_VITEST_CONFIG, 11],
|
|
[EXTENSION_ACPX_VITEST_CONFIG, 10],
|
|
[EXTENSION_DIFFS_VITEST_CONFIG, 8],
|
|
[EXTENSION_ACTIVE_MEMORY_VITEST_CONFIG, 7],
|
|
[EXTENSION_MEMORY_VITEST_CONFIG, 6],
|
|
[EXTENSION_MSTEAMS_VITEST_CONFIG, 4],
|
|
]);
|
|
|
|
function resolveSpecSortWeight(
|
|
spec: VitestShardTimingSpec,
|
|
shardTimings: ReadonlyMap<string, number>,
|
|
) {
|
|
const observed = shardTimings.get(resolveShardTimingKey(spec));
|
|
if (observed !== undefined) {
|
|
return observed;
|
|
}
|
|
// Exact selections use their file costs; a whole-config sample would price a
|
|
// single worker proof like all tooling. Globs keep the whole-config fallback.
|
|
const includes = spec.timingTargets ?? spec.includePatterns;
|
|
const estimateFileSeconds =
|
|
spec.config === TOOLING_VITEST_CONFIG
|
|
? estimateVitestToolingFileSeconds
|
|
: estimateVitestTestFileSeconds;
|
|
const seconds =
|
|
includes?.length && includes.every((file) => isTestFileTarget(file) && !isGlobTarget(file))
|
|
? includes.reduce((total, file) => total + estimateFileSeconds(file), 0)
|
|
: (FULL_SUITE_CONFIG_WEIGHT.get(spec.config) ?? 0);
|
|
return seconds * 1000;
|
|
}
|
|
|
|
function interleaveSlowAndFastSpecs<T>(sortedSpecs: T[]) {
|
|
const ordered: T[] = [];
|
|
let slowIndex = 0;
|
|
let fastIndex = sortedSpecs.length - 1;
|
|
while (slowIndex <= fastIndex) {
|
|
const slowSpec = sortedSpecs[slowIndex];
|
|
if (slowSpec !== undefined) {
|
|
ordered.push(slowSpec);
|
|
}
|
|
slowIndex += 1;
|
|
if (slowIndex <= fastIndex) {
|
|
const fastSpec = sortedSpecs[fastIndex];
|
|
if (fastSpec !== undefined) {
|
|
ordered.push(fastSpec);
|
|
}
|
|
fastIndex -= 1;
|
|
}
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
function uniqueOrdered<T>(values: T[]) {
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
function isPathAtOrUnder(relative: string, root: string) {
|
|
return relative === root || relative.startsWith(`${root}/`);
|
|
}
|
|
|
|
/**
|
|
* Orders full-suite specs so expensive shards start first in parallel runs.
|
|
*/
|
|
export function orderFullSuiteSpecsForParallelRun<T extends VitestShardTimingSpec>(
|
|
specs: T[],
|
|
shardTimings = new Map<string, number>(),
|
|
): T[] {
|
|
const sortedSpecs = specs.toSorted((a, b) => {
|
|
const weightDelta =
|
|
resolveSpecSortWeight(b, shardTimings) - resolveSpecSortWeight(a, shardTimings);
|
|
if (weightDelta !== 0) {
|
|
return weightDelta;
|
|
}
|
|
return a.config.localeCompare(b.config);
|
|
});
|
|
return interleaveSlowAndFastSpecs(sortedSpecs);
|
|
}
|
|
const PROCESS_VITEST_CONFIG = "test/vitest/vitest.process.config.ts";
|
|
const RUNTIME_CONFIG_VITEST_CONFIG = "test/vitest/vitest.runtime-config.config.ts";
|
|
const SECRETS_VITEST_CONFIG = "test/vitest/vitest.secrets.config.ts";
|
|
const SHARED_CORE_VITEST_CONFIG = "test/vitest/vitest.shared-core.config.ts";
|
|
const PACKAGE_CONTRACT_VITEST_CONFIG = "test/vitest/vitest.package-contract.config.ts";
|
|
const TOOLING_DOCKER_VITEST_CONFIG = "test/vitest/vitest.tooling-docker.config.ts";
|
|
const TOOLING_ISOLATED_VITEST_CONFIG = "test/vitest/vitest.tooling-isolated.config.ts";
|
|
const TOOLING_VITEST_CONFIG = "test/vitest/vitest.tooling.config.ts";
|
|
const TOOLING_DOCKER_TEST_TARGET = "test/scripts/docker-build-helper.test.ts";
|
|
const BROAD_TOOLING_SCRIPT_TEST_PATTERNS = new Set([
|
|
"test/scripts/**/*.test.ts",
|
|
"test/scripts/*.test.ts",
|
|
]);
|
|
const BROAD_TOOLING_SCRIPT_TEST_TARGET_CHUNK_SIZE = 60;
|
|
const FULL_SUITE_AGENTS_CORE_TEST_TARGET_CHUNK_COUNT = 6;
|
|
const FULL_SUITE_INFRA_TEST_TARGET_CHUNK_SIZE = 64;
|
|
const FULL_SUITE_TOOLING_TEST_TARGET_CHUNK_SIZE = 2;
|
|
const FULL_SUITE_UNIT_FAST_TEST_TARGET_CHUNK_SIZE = 70;
|
|
const FULL_SUITE_UNIT_SRC_TEST_TARGET_CHUNK_SIZE = 150;
|
|
const TUI_VITEST_CONFIG = "test/vitest/vitest.tui.config.ts";
|
|
const TUI_PTY_VITEST_CONFIG = "test/vitest/vitest.tui-pty.config.ts";
|
|
const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
|
|
const UI_BROWSER_VITEST_CONFIG = "test/vitest/vitest.ui-browser.config.ts";
|
|
export const UI_E2E_VITEST_CONFIG = "test/vitest/vitest.ui-e2e.config.ts";
|
|
const UI_ISOLATED_VITEST_CONFIG = "test/vitest/vitest.ui-isolated.config.ts";
|
|
const UTILS_VITEST_CONFIG = "test/vitest/vitest.utils.config.ts";
|
|
const WIZARD_VITEST_CONFIG = "test/vitest/vitest.wizard.config.ts";
|
|
const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE";
|
|
const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH";
|
|
const FAILED_SHARD_DIGEST_LIMIT = 12;
|
|
const CHANGED_ARGS_PATTERN = /^--changed(?:=(.+))?$/u;
|
|
const VITEST_CONFIG_BY_KIND: Record<string, string> = {
|
|
unitFast: UNIT_FAST_VITEST_CONFIG,
|
|
unitFastIsolated: UNIT_FAST_ISOLATED_VITEST_CONFIG,
|
|
unitFastFakeTimers: UNIT_FAST_FAKE_TIMERS_VITEST_CONFIG,
|
|
boundary: BOUNDARY_VITEST_CONFIG,
|
|
toolingDocker: TOOLING_DOCKER_VITEST_CONFIG,
|
|
toolingIsolated: TOOLING_ISOLATED_VITEST_CONFIG,
|
|
tooling: TOOLING_VITEST_CONFIG,
|
|
contractsChannelSurface: CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG,
|
|
contractsChannelConfig: CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG,
|
|
contractsChannelRegistry: CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG,
|
|
contractsChannelSession: CONTRACTS_CHANNEL_SESSION_VITEST_CONFIG,
|
|
contractsPlugin: CONTRACTS_PLUGIN_VITEST_CONFIG,
|
|
bundled: BUNDLED_VITEST_CONFIG,
|
|
gateway: GATEWAY_VITEST_CONFIG,
|
|
gatewayCore: GATEWAY_CORE_VITEST_CONFIG,
|
|
gatewayDatabaseWorkers: GATEWAY_DATABASE_WORKERS_VITEST_CONFIG,
|
|
gatewayClient: GATEWAY_CLIENT_VITEST_CONFIG,
|
|
gatewayMethods: GATEWAY_METHODS_VITEST_CONFIG,
|
|
gatewayServer: GATEWAY_SERVER_VITEST_CONFIG,
|
|
hooks: HOOKS_VITEST_CONFIG,
|
|
infra: INFRA_VITEST_CONFIG,
|
|
runtimeConfig: RUNTIME_CONFIG_VITEST_CONFIG,
|
|
cron: CRON_VITEST_CONFIG,
|
|
daemon: DAEMON_VITEST_CONFIG,
|
|
media: MEDIA_VITEST_CONFIG,
|
|
logging: LOGGING_VITEST_CONFIG,
|
|
packageContract: PACKAGE_CONTRACT_VITEST_CONFIG,
|
|
pluginSdkLight: PLUGIN_SDK_LIGHT_VITEST_CONFIG,
|
|
pluginSdk: PLUGIN_SDK_VITEST_CONFIG,
|
|
process: PROCESS_VITEST_CONFIG,
|
|
secrets: SECRETS_VITEST_CONFIG,
|
|
sharedCore: SHARED_CORE_VITEST_CONFIG,
|
|
tui: TUI_VITEST_CONFIG,
|
|
tuiPty: TUI_PTY_VITEST_CONFIG,
|
|
mediaUnderstanding: MEDIA_UNDERSTANDING_VITEST_CONFIG,
|
|
acp: ACP_VITEST_CONFIG,
|
|
cliProcess: CLI_PROCESS_VITEST_CONFIG,
|
|
cli: CLI_VITEST_CONFIG,
|
|
commandLight: COMMANDS_LIGHT_VITEST_CONFIG,
|
|
command: COMMANDS_VITEST_CONFIG,
|
|
autoReply: AUTO_REPLY_VITEST_CONFIG,
|
|
autoReplyCore: AUTO_REPLY_CORE_VITEST_CONFIG,
|
|
autoReplyReply: AUTO_REPLY_REPLY_VITEST_CONFIG,
|
|
autoReplyTopLevel: AUTO_REPLY_TOP_LEVEL_VITEST_CONFIG,
|
|
agentCore: AGENTS_CORE_VITEST_CONFIG,
|
|
agentEmbedded: AGENTS_EMBEDDED_AGENT_VITEST_CONFIG,
|
|
agentEmbeddedIncompleteTurn: AGENTS_EMBEDDED_AGENT_INCOMPLETE_TURN_VITEST_CONFIG,
|
|
agentEmbeddedOverflowCompaction: AGENTS_EMBEDDED_AGENT_OVERFLOW_COMPACTION_VITEST_CONFIG,
|
|
agentEmbeddedRun: AGENTS_EMBEDDED_AGENT_RUN_VITEST_CONFIG,
|
|
agentSupport: AGENTS_SUPPORT_VITEST_CONFIG,
|
|
agentTools: AGENTS_TOOLS_VITEST_CONFIG,
|
|
agent: AGENTS_VITEST_CONFIG,
|
|
agentsSpawnProductionBoundary: AGENTS_SPAWN_PRODUCTION_BOUNDARY_VITEST_CONFIG,
|
|
agentsCoreIsolated: AGENTS_CORE_ISOLATED_VITEST_CONFIG,
|
|
agentsCore: AGENTS_CORE_VITEST_CONFIG,
|
|
agentsSupport: AGENTS_SUPPORT_VITEST_CONFIG,
|
|
agentsTools: AGENTS_TOOLS_VITEST_CONFIG,
|
|
plugin: PLUGINS_VITEST_CONFIG,
|
|
ui: UI_VITEST_CONFIG,
|
|
uiIsolated: UI_ISOLATED_VITEST_CONFIG,
|
|
uiBrowser: UI_BROWSER_VITEST_CONFIG,
|
|
uiTiming: "test/vitest/vitest.ui-timing.config.ts",
|
|
uiE2e: UI_E2E_VITEST_CONFIG,
|
|
unitSrc: UNIT_SRC_VITEST_CONFIG,
|
|
unitSecurity: UNIT_SECURITY_VITEST_CONFIG,
|
|
unitSupport: UNIT_SUPPORT_VITEST_CONFIG,
|
|
utils: UTILS_VITEST_CONFIG,
|
|
wizard: WIZARD_VITEST_CONFIG,
|
|
e2e: E2E_VITEST_CONFIG,
|
|
extensionActiveMemory: EXTENSION_ACTIVE_MEMORY_VITEST_CONFIG,
|
|
extensionAcpx: EXTENSION_ACPX_VITEST_CONFIG,
|
|
extensionCodex: EXTENSION_CODEX_VITEST_CONFIG,
|
|
extensionDiffs: EXTENSION_DIFFS_VITEST_CONFIG,
|
|
extensionBrowser: EXTENSION_BROWSER_VITEST_CONFIG,
|
|
extensionDiscord: EXTENSION_DISCORD_VITEST_CONFIG,
|
|
extensionFeishu: EXTENSION_FEISHU_VITEST_CONFIG,
|
|
extensionImessage: EXTENSION_IMESSAGE_VITEST_CONFIG,
|
|
extensionIrc: EXTENSION_IRC_VITEST_CONFIG,
|
|
extensionLine: EXTENSION_LINE_VITEST_CONFIG,
|
|
extensionMattermost: EXTENSION_MATTERMOST_VITEST_CONFIG,
|
|
extensionDatabaseWorkers: EXTENSION_DATABASE_WORKERS_VITEST_CONFIG,
|
|
extensionTelegram: EXTENSION_TELEGRAM_VITEST_CONFIG,
|
|
extensionVoiceCall: EXTENSION_VOICE_CALL_VITEST_CONFIG,
|
|
extensionWhatsApp: EXTENSION_WHATSAPP_VITEST_CONFIG,
|
|
extensionZalo: EXTENSION_ZALO_VITEST_CONFIG,
|
|
extensionMatrix: EXTENSION_MATRIX_VITEST_CONFIG,
|
|
extensionMedia: EXTENSION_MEDIA_VITEST_CONFIG,
|
|
extensionMemory: EXTENSION_MEMORY_VITEST_CONFIG,
|
|
extensionMisc: EXTENSION_MISC_VITEST_CONFIG,
|
|
extensionMsTeams: EXTENSION_MSTEAMS_VITEST_CONFIG,
|
|
extensionMessaging: EXTENSION_MESSAGING_VITEST_CONFIG,
|
|
extensionProviderOpenAi: EXTENSION_PROVIDER_OPENAI_VITEST_CONFIG,
|
|
extensionProvider: EXTENSION_PROVIDERS_VITEST_CONFIG,
|
|
extensionQa: EXTENSION_QA_VITEST_CONFIG,
|
|
extensionSignal: EXTENSION_SIGNAL_VITEST_CONFIG,
|
|
extensionSlack: EXTENSION_SLACK_VITEST_CONFIG,
|
|
extensionFull: FULL_EXTENSIONS_VITEST_CONFIG,
|
|
channel: CHANNEL_VITEST_CONFIG,
|
|
extension: EXTENSIONS_VITEST_CONFIG,
|
|
};
|
|
const BROAD_CHANGED_FALLBACK_PATTERNS = [
|
|
/^package\.json$/u,
|
|
/^pnpm-lock\.yaml$/u,
|
|
/^test\/setup(?:\.shared|\.extensions|-openclaw-runtime)?\.ts$/u,
|
|
/^vitest(?:\..+)?\.(?:config\.ts|paths\.mjs)$/u,
|
|
/^test\/vitest\/vitest\.(?:config|shared\.config|scoped-config|performance-config)\.ts$/u,
|
|
/^test\/helpers\//u,
|
|
];
|
|
const PRECISE_SOURCE_TEST_TARGETS = new Map<string, string[]>([
|
|
["src/plugins/runtime.retention.test-support.ts", ["src/plugins/runtime.retention.test.ts"]],
|
|
[
|
|
"src/agents/bash-tools.process-liveness-child.test-support.ts",
|
|
["src/agents/bash-tools.process.liveness.test.ts"],
|
|
],
|
|
[
|
|
"patches/vitest@5.0.1.patch",
|
|
[
|
|
"test/scripts/run-vitest-profile.test.ts",
|
|
"test/scripts/run-vitest-state-cleanup.test.ts",
|
|
"test/scripts/vitest-fork-shutdown.test.ts",
|
|
"test/scripts/vitest-runner-task-updates.test.ts",
|
|
],
|
|
],
|
|
["test/fixtures/vitest-fork-shutdown.mjs", ["test/scripts/vitest-fork-shutdown.test.ts"]],
|
|
...["clock", "runner"].map<[string, string[]]>((part) => [
|
|
`test/fixtures/vitest-runner-task-updates.${part}.mjs`,
|
|
["test/scripts/vitest-runner-task-updates.test.ts"],
|
|
]),
|
|
...[
|
|
"src/system-agent/setup-inference-turn.ts",
|
|
"src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts",
|
|
].map<[string, string[]]>((sourcePath) => [
|
|
sourcePath,
|
|
[
|
|
"src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts",
|
|
"src/commands/onboard-guided.inference.e2e.test.ts",
|
|
],
|
|
]),
|
|
[
|
|
"src/plugins/contracts/tts-contract-suites.ts",
|
|
[
|
|
"src/plugins/contracts/core-extension-facade-boundary.test.ts",
|
|
"src/plugins/contracts/tts.contract.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"extensions/slack/src/monitor/enterprise-install.ts",
|
|
[
|
|
"extensions/slack/src/monitor/enterprise-install.test.ts",
|
|
"extensions/slack/src/monitor/provider.auth-test-token.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"extensions/slack/src/channel-actions.ts",
|
|
[
|
|
"extensions/slack/src/actions.reactions-limit.test.ts",
|
|
"extensions/slack/src/channel-actions-setup-status.contract.test.ts",
|
|
"extensions/slack/src/message-tools.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"src/gateway/worker-environments/worker-turn-launcher.ts",
|
|
[
|
|
"src/gateway/worker-environments/worker-turn-launcher.test.ts",
|
|
"src/gateway/worker-environments/worker-turn-launcher-claim-admission.test.ts",
|
|
"src/gateway/worker-environments/worker-turn-launcher-failure-recovery.test.ts",
|
|
"src/gateway/worker-environments/worker-turn-launcher-reclaimed-placement.test.ts",
|
|
"src/gateway/worker-environments/worker-turn-launcher-remote-handoff.test.ts",
|
|
"src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts",
|
|
],
|
|
],
|
|
]);
|
|
const DOCS_CONFIG_EXAMPLES_TEST_TARGET = "src/config/docs-config-examples.test.ts";
|
|
const RUNTIME_SIDECAR_BASELINE_OWNER_TEST_TARGETS = ["src/plugins/bundled-plugin-metadata.test.ts"];
|
|
const RUNTIME_SIDECAR_PATH_CONSUMER_TEST_TARGETS = [
|
|
...RUNTIME_SIDECAR_BASELINE_OWNER_TEST_TARGETS,
|
|
"src/infra/update-global.test.ts",
|
|
"src/infra/update-runner.test.ts",
|
|
"test/openclaw-npm-postpublish-verify.test.ts",
|
|
];
|
|
const GITHUB_YAML_PINNING_GUARD_TEST_TARGETS = ["test/scripts/ci-workflow-guards.test.ts"];
|
|
const GROUP_VISIBLE_REPLY_TEST_TARGETS = [
|
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
|
"src/auto-reply/reply/dispatch-from-config.delivery.test.ts",
|
|
"src/auto-reply/reply/dispatch-from-config.lifecycle.test.ts",
|
|
"src/auto-reply/reply/followup-runner.test.ts",
|
|
"src/auto-reply/reply/groups.test.ts",
|
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
|
];
|
|
const GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS = [
|
|
"src/agents/system-prompt.test.ts",
|
|
...GROUP_VISIBLE_REPLY_TEST_TARGETS,
|
|
];
|
|
const CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS = [
|
|
"directory",
|
|
"plugin",
|
|
"surfaces-only",
|
|
"threading",
|
|
].flatMap((suite) =>
|
|
"abcdefgh"
|
|
.split("")
|
|
.map(
|
|
(shard) =>
|
|
`src/channels/plugins/contracts/${suite}.registry-backed-shard-${shard}.contract.test.ts`,
|
|
),
|
|
);
|
|
const CHANNEL_PLUGIN_SHAPE_PARITY_TEST_TARGET =
|
|
"src/channels/plugins/contracts/plugin-shape.contract.test.ts";
|
|
const CHANNEL_PLUGIN_SHAPE_PARITY_WIRING_PATHS = new Set([
|
|
"extensions/imessage/message-tool-api.ts",
|
|
"extensions/imessage/src/actions.ts",
|
|
"extensions/imessage/src/channel.ts",
|
|
"extensions/slack/message-tool-api.ts",
|
|
"extensions/slack/src/channel-actions.ts",
|
|
"extensions/slack/src/channel.ts",
|
|
"extensions/mattermost/gateway-auth-api.ts",
|
|
"extensions/mattermost/src/channel.ts",
|
|
"extensions/feishu/session-key-api.ts",
|
|
"extensions/feishu/src/channel.ts",
|
|
"extensions/telegram/session-key-api.ts",
|
|
"extensions/telegram/src/channel.ts",
|
|
"extensions/discord/session-key-api.ts",
|
|
"extensions/discord/thread-binding-api.ts",
|
|
"extensions/discord/src/channel.ts",
|
|
"extensions/matrix/thread-binding-api.ts",
|
|
"extensions/matrix/src/channel.ts",
|
|
]);
|
|
const TEST_HELPER_NORMALIZE_TEXT_TARGETS = [
|
|
"src/auto-reply/reply/commands-status.test.ts",
|
|
"src/auto-reply/status.test.ts",
|
|
"src/tui/components/chat-log.test.ts",
|
|
];
|
|
const HAPPY_PATH_PROMPT_SNAPSHOT_HELPER_TEST_TARGETS = ["test/scripts/prompt-snapshots.test.ts"];
|
|
const APPCAST_TEST_TARGETS = ["test/appcast.test.ts", "test/scripts/make-appcast.test.ts"];
|
|
const CODEX_VERSION_CONTRACT_TEST_TARGETS = [
|
|
"extensions/codex/src/manifest.test.ts",
|
|
"extensions/openai/openai-provider.test.ts",
|
|
"test/scripts/codex-client-version-contract.test.ts",
|
|
];
|
|
// The iframe script and native document load as assets outside the import graph.
|
|
const MERMAID_RENDERER_TEST_TARGETS = [
|
|
"ui/src/components/markdown-mermaid.runtime.browser.test.ts",
|
|
"ui/src/components/markdown-mermaid-native.browser.test.ts",
|
|
];
|
|
const SOURCE_TEST_TARGETS = new Map([
|
|
...PRECISE_SOURCE_TEST_TARGETS,
|
|
[
|
|
"src/agents/live-provider-owner.ts",
|
|
[
|
|
"src/agents/live-model-dynamic-candidates.test.ts",
|
|
"src/agents/live-model-filter.test.ts",
|
|
"src/agents/live-target-matcher.test.ts",
|
|
"src/agents/model-compat.test.ts",
|
|
],
|
|
],
|
|
["src/plugin-sdk/memory-host-events.ts", ["src/plugin-sdk/memory-host-events.test.ts"]],
|
|
["src/plugin-sdk/persistent-dedupe.ts", ["src/plugin-sdk/memory-host-events.test.ts"]],
|
|
[
|
|
"extensions/browser/src/browser/chrome-mcp-options.ts",
|
|
["extensions/browser/src/browser/chrome-mcp.test.ts"],
|
|
],
|
|
[
|
|
"scripts/prepare-apple-mermaid.mjs",
|
|
[
|
|
"test/scripts/build-and-run-mac.test.ts",
|
|
"test/scripts/package-mac-app.test.ts",
|
|
"test/scripts/ci-workflow-guards.test.ts",
|
|
],
|
|
],
|
|
["packages/mermaid-renderer/package.json", MERMAID_RENDERER_TEST_TARGETS],
|
|
["packages/mermaid-renderer/vite.config.ts", MERMAID_RENDERER_TEST_TARGETS],
|
|
["packages/mermaid-renderer/native/index.html", MERMAID_RENDERER_TEST_TARGETS],
|
|
["packages/mermaid-renderer/src/renderer.ts", MERMAID_RENDERER_TEST_TARGETS],
|
|
["packages/mermaid-renderer/src/frame.js", MERMAID_RENDERER_TEST_TARGETS],
|
|
["packages/mermaid-renderer/src/native.ts", MERMAID_RENDERER_TEST_TARGETS],
|
|
[
|
|
"packages/normalization-core/src/record-coerce.ts",
|
|
["packages/normalization-core/src/record-coerce.test.ts", ...MERMAID_RENDERER_TEST_TARGETS],
|
|
],
|
|
[
|
|
"packages/normalization-core/package.json",
|
|
["packages/normalization-core/src/package-exports.test.ts", ...MERMAID_RENDERER_TEST_TARGETS],
|
|
],
|
|
["extensions/codex/package.json", CODEX_VERSION_CONTRACT_TEST_TARGETS],
|
|
["extensions/codex/src/app-server/version.ts", CODEX_VERSION_CONTRACT_TEST_TARGETS],
|
|
...["index", "harness"].map<[string, string[]]>((entry) => [
|
|
`extensions/copilot/${entry}.ts`,
|
|
[
|
|
`extensions/copilot/${entry}.test.ts`,
|
|
"src/agents/prepared-model-runtime.copilot.integration.test.ts",
|
|
],
|
|
]),
|
|
[
|
|
"extensions/copilot/openclaw.plugin.json",
|
|
[
|
|
"extensions/copilot/openclaw.plugin.json",
|
|
DOCS_CONFIG_EXAMPLES_TEST_TARGET,
|
|
"src/agents/prepared-model-runtime.copilot.integration.test.ts",
|
|
],
|
|
],
|
|
["src/test-utils/openclaw-test-state.ts", ["src/test-utils/openclaw-test-state.test.ts"]],
|
|
[
|
|
"src/channels/plugins/contracts/test-helpers/manifest.ts",
|
|
[
|
|
...CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS,
|
|
"src/channels/plugins/contracts/registry.contract.test.ts",
|
|
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts",
|
|
CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS,
|
|
],
|
|
["test/helpers/normalize-text.ts", TEST_HELPER_NORMALIZE_TEXT_TARGETS],
|
|
[
|
|
"test/helpers/agents/happy-path-prompt-snapshots.ts",
|
|
HAPPY_PATH_PROMPT_SNAPSHOT_HELPER_TEST_TARGETS,
|
|
],
|
|
[
|
|
"test/e2e/qa-lab/runtime/qa-otel-smoke-runtime.ts",
|
|
["test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts"],
|
|
],
|
|
[
|
|
"test/e2e/qa-lab/runtime/heartbeat-active-hours-runtime.ts",
|
|
["test/e2e/qa-lab/runtime/heartbeat-active-hours-runtime.test.ts"],
|
|
],
|
|
[
|
|
"test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts",
|
|
["test/e2e/qa-lab/runtime/telegram-bot-token-runtime.test.ts"],
|
|
],
|
|
["src/plugins/runtime-sidecar-paths-baseline.ts", RUNTIME_SIDECAR_BASELINE_OWNER_TEST_TARGETS],
|
|
["src/plugins/runtime-sidecar-paths.ts", RUNTIME_SIDECAR_PATH_CONSUMER_TEST_TARGETS],
|
|
["ui/config/control-ui-chunking.ts", ["ui/src/app/control-ui-chunking.test.ts"]],
|
|
[
|
|
"ui/config/control-ui-boot-modules.json",
|
|
["ui/src/app/control-ui-chunking.test.ts", "ui/src/app/vite-config.node.test.ts"],
|
|
],
|
|
["ui/config/control-ui-locales.ts", ["ui/src/app/vite-config.node.test.ts"]],
|
|
[
|
|
"src/plugin-sdk/test-helpers/directory-ids.ts",
|
|
[
|
|
"extensions/discord/src/directory-contract.test.ts",
|
|
"extensions/slack/src/directory-contract.test.ts",
|
|
"extensions/telegram/src/directory-contract.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"src/channels/message/reply-pipeline.ts",
|
|
["src/plugins/contracts/plugin-sdk-subpaths.test.ts", ...GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
],
|
|
["src/plugin-sdk/reply-runtime.ts", ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"]],
|
|
["extensions/google-meet/index.ts", ["extensions/google-meet/index.test.ts"]],
|
|
[
|
|
"extensions/google-meet/src/cli.ts",
|
|
[
|
|
"extensions/google-meet/src/cli-artifacts.test.ts",
|
|
"extensions/google-meet/src/cli-runtime.test.ts",
|
|
"extensions/google-meet/src/cli.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"extensions/google-meet/src/cli-artifact-commands.ts",
|
|
["extensions/google-meet/src/cli-artifacts.test.ts"],
|
|
],
|
|
[
|
|
"extensions/google-meet/src/cli-export.ts",
|
|
["extensions/google-meet/src/cli-artifacts.test.ts"],
|
|
],
|
|
[
|
|
"extensions/google-meet/src/cli-space-commands.ts",
|
|
["extensions/google-meet/src/cli-artifacts.test.ts"],
|
|
],
|
|
[
|
|
"extensions/google-meet/src/cli-runtime-commands.ts",
|
|
["extensions/google-meet/src/cli-runtime.test.ts"],
|
|
],
|
|
["extensions/google-meet/src/cli-doctor.ts", ["extensions/google-meet/src/cli.test.ts"]],
|
|
[
|
|
"extensions/google-meet/src/cli-command-context.ts",
|
|
[
|
|
"extensions/google-meet/src/cli-artifacts.test.ts",
|
|
"extensions/google-meet/src/cli-runtime.test.ts",
|
|
"extensions/google-meet/src/cli.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"extensions/google-meet/src/cli-shared.ts",
|
|
[
|
|
"extensions/google-meet/src/cli-artifacts.test.ts",
|
|
"extensions/google-meet/src/cli-runtime.test.ts",
|
|
"extensions/google-meet/src/cli.test.ts",
|
|
],
|
|
],
|
|
["extensions/google-meet/src/create.ts", ["extensions/google-meet/index.test.ts"]],
|
|
["extensions/google-meet/src/oauth.ts", ["extensions/google-meet/src/oauth.test.ts"]],
|
|
[
|
|
"extensions/discord/src/monitor/message-handler.ts",
|
|
[
|
|
"extensions/discord/src/channel-actions.contract.test.ts",
|
|
"extensions/discord/src/channel.message-adapter.test.ts",
|
|
"extensions/discord/src/channel.test.ts",
|
|
"extensions/discord/src/durable-delivery.test.ts",
|
|
"extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts",
|
|
"extensions/discord/src/monitor/message-handler.queue.test.ts",
|
|
"extensions/discord/src/monitor/provider.skill-dedupe.test.ts",
|
|
"extensions/discord/src/monitor/provider.test.ts",
|
|
],
|
|
],
|
|
["src/commands/doctor-memory-search.ts", ["src/commands/doctor-memory-search.test.ts"]],
|
|
[
|
|
"src/agents/test-helpers/live-model-turn-probes.ts",
|
|
["src/agents/live-model-turn-probes.test.ts"],
|
|
],
|
|
[
|
|
"src/plugins/provider-auth-choice.ts",
|
|
["src/commands/auth-choice.apply.plugin-provider.test.ts", "src/commands/auth-choice.test.ts"],
|
|
],
|
|
[
|
|
"src/secrets/provider-env-vars.ts",
|
|
["src/secrets/provider-env-vars.dynamic.test.ts", "src/secrets/provider-env-vars.test.ts"],
|
|
],
|
|
[
|
|
"packages/memory-host-sdk/src/host/embedding-defaults.ts",
|
|
["extensions/memory-core/src/memory/embeddings.test.ts"],
|
|
],
|
|
["src/auto-reply/reply/dispatch-from-config.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
["src/auto-reply/reply/source-reply-delivery-mode.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
[
|
|
"src/auto-reply/reply/effective-reply-route.ts",
|
|
["src/auto-reply/reply/effective-reply-route.test.ts", ...GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
],
|
|
["src/auto-reply/reply/get-reply-run.ts", ["src/auto-reply/reply/followup-runner.test.ts"]],
|
|
["src/auto-reply/reply/groups.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
["src/auto-reply/get-reply-options.types.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
["src/agents/system-prompt.ts", GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS],
|
|
["src/config/types.messages.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
["src/config/zod-schema.core.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
|
["src/auto-reply/reply/commands-acp.ts", ["src/auto-reply/reply/commands-acp.test.ts"]],
|
|
[
|
|
"src/auto-reply/reply/dispatch-acp-command-bypass.ts",
|
|
["src/auto-reply/reply/dispatch-acp-command-bypass.test.ts"],
|
|
],
|
|
]);
|
|
const GENERATED_CHANGED_TEST_TARGET_PATTERNS = [
|
|
/^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u,
|
|
/^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u,
|
|
];
|
|
const SOURCE_ROOTS_FOR_IMPORT_GRAPH = [
|
|
"src",
|
|
"extensions",
|
|
"packages",
|
|
"ui/src",
|
|
"ui/config",
|
|
"ui/public",
|
|
"test",
|
|
];
|
|
const IMPORTABLE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
|
|
function importGraphPathspecs(roots: string[], suffixes: readonly string[]) {
|
|
return [
|
|
...roots.flatMap((root) => suffixes.map((suffix) => `:(glob)${root}/**/*${suffix}`)),
|
|
...suffixes.map((suffix) => `:(glob)ui/*${suffix}`),
|
|
];
|
|
}
|
|
const IMPORT_GRAPH_GREP_PATHS = importGraphPathspecs(
|
|
SOURCE_ROOTS_FOR_IMPORT_GRAPH,
|
|
IMPORTABLE_FILE_EXTENSIONS,
|
|
);
|
|
const TOOLING_IMPORT_GRAPH_ROOTS = [...SOURCE_ROOTS_FOR_IMPORT_GRAPH, "scripts"];
|
|
const TOOLING_IMPORTABLE_FILE_EXTENSIONS = [
|
|
...IMPORTABLE_FILE_EXTENSIONS,
|
|
".js",
|
|
".jsx",
|
|
".mjs",
|
|
".cjs",
|
|
];
|
|
const TOOLING_IMPORT_GRAPH_GREP_PATHS = importGraphPathspecs(
|
|
TOOLING_IMPORT_GRAPH_ROOTS,
|
|
TOOLING_IMPORTABLE_FILE_EXTENSIONS,
|
|
);
|
|
const BROAD_CHANGED_ENV_KEY = "OPENCLAW_TEST_CHANGED_BROAD";
|
|
const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS";
|
|
const VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_HEARTBEAT_MS";
|
|
/** Default no-output timeout applied to test-projects Vitest children. */
|
|
const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = String(900_000);
|
|
/** Default heartbeat interval applied to test-projects Vitest children. */
|
|
const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS = String(
|
|
DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS,
|
|
);
|
|
|
|
export function formatNoChangedTestTargetLines(skippedBroadFallbackPaths: string[]) {
|
|
if (skippedBroadFallbackPaths.length === 0) {
|
|
return ["[test] no changed test targets; skipping Vitest."];
|
|
}
|
|
|
|
return [
|
|
"[test] no precise changed test targets; skipping Vitest.",
|
|
`[test] ${skippedBroadFallbackPaths.length} changed path${
|
|
skippedBroadFallbackPaths.length === 1 ? "" : "s"
|
|
} require broad Vitest fallback:`,
|
|
...skippedBroadFallbackPaths.map((changedPath) => `[test] ${changedPath}`),
|
|
"[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.",
|
|
];
|
|
}
|
|
|
|
const EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD = 12;
|
|
function resolveTestProjectsVitestNoOutputTimeoutMs(config: string) {
|
|
const directRunnerTimeoutMs = resolveDefaultVitestNoOutputTimeoutMs(["run", "--config", config]);
|
|
return String(
|
|
Math.max(Number(DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS), directRunnerTimeoutMs),
|
|
);
|
|
}
|
|
const VITEST_CONFIG_TARGET_KIND_BY_PATH = new Map<string, string>(
|
|
Object.entries(VITEST_CONFIG_BY_KIND).map(([kind, config]) => [config, kind]),
|
|
);
|
|
const RUNNABLE_VITEST_CONFIG_TARGETS = new Set([
|
|
"ui/vitest.config.ts",
|
|
"vitest.config.ts",
|
|
DEFAULT_VITEST_CONFIG,
|
|
...Object.values(VITEST_CONFIG_BY_KIND),
|
|
...fullSuiteVitestShards.flatMap((shard) => [shard.config, ...shard.projects]),
|
|
]);
|
|
export const CHANNEL_CONTRACT_CONFIG_PATTERNS = new Map<string, readonly string[]>([
|
|
[CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG, channelSurfaceContractPatterns],
|
|
[CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG, channelConfigContractPatterns],
|
|
[CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG, channelRegistryContractPatterns],
|
|
[CONTRACTS_CHANNEL_SESSION_VITEST_CONFIG, channelSessionContractPatterns],
|
|
]);
|
|
|
|
function normalizePathPattern(value: string) {
|
|
return value.replaceAll("\\", "/");
|
|
}
|
|
|
|
function listRepoFilesRecursive(root: string, cwd: string): string[] {
|
|
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
return entries.flatMap((entry) => {
|
|
const absolute = path.join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return listRepoFilesRecursive(absolute, cwd);
|
|
}
|
|
if (!entry.isFile()) {
|
|
return [];
|
|
}
|
|
return [normalizePathPattern(path.relative(cwd, absolute))];
|
|
});
|
|
}
|
|
|
|
let cachedBroadScriptTestTargets: string[] = [];
|
|
let cachedBroadScriptTestTargetsCwd: string | null = null;
|
|
|
|
function listBroadScriptTestTargets(pattern: string, cwd: string) {
|
|
const root = path.join(cwd, "test/scripts");
|
|
if (cachedBroadScriptTestTargetsCwd !== cwd) {
|
|
// Broad-target expansion can ask for the same process-stable checkout twice.
|
|
// Keep one inventory so planning does not repeat the directory walk.
|
|
cachedBroadScriptTestTargets = fs.existsSync(root)
|
|
? listRepoFilesRecursive(root, cwd)
|
|
.filter((file) => file.endsWith(".test.ts"))
|
|
.toSorted((left, right) => left.localeCompare(right))
|
|
: [];
|
|
cachedBroadScriptTestTargetsCwd = cwd;
|
|
}
|
|
return cachedBroadScriptTestTargets.filter((file) => path.matchesGlob(file, pattern));
|
|
}
|
|
|
|
function listBroadToolingScriptTestTargets(pattern: string, cwd: string) {
|
|
return listBroadScriptTestTargets(pattern, cwd).filter(
|
|
(file) => classifyTarget(file, cwd) === "tooling",
|
|
);
|
|
}
|
|
|
|
let cachedToolingFullSuiteTestTargets: string[] | null = null;
|
|
let cachedToolingFullSuiteTestTargetsCwd: string | null = null;
|
|
|
|
function listToolingFullSuiteTestTargets(cwd: string) {
|
|
if (cachedToolingFullSuiteTestTargets && cachedToolingFullSuiteTestTargetsCwd === cwd) {
|
|
return cachedToolingFullSuiteTestTargets;
|
|
}
|
|
// The CLI plans against one process-stable checkout. Reuse its inventory when
|
|
// callers compare full-suite modes instead of walking the tree for every mode.
|
|
cachedToolingFullSuiteTestTargets = uniqueOrdered(
|
|
[path.join(cwd, "test"), path.join(cwd, "src", "scripts")].flatMap((root) =>
|
|
fs.existsSync(root) ? listRepoFilesRecursive(root, cwd) : [],
|
|
),
|
|
)
|
|
// Match Vitest's fixture/live exclusions before forming explicit leaf chunks.
|
|
.filter(
|
|
(file) =>
|
|
file.endsWith(".test.ts") &&
|
|
!file.startsWith("test/fixtures/") &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
classifyTarget(file, cwd) === "tooling",
|
|
)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
cachedToolingFullSuiteTestTargetsCwd = cwd;
|
|
return cachedToolingFullSuiteTestTargets;
|
|
}
|
|
|
|
function listUnitFastFullSuiteTestTargets() {
|
|
const timerTargets = new Set(getUnitFastTimerTestFiles());
|
|
const isolatedTargets = new Set(getUnitFastIsolatedTestFiles());
|
|
return getUnitFastTestFiles().filter(
|
|
(file) => !timerTargets.has(file) && !isolatedTargets.has(file),
|
|
);
|
|
}
|
|
|
|
let cachedUnitSrcFullSuiteTestTargets: string[] | null = null;
|
|
let cachedUnitSrcFullSuiteTestTargetsCwd: string | null = null;
|
|
|
|
function listUnitSrcFullSuiteTestTargets(cwd: string) {
|
|
if (cachedUnitSrcFullSuiteTestTargets && cachedUnitSrcFullSuiteTestTargetsCwd === cwd) {
|
|
return cachedUnitSrcFullSuiteTestTargets;
|
|
}
|
|
const unitFastTargets = new Set(getUnitFastTestFiles());
|
|
const srcDir = path.join(cwd, "src");
|
|
cachedUnitSrcFullSuiteTestTargets = filterUnitConfigTestFiles(
|
|
(fs.existsSync(srcDir) ? listRepoFilesRecursive(srcDir, cwd) : []).filter((file) =>
|
|
file.endsWith(".test.ts"),
|
|
),
|
|
)
|
|
.filter(
|
|
(file) =>
|
|
!unitFastTargets.has(file) &&
|
|
!path.matchesGlob(file, "src/acp/**") &&
|
|
!path.matchesGlob(file, "src/security/**"),
|
|
)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
cachedUnitSrcFullSuiteTestTargetsCwd = cwd;
|
|
return cachedUnitSrcFullSuiteTestTargets;
|
|
}
|
|
|
|
function listAgentsCoreFullSuiteTestTargets(cwd: string) {
|
|
const isolatedTests = new Set([
|
|
...cliProcessTestFiles,
|
|
...agentVitestProjectOwners.spawnProductionBoundary.include,
|
|
...agentVitestProjectOwners.coreIsolated.include,
|
|
]);
|
|
const agentsDir = path.join(cwd, "src/agents");
|
|
if (!fs.existsSync(agentsDir)) {
|
|
return [];
|
|
}
|
|
return fs
|
|
.readdirSync(agentsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts"))
|
|
.map((entry) => `src/agents/${entry.name}`)
|
|
.filter((file) => !isolatedTests.has(file))
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function listInfraFullSuiteTestTargets(cwd: string) {
|
|
const infraDir = path.join(cwd, "src/infra");
|
|
return uniqueOrdered([
|
|
...(fs.existsSync(infraDir) ? listRepoFilesRecursive(infraDir, cwd) : []),
|
|
...databaseWorkerCoreTestFiles.filter((file) => fs.existsSync(path.join(cwd, file))),
|
|
])
|
|
.filter(
|
|
(file) =>
|
|
file.endsWith(".test.ts") &&
|
|
!isSharedVitestExcludedPath(file) &&
|
|
classifyTarget(file, cwd) === "infra",
|
|
)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function createBroadToolingScriptPlans(params: VitestRunPlan & { cwd: string }) {
|
|
const { config, forwardedArgs, includePatterns, watchMode, cwd } = params;
|
|
if (watchMode || config !== TOOLING_VITEST_CONFIG || !includePatterns) {
|
|
return null;
|
|
}
|
|
const pattern = includePatterns[0];
|
|
const targets =
|
|
pattern !== undefined &&
|
|
includePatterns.length === 1 &&
|
|
BROAD_TOOLING_SCRIPT_TEST_PATTERNS.has(pattern)
|
|
? listBroadToolingScriptTestTargets(pattern, cwd)
|
|
: includePatterns.every((target) => target.startsWith("test/scripts/"))
|
|
? includePatterns
|
|
: [];
|
|
if (targets.length <= BROAD_TOOLING_SCRIPT_TEST_TARGET_CHUNK_SIZE) {
|
|
return null;
|
|
}
|
|
const chunkCount = Math.ceil(targets.length / BROAD_TOOLING_SCRIPT_TEST_TARGET_CHUNK_SIZE);
|
|
const chunks = splitTargetChunks(targets, chunkCount);
|
|
return chunks.length > 0
|
|
? chunks.map((chunk) => ({
|
|
config,
|
|
forwardedArgs,
|
|
includePatterns: chunk,
|
|
watchMode,
|
|
}))
|
|
: null;
|
|
}
|
|
|
|
function ownsIncludeSelection(
|
|
includePatterns: string[] | null,
|
|
ownedTargets?: ReadonlySet<string>,
|
|
) {
|
|
return (
|
|
includePatterns !== null &&
|
|
includePatterns.length > 0 &&
|
|
includePatterns.every((pattern) => ownedTargets?.has(pattern))
|
|
);
|
|
}
|
|
|
|
function resolveInheritedIncludeScope(
|
|
includePatterns: string[],
|
|
inheritedPatterns: string[],
|
|
ownedTargets?: ReadonlySet<string>,
|
|
) {
|
|
const owned = includePatterns.filter((pattern) => ownedTargets?.has(pattern));
|
|
if (owned.length === includePatterns.length) {
|
|
return includePatterns;
|
|
}
|
|
return (
|
|
intersectIncludePatterns(
|
|
uniqueOrdered([...inheritedPatterns, ...owned]),
|
|
includePatterns,
|
|
path.matchesGlob,
|
|
) ?? includePatterns
|
|
);
|
|
}
|
|
|
|
function createBoundedExtensionPlans(
|
|
plan: VitestRunPlan,
|
|
cwd: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
ownedTargets?: ReadonlySet<string>,
|
|
) {
|
|
const { config, forwardedArgs, watchMode } = plan;
|
|
const roots = EXTENSION_TEST_PROCESS_ROOTS.get(config);
|
|
if (watchMode || !roots) {
|
|
return [plan];
|
|
}
|
|
// Broad CI selections keep their inherited scope and process limits.
|
|
// Explicit owned selections do not depend on borrowed include metadata.
|
|
const includeFilePath = ownsIncludeSelection(plan.includePatterns, ownedTargets)
|
|
? undefined
|
|
: env?.[INCLUDE_FILE_ENV_KEY]?.trim();
|
|
if (includeFilePath) {
|
|
if (!fs.existsSync(includeFilePath)) {
|
|
return [{ ...plan, includePatterns: null }];
|
|
}
|
|
const inheritedTargets = loadIncludePatternsForSpecFilter(env ?? {}) ?? [];
|
|
const scopedTargets = plan.includePatterns
|
|
? resolveInheritedIncludeScope(plan.includePatterns, inheritedTargets, ownedTargets)
|
|
: inheritedTargets;
|
|
if (scopedTargets.length === 0) {
|
|
return [];
|
|
}
|
|
const chunks = splitExtensionTestProcessTargets(config, scopedTargets);
|
|
if (chunks.length === 0) {
|
|
return [];
|
|
}
|
|
if (chunks.length <= 1) {
|
|
return [
|
|
{
|
|
...plan,
|
|
includePatterns:
|
|
!plan.includePatterns?.some((target) => ownedTargets?.has(target)) &&
|
|
scopedTargets.length === inheritedTargets.length &&
|
|
scopedTargets.every((target, index) => target === inheritedTargets[index])
|
|
? null
|
|
: scopedTargets,
|
|
},
|
|
];
|
|
}
|
|
return chunks.map((includePatterns) => ({
|
|
config,
|
|
forwardedArgs,
|
|
includePatterns,
|
|
watchMode,
|
|
}));
|
|
}
|
|
const chunks = createExtensionTestProcessTargetChunks(
|
|
config,
|
|
config === EXTENSION_DATABASE_WORKERS_VITEST_CONFIG && plan.includePatterns
|
|
? plan.includePatterns
|
|
: roots,
|
|
forwardedArgs,
|
|
cwd,
|
|
);
|
|
if (chunks.length === 0) {
|
|
// Preserve exact requests for Vitest's existing empty-test diagnostic, never a broad fallback.
|
|
return ownsIncludeSelection(plan.includePatterns, ownedTargets) ? [plan] : [];
|
|
}
|
|
if (chunks.length <= 1) {
|
|
return [plan];
|
|
}
|
|
return chunks.map((includePatterns) => ({
|
|
config,
|
|
forwardedArgs,
|
|
includePatterns,
|
|
watchMode,
|
|
}));
|
|
}
|
|
|
|
function expandBroadToolingScriptTargets(targetArgs: string[], cwd: string, watchMode: boolean) {
|
|
if (watchMode) {
|
|
return targetArgs;
|
|
}
|
|
return uniqueOrdered(
|
|
targetArgs.flatMap((targetArg) => {
|
|
const pattern = toScopedIncludePattern(targetArg, cwd);
|
|
if (!BROAD_TOOLING_SCRIPT_TEST_PATTERNS.has(pattern)) {
|
|
return [targetArg];
|
|
}
|
|
const targets = listBroadScriptTestTargets(pattern, cwd);
|
|
return targets.length > 0 ? targets : [targetArg];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function isExistingPathTarget(arg: string, cwd: string) {
|
|
return fs.existsSync(path.resolve(cwd, arg));
|
|
}
|
|
|
|
function isExistingFileTarget(arg: string, cwd: string) {
|
|
try {
|
|
return fs.statSync(path.resolve(cwd, arg)).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isExistingDirectoryTarget(arg: string, cwd: string) {
|
|
try {
|
|
return fs.statSync(path.resolve(cwd, arg)).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isGlobTarget(arg: string) {
|
|
return /[*?[\]{}]|[@+!]\(/u.test(arg);
|
|
}
|
|
|
|
function isLikelyFileTarget(arg: string) {
|
|
return /(?:^|\/)[^/]+\.[A-Za-z0-9]+$/u.test(arg);
|
|
}
|
|
|
|
function isPathLikeTargetArg(arg: string, cwd: string) {
|
|
if (!arg || arg === "--" || arg.startsWith("-")) {
|
|
return false;
|
|
}
|
|
const relative = toRepoRelativeTarget(arg, cwd);
|
|
return (
|
|
isGlobTarget(arg) ||
|
|
isTestFileTarget(arg) ||
|
|
isVitestConfigPathLikeTarget(relative) ||
|
|
isExistingPathTarget(arg, cwd) ||
|
|
(path.posix.extname(relative) === "" &&
|
|
/^(?:src|test|extensions|ui|packages|apps)\//u.test(relative)) ||
|
|
Boolean(resolveExplicitTestPrefixTargets(arg, cwd)?.length)
|
|
);
|
|
}
|
|
|
|
function toRepoRelativeTarget(arg: string, cwd: string) {
|
|
if (isGlobTarget(arg)) {
|
|
return normalizePathPattern(arg.replace(/^\.\//u, ""));
|
|
}
|
|
const absolute = path.resolve(cwd, arg);
|
|
return normalizePathPattern(path.relative(cwd, absolute));
|
|
}
|
|
|
|
function explicitIncludeTargets(targetArgs: string[], cwd: string) {
|
|
return new Set(
|
|
targetArgs
|
|
.map((target) => toRepoRelativeTarget(target, cwd))
|
|
.filter((target) => isTestFileTarget(target) && !isGlobTarget(target)),
|
|
);
|
|
}
|
|
|
|
function toScopedIncludePattern(arg: string, cwd: string) {
|
|
const relative = toRepoRelativeTarget(arg, cwd);
|
|
if (isGlobTarget(relative) || isTestFileTarget(relative)) {
|
|
return relative;
|
|
}
|
|
if (isExistingFileTarget(arg, cwd) || isLikelyFileTarget(relative)) {
|
|
const directory = normalizePathPattern(path.posix.dirname(relative));
|
|
return directory === "." ? "**/*.test.ts" : `${directory}/**/*.test.ts`;
|
|
}
|
|
return `${relative.replace(/\/+$/u, "")}/**/*.test.ts`;
|
|
}
|
|
|
|
const EXPLICIT_TEST_TARGET_ROOTS = ["src", "test", "extensions", "ui", "packages", "apps"];
|
|
let cachedExplicitTestTargetFiles: string[] | null = null;
|
|
let cachedExplicitTestTargetFilesCwd: string | null = null;
|
|
|
|
function listExplicitTestTargetFilesFromGit(cwd: string) {
|
|
const result = spawnSync(
|
|
"git",
|
|
[
|
|
"ls-files",
|
|
"-z",
|
|
"--cached",
|
|
"--others",
|
|
"--exclude-standard",
|
|
"--",
|
|
...EXPLICIT_TEST_TARGET_ROOTS,
|
|
],
|
|
{
|
|
cwd,
|
|
encoding: "utf8",
|
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
return null;
|
|
}
|
|
return result.stdout.split("\0").filter((line) => line.length > 0 && isImportableGraphFile(line));
|
|
}
|
|
|
|
function listExplicitTestTargetFilesForCwd(cwd: string) {
|
|
if (cachedExplicitTestTargetFiles && cachedExplicitTestTargetFilesCwd === cwd) {
|
|
return cachedExplicitTestTargetFiles;
|
|
}
|
|
|
|
cachedExplicitTestTargetFiles =
|
|
listExplicitTestTargetFilesFromGit(cwd) ??
|
|
EXPLICIT_TEST_TARGET_ROOTS.flatMap((root) => listImportGraphFiles(cwd, root));
|
|
cachedExplicitTestTargetFilesCwd = cwd;
|
|
return cachedExplicitTestTargetFiles;
|
|
}
|
|
|
|
function resolveExplicitTestPrefixTargets(targetArg: string, cwd: string) {
|
|
if (isExistingPathTarget(targetArg, cwd) || isGlobTarget(targetArg)) {
|
|
return null;
|
|
}
|
|
const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, "");
|
|
if (!relative || isLikelyFileTarget(relative)) {
|
|
return null;
|
|
}
|
|
const directory = path.posix.dirname(relative);
|
|
const prefix = `${relative}.`;
|
|
// Bound filesystem probes to matching tests, even with a cached repo-wide inventory.
|
|
const targets = listExplicitTestTargetFilesForCwd(cwd).filter(
|
|
(file) =>
|
|
path.posix.dirname(file) === directory &&
|
|
file.startsWith(prefix) &&
|
|
isTestFileTarget(file) &&
|
|
fs.existsSync(path.join(cwd, file)),
|
|
);
|
|
return targets.length > 0 ? targets.toSorted((left, right) => left.localeCompare(right)) : null;
|
|
}
|
|
|
|
function isNormalizedLiteralPath(value: string) {
|
|
return /^[\w.-]+(?:\/[\w.-]+)*$/u.test(value) && !/(?:^|\/)\.{1,2}(?:\/|$)/u.test(value);
|
|
}
|
|
|
|
function includePatternMatchesAnyFile(pattern: string, files: string[]) {
|
|
const literalPattern = isNormalizedLiteralPath(pattern);
|
|
return files.some(
|
|
(file) =>
|
|
file === pattern ||
|
|
// Keep Node's separator, dot-segment, and platform handling for other paths.
|
|
((!literalPattern || !isNormalizedLiteralPath(file)) && path.matchesGlob(file, pattern)),
|
|
);
|
|
}
|
|
|
|
function resolveExplicitSourceTestTargets(
|
|
targetArg: string,
|
|
cwd: string,
|
|
options: Pick<ChangedTestTargetOptions, "forceFullImportGraph" | "watchMode"> = {},
|
|
) {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
const kind = classifyTarget(targetArg, cwd);
|
|
if (shouldUseWholeConfigTarget(kind, targetArg, cwd)) {
|
|
return null;
|
|
}
|
|
if (!isExistingFileTarget(targetArg, cwd)) {
|
|
return null;
|
|
}
|
|
if (isTestFileTarget(relative)) {
|
|
return null;
|
|
}
|
|
const preciseTargets = [
|
|
...(resolvePreciseChangedTestTargets(relative, {
|
|
cwd,
|
|
forceFullImportGraph: options.forceFullImportGraph === true,
|
|
}) ?? []),
|
|
...(options.watchMode ? [] : resolveKovaSchemaTestTargets(relative)),
|
|
];
|
|
if (preciseTargets.length > 0) {
|
|
return [...new Set(preciseTargets)].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
if (!isTestSupportFileTarget(relative)) {
|
|
return null;
|
|
}
|
|
return [
|
|
...new Set(
|
|
resolveAffectedTestsFromImportGraph(relative, cwd, {
|
|
forceFull: options.forceFullImportGraph === true,
|
|
}),
|
|
),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function listDirectoryTestTargets(directory: string, cwd: string): string[] {
|
|
if (isSharedVitestExcludedPath(directory) || isSharedVitestExcludedPath(`${directory}/`)) {
|
|
return [];
|
|
}
|
|
return fs.readdirSync(path.join(cwd, directory), { withFileTypes: true }).flatMap((entry) => {
|
|
const relative = path.posix.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return listDirectoryTestTargets(relative, cwd);
|
|
}
|
|
return entry.isFile() && relative.endsWith(".test.ts") && !isSharedVitestExcludedPath(relative)
|
|
? [relative]
|
|
: [];
|
|
});
|
|
}
|
|
|
|
function isOrdinaryAgentGlobTarget(relative: string): boolean {
|
|
return (
|
|
isPathAtOrUnder(relative, agentVitestProjectOwners.all.root) &&
|
|
isGlobTarget(relative) &&
|
|
!/\.(?:live|e2e)\.test\.ts$/u.test(relative)
|
|
);
|
|
}
|
|
|
|
function expandExplicitSourceTestTargets(targetArgs: string[], cwd: string, watchMode: boolean) {
|
|
const sourceTargetCount = targetArgs.filter((targetArg) => {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
return isExistingFileTarget(targetArg, cwd) && !isTestFileTarget(relative);
|
|
}).length;
|
|
const forceFullImportGraph = sourceTargetCount > EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD;
|
|
return targetArgs.flatMap((targetArg) => {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
if (classifyTarget(targetArg, cwd) === "extensionFull") {
|
|
// The full aggregate already includes the dedicated database-worker project.
|
|
return [targetArg];
|
|
}
|
|
const glob = isGlobTarget(relative);
|
|
const directory = isExistingDirectoryTarget(targetArg, cwd);
|
|
if (
|
|
!watchMode &&
|
|
!glob &&
|
|
directory &&
|
|
(isPathAtOrUnder(relative, "packages") ||
|
|
isPathAtOrUnder(relative, agentVitestProjectOwners.all.root))
|
|
) {
|
|
const targets = listDirectoryTestTargets(relative, cwd).toSorted((left, right) =>
|
|
left.localeCompare(right),
|
|
);
|
|
return targets.length > 0 ? targets : [targetArg];
|
|
}
|
|
if (!watchMode && isOrdinaryAgentGlobTarget(relative)) {
|
|
// Assign every leaf before choosing configs: agent shards exclude light,
|
|
// isolated, and database-worker tests owned by other projects.
|
|
const targets = expandVitestIncludePatterns([relative], cwd).filter(
|
|
(file) =>
|
|
file.endsWith(".test.ts") &&
|
|
!isSharedVitestExcludedPath(file) &&
|
|
isExistingFileTarget(file, cwd),
|
|
);
|
|
return targets.length > 0 ? targets : [targetArg];
|
|
}
|
|
// Target shape is invariant across the worker inventory; literal files need no expansion.
|
|
const databaseWorkerTargets =
|
|
glob || directory
|
|
? databaseWorkerExtensionTestFiles.filter((file) =>
|
|
glob ? path.matchesGlob(file, relative) : isPathAtOrUnder(file, relative),
|
|
)
|
|
: [];
|
|
if (databaseWorkerTargets.length > 0) {
|
|
return [...databaseWorkerTargets, targetArg];
|
|
}
|
|
if ((isPathAtOrUnder(relative, "ui") || isPluginControlUiPath(relative)) && glob) {
|
|
// Expand mixed browser globs before assigning files to their disjoint runners.
|
|
const targets = listExplicitTestTargetFilesForCwd(cwd).filter(
|
|
(file) => isTestFileTarget(file) && path.matchesGlob(file, relative),
|
|
);
|
|
return targets.length > 0 ? targets : [targetArg];
|
|
}
|
|
const prefixTargets = resolveExplicitTestPrefixTargets(targetArg, cwd);
|
|
if (prefixTargets) {
|
|
return prefixTargets;
|
|
}
|
|
if (relative === "src/commands" && directory) {
|
|
return [COMMANDS_LIGHT_VITEST_CONFIG, COMMANDS_VITEST_CONFIG];
|
|
}
|
|
// Contract directory targets must fan out to the owning contract lanes; the
|
|
// generic channels/plugins projects exclude contracts/**, so routing a
|
|
// contracts directory there silently runs zero tests (passWithNoTests).
|
|
if (directory) {
|
|
if (isPathAtOrUnder(relative, "src/channels/plugins/contracts")) {
|
|
return [
|
|
CONTRACTS_CHANNEL_SURFACE_VITEST_CONFIG,
|
|
CONTRACTS_CHANNEL_CONFIG_VITEST_CONFIG,
|
|
CONTRACTS_CHANNEL_REGISTRY_VITEST_CONFIG,
|
|
CONTRACTS_CHANNEL_SESSION_VITEST_CONFIG,
|
|
];
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/plugins/contracts")) {
|
|
return [CONTRACTS_PLUGIN_VITEST_CONFIG];
|
|
}
|
|
}
|
|
const exactDirectoryTargets = resolveExactSourceDirectoryTestTargets(targetArg, cwd);
|
|
if (exactDirectoryTargets) {
|
|
return exactDirectoryTargets;
|
|
}
|
|
const targets = resolveExplicitSourceTestTargets(targetArg, cwd, {
|
|
forceFullImportGraph,
|
|
watchMode,
|
|
});
|
|
return targets && targets.length > 0 ? targets : [targetArg];
|
|
});
|
|
}
|
|
|
|
const exactSourceDirectoryRoots = [
|
|
"src/acp",
|
|
"src/agents",
|
|
"src/auto-reply",
|
|
"src/channels",
|
|
"src/cli",
|
|
"src/commands",
|
|
"src/config",
|
|
"src/cron",
|
|
"src/daemon",
|
|
"src/gateway",
|
|
"src/hooks",
|
|
"src/infra",
|
|
"src/logging",
|
|
"src/media",
|
|
"src/media-understanding",
|
|
"src/plugin-sdk",
|
|
"src/plugins",
|
|
"src/process",
|
|
"src/secrets",
|
|
"src/shared",
|
|
"src/tui",
|
|
"src/utils",
|
|
"src/wizard",
|
|
"ui/src",
|
|
];
|
|
|
|
function isExactSourceDirectoryTarget(relative: string) {
|
|
return exactSourceDirectoryRoots.some((root) => isPathAtOrUnder(relative, root));
|
|
}
|
|
|
|
function resolveExactSourceDirectoryTestTargets(targetArg: string, cwd: string) {
|
|
if (!isExistingDirectoryTarget(targetArg, cwd)) {
|
|
return null;
|
|
}
|
|
const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, "");
|
|
if (!isExactSourceDirectoryTarget(relative)) {
|
|
return null;
|
|
}
|
|
if (isCanonicalAgentOwnerDirectoryTarget(targetArg, cwd)) {
|
|
return [targetArg];
|
|
}
|
|
const prefix = `${relative}/`;
|
|
const lightTargets = uniqueOrdered([
|
|
...getUnitFastTestFiles(),
|
|
...pluginSdkLightTestFiles,
|
|
...commandsLightTestFiles,
|
|
]).filter((file) => file.startsWith(prefix));
|
|
return lightTargets.length > 0 ? [...lightTargets, targetArg] : null;
|
|
}
|
|
|
|
function isCanonicalAgentOwnerDirectoryTarget(targetArg: string, cwd: string) {
|
|
if (!isExistingDirectoryTarget(targetArg, cwd)) {
|
|
return false;
|
|
}
|
|
const kind = classifyTarget(targetArg, cwd);
|
|
if (kind === agentVitestProjectOwners.all.kind) {
|
|
return false;
|
|
}
|
|
const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, "");
|
|
return Object.values(agentVitestProjectOwners).some(
|
|
(owner) => owner.kind === kind && isPathAtOrUnder(relative, owner.root),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Finds explicit test path targets that do not match any known project plan.
|
|
*/
|
|
export function findUnmatchedExplicitTestTargets(args: string[], cwd = process.cwd()) {
|
|
const { targetArgs, watchMode } = parseTestProjectsArgs(args, cwd);
|
|
if (targetArgs.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
let candidateFiles: string[] | null = null;
|
|
const getCandidateFiles = () => {
|
|
candidateFiles ??= listExplicitTestTargetFilesForCwd(cwd);
|
|
return candidateFiles;
|
|
};
|
|
const unmatched: UnmatchedExplicitTestTarget[] = [];
|
|
for (const targetArg of targetArgs) {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
const absolute = path.resolve(cwd, targetArg);
|
|
// Existing exact tests need no inventory scan; lane assignment belongs to the run planner.
|
|
if (!isGlobTarget(relative) && isTestFileTarget(relative) && fs.existsSync(absolute)) {
|
|
continue;
|
|
}
|
|
if (
|
|
resolveVitestConfigTargetKind(relative) ||
|
|
(isVitestConfigFileTarget(relative) && isExistingFileTarget(targetArg, cwd))
|
|
) {
|
|
continue;
|
|
}
|
|
const kind = classifyTarget(targetArg, cwd);
|
|
if (shouldUseWholeConfigTarget(kind, targetArg, cwd)) {
|
|
continue;
|
|
}
|
|
if (isGlobTarget(relative)) {
|
|
if (!includePatternMatchesAnyFile(relative, getCandidateFiles())) {
|
|
unmatched.push({
|
|
target: targetArg,
|
|
reason: "glob-matched-no-files",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!fs.existsSync(absolute)) {
|
|
if (resolveExplicitTestPrefixTargets(targetArg, cwd)) {
|
|
continue;
|
|
}
|
|
unmatched.push({
|
|
target: targetArg,
|
|
reason: "path-does-not-exist",
|
|
...(path.posix.extname(relative) === ""
|
|
? { includePattern: `${relative}{,.*}.{test,spec}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}` }
|
|
: {}),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const explicitSupportTargets = resolveExplicitSourceTestTargets(targetArg, cwd, { watchMode });
|
|
if (explicitSupportTargets) {
|
|
if (explicitSupportTargets.length === 0) {
|
|
unmatched.push({
|
|
target: targetArg,
|
|
reason: "target-matched-no-test-files",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const includePattern = toScopedIncludePattern(targetArg, cwd);
|
|
if (!includePatternMatchesAnyFile(includePattern, getCandidateFiles())) {
|
|
unmatched.push({
|
|
target: targetArg,
|
|
reason: "target-matched-no-test-files",
|
|
includePattern,
|
|
});
|
|
}
|
|
}
|
|
return unmatched;
|
|
}
|
|
|
|
function isSkippedImportGraphDirectory(name: string) {
|
|
return name === ".git" || name === "dist" || name === "node_modules" || name === "vendor";
|
|
}
|
|
|
|
function listImportGraphFiles(
|
|
cwd: string,
|
|
directory: string,
|
|
files: string[] = [],
|
|
extensions: readonly string[] = IMPORTABLE_FILE_EXTENSIONS,
|
|
recursive = true,
|
|
) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(path.join(cwd, directory), { withFileTypes: true });
|
|
} catch {
|
|
return files;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const relative = normalizePathPattern(path.posix.join(directory, entry.name));
|
|
if (entry.isDirectory()) {
|
|
if (recursive && !isSkippedImportGraphDirectory(entry.name)) {
|
|
listImportGraphFiles(cwd, relative, files, extensions);
|
|
}
|
|
continue;
|
|
}
|
|
if (entry.isFile() && extensions.some((ext) => relative.endsWith(ext))) {
|
|
files.push(relative);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function resolveImportSpecifiers(
|
|
importer: string,
|
|
specifier: string,
|
|
fileSet: ReadonlySet<string>,
|
|
extensions: readonly string[] = IMPORTABLE_FILE_EXTENSIONS,
|
|
aliases: readonly ImportGraphAlias[] = [],
|
|
aliasResolutions?: Map<string, string[]>,
|
|
runtimeOnly = false,
|
|
): string[] {
|
|
if (!specifier.startsWith(".")) {
|
|
if (aliasResolutions?.has(specifier)) {
|
|
return aliasResolutions.get(specifier) ?? [];
|
|
}
|
|
const resolved = new Set<string>();
|
|
for (const { pattern, targets } of aliases) {
|
|
const star = pattern.indexOf("*");
|
|
const wildcard =
|
|
star >= 0 &&
|
|
specifier.startsWith(pattern.slice(0, star)) &&
|
|
specifier.endsWith(pattern.slice(star + 1))
|
|
? specifier.slice(star, specifier.length - (pattern.length - star - 1))
|
|
: null;
|
|
if (specifier !== pattern && wildcard === null) {
|
|
continue;
|
|
}
|
|
for (const target of targets) {
|
|
for (const file of resolveImportSpecifiers(
|
|
"package.json",
|
|
`./${target.replace("*", wildcard ?? "")}`,
|
|
fileSet,
|
|
extensions,
|
|
[],
|
|
undefined,
|
|
runtimeOnly,
|
|
)) {
|
|
resolved.add(file);
|
|
}
|
|
}
|
|
}
|
|
const result = [...resolved];
|
|
aliasResolutions?.set(specifier, result);
|
|
return result;
|
|
}
|
|
|
|
const importerDir = path.posix.dirname(importer);
|
|
const base = normalizePathPattern(path.posix.normalize(path.posix.join(importerDir, specifier)));
|
|
const candidates = [];
|
|
const ext = path.posix.extname(base);
|
|
if (ext) {
|
|
candidates.push(base);
|
|
if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) {
|
|
const withoutExt = base.slice(0, -ext.length);
|
|
candidates.push(...extensions.map((candidateExt) => `${withoutExt}${candidateExt}`));
|
|
}
|
|
} else {
|
|
candidates.push(
|
|
...extensions.map((candidateExt) => `${base}${candidateExt}`),
|
|
...extensions.map((candidateExt) => `${base}/index${candidateExt}`),
|
|
);
|
|
}
|
|
|
|
// A .js runtime sibling must not hide the TypeScript source selected by
|
|
// extension substitution. Combined graphs retain both kinds of consumers.
|
|
const resolved = [...new Set(candidates.filter((candidate) => fileSet.has(candidate)))];
|
|
return runtimeOnly || ![".js", ".jsx", ".mjs", ".cjs"].includes(ext)
|
|
? resolved.slice(0, 1)
|
|
: resolved;
|
|
}
|
|
|
|
const cachedImportGraphs = new Map<string, { graph: ImportGraph; additionalPaths: string }>();
|
|
const cachedImportGraphFiles = new Map<string, string[]>();
|
|
const cachedImportGraphGrepMatches = new Map<string, ImportGraphEdges[] | null>();
|
|
const cachedImportGraphEdges = new Map<string, ImportGraphEdges>();
|
|
const cachedImportGraphAliases = new Map<string, ImportGraphAlias[]>();
|
|
|
|
function readImportGraphManifest(cwd: string, file: string): Record<string, unknown> {
|
|
if (!fs.existsSync(path.join(cwd, file))) {
|
|
return {};
|
|
}
|
|
const value: unknown = JSON.parse(fs.readFileSync(path.join(cwd, file), "utf8"));
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`Invalid test selector manifest: ${file}`);
|
|
}
|
|
return Object.fromEntries(Object.entries(value));
|
|
}
|
|
|
|
/** Read the maintained source mappings without loading a Vitest config or installed package. */
|
|
function getImportGraphAliases(cwd: string): ImportGraphAlias[] {
|
|
const cached = cachedImportGraphAliases.get(cwd);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const aliases: ImportGraphAlias[] = [];
|
|
const files = listTrackedTestPlanFiles(cwd, [
|
|
"package.json",
|
|
":(glob)packages/*/package.json",
|
|
":(glob)extensions/*/package.json",
|
|
]) ?? [
|
|
"package.json",
|
|
...["packages", "extensions"].flatMap((root) =>
|
|
fs.existsSync(path.join(cwd, root))
|
|
? fs.readdirSync(path.join(cwd, root)).map((entry) => `${root}/${entry}/package.json`)
|
|
: [],
|
|
),
|
|
];
|
|
const exportTargets = (value: unknown): string[] => {
|
|
if (typeof value === "string") {
|
|
return [value];
|
|
}
|
|
return value && typeof value === "object" ? Object.values(value).flatMap(exportTargets) : [];
|
|
};
|
|
for (const file of files) {
|
|
const manifest = readImportGraphManifest(cwd, file);
|
|
if (typeof manifest.name !== "string" || !manifest.exports) {
|
|
continue;
|
|
}
|
|
const exports = manifest.exports;
|
|
const entries =
|
|
typeof exports === "object" &&
|
|
!Array.isArray(exports) &&
|
|
Object.keys(exports).some((key) => key.startsWith("."))
|
|
? Object.entries(exports)
|
|
: [[".", exports]];
|
|
for (const [subpath, value] of entries) {
|
|
if (typeof subpath !== "string" || !subpath.startsWith(".")) {
|
|
continue;
|
|
}
|
|
const targets = exportTargets(value)
|
|
.filter(
|
|
(target) =>
|
|
target.startsWith("./") && !target.includes("/dist/") && !/\.d\.[cm]?ts$/u.test(target),
|
|
)
|
|
.map((target) => path.posix.join(path.posix.dirname(file), target));
|
|
if (targets.length > 0) {
|
|
aliases.push({
|
|
pattern: `${manifest.name}${subpath === "." ? "" : subpath.slice(1)}`,
|
|
targets,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const config = readImportGraphManifest(cwd, "tsconfig.json");
|
|
const compiler = config.compilerOptions;
|
|
if (compiler && typeof compiler === "object" && "paths" in compiler) {
|
|
const paths = compiler.paths;
|
|
if (paths && typeof paths === "object") {
|
|
for (const [pattern, targets] of Object.entries(paths)) {
|
|
if (Array.isArray(targets) && targets.every((target) => typeof target === "string")) {
|
|
aliases.push({ pattern, targets });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Keep deterministic source mappings; conditional source exports retain every branch.
|
|
aliases.sort((left, right) => {
|
|
const leftStar = left.pattern.indexOf("*");
|
|
const rightStar = right.pattern.indexOf("*");
|
|
return (
|
|
Number(leftStar >= 0) - Number(rightStar >= 0) ||
|
|
(leftStar >= 0 && rightStar >= 0 ? rightStar - leftStar : 0)
|
|
);
|
|
});
|
|
cachedImportGraphAliases.set(cwd, aliases);
|
|
return aliases;
|
|
}
|
|
|
|
function isImportableGraphFile(relative: string) {
|
|
return IMPORTABLE_FILE_EXTENSIONS.some((ext) => relative.endsWith(ext));
|
|
}
|
|
|
|
function listImportGraphFilesForCwd(cwd: string, options: ImportGraphOptions = {}) {
|
|
const tooling = options.tooling === true;
|
|
const cacheKey = `${cwd}\0${tooling ? "tooling" : "source"}`;
|
|
if (cachedImportGraphFiles.has(cacheKey)) {
|
|
return cachedImportGraphFiles.get(cacheKey) ?? [];
|
|
}
|
|
const roots = tooling ? TOOLING_IMPORT_GRAPH_ROOTS : SOURCE_ROOTS_FOR_IMPORT_GRAPH;
|
|
const extensions = tooling ? TOOLING_IMPORTABLE_FILE_EXTENSIONS : IMPORTABLE_FILE_EXTENSIONS;
|
|
const files = listTrackedTestPlanFiles(
|
|
cwd,
|
|
tooling ? TOOLING_IMPORT_GRAPH_GREP_PATHS : IMPORT_GRAPH_GREP_PATHS,
|
|
) ?? [
|
|
...roots.flatMap((root) => listImportGraphFiles(cwd, root, [], extensions)),
|
|
// Root configs are inputs; UI caches and generated sibling trees are not.
|
|
...listImportGraphFiles(cwd, "ui", [], extensions, false),
|
|
];
|
|
cachedImportGraphFiles.set(cacheKey, files);
|
|
return files;
|
|
}
|
|
|
|
function stripImportableGraphExtension(
|
|
relative: string,
|
|
extensions: readonly string[] = IMPORTABLE_FILE_EXTENSIONS,
|
|
) {
|
|
for (const ext of extensions) {
|
|
if (relative.endsWith(ext)) {
|
|
return relative.slice(0, -ext.length);
|
|
}
|
|
}
|
|
return relative;
|
|
}
|
|
|
|
function resolveImportGraphSearchTerms(
|
|
relative: string,
|
|
extensions: readonly string[] = IMPORTABLE_FILE_EXTENSIONS,
|
|
) {
|
|
const withoutExtension = stripImportableGraphExtension(relative, extensions);
|
|
const basename = path.posix.basename(withoutExtension);
|
|
if (basename === "index" || basename.length < 3) {
|
|
return [];
|
|
}
|
|
const terms = [];
|
|
const segments = withoutExtension.split("/");
|
|
if (segments.length > 1) {
|
|
terms.push(segments.slice(-2).join("/"), withoutExtension);
|
|
}
|
|
if (relative.startsWith("test/helpers/")) {
|
|
return [...new Set(terms)];
|
|
}
|
|
terms.push(basename);
|
|
return [...new Set(terms)];
|
|
}
|
|
|
|
function resolveImportGraphAliasSearchTerms(
|
|
file: string,
|
|
aliases: ImportGraphAlias[],
|
|
fileSet: ReadonlySet<string>,
|
|
extensions: readonly string[],
|
|
) {
|
|
const withoutExtension = stripImportableGraphExtension(file, extensions);
|
|
const identities = [
|
|
file,
|
|
withoutExtension,
|
|
...extensions.map((extension) => `${withoutExtension}${extension}`),
|
|
...(path.posix.basename(withoutExtension) === "index"
|
|
? [path.posix.dirname(withoutExtension)]
|
|
: []),
|
|
];
|
|
const terms = new Set<string>();
|
|
for (const { pattern, targets } of aliases) {
|
|
for (const target of targets) {
|
|
const normalized = path.posix.normalize(target);
|
|
const star = normalized.indexOf("*");
|
|
if (star < 0) {
|
|
if (
|
|
!resolveImportSpecifiers("package.json", `./${target}`, fileSet, extensions).includes(
|
|
file,
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
const term = pattern.split("*", 1)[0];
|
|
if (!term) {
|
|
return null;
|
|
}
|
|
terms.add(term);
|
|
continue;
|
|
}
|
|
for (const identity of identities) {
|
|
const suffix = normalized.slice(star + 1);
|
|
if (!identity.startsWith(normalized.slice(0, star)) || !identity.endsWith(suffix)) {
|
|
continue;
|
|
}
|
|
const wildcard = identity.slice(star, identity.length - suffix.length);
|
|
const term = stripImportableGraphExtension(pattern.replace("*", wildcard), extensions);
|
|
if (!term) {
|
|
return null;
|
|
}
|
|
terms.add(term);
|
|
}
|
|
}
|
|
}
|
|
return [...terms];
|
|
}
|
|
|
|
function readImportGraphEdges(
|
|
cwd: string,
|
|
files: string[],
|
|
fileSet: ReadonlySet<string>,
|
|
tooling = false,
|
|
terms: string[] = [],
|
|
) {
|
|
const cacheKey = (file: string) => `${cwd}\0${tooling}\0${file}`;
|
|
const extensions = tooling ? TOOLING_IMPORTABLE_FILE_EXTENSIONS : IMPORTABLE_FILE_EXTENSIONS;
|
|
const resolve = (file: string, specifiers: string[]) =>
|
|
new Set(
|
|
specifiers.flatMap((specifier) =>
|
|
resolveImportSpecifiers(file, specifier, fileSet, extensions),
|
|
),
|
|
);
|
|
// Source facts survive a scope change; relative edges still use this inventory.
|
|
for (const file of files) {
|
|
if (cachedImportGraphEdges.has(cacheKey(file))) {
|
|
continue;
|
|
}
|
|
const previous = cachedImportGraphEdges.get(`${cwd}\0${!tooling}\0${file}`);
|
|
if (previous) {
|
|
cachedImportGraphEdges.set(cacheKey(file), {
|
|
...previous,
|
|
imports: resolve(file, previous.specifiers),
|
|
references: new Set(previous.references),
|
|
});
|
|
}
|
|
}
|
|
const requests = files
|
|
.map((file) => ({ file, parseImports: !cachedImportGraphEdges.has(cacheKey(file)) }))
|
|
.filter(({ parseImports }) => parseImports || terms.length > 0);
|
|
return readTestSelectorSourceFacts(cwd, requests, terms, GIT_LS_FILES_MAX_BUFFER_BYTES, {
|
|
matchingOnly: terms.length > 0,
|
|
}).map(({ file, imports, typeOnlyImports, matches, references }) => {
|
|
const edges = cachedImportGraphEdges.get(cacheKey(file)) ?? {
|
|
file,
|
|
specifiers: imports,
|
|
typeOnlySpecifiers: new Set(typeOnlyImports),
|
|
imports: resolve(file, imports),
|
|
references: new Set<string>(),
|
|
};
|
|
for (const reference of references) {
|
|
edges.references.add(reference);
|
|
}
|
|
cachedImportGraphEdges.set(cacheKey(file), edges);
|
|
return { edges, matches };
|
|
});
|
|
}
|
|
|
|
function listImportGraphGrepMatches(
|
|
cwd: string,
|
|
terms: string[],
|
|
options: ImportGraphOptions & { testFilesOnly?: boolean } = {},
|
|
) {
|
|
const tooling = options.tooling === true;
|
|
const testFilesOnly = options.testFilesOnly === true;
|
|
// Narrow reference matches must not satisfy a later full import-frontier query.
|
|
const cacheKey = (term: string) => `${cwd}\0${tooling}\0${testFilesOnly}\0${term}`;
|
|
const matches = new Map(
|
|
terms.map((term) => [term, cachedImportGraphGrepMatches.get(cacheKey(term)) ?? null]),
|
|
);
|
|
const missing = [...new Set(terms)].filter(
|
|
(term) => !cachedImportGraphGrepMatches.has(cacheKey(term)),
|
|
);
|
|
if (missing.length === 0) {
|
|
return matches;
|
|
}
|
|
const roots = tooling ? TOOLING_IMPORT_GRAPH_ROOTS : SOURCE_ROOTS_FOR_IMPORT_GRAPH;
|
|
const extensions = tooling ? TOOLING_IMPORTABLE_FILE_EXTENSIONS : IMPORTABLE_FILE_EXTENSIONS;
|
|
// Literal-reference routing only consumes tests; keep import frontiers on the full scope.
|
|
const suffixes = testFilesOnly
|
|
? extensions.flatMap((ext) => [`.test${ext}`, `.spec${ext}`])
|
|
: extensions;
|
|
const grepPaths = importGraphPathspecs(roots, suffixes);
|
|
const spawnOptions: SpawnSyncOptionsWithStringEncoding = {
|
|
cwd,
|
|
encoding: "utf8",
|
|
// A frontier can exceed the platform argv limit; Git accepts stdin patterns.
|
|
input: missing.join("\n"),
|
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
};
|
|
// Git's fixed-string prefilter rescans for each term. Large frontiers use the
|
|
// source reader's single-pass multi-term matcher over the same inventory.
|
|
const result =
|
|
missing.length <= 64
|
|
? spawnSync(
|
|
"git",
|
|
["grep", "-l", "-z", "--fixed-strings", "-f", "-", "--", ...grepPaths],
|
|
spawnOptions,
|
|
)
|
|
: undefined;
|
|
for (const term of missing) {
|
|
matches.set(term, []);
|
|
}
|
|
if (result?.status !== 1) {
|
|
const trackedFiles = new Set(listImportGraphFilesForCwd(cwd, { tooling }));
|
|
// Source archives use the same filesystem inventory and native reader as the full graph.
|
|
const candidates = (
|
|
result?.status === 0
|
|
? result.stdout.split("\0").filter((file) => trackedFiles.has(file))
|
|
: [...trackedFiles].filter((file) => !testFilesOnly || isTestFileTarget(file))
|
|
).toSorted((left, right) => left.localeCompare(right));
|
|
// Per-term membership preserves the helper first-success rule. One native pass
|
|
// matches every candidate, parses only uncached matches, and leaves nonmatches
|
|
// uncached so later full-graph queries still read them.
|
|
for (const { edges, matches: fileTerms } of readImportGraphEdges(
|
|
cwd,
|
|
candidates,
|
|
trackedFiles,
|
|
tooling,
|
|
missing,
|
|
)) {
|
|
for (const term of fileTerms) {
|
|
matches.get(term)?.push(edges);
|
|
}
|
|
}
|
|
}
|
|
for (const term of missing) {
|
|
cachedImportGraphGrepMatches.set(cacheKey(term), matches.get(term) ?? null);
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
function findDirectImporters(
|
|
importedFile: string,
|
|
matches: ReadonlyMap<string, ImportGraphEdges[] | null>,
|
|
extensions: readonly string[],
|
|
resolution?: {
|
|
terms: string[];
|
|
files: ReadonlySet<string>;
|
|
aliases: ImportGraphAlias[];
|
|
runtimeOnly?: boolean;
|
|
},
|
|
) {
|
|
const isTestHelper = importedFile.startsWith("test/helpers/");
|
|
const terms = resolution?.terms ?? resolveImportGraphSearchTerms(importedFile, extensions);
|
|
if (terms.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const importers: string[] = [];
|
|
for (const term of terms) {
|
|
const candidates = matches.get(term);
|
|
if (!candidates) {
|
|
return null;
|
|
}
|
|
for (const edges of candidates) {
|
|
const { file, imports } = edges;
|
|
const importsChanged = resolution
|
|
? edges.specifiers.some(
|
|
(specifier) =>
|
|
(!resolution.runtimeOnly || !edges.typeOnlySpecifiers.has(specifier)) &&
|
|
resolveImportSpecifiers(
|
|
file,
|
|
specifier,
|
|
resolution.files,
|
|
extensions,
|
|
resolution.aliases,
|
|
undefined,
|
|
resolution.runtimeOnly,
|
|
).includes(importedFile),
|
|
)
|
|
: imports.has(importedFile);
|
|
if (file !== importedFile && !importers.includes(file) && importsChanged) {
|
|
importers.push(file);
|
|
}
|
|
}
|
|
if (isTestHelper && importers.length > 0 && term.includes("/")) {
|
|
break;
|
|
}
|
|
}
|
|
return importers;
|
|
}
|
|
|
|
/** Prove an entry is unshared using the canonical targeted reverse-import scan. */
|
|
export function hasImportGraphConsumers(
|
|
changedPaths: string[],
|
|
cwd = process.cwd(),
|
|
options: ImportGraphOptions = {},
|
|
) {
|
|
const tracked = listTrackedTestPlanFiles(
|
|
cwd,
|
|
options.tooling ? TOOLING_IMPORT_GRAPH_GREP_PATHS : IMPORT_GRAPH_GREP_PATHS,
|
|
);
|
|
// An incomplete archive/filesystem inventory cannot prove that an entry is unshared.
|
|
if (tracked === null) {
|
|
return true;
|
|
}
|
|
cachedImportGraphFiles.set(`${cwd}\0${options.tooling ? "tooling" : "source"}`, tracked);
|
|
const files = new Set(tracked);
|
|
const extensions = options.tooling
|
|
? TOOLING_IMPORTABLE_FILE_EXTENSIONS
|
|
: IMPORTABLE_FILE_EXTENSIONS;
|
|
const aliases = options.resolveAliases ? getImportGraphAliases(cwd) : [];
|
|
const termsByFile = new Map<string, string[]>();
|
|
for (const file of changedPaths) {
|
|
const terms = resolveImportGraphSearchTerms(file, extensions);
|
|
const aliasTerms = resolveImportGraphAliasSearchTerms(file, aliases, files, extensions);
|
|
if (!files.has(file) || !terms.length || aliasTerms === null) {
|
|
return true;
|
|
}
|
|
termsByFile.set(file, [...new Set([...terms, ...aliasTerms])]);
|
|
}
|
|
const cached = cachedImportGraphs.get(importGraphCacheKey(cwd, options));
|
|
if (
|
|
cached?.additionalPaths === "" &&
|
|
cached.graph.files.length === tracked.length &&
|
|
cached.graph.files.every((file, index) => file === tracked[index]) &&
|
|
changedPaths.every((file) => fs.existsSync(path.join(cwd, file)))
|
|
) {
|
|
return changedPaths.some((file) =>
|
|
cached.graph.reverseImports.get(file)?.some((importer) => importer !== file),
|
|
);
|
|
}
|
|
const terms = [...termsByFile.values()].flat();
|
|
const matches = listImportGraphGrepMatches(cwd, terms, options);
|
|
return changedPaths.some((file) => {
|
|
const consumers = findDirectImporters(file, matches, extensions, {
|
|
terms: termsByFile.get(file) ?? [],
|
|
files,
|
|
aliases,
|
|
runtimeOnly: options.runtimeOnly,
|
|
});
|
|
return consumers === null || consumers.length > 0;
|
|
});
|
|
}
|
|
|
|
function resolveAffectedTestsFromTargetedImportScan(
|
|
changedPath: string,
|
|
cwd: string,
|
|
options: ImportGraphOptions = {},
|
|
) {
|
|
const tooling = options.tooling === true;
|
|
const files = listImportGraphFilesForCwd(cwd, { tooling });
|
|
const fileSet = new Set(files);
|
|
if (!fileSet.has(changedPath)) {
|
|
return [];
|
|
}
|
|
|
|
const testFiles = new Set(
|
|
files.filter((file) => isTestFileTarget(file) && !file.endsWith(".live.test.ts")),
|
|
);
|
|
let frontier = [changedPath];
|
|
const seen = new Set(frontier);
|
|
const targets = [];
|
|
const extensions = tooling ? TOOLING_IMPORTABLE_FILE_EXTENSIONS : IMPORTABLE_FILE_EXTENSIONS;
|
|
while (frontier.length > 0) {
|
|
const terms = frontier.flatMap((file) => resolveImportGraphSearchTerms(file, extensions));
|
|
const matches = listImportGraphGrepMatches(cwd, terms, { tooling });
|
|
const next = [];
|
|
for (const current of frontier) {
|
|
const importers = findDirectImporters(current, matches, extensions);
|
|
if (importers === null) {
|
|
return null;
|
|
}
|
|
for (const importer of importers) {
|
|
if (seen.has(importer)) {
|
|
continue;
|
|
}
|
|
seen.add(importer);
|
|
if (testFiles.has(importer)) {
|
|
targets.push(importer);
|
|
} else if (options.direct !== true) {
|
|
next.push(importer);
|
|
}
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
|
|
return [...new Set(targets)].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function importGraphCacheKey(cwd: string, options: ImportGraphOptions) {
|
|
return `${cwd}\0${options.tooling === true}\0${options.resolveAliases === true}\0${options.runtimeOnly === true}`;
|
|
}
|
|
|
|
function getImportGraph(
|
|
cwd: string,
|
|
options: ImportGraphOptions = {},
|
|
additionalPaths: string[] = [],
|
|
) {
|
|
const files = listImportGraphFilesForCwd(cwd, options);
|
|
const fileSet = new Set(files);
|
|
const missingPaths = additionalPaths.filter((file) => !fileSet.has(file));
|
|
const missingKey = missingPaths.toSorted().join("\0");
|
|
const cacheKey = importGraphCacheKey(cwd, options);
|
|
const cached = cachedImportGraphs.get(cacheKey);
|
|
if (cached?.additionalPaths === missingKey) {
|
|
return cached.graph;
|
|
}
|
|
for (const file of missingPaths) {
|
|
fileSet.add(file);
|
|
}
|
|
const reverseImports = new Map<string, string[]>();
|
|
const testFiles = new Set(
|
|
files.filter((file) => isTestFileTarget(file) && !file.endsWith(".live.test.ts")),
|
|
);
|
|
|
|
readImportGraphEdges(cwd, files, fileSet, options.tooling);
|
|
const aliases = options.resolveAliases ? getImportGraphAliases(cwd) : [];
|
|
const aliasResolutions = new Map<string, string[]>();
|
|
const extensions = options.tooling
|
|
? TOOLING_IMPORTABLE_FILE_EXTENSIONS
|
|
: IMPORTABLE_FILE_EXTENSIONS;
|
|
for (const file of files) {
|
|
const edges = cachedImportGraphEdges.get(`${cwd}\0${options.tooling === true}\0${file}`);
|
|
if (!edges) {
|
|
continue;
|
|
}
|
|
// Re-resolve cached source facts against deleted paths and this query's alias policy.
|
|
for (const specifier of edges.specifiers) {
|
|
if (options.runtimeOnly && edges.typeOnlySpecifiers.has(specifier)) {
|
|
continue;
|
|
}
|
|
for (const imported of resolveImportSpecifiers(
|
|
file,
|
|
specifier,
|
|
fileSet,
|
|
extensions,
|
|
aliases,
|
|
aliasResolutions,
|
|
options.runtimeOnly,
|
|
)) {
|
|
const importers = reverseImports.get(imported) ?? [];
|
|
importers.push(file);
|
|
reverseImports.set(imported, importers);
|
|
}
|
|
}
|
|
}
|
|
|
|
const graph = { files, reverseImports, testFiles };
|
|
cachedImportGraphs.set(cacheKey, { graph, additionalPaths: missingKey });
|
|
return graph;
|
|
}
|
|
|
|
/** Query imports/re-exports from targets without scanning unrelated source bodies. */
|
|
export function hasImportGraphImpactOnTargets(
|
|
changedPaths: string[],
|
|
targetPaths: string[] | ((file: string) => boolean),
|
|
cwd = process.cwd(),
|
|
options: ImportGraphOptions = {},
|
|
) {
|
|
const changed = new Set(changedPaths);
|
|
if (changed.size === 0) {
|
|
return false;
|
|
}
|
|
const files = listImportGraphFilesForCwd(cwd, options);
|
|
const targets = Array.isArray(targetPaths) ? targetPaths : files.filter(targetPaths);
|
|
if (targets.some((file) => changed.has(file))) {
|
|
return true;
|
|
}
|
|
// Staged deletions have left the index, but surviving importers still own their edges.
|
|
const fileSet = new Set([...files, ...changed]);
|
|
const extensions = options.tooling
|
|
? TOOLING_IMPORTABLE_FILE_EXTENSIONS
|
|
: IMPORTABLE_FILE_EXTENSIONS;
|
|
const seen = new Set(targets);
|
|
const aliases = options.resolveAliases ? getImportGraphAliases(cwd) : [];
|
|
const aliasResolutions = new Map<string, string[]>();
|
|
let frontier = targets;
|
|
while (frontier.length > 0) {
|
|
readImportGraphEdges(cwd, frontier, fileSet, options.tooling);
|
|
const next: string[] = [];
|
|
for (const file of frontier) {
|
|
const edges = cachedImportGraphEdges.get(`${cwd}\0${options.tooling === true}\0${file}`);
|
|
// Resolve raw cached facts against this query's deleted-path identities too.
|
|
for (const specifier of edges?.specifiers ?? []) {
|
|
if (options.runtimeOnly && edges?.typeOnlySpecifiers.has(specifier)) {
|
|
continue;
|
|
}
|
|
for (const dependency of resolveImportSpecifiers(
|
|
file,
|
|
specifier,
|
|
fileSet,
|
|
extensions,
|
|
aliases,
|
|
aliasResolutions,
|
|
options.runtimeOnly,
|
|
)) {
|
|
if (changed.has(dependency)) {
|
|
return true;
|
|
}
|
|
if (!options.direct && !seen.has(dependency)) {
|
|
seen.add(dependency);
|
|
next.push(dependency);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function walkAffectedTestsFromImportGraph(
|
|
changedPaths: string[],
|
|
{ reverseImports, testFiles }: ImportGraph,
|
|
maxDepth = Infinity,
|
|
) {
|
|
const queue = changedPaths.map((file) => ({ file, depth: 0 }));
|
|
const seen = new Set(changedPaths);
|
|
const targets: string[] = [];
|
|
for (const { file, depth } of queue) {
|
|
if (depth >= maxDepth) {
|
|
continue;
|
|
}
|
|
for (const importer of reverseImports.get(file) ?? []) {
|
|
if (seen.has(importer)) {
|
|
continue;
|
|
}
|
|
seen.add(importer);
|
|
if (testFiles.has(importer)) {
|
|
targets.push(importer);
|
|
}
|
|
queue.push({ file: importer, depth: depth + 1 });
|
|
}
|
|
}
|
|
return targets.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function resolveAffectedTestsFromImportGraph(
|
|
changedPath: string | string[],
|
|
cwd: string,
|
|
options: ImportGraphOptions & { forceFull?: boolean } = {},
|
|
) {
|
|
const paths = typeof changedPath === "string" ? [changedPath] : changedPath;
|
|
const changedTests = options.direct
|
|
? paths.filter(
|
|
(file) =>
|
|
isTestFileTarget(file) &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
fs.existsSync(path.join(cwd, file)),
|
|
)
|
|
: [];
|
|
if (
|
|
!paths.length ||
|
|
(paths.every(isTestFileTarget) && !hasImportGraphConsumers(paths, cwd, options))
|
|
) {
|
|
return changedTests;
|
|
}
|
|
if (
|
|
!options.resolveAliases &&
|
|
!options.runtimeOnly &&
|
|
options.forceFull !== true &&
|
|
options.maxDepth === undefined &&
|
|
typeof changedPath === "string"
|
|
) {
|
|
const targetedTargets = resolveAffectedTestsFromTargetedImportScan(changedPath, cwd, options);
|
|
if (targetedTargets !== null) {
|
|
return uniqueOrdered([...changedTests, ...targetedTargets]).toSorted((left, right) =>
|
|
left.localeCompare(right),
|
|
);
|
|
}
|
|
}
|
|
|
|
return uniqueOrdered([
|
|
...changedTests,
|
|
...walkAffectedTestsFromImportGraph(
|
|
paths,
|
|
getImportGraph(cwd, options, paths),
|
|
options.direct ? 1 : options.maxDepth,
|
|
),
|
|
]).toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
/** Complete transitive consumers, including erased type imports unless runtimeOnly is requested. */
|
|
export function resolveImportGraphDependents(
|
|
changedPaths: readonly string[],
|
|
cwd = process.cwd(),
|
|
options: ImportGraphOptions = {},
|
|
) {
|
|
const roots = new Set(changedPaths);
|
|
const { reverseImports } = getImportGraph(cwd, options, [...roots]);
|
|
const seen = new Set(roots);
|
|
// Set iteration visits newly admitted consumers once, including across cycles.
|
|
for (const current of seen) {
|
|
for (const importer of reverseImports.get(current) ?? []) {
|
|
seen.add(importer);
|
|
}
|
|
}
|
|
return [...seen]
|
|
.filter((file) => !roots.has(file))
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
/** Changed resolved dependencies enter the same graph at their literal import consumers. */
|
|
export function resolveDependencyTestConsumers(
|
|
importers: Array<{ root: string; dependencies: string[] }>,
|
|
cwd = process.cwd(),
|
|
{
|
|
runtimeOnly = false,
|
|
importerBindings,
|
|
direct = false,
|
|
}: Pick<ImportGraphOptions, "runtimeOnly" | "direct"> & {
|
|
importerBindings?: Array<{ root: string; dependencies: string[] }>;
|
|
} = {},
|
|
) {
|
|
if (importers.every(({ dependencies }) => dependencies.length === 0)) {
|
|
return { sources: [], tests: [], unresolved: [] };
|
|
}
|
|
const options = { tooling: true, resolveAliases: true, runtimeOnly };
|
|
getImportGraph(cwd, options);
|
|
const files = listImportGraphFilesForCwd(cwd, options);
|
|
const unresolved = new Map<string, { root: string; dependency: string }>();
|
|
for (const { root, dependencies } of importers) {
|
|
for (const dependency of dependencies) {
|
|
unresolved.set(`${root}\0${dependency}`, { root, dependency });
|
|
}
|
|
}
|
|
const dependencyRoots = new Map<string, string[]>();
|
|
// Changed roots retain removed bindings; the lock owner supplies unchanged
|
|
// workspace bindings so a root update cannot claim their dependency consumers.
|
|
for (const { root, dependencies } of [...(importerBindings ?? []), ...importers]) {
|
|
for (const dependency of dependencies) {
|
|
const roots = dependencyRoots.get(dependency) ?? [];
|
|
if (!roots.includes(root)) {
|
|
roots.push(root);
|
|
}
|
|
dependencyRoots.set(dependency, roots);
|
|
}
|
|
}
|
|
for (const roots of dependencyRoots.values()) {
|
|
roots.sort((left, right) => right.length - left.length);
|
|
}
|
|
const bindingOwner = (file: string, dependency: string) =>
|
|
dependencyRoots.get(dependency)?.find((root) => root !== "." && file.startsWith(`${root}/`)) ??
|
|
".";
|
|
const sources = files.filter((file) => {
|
|
const edges = cachedImportGraphEdges.get(`${cwd}\0true\0${file}`);
|
|
if (!edges) {
|
|
return false;
|
|
}
|
|
let runtimeConsumer = false;
|
|
for (const { root, dependencies } of importers) {
|
|
if (root !== "." && !file.startsWith(`${root}/`)) {
|
|
continue;
|
|
}
|
|
for (const dependency of dependencies) {
|
|
if (importerBindings && bindingOwner(file, dependency) !== root) {
|
|
continue;
|
|
}
|
|
const specifiers = edges.specifiers.filter(
|
|
(specifier) => specifier === dependency || specifier.startsWith(`${dependency}/`),
|
|
);
|
|
if (specifiers.length > 0) {
|
|
// Erased imports prove type ownership; absent usage may be a CLI or
|
|
// generated child script and must remain unresolved for the planner.
|
|
unresolved.delete(`${root}\0${dependency}`);
|
|
runtimeConsumer ||= specifiers.some(
|
|
(specifier) => !runtimeOnly || !edges.typeOnlySpecifiers.has(specifier),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return runtimeConsumer;
|
|
});
|
|
return {
|
|
sources,
|
|
unresolved: [...unresolved.values()],
|
|
tests: uniqueOrdered([
|
|
...sources.filter((file) => isTestFileTarget(file) && !file.endsWith(".live.test.ts")),
|
|
...(direct ? [] : resolveAffectedTestsFromImportGraph(sources, cwd, options)),
|
|
]).toSorted((left, right) => left.localeCompare(right)),
|
|
};
|
|
}
|
|
|
|
/** Whole-area UI fallback also owns host tests importing UI and changed-source readers. */
|
|
export function resolveControlUiTestConsumers(changedPaths: string[], cwd = process.cwd()) {
|
|
const uiFiles = listImportGraphFilesForCwd(cwd, { tooling: true }).filter(isControlUiSourcePath);
|
|
return uniqueOrdered([
|
|
...resolveAffectedTestsFromImportGraph(uiFiles, cwd, { forceFull: true, tooling: true }),
|
|
...resolveDirectToolingReferenceTests(changedPaths, cwd),
|
|
]).filter((file) => !isControlUiSourcePath(file));
|
|
}
|
|
|
|
function resolveVitestConfigTargetKind(relative: string) {
|
|
return VITEST_CONFIG_TARGET_KIND_BY_PATH.get(relative) ?? null;
|
|
}
|
|
|
|
function isVitestConfigPathLikeTarget(relative: string) {
|
|
return (
|
|
relative === "vitest.config.ts" || /^test\/vitest\/vitest\..+\.config\.ts$/u.test(relative)
|
|
);
|
|
}
|
|
|
|
function isVitestConfigFileTarget(relative: string) {
|
|
return RUNNABLE_VITEST_CONFIG_TARGETS.has(relative);
|
|
}
|
|
|
|
/** Config identities do not require test discovery or CI shard construction. */
|
|
export function listRunnableVitestConfigTargets(): string[] {
|
|
return [...RUNNABLE_VITEST_CONFIG_TARGETS];
|
|
}
|
|
|
|
function isVitestConfigTargetForKind(kind: string, targetArg: string, cwd: string) {
|
|
return resolveVitestConfigTargetKind(toRepoRelativeTarget(targetArg, cwd)) === kind;
|
|
}
|
|
|
|
function isControlUiE2eTarget(relative: string) {
|
|
return (
|
|
uiE2eRealGatewayTestFiles.includes(relative) ||
|
|
relative === "ui/src/test-helpers/control-ui-e2e.ts" ||
|
|
relative === "ui/src/e2e" ||
|
|
relative.startsWith("ui/src/e2e/") ||
|
|
(isControlUiSourcePath(relative) && relative.endsWith(".e2e.test.ts"))
|
|
);
|
|
}
|
|
|
|
function resolveChannelContractTargetKind(relative: string) {
|
|
if (!relative.startsWith("src/channels/plugins/contracts/")) {
|
|
return null;
|
|
}
|
|
for (const [config, patterns] of CHANNEL_CONTRACT_CONFIG_PATTERNS) {
|
|
if (patterns.some((pattern) => path.matchesGlob(relative, pattern))) {
|
|
return VITEST_CONFIG_TARGET_KIND_BY_PATH.get(config);
|
|
}
|
|
}
|
|
return "contractsChannelSession";
|
|
}
|
|
|
|
function listChangedPathsFromGit(baseRef: string, cwd: string) {
|
|
return listChangedPathsFromGitSource({ base: baseRef, cwd });
|
|
}
|
|
|
|
function extractChangedBaseRef(args: string[]) {
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === undefined) {
|
|
continue;
|
|
}
|
|
const match = arg.match(CHANGED_ARGS_PATTERN);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
if (match[1]) {
|
|
return match[1];
|
|
}
|
|
const nextArg = args[index + 1];
|
|
return nextArg && nextArg !== "--" && !nextArg.startsWith("-") ? nextArg : "HEAD";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function stripChangedArgs(args: string[]) {
|
|
const strippedArgs = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === undefined) {
|
|
continue;
|
|
}
|
|
const match = arg.match(CHANGED_ARGS_PATTERN);
|
|
if (!match) {
|
|
strippedArgs.push(arg);
|
|
continue;
|
|
}
|
|
if (!match[1]) {
|
|
const nextArg = args[index + 1];
|
|
if (nextArg && nextArg !== "--" && !nextArg.startsWith("-")) {
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
return strippedArgs;
|
|
}
|
|
|
|
function shouldKeepBroadChangedRun(changedPaths: string[]) {
|
|
return changedPaths.some((changedPath) =>
|
|
PRECISE_SOURCE_TEST_TARGETS.has(changedPath)
|
|
? false
|
|
: BROAD_CHANGED_FALLBACK_PATTERNS.some((pattern) => pattern.test(changedPath)),
|
|
);
|
|
}
|
|
|
|
function resolveToolingChangedTestTargets(
|
|
changedPaths: string[],
|
|
cwd = process.cwd(),
|
|
options: ChangedTestTargetOptions = {},
|
|
) {
|
|
const targets = [];
|
|
for (const changedPath of changedPaths) {
|
|
const testTargets =
|
|
SOURCE_TEST_TARGETS.get(changedPath) ?? resolveToolingTestTargets(changedPath, cwd, options);
|
|
if (!testTargets) {
|
|
return null;
|
|
}
|
|
targets.push(...testTargets);
|
|
if (CHANNEL_PLUGIN_SHAPE_PARITY_WIRING_PATHS.has(changedPath)) {
|
|
targets.push(CHANNEL_PLUGIN_SHAPE_PARITY_TEST_TARGET);
|
|
}
|
|
}
|
|
return [...new Set(targets)];
|
|
}
|
|
|
|
const TOOLING_SCRIPT_PATH_PATTERN = /^scripts\/(.+)\.(?:mjs|cjs|js|mts|cts|ts|sh|py|ps1)$/u;
|
|
|
|
function resolveConventionalToolingTestTargets(changedPath: string, cwd = process.cwd()) {
|
|
const match = TOOLING_SCRIPT_PATH_PATTERN.exec(changedPath);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const stem = match[1];
|
|
if (stem === undefined) {
|
|
return null;
|
|
}
|
|
const basename = path.posix.basename(stem);
|
|
const dashedStem = stem.replaceAll("/", "-");
|
|
const e2eLibStem = stem.startsWith("e2e/lib/") ? stem.slice("e2e/lib/".length) : null;
|
|
const e2eLibDashedStem = e2eLibStem?.replaceAll("/", "-");
|
|
const e2eLibParts = e2eLibStem?.split("/") ?? [];
|
|
const e2eLibFamily = e2eLibParts.length > 1 ? e2eLibParts[0] : null;
|
|
const e2eLibFamilyCandidates = e2eLibFamily
|
|
? [
|
|
`test/scripts/${e2eLibFamily}.test.ts`,
|
|
`test/scripts/${e2eLibFamily}-client.test.ts`,
|
|
`test/scripts/${e2eLibFamily}-assertions.test.ts`,
|
|
`test/scripts/${e2eLibFamily}-probe.test.ts`,
|
|
]
|
|
: [];
|
|
const candidates = [
|
|
`test/scripts/${stem}.test.ts`,
|
|
`test/scripts/${dashedStem}.test.ts`,
|
|
`test/scripts/${basename}.test.ts`,
|
|
...(e2eLibDashedStem
|
|
? [`test/scripts/${e2eLibDashedStem}.test.ts`, `test/scripts/e2e-${e2eLibDashedStem}.test.ts`]
|
|
: []),
|
|
...e2eLibFamilyCandidates,
|
|
`src/scripts/${stem}.test.ts`,
|
|
`src/scripts/${dashedStem}.test.ts`,
|
|
`src/scripts/${basename}.test.ts`,
|
|
];
|
|
const targets = candidates.filter((candidate) => fs.existsSync(path.join(cwd, candidate)));
|
|
return targets.length > 0 ? targets : null;
|
|
}
|
|
|
|
function isToolingScriptPath(changedPath: string) {
|
|
return TOOLING_SCRIPT_PATH_PATTERN.test(changedPath);
|
|
}
|
|
|
|
function resolveUpgradeSurvivorConfigRecipeTargets(changedPath: string) {
|
|
if (!/^scripts\/e2e\/lib\/upgrade-survivor\/config-recipe\/[^/]+\.json$/u.test(changedPath)) {
|
|
return null;
|
|
}
|
|
return ["test/scripts/upgrade-survivor-config-recipe.test.ts"];
|
|
}
|
|
|
|
function resolveDocsI18nBehaviorTargets(changedPath: string) {
|
|
if (!/^scripts\/docs-i18n\/testdata\/behavior\/[^/]+\/[^/]+$/u.test(changedPath)) {
|
|
return null;
|
|
}
|
|
return ["test/scripts/docs-i18n.test.ts"];
|
|
}
|
|
|
|
function resolveDocsI18nGoTargets(changedPath: string) {
|
|
if (!/^scripts\/docs-i18n\/(?:go\.(?:mod|sum)|[^/]+\.go)$/u.test(changedPath)) {
|
|
return null;
|
|
}
|
|
const targets = ["test/scripts/docs-i18n.test.ts"];
|
|
if (changedPath === "scripts/docs-i18n/go.mod") {
|
|
targets.push("test/scripts/ci-workflow-planning.test.ts");
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function resolveK8sManifestTargets(changedPath: string) {
|
|
if (!/^scripts\/k8s\/manifests\/[^/]+\.yaml$/u.test(changedPath)) {
|
|
return null;
|
|
}
|
|
return ["test/scripts/k8s-manifests.test.ts"];
|
|
}
|
|
|
|
function resolveParallelsToolingTestTargets(changedPath: string) {
|
|
if (
|
|
!/^scripts\/e2e\/parallels\/[^/]+\.ts$/u.test(changedPath) &&
|
|
!/^scripts\/e2e\/parallels-(?:linux|macos|npm-update|windows)-smoke\.sh$/u.test(changedPath)
|
|
) {
|
|
return null;
|
|
}
|
|
const targets = ["test/scripts/parallels-smoke-model.test.ts"];
|
|
if (
|
|
[
|
|
"scripts/e2e/parallels/guest-transports.ts",
|
|
"scripts/e2e/parallels/host-command.ts",
|
|
"scripts/e2e/parallels/npm-update-scripts.ts",
|
|
"scripts/e2e/parallels/npm-update-smoke.ts",
|
|
].includes(changedPath)
|
|
) {
|
|
targets.push("test/scripts/parallels-npm-update-smoke.test.ts");
|
|
}
|
|
if (changedPath === "scripts/e2e/parallels/update-job-timeout.ts") {
|
|
targets.push("test/scripts/parallels-update-job-timeout.test.ts");
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function resolveToolingTestOwnerTargets(...owners: string[]) {
|
|
return owners.map((owner) => (owner.includes("/") ? owner : `test/scripts/${owner}.test.ts`));
|
|
}
|
|
|
|
const packageAcceptance = "package-acceptance-workflow";
|
|
const dockerBuild = "docker-build-helper";
|
|
const dockerE2e = "docker-e2e-plan";
|
|
const workflowGuards = "ci-workflow-guards";
|
|
const workflowPlanning = "ci-workflow-planning";
|
|
const workflowEvidence = "ci-workflow-evidence";
|
|
const pluginPrerelease = "plugin-prerelease-test-plan";
|
|
const releaseCheck = "test/release-check.test.ts";
|
|
const installDocker = "test-install-sh-docker";
|
|
const changedScope = "src/scripts/ci-changed-scope.test.ts";
|
|
const changedScopeTests = [
|
|
"src/scripts/ci-changed-scope.contract-fixtures.test.ts",
|
|
"src/scripts/ci-changed-scope.control-ui.test.ts",
|
|
"src/scripts/ci-changed-scope.git-owner.test.ts",
|
|
"src/scripts/ci-changed-scope.native-i18n.test.ts",
|
|
changedScope,
|
|
"src/scripts/ci-changed-scope.windows.test.ts",
|
|
];
|
|
const dockerCache = "src/docker-build-cache.test.ts";
|
|
const dockerDigests = "src/docker-image-digests.test.ts";
|
|
const openaiChatToolsE2e = "test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts";
|
|
const npmPostpublish = "test/openclaw-npm-postpublish-verify.test.ts";
|
|
const crossOsReleaseChecks = "openclaw-cross-os-release-checks";
|
|
const runNode = "src/infra/run-node.test.ts";
|
|
const pluginSdkEntryOwners = [
|
|
"src/plugins/contracts/plugin-sdk-index.bundle.test.ts",
|
|
"src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts",
|
|
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
|
|
"src/plugins/contracts/extension-package-project-boundaries.test.ts",
|
|
"plugin-sdk-surface-report",
|
|
"build-all",
|
|
releaseCheck,
|
|
"prepare-extension-package-boundary-artifacts",
|
|
"ts-topology",
|
|
"test/vitest/vitest.tooling.config.ts",
|
|
];
|
|
|
|
// Keep only genuinely ambiguous paths explicit; conventional discovery owns
|
|
// unambiguous scripts and direct imports without a second inventory.
|
|
const EXACT_TOOLING_TARGETS = new Map<string, string[]>([
|
|
// The native gate/check handoff crosses processes outside the import graph.
|
|
["scripts/check.mts", ["check", "pr-gate-base"]],
|
|
[
|
|
"scripts/pr-lib/gates.sh",
|
|
[
|
|
"pr-correction-preparation",
|
|
"pr-crabbox-gate-plan",
|
|
"pr-main-refresh",
|
|
"pr-merge-hosted",
|
|
"pr-metadata",
|
|
"pr-prepare-gates",
|
|
"pr-prepare-preflight",
|
|
"pr-wrappers",
|
|
"pr-gate-base",
|
|
],
|
|
],
|
|
[".github/workflows/ci.yml", ["ci-platform-checkout", "ci-linux-git", "ci-git-owner"]],
|
|
[".github/actions/setup-android-toolchain/action.yml", [workflowPlanning]],
|
|
[".github/workflows/docs-sync-publish.yml", ["docs-sync-publish"]],
|
|
[".github/workflows/docs-agent.yml", ["docs-agent-workflow"]],
|
|
["scripts/generate-ci-git-owner.mts", ["ci-git-owner"]],
|
|
[
|
|
".github/workflows/openclaw-live-and-e2e-checks-reusable.yml",
|
|
[packageAcceptance, workflowGuards, "release-workflow-matrix-plan", installDocker],
|
|
],
|
|
[
|
|
".github/workflows/plugin-clawhub-release.yml",
|
|
[packageAcceptance, "plugin-release-git-lifecycle", workflowGuards],
|
|
],
|
|
[
|
|
".github/workflows/plugin-npm-release.yml",
|
|
[
|
|
packageAcceptance,
|
|
"plugin-npm-extended-stable-workflow",
|
|
"plugin-release-git-lifecycle",
|
|
workflowGuards,
|
|
],
|
|
],
|
|
[".github/workflows/qa-live-transports-convex.yml", [packageAcceptance, workflowGuards]],
|
|
[".github/workflows/update-migration.yml", [packageAcceptance, workflowGuards]],
|
|
[
|
|
".github/actions/setup-node-env/action.yml",
|
|
["setup-node-env-bun", "setup-node-env-semantic-memory", packageAcceptance, workflowGuards],
|
|
],
|
|
[
|
|
".github/actions/setup-node-env/dependency-fingerprint.mjs",
|
|
[workflowGuards, "setup-node-env-dependency-fingerprint"],
|
|
],
|
|
[".github/actions/setup-node-env/seed-bun-from-image.mjs", ["setup-node-env-bun"]],
|
|
[".github/actions/setup-pnpm-store-cache/action.yml", [packageAcceptance, workflowGuards]],
|
|
[".github/actions/setup-pnpm-store-cache/ensure-node.sh", ["setup-pnpm-store-cache-ensure-node"]],
|
|
["test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", [dockerE2e, pluginPrerelease]],
|
|
["scripts/e2e/lib/mcp-code-mode-probe-server.ts", ["mcp-code-mode-gateway-client"]],
|
|
["scripts/e2e/cron-cli-docker.sh", [dockerBuild, "docker-e2e-observability"]],
|
|
["scripts/ios-release-upload.sh", ["ios-release-wrapper-args", "ios-release-fastlane-gates"]],
|
|
["scripts/release-verify-beta.ts", ["release-wrapper-scripts"]],
|
|
["scripts/lib/bundled-plugin-build-entries.mjs", ["bundled-plugin-build-entries", releaseCheck]],
|
|
["scripts/lib/docker-e2e-package.sh", [dockerBuild]],
|
|
["scripts/relay-build-limit-warnings.mts", [dockerBuild]],
|
|
[
|
|
"scripts/lib/release-version.mjs",
|
|
[
|
|
"test/release-version.test.ts",
|
|
"test/npm-publish-plan.test.ts",
|
|
"test/openclaw-npm-release-check.test.ts",
|
|
npmPostpublish,
|
|
"test/plugin-npm-release.test.ts",
|
|
"test/plugin-clawhub-release.test.ts",
|
|
"android-version",
|
|
"android-pin-version",
|
|
"docker-release-policy",
|
|
"docker-release-artifacts",
|
|
"full-release-validation-at-sha",
|
|
"ios-version",
|
|
"openclaw-npm-extended-stable-release",
|
|
"openclaw-npm-publish",
|
|
"npm-prepared-bundle",
|
|
"release-preflight",
|
|
"release-prepare",
|
|
"release-upgrade-baseline",
|
|
"release-version",
|
|
"upgrade-survivor-baselines",
|
|
"upgrade-survivor-config-recipe",
|
|
],
|
|
],
|
|
[
|
|
"scripts/lib/release-context.mjs",
|
|
["full-release-validation-at-sha", "release-candidate-checklist", packageAcceptance],
|
|
],
|
|
[
|
|
"scripts/lib/clawhub-bootstrap-artifact.mjs",
|
|
["clawhub-bootstrap-artifact", "verify-clawhub-published-artifact"],
|
|
],
|
|
[
|
|
"scripts/lib/plugin-npm-release.ts",
|
|
["test/plugin-npm-release.test.ts", "test/plugin-clawhub-release.test.ts"],
|
|
],
|
|
[
|
|
"scripts/lib/extension-source-classifier.mts",
|
|
[
|
|
"extension-source-classifier",
|
|
"src/channels/plugins/contracts/channel-import-guardrails.test.ts",
|
|
],
|
|
],
|
|
["scripts/build-stamp.mts", ["src/infra/build-stamp.test.ts"]],
|
|
["scripts/run-vitest.mjs", ["run-vitest", "test-projects", "vitest-local-scheduling"]],
|
|
["scripts/run-vitest.mts", ["run-vitest", "test-projects", "vitest-local-scheduling"]],
|
|
["scripts/run-oxlint-shards.mts", ["run-oxlint", "lint-status"]],
|
|
["scripts/run-oxlint.mts", ["run-oxlint", "lint-status"]],
|
|
["scripts/run-oxlint.mjs", ["run-oxlint", "lint-status"]],
|
|
["scripts/run-lint.mts", ["run-oxlint", "lint-status"]],
|
|
["scripts/run-stylelint.mts", ["changed-lanes", "lint-status"]],
|
|
[
|
|
"scripts/lib/failed-trailer.mts",
|
|
["run-oxlint", "run-tsgo", "run-vitest", "changed-lanes", "lint-status"],
|
|
],
|
|
["scripts/lib/managed-child-process.mts", ["managed-child-process", "lint-status"]],
|
|
["scripts/lib/dist-artifact-ownership.mts", ["dist-artifact-ownership", "lint-status"]],
|
|
["scripts/lib/dist-artifact-lock.mts", ["dist-artifact-ownership", "lint-status"]],
|
|
["scripts/docker-e2e-rerun.mts", ["docker-e2e-helper-cli"]],
|
|
["scripts/openclaw-postpack.mjs", [TOOLING_VITEST_CONFIG]],
|
|
["scripts/package-manifest.mjs", ["test/openclaw-prepack.test.ts"]],
|
|
["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]],
|
|
["scripts/lib/docker-e2e-scenarios.mts", [dockerE2e, pluginPrerelease]],
|
|
["scripts/lib/upgrade-survivor-policy.mjs", [dockerE2e]],
|
|
["scripts/lib/upgrade-survivor-scenarios.json", [dockerE2e]],
|
|
["scripts/e2e/kitchen-sink-rpc-walk.mts", ["kitchen-sink-rpc-walk", pluginPrerelease]],
|
|
[
|
|
"scripts/e2e/agents-delete-shared-workspace-docker.sh",
|
|
[dockerE2e, changedScope, "src/commands/agents.delete.test.ts"],
|
|
],
|
|
[
|
|
"scripts/e2e/browser-cdp-snapshot-docker.sh",
|
|
[dockerBuild, "browser-cdp-snapshot", "e2e-helper-env-limits"],
|
|
],
|
|
[
|
|
"scripts/e2e/config-reload-source-docker.sh",
|
|
[
|
|
dockerE2e,
|
|
packageAcceptance,
|
|
"fixture-config",
|
|
"e2e-mock-config-limits",
|
|
"src/gateway/config-reload.test.ts",
|
|
],
|
|
],
|
|
[
|
|
"scripts/e2e/gateway-network-docker.sh",
|
|
[dockerBuild, dockerE2e, packageAcceptance, "gateway-network-client", changedScope],
|
|
],
|
|
["scripts/e2e/npm-telegram-live-runner.ts", ["npm-telegram-live"]],
|
|
[
|
|
"scripts/e2e/upgrade-survivor-docker.sh",
|
|
[
|
|
dockerBuild,
|
|
dockerE2e,
|
|
packageAcceptance,
|
|
"upgrade-survivor-probe-gateway",
|
|
"upgrade-survivor-assertions",
|
|
"upgrade-survivor-mobile-pairing",
|
|
"upgrade-survivor-recovery-cleanup",
|
|
"openclaw-test-state",
|
|
],
|
|
],
|
|
[
|
|
"scripts/e2e/lib/upgrade-survivor/run.sh",
|
|
[
|
|
packageAcceptance,
|
|
"upgrade-survivor-missing-load-path",
|
|
"upgrade-survivor-assertions",
|
|
"upgrade-survivor-mobile-pairing",
|
|
"upgrade-survivor-recovery-cleanup",
|
|
"upgrade-survivor-watchos-direct-node",
|
|
],
|
|
],
|
|
[
|
|
"scripts/e2e/lib/upgrade-survivor/watchos-direct-node.mjs",
|
|
["upgrade-survivor-watchos-direct-node"],
|
|
],
|
|
[
|
|
"scripts/e2e/lib/upgrade-survivor/mobile-pairing-client.mts",
|
|
["upgrade-survivor-mobile-pairing"],
|
|
],
|
|
[
|
|
"scripts/e2e/lib/upgrade-survivor/recovery-cleanup-fixture.mjs",
|
|
["upgrade-survivor-recovery-cleanup"],
|
|
],
|
|
[
|
|
"scripts/e2e/bundled-plugin-install-uninstall-docker.sh",
|
|
[dockerBuild, dockerE2e, pluginPrerelease, "bundled-plugin-install-uninstall-probe"],
|
|
],
|
|
["scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh", ["plugin-update-unchanged-docker"]],
|
|
["scripts/e2e/lib/plugin-update/probe.mjs", ["plugin-update-unchanged-docker"]],
|
|
["scripts/e2e/lib/plugin-update/unchanged-scenario.sh", ["plugin-update-unchanged-docker"]],
|
|
[
|
|
"scripts/e2e/update-corrupt-plugin-docker.sh",
|
|
[dockerBuild, dockerE2e, packageAcceptance, "plugin-update-unchanged-docker"],
|
|
],
|
|
["scripts/e2e/plugins-docker.sh", [dockerBuild, dockerE2e, "plugins-assertions"]],
|
|
[
|
|
"scripts/e2e/release-user-journey-docker.sh",
|
|
[dockerBuild, dockerE2e, packageAcceptance, "release-user-journey-assertions"],
|
|
],
|
|
[
|
|
"scripts/e2e/openai-image-auth-docker.sh",
|
|
[
|
|
dockerBuild,
|
|
dockerE2e,
|
|
"openai-image-auth-docker-client",
|
|
"extensions/openai/image-generation-provider.test.ts",
|
|
],
|
|
],
|
|
["scripts/e2e/lib/openai-chat-tools/client.mjs", [openaiChatToolsE2e]],
|
|
[
|
|
"scripts/e2e/lib/openai-web-search-minimal/client.mjs",
|
|
["test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts"],
|
|
],
|
|
["scripts/e2e/qr-import-docker.sh", [dockerBuild]],
|
|
["scripts/bundle-a2ui.mts", ["bundled-plugin-assets"]],
|
|
]);
|
|
|
|
const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
|
|
[
|
|
/^test\/tsconfig\/tsconfig\.test\.root(?:\.(?:tooling|scripts|e2e|other))?\.json$/u,
|
|
["tsgo-core-test-shards", "changed-lanes"],
|
|
],
|
|
[
|
|
/^(?:git-hooks\/pre-commit|scripts\/pre-commit\/(?:guard-staged-content\.mjs|filter-staged-files\.mjs|format-staged\.sh|run-node-tool\.sh)|test\/git-hooks-pre-commit\.test-support\.ts)$/u,
|
|
["test/git-hooks-pre-commit.test.ts", "test/git-hooks-pre-commit-boundaries.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/pr$/u,
|
|
[
|
|
"pr-merge",
|
|
"pr-merge-outcome",
|
|
"pr-merge-qualified-refusal",
|
|
"pr-operation-lock",
|
|
"pr-wrappers",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/pr-lib\/crabbox-gate-contract\.mjs$/u,
|
|
["pr-crabbox-gate-publisher", "pr-crabbox-merge-bypass"],
|
|
],
|
|
[
|
|
/^scripts\/pr-lib\/crabbox-gate-plan\.mts$/u,
|
|
["pr-crabbox-gate-plan", "pr-crabbox-gate-publisher", "pr-prepare-gates"],
|
|
],
|
|
[
|
|
/^scripts\/pr-lib\/crabbox-merge-bypass\.sh$/u,
|
|
["pr-crabbox-merge-bypass", "pr-merge", "pr-merge-outcome"],
|
|
],
|
|
[/^scripts\/lib\/windows-taskkill\.mjs$/u, ["managed-child-process", "run-with-env"]],
|
|
[
|
|
/^scripts\/lib\/config-boundary-guard\.mts$/u,
|
|
[
|
|
"src/plugins/contracts/config-boundary-guard.test.ts",
|
|
"src/plugins/contracts/deprecated-internal-config-api.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/ci\.yml$/u,
|
|
[
|
|
workflowGuards,
|
|
workflowPlanning,
|
|
workflowEvidence,
|
|
"changed-lanes",
|
|
"check-workflows",
|
|
"plugin-contract-test-plan",
|
|
pluginPrerelease,
|
|
"verify-pr-hosted-gates",
|
|
],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/ci-check-testbox\.yml$/u,
|
|
[workflowGuards, packageAcceptance, "changed-lanes"],
|
|
],
|
|
[/^\.github\/workflows\/ci-check-arm-testbox\.yml$/u, [workflowGuards, packageAcceptance]],
|
|
[/^\.github\/workflows\/crabbox-hydrate\.yml$/u, [workflowGuards, packageAcceptance]],
|
|
[
|
|
/^\.github\/workflows\/ci-build-artifacts-testbox\.yml$/u,
|
|
[packageAcceptance, workflowGuards, workflowPlanning],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/full-release-validation\.yml$/u,
|
|
[
|
|
"src/dockerfile.test.ts",
|
|
"full-release-validation-state",
|
|
"full-release-validation-at-sha",
|
|
"full-release-candidate-reuse",
|
|
"find-reusable-release-validation",
|
|
"openclaw-npm-extended-stable-full-validation-workflow",
|
|
"release-no-push-workflow",
|
|
"release-ci-summary",
|
|
packageAcceptance,
|
|
pluginPrerelease,
|
|
"check-workflows",
|
|
],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/full-release-candidate\.yml$/u,
|
|
["full-release-candidate-reuse", packageAcceptance, "check-workflows", workflowGuards],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/openclaw-release-checks\.yml$/u,
|
|
[packageAcceptance, crossOsReleaseChecks, pluginPrerelease, installDocker, workflowEvidence],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/docker-release(?:-prepare)?\.yml$/u,
|
|
[
|
|
"src/dockerfile.test.ts",
|
|
"docker-channel-promote",
|
|
"docker-release-artifacts",
|
|
"vercel-container-registry-publish",
|
|
],
|
|
],
|
|
[/^\.github\/workflows\/install-smoke\.yml$/u, ["install-smoke-no-push-workflow", installDocker]],
|
|
[
|
|
/^\.github\/workflows\/openclaw-performance\.yml$/u,
|
|
["openclaw-performance-workflow", "openclaw-performance-git-lifecycle"],
|
|
],
|
|
[/^\.github\/workflows\/linux-app-release\.yml$/u, ["release-workflow-git-lifecycle"]],
|
|
[
|
|
/^\.github\/workflows\/macos-release\.yml$/u,
|
|
["release-workflow-git-lifecycle", packageAcceptance],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/npm-placeholder-bootstrap\.yml$/u,
|
|
["release-workflow-git-lifecycle", "npm-placeholder-publication"],
|
|
],
|
|
[/^\.github\/workflows\/plugin-prerelease\.yml$/u, [pluginPrerelease]],
|
|
[/^\.github\/workflows\/tui-pty\.yml$/u, [packageAcceptance]],
|
|
[
|
|
/^\.github\/workflows\/openclaw-cross-os-release-checks-reusable\.yml$/u,
|
|
[crossOsReleaseChecks, "openclaw-cross-os-release-workflow", packageAcceptance],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/openclaw-release-publish\.yml$/u,
|
|
[packageAcceptance, "docker-release-artifacts", "vercel-container-registry-publish"],
|
|
],
|
|
[/^\.github\/workflows\/package-acceptance\.yml$/u, [packageAcceptance]],
|
|
[
|
|
/^\.github\/workflows\/vercel-container-registry-publish\.yml$/u,
|
|
["docker-channel-promote", "release-plan-producer", "vercel-container-registry-publish"],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/plugin-clawhub-new\.yml$/u,
|
|
[packageAcceptance, "plugin-clawhub-new-workflow"],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/openclaw-npm-release\.yml$/u,
|
|
[npmPostpublish, "openclaw-npm-extended-stable-workflow", packageAcceptance],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^\\.github\\/workflows\\/(?:auto-response|clawsweeper-dispatch|labeler|",
|
|
"real-behavior-proof|stale)\\.yml$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[workflowGuards],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^\\.github\\/workflows\\/mantis-(?:discord-(?:smoke|status-reactions|",
|
|
"thread-attachment)|slack-desktop-smoke)\\.yml$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[packageAcceptance, workflowGuards],
|
|
],
|
|
[
|
|
/^\.github\/(?:workflows\/mantis-web-ui-chat-proof\.yml|actions\/mantis-validate-trusted-ref\/action\.yml)$/u,
|
|
["mantis-web-ui-chat-proof-workflow", packageAcceptance, workflowGuards],
|
|
],
|
|
[/^\.github\/workflows\/android-release\.yml$/u, [packageAcceptance, workflowGuards]],
|
|
[
|
|
/^\.github\/workflows\/(?:qa-profile-evidence|maturity-scorecard|mantis-discord-(?:status-reactions|thread-attachment))\.yml$/u,
|
|
[workflowEvidence],
|
|
],
|
|
[
|
|
/^\.github\/(?:actions\/(?:ensure-base-commit|git-owner|publish-generated-pr|mantis-validate-trusted-ref)\/|workflows\/(?:workflow-sanity|qa-profile-evidence|maturity-scorecard|docs-agent|docs-sync-publish|openclaw-performance|linux-app-release|macos-release|npm-placeholder-bootstrap|plugin-clawhub-release|plugin-npm-release|mantis-(?:discord-(?:smoke|status-reactions|thread-attachment)|slack-desktop-smoke|web-ui-chat-proof))\.yml$)/u,
|
|
[
|
|
"ci-git-owner",
|
|
"ci-linux-git",
|
|
"ci-platform-checkout",
|
|
"src/scripts/ci-changed-scope.git-owner.test.ts",
|
|
],
|
|
],
|
|
[/^\.github\/actions\/publish-generated-pr\//u, [workflowGuards]],
|
|
[/^tsconfig\.scripts\.json$/u, ["changed-lanes", "test-projects"]],
|
|
[/^scripts\/test-projects\.test-support\.mts$/u, ["test-projects"]],
|
|
[/^scripts\/ci-changed-scope\.mjs$/u, [...changedScopeTests, "control-ui-i18n"]],
|
|
[/^scripts\/check-changed\.(?:mjs|mts)$/u, ["changed-lanes"]],
|
|
[/^scripts\/changed-lanes\.(?:mjs|mts)$/u, ["changed-lanes"]],
|
|
[
|
|
/^scripts\/(?:lib\/tsx-cli-shim|tsx)\.mjs$/u,
|
|
["direct-run-entrypoints", "lint-status", "local-check-runtime"],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^scripts\\/(?:generate-prompt-snapshots|prompt-snapshot-files|",
|
|
"sync-codex-model-prompt-fixture)\\.ts$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
["prompt-snapshots"],
|
|
],
|
|
[/^scripts\/e2e\/npm-telegram-live-docker\.sh$/u, ["npm-telegram-live"]],
|
|
[
|
|
/^scripts\/package-openclaw-for-docker\.m[jt]s$/u,
|
|
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
|
|
],
|
|
[/^scripts\/run-node\.(?:mjs|mts)$/u, [runNode]],
|
|
[/^scripts\/ios-write-swift-filelist\.m[jt]s$/u, ["ios-run"]],
|
|
[
|
|
/^scripts\/pr-lib\/(?:merge(?:-outcome)?\.sh|merge-(?:legacy|pre-dispatch)-refusal\.mjs)$/u,
|
|
["pr-merge", "pr-merge-outcome", "pr-merge-pre-dispatch-refusal", "pr-merge-qualified-refusal"],
|
|
],
|
|
[/^scripts\/plugin-clawhub-publish\.sh$/u, ["test/plugin-clawhub-release.test.ts"]],
|
|
[/^scripts\/openclaw-npm-postpublish-verify\.ts$/u, [npmPostpublish]],
|
|
[
|
|
/^scripts\/install\.ps1$/u,
|
|
["install-ps1", "website-installer-sync-workflow", crossOsReleaseChecks, changedScope],
|
|
],
|
|
[
|
|
/^scripts\/(?:crabbox-wrapper(?:-providers)?|crabbox-routing-policy|testbox-lease-freshness)\.mts$/u,
|
|
["crabbox-wrapper"],
|
|
],
|
|
[/^scripts\/crabbox-wrapper\.mjs$/u, ["crabbox-wrapper"]],
|
|
[
|
|
/^scripts\/copy-bundled-plugin-metadata\.(?:mjs|mts)$/u,
|
|
["src/plugins/copy-bundled-plugin-metadata.test.ts", runNode],
|
|
],
|
|
[
|
|
/^scripts\/github\/run-openclaw-cross-os-release-checks\.sh$/u,
|
|
["openclaw-cross-os-release-workflow"],
|
|
],
|
|
[
|
|
/^scripts\/(?:write-plugin-sdk-entry-dts\.ts|lib\/local-check-runtime\.mts)$/u,
|
|
[
|
|
"test/scripts/write-plugin-sdk-entry-dts.test.ts",
|
|
"build-all",
|
|
"declaration-stage",
|
|
"tsdown-build",
|
|
"prepare-extension-package-boundary-artifacts",
|
|
],
|
|
],
|
|
[/^scripts\/pr-lib\/worktree\.sh$/u, ["test/vitest/vitest.tooling.config.ts"]],
|
|
[/^scripts\/dev\/gateway-smoke\.ts$/u, ["test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts"]],
|
|
[/^scripts\/e2e\/mock-openai-server\.mjs$/u, ["e2e-mock-config-limits"]],
|
|
[/^apps\/android\/scripts\/build-release-artifacts\.ts$/u, ["android-release-artifacts"]],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^scripts\\/(?:auth-monitor|mobile-reauth|setup-auth-system|",
|
|
"termux-(?:auth-widget|quick-auth|sync-widget))\\.sh$|",
|
|
"^scripts\\/systemd\\/openclaw-auth-monitor\\.(?:service|timer)$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
["auth-monitor"],
|
|
],
|
|
[/^scripts\/native-app-i18n\.ts$/u, ["native-app-i18n", workflowGuards]],
|
|
[
|
|
/^scripts\/github\/(?:dependency-guard|guard-shared)\.mjs$/u,
|
|
["dependency-guard-script", "security-review-workflow"],
|
|
],
|
|
[
|
|
/^scripts\/github\/(?:security-sensitive-guard|guard-shared)\.mjs$/u,
|
|
["security-sensitive-guard-script", "security-review-workflow"],
|
|
],
|
|
[
|
|
/^\.github\/workflows\/security-review\.yml$/u,
|
|
["security-review-workflow", "security-review-event", "security-review-script", workflowGuards],
|
|
],
|
|
[
|
|
/^scripts\/github\/(?:security-review|security-review-rollout)\.mjs$/u,
|
|
["security-review-script", "security-review-rollout"],
|
|
],
|
|
[
|
|
/^scripts\/github\/(?:guard-review|security-review-policy)\.mjs$/u,
|
|
[
|
|
"dependency-guard-script",
|
|
"security-sensitive-guard-script",
|
|
"security-review-script",
|
|
"security-review-rollout",
|
|
],
|
|
],
|
|
[/^scripts\/github\/guard-shared\.mjs$/u, ["security-review-script", "security-review-event"]],
|
|
[/^scripts\/plugin-clawhub-release-check\.ts$/u, ["release-wrapper-scripts"]],
|
|
[
|
|
/^scripts\/generate-runtime-sidecar-paths-baseline\.ts$/u,
|
|
["src/plugins/bundled-plugin-metadata.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/lib\/guard-inventory-utils\.mjs$/u,
|
|
[
|
|
"test/extension-import-boundaries.test.ts",
|
|
"test/plugin-extension-import-boundary.test.ts",
|
|
"test/architecture-smells.test.ts",
|
|
"test/test-helper-extension-import-boundary.test.ts",
|
|
"extension-import-boundary-checker",
|
|
"web-fetch-provider-boundary",
|
|
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/check-workflows\.mts$/u,
|
|
["check-composite-action-input-interpolation", "check-no-conflict-markers", workflowGuards],
|
|
],
|
|
[/^apps\/android\/fastlane\/Fastfile$/u, ["android-release-fastlane-gates"]],
|
|
[/^apps\/ios\/fastlane\/Fastfile$/u, ["ios-release-fastlane-gates"]],
|
|
[/^scripts\/ios-release-cut\.(?:sh|ts)$/u, ["ios-release-plan"]],
|
|
[/^scripts\/ios-release-prepare\.sh$/u, ["ios-release-prepare", "ios-release-wrapper-args"]],
|
|
[
|
|
/^scripts\/lib\/bundled-runtime-sidecar-paths\.json$/u,
|
|
[
|
|
"src/plugins/bundled-plugin-metadata.test.ts",
|
|
"src/infra/update-global.test.ts",
|
|
"src/infra/update-runner.test.ts",
|
|
npmPostpublish,
|
|
],
|
|
],
|
|
[/^scripts\/lib\/android-version\.ts$/u, ["android-version", "android-pin-version"]],
|
|
[
|
|
/^scripts\/lib\/docker-e2e-plan\.(?:mjs|mts)$/u,
|
|
[dockerE2e, "docker-all-scheduler", pluginPrerelease],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^scripts\\/lib\\/plugin-sdk-(?:deprecated-barrel-subpaths|entrypoints|",
|
|
"private-local-only-subpaths)\\.json$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
pluginSdkEntryOwners,
|
|
],
|
|
[
|
|
/^scripts\/lib\/plugin-sdk-deprecated-public-subpaths\.json$/u,
|
|
[
|
|
"check-deprecated-api-usage",
|
|
"src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts",
|
|
"plugin-sdk-surface-report",
|
|
"build-all",
|
|
],
|
|
],
|
|
[/^scripts\/lib\/plugin-sdk-entries\.(?:mjs|mts)$/u, pluginSdkEntryOwners],
|
|
[
|
|
/^scripts\/lib\/official-external-channel-(?:catalog|seed)\.json$/u,
|
|
[
|
|
"src/plugins/official-external-plugin-catalog.test.ts",
|
|
releaseCheck,
|
|
"test/official-channel-catalog.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/lib\/official-external-provider-catalog\.json$/u,
|
|
["src/plugins/official-external-plugin-catalog.test.ts", releaseCheck],
|
|
],
|
|
[
|
|
/^scripts\/lib\/official-external-plugin-catalog\.json$/u,
|
|
["src/plugins/official-external-plugin-catalog.test.ts", releaseCheck],
|
|
],
|
|
[
|
|
/^scripts\/lib\/workspace-bootstrap-smoke\.mts$/u,
|
|
[releaseCheck, "test/openclaw-npm-release-check.test.ts"],
|
|
],
|
|
[/^scripts\/lib\/extension-test-plan\.(?:mjs|mts)$/u, ["test-extension"]],
|
|
[
|
|
/^scripts\/lib\/recommended-tool-installs\.json$/u,
|
|
["src/plugins/recommended-tool-installs.test.ts", releaseCheck],
|
|
],
|
|
[/^scripts\/docker\/install-sh-common\/version-parse\.sh$/u, [installDocker]],
|
|
[
|
|
/^scripts\/lib\/local-build-metadata(?:-paths)?\.(?:mjs|mts)$/u,
|
|
[
|
|
"src/infra/build-stamp.test.ts",
|
|
"runtime-postbuild-stamp",
|
|
runNode,
|
|
"src/infra/package-dist-inventory.test.ts",
|
|
releaseCheck,
|
|
"test/openclaw-npm-release-check.test.ts",
|
|
"check-gateway-watch-regression",
|
|
"check-openclaw-package-tarball",
|
|
crossOsReleaseChecks,
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/lib\/package-dist-imports\.mjs$/u,
|
|
[
|
|
"check-package-dist-imports",
|
|
"check-openclaw-package-tarball",
|
|
"postinstall-bundled-plugins",
|
|
releaseCheck,
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/lib\/build-metadata\.sh$/u,
|
|
[
|
|
"test/scripts/docker-setup.test.ts",
|
|
"apple-release-source-check",
|
|
"ios-version",
|
|
"package-mac-app",
|
|
installDocker,
|
|
],
|
|
],
|
|
[/^scripts\/lib\/plistbuddy\.sh$/u, ["create-dmg", "package-mac-app", "package-mac-dist"]],
|
|
[
|
|
/^scripts\/lib\/swift-toolchain\.sh$/u,
|
|
["package-mac-app", "package-mac-dist", "xcode-test-logs"],
|
|
],
|
|
[/^scripts\/stage-cua-driver-macos\.sh$/u, ["package-mac-app"]],
|
|
[
|
|
/^scripts\/(stage-cloudflared-macos\.sh|lib\/cloudflared-macos\.json)$/u,
|
|
["stage-cloudflared-macos", "package-mac-app"],
|
|
],
|
|
[
|
|
/^scripts\/lib\/npm-publish-plan\.mjs$/u,
|
|
[
|
|
"test/npm-publish-plan.test.ts",
|
|
"test/openclaw-npm-release-check.test.ts",
|
|
npmPostpublish,
|
|
"test/plugin-npm-release.test.ts",
|
|
"test/plugin-clawhub-release.test.ts",
|
|
"release-upgrade-baseline",
|
|
"android-version",
|
|
"ios-version",
|
|
"upgrade-survivor-baselines",
|
|
"upgrade-survivor-config-recipe",
|
|
],
|
|
],
|
|
[/^scripts\/lib\/npm-pack-budget\.mts$/u, [releaseCheck, installDocker]],
|
|
[
|
|
/^scripts\/lib\/actions-artifact-archive\.mjs$/u,
|
|
["full-release-candidate-reuse", "plugin-publication-artifact"],
|
|
],
|
|
[
|
|
/^scripts\/(?:lib\/)?full-release-candidate-reuse\.(?:mjs|d\.mts)$/u,
|
|
["full-release-candidate-reuse"],
|
|
],
|
|
[
|
|
/^scripts\/lib\/static-extension-assets\.(?:mjs|mts)$/u,
|
|
["bundled-plugin-assets", "runtime-postbuild", runNode, "plugin-npm-runtime-build-args"],
|
|
],
|
|
[
|
|
/^scripts\/lib\/plugin-npm-runtime-build\.(?:mjs|mts)$/u,
|
|
["plugin-npm-runtime-build-args", "test/plugin-npm-runtime-build.test.ts"],
|
|
],
|
|
[/^scripts\/lib\/run-node\.(?:mjs|mts)$/u, [runNode]],
|
|
[
|
|
/^\.agents\/skills\/openclaw-changelog-update\/scripts\/verify-release-notes\.mjs$/u,
|
|
["release-notes-ledger", "verify-release-notes"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/docker-stats\/assert-resource-ceiling\.mjs$/u,
|
|
["docker-stats-resource-ceiling"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/mcp-channels-docker\.sh$/u,
|
|
[dockerBuild, "docker-e2e-observability", dockerE2e, pluginPrerelease],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u,
|
|
[dockerBuild, "docker-e2e-observability", dockerE2e, pluginPrerelease],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/(?:mcp-code-mode-gateway-(?:live-)?docker|lib\/mcp-code-mode\/scenario)\.sh$/u,
|
|
[
|
|
dockerBuild,
|
|
dockerE2e,
|
|
pluginPrerelease,
|
|
"mcp-code-mode-gateway-client",
|
|
"session-log-mentions",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/agent-bundle-mcp-tools-docker\.sh$/u,
|
|
[
|
|
dockerBuild,
|
|
dockerE2e,
|
|
pluginPrerelease,
|
|
"src/agents/agent-bundle-mcp-runtime.test.ts",
|
|
"src/agents/agent-bundle-mcp-tools.materialize.test.ts",
|
|
],
|
|
],
|
|
[/^scripts\/lib\/tsgo-sparse-guard\.(?:mjs|mts)$/u, ["run-tsgo", "changed-lanes"]],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^(?:scripts\\/materialize-clawhub-cli\\.sh|",
|
|
"\\.github\\/release\\/clawhub-cli\\/package(?:-lock)?\\.json)$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[packageAcceptance, "plugin-clawhub-new-workflow"],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^(?:scripts\\/materialize-vercel-cli\\.sh|",
|
|
"\\.github\\/release\\/vercel-cli\\/package(?:-lock)?\\.json)$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
["test/scripts/vercel-container-registry-publish.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/lib\/generated-text-asset\.mts$/u,
|
|
["build-diffs-viewer-runtime", "bundled-plugin-assets"],
|
|
],
|
|
[/^scripts\/check-plugin-npm-runtime-builds\.mts$/u, ["plugin-npm-runtime-build-args"]],
|
|
[
|
|
/^scripts\/install\.sh$/u,
|
|
[
|
|
"install-sh",
|
|
installDocker,
|
|
"website-installer-sync-workflow",
|
|
crossOsReleaseChecks,
|
|
changedScope,
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/sparkle-build\.ts$/u,
|
|
["test/appcast.test.ts", releaseCheck, "package-mac-app", "package-mac-dist"],
|
|
],
|
|
[
|
|
/^test\/vitest\/vitest\.contracts-paths\.mjs$/u,
|
|
[
|
|
"test-projects",
|
|
"test/vitest-projects-config.test.ts",
|
|
"test/vitest/vitest.contracts-channel-surface.config.ts",
|
|
"test/vitest/vitest.contracts-channel-config.config.ts",
|
|
"test/vitest/vitest.contracts-channel-registry.config.ts",
|
|
"test/vitest/vitest.contracts-channel-session.config.ts",
|
|
],
|
|
],
|
|
[/^scripts\/e2e\/openai-chat-tools-docker\.sh$/u, [openaiChatToolsE2e, dockerE2e]],
|
|
[
|
|
/^scripts\/e2e\/session-runtime-context-docker\.sh$/u,
|
|
[
|
|
dockerE2e,
|
|
"src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts",
|
|
"src/agents/embedded-agent-runner/transcript-rewrite.test.ts",
|
|
],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^scripts\\/e2e\\/(?!(?:config-reload-source|",
|
|
"kitchen-sink-(?:plugin|rpc)|npm-telegram-live|onboard|openai-chat-tools|",
|
|
"plugin-lifecycle-matrix|release-media-memory|session-runtime-context|",
|
|
"update-corrupt-plugin)-docker\\.sh$).+-docker\\.sh$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[dockerBuild, dockerE2e],
|
|
],
|
|
[/^scripts\/e2e\/kitchen-sink-rpc-docker\.sh$/u, [dockerBuild, pluginPrerelease]],
|
|
[/^scripts\/e2e\/kitchen-sink-plugin-docker\.sh$/u, [dockerBuild, pluginPrerelease]],
|
|
[/^scripts\/e2e\/plugin-lifecycle-matrix-docker\.sh$/u, [dockerBuild]],
|
|
[
|
|
/^scripts\/e2e\/release-media-memory-docker\.sh$/u,
|
|
[dockerE2e, "release-media-memory-scenario"],
|
|
],
|
|
[/^scripts\/e2e\/codex-media-path-docker\.sh$/u, ["codex-media-path-client"]],
|
|
[/^scripts\/e2e\/live-plugin-tool-docker\.sh$/u, ["live-plugin-tool-assertions"]],
|
|
[/^scripts\/e2e\/onboard-docker\.sh$/u, [dockerBuild, "openclaw-test-state"]],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^scripts\\/e2e\\/(?:codex-npm-plugin-live|config-reload-source|",
|
|
"gateway-network|npm-onboard-channel-agent|doctor-install-switch|",
|
|
"update-channel-switch|skill-install|upgrade-survivor|",
|
|
"update-corrupt-plugin|release-(?:plugin-marketplace|typed-onboarding|",
|
|
"upgrade-user-journey|user-journey)|plugin-binding-command-escape)-docker",
|
|
"\\.sh$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[packageAcceptance],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/npm-onboard-channel-agent-docker\.sh$/u,
|
|
["npm-onboard-channel-agent-assertions"],
|
|
],
|
|
[/^scripts\/docker\/cleanup-smoke\/Dockerfile$/u, [dockerCache, dockerDigests, dockerBuild]],
|
|
[
|
|
/^scripts\/docker\/install-sh-(?:e2e|nonroot|smoke)\/Dockerfile$/u,
|
|
[dockerCache, dockerDigests, installDocker],
|
|
],
|
|
[
|
|
/^scripts\/docker\/sandbox\/Dockerfile$/u,
|
|
[dockerCache, dockerDigests, "src/dockerfile.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/docker\/sandbox\/Dockerfile\.browser$/u,
|
|
[dockerCache, dockerDigests, "src/agents/sandbox/browser.create.test.ts"],
|
|
],
|
|
[/^scripts\/docker\/sandbox\/Dockerfile\.common$/u, [dockerCache]],
|
|
[/^scripts\/e2e\/Dockerfile$/u, [dockerCache, dockerDigests, dockerBuild, dockerE2e]],
|
|
[/^scripts\/e2e\/Dockerfile\.qr-import$/u, [dockerCache, dockerDigests, dockerBuild]],
|
|
[
|
|
/^scripts\/e2e\/plugin-binding-command-escape\.Dockerfile$/u,
|
|
[dockerDigests, dockerBuild, dockerE2e],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^(?:scripts\\/e2e|test\\/e2e\\/qa-lab\\/runtime)\\/agent-bundle-mcp-tools-doc",
|
|
"ker(?:-client)?\\.(?:sh|ts)$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[
|
|
"src/agents/agent-bundle-mcp-runtime.test.ts",
|
|
"src/agents/agent-bundle-mcp-tools.materialize.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^test\/e2e\/qa-lab\/runtime\/system-agent-first-run-docker-client\.ts$/u,
|
|
[
|
|
"src/cli/program/register.onboard.test.ts",
|
|
"src/cli/run-main.test.ts",
|
|
"src/cli/run-main.exit.test.ts",
|
|
"src/commands/system-agent-with-inference.test.ts",
|
|
"src/system-agent/assistant.configured.test.ts",
|
|
"src/system-agent/assistant.test.ts",
|
|
"src/system-agent/system-agent.test.ts",
|
|
"src/system-agent/operations.test.ts",
|
|
"src/system-agent/overview.test.ts",
|
|
"src/system-agent/audit.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/system-agent-first-run-spec\.json$/u,
|
|
["src/system-agent/operations.test.ts", "src/system-agent/audit.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/system-agent-rescue-docker-client\.ts$/u,
|
|
[
|
|
"src/system-agent/rescue-policy.test.ts",
|
|
"src/system-agent/rescue-message.test.ts",
|
|
"src/system-agent/operations.test.ts",
|
|
"src/system-agent/audit.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/session-runtime-context-docker(?:-client)?\.(?:sh|ts)$/u,
|
|
[
|
|
"src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts",
|
|
"src/agents/embedded-agent-runner/transcript-rewrite.test.ts",
|
|
],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^(?:scripts\\/e2e\\/(?!(?:cron-mcp-cleanup-docker-client\\.ts)$)(?:agent-bu",
|
|
"ndle-mcp-tools|mcp-[^/]+-docker|cron-mcp-cleanup-docker|",
|
|
"bundled-plugin-install-uninstall|plugin-update-unchanged).+|",
|
|
"test\\/e2e\\/qa-lab\\/runtime\\/mcp-channels-docker-client\\.ts)$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
[pluginPrerelease, dockerE2e],
|
|
],
|
|
[
|
|
new RegExp(
|
|
[
|
|
"^(?:scripts\\/e2e\\/mcp-code-mode-gateway-(?:docker|live-docker|",
|
|
"docker-client).+|scripts\\/mcp-code-mode-gateway-e2e\\.ts)$",
|
|
].join(""),
|
|
"u",
|
|
),
|
|
["mcp-code-mode-gateway-client", "session-log-mentions"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/(?:mcp-channels|cron-cli|cron-mcp-cleanup)-docker\.sh$/u,
|
|
["docker-e2e-observability"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/cron-mcp-cleanup-docker-client\.ts$/u,
|
|
[
|
|
"cron-mcp-cleanup-docker-client",
|
|
"src/gateway/server.cron.test.ts",
|
|
"src/gateway/server-methods/agent.test.ts",
|
|
"src/cron/isolated-agent/run.fast-mode.test.ts",
|
|
"src/cron/active-jobs-manual-run.test.ts",
|
|
],
|
|
],
|
|
[/^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u, ["cron-mcp-cleanup-docker-client"]],
|
|
[
|
|
/^test\/e2e\/qa-lab\/runtime\/mcp-channels\.fixture\.ts$/u,
|
|
["test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", "cron-mcp-cleanup-docker-client"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/auth-profile-store-assertions\.mjs$/u,
|
|
["release-scenarios-assertions", "npm-onboard-channel-agent-assertions"],
|
|
],
|
|
[
|
|
/^scripts\/lib\/plugin-npm-package-manifest\.(?:mjs|mts)$/u,
|
|
["plugin-npm-package-manifest-args", "test/plugin-npm-package-manifest.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/release-assertion-files\.mjs$/u,
|
|
["release-scenarios-assertions", "release-user-journey-assertions"],
|
|
],
|
|
[/^scripts\/e2e\/lib\/fixtures\/config\.mjs$/u, ["fixture-config"]],
|
|
[/^scripts\/e2e\/lib\/fixtures\/plugins\.mjs$/u, ["fixture-plugin-commands"]],
|
|
[
|
|
/^scripts\/e2e\/lib\/codex-app-server-fixture\.mjs$/u,
|
|
["codex-media-path-client", "test/e2e/qa-lab/runtime/codex-auth-product-proof.e2e.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/incremental-line-reader\.mjs$/u,
|
|
[
|
|
"incremental-line-reader",
|
|
"config-reload-log-scanner",
|
|
"codex-media-path-client",
|
|
"test/e2e/qa-lab/runtime/codex-auth-product-proof.e2e.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/fixture\.mjs$/u,
|
|
["fixture-config", "fixtures-workspace", "fixture-plugin-commands"],
|
|
],
|
|
[
|
|
/^test\/e2e\/qa-lab\/runtime\/openai-image-auth-docker-client\.ts$/u,
|
|
[
|
|
"openai-image-auth-docker-client",
|
|
"extensions/openai/image-generation-provider.test.ts",
|
|
"src/image-generation/openai-compatible-image-provider.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^test\/e2e\/qa-lab\/runtime\/codex-auth-app-server\.fixture\.mjs$/u,
|
|
["test/e2e/qa-lab/runtime/codex-auth-product-proof.e2e.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/openai-web-search-minimal\/(?:mock-server\.mjs|scenario\.sh)$/u,
|
|
[
|
|
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
|
|
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/openwebui\/http-probe\.mjs$/u,
|
|
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/(?:browser-cdp-snapshot-docker\.sh|lib\/env-limits\.mjs)$/u,
|
|
["e2e-helper-env-limits"],
|
|
],
|
|
[/^scripts\/e2e\/skill-install-docker\.sh$/u, ["e2e-shell-tempfiles"]],
|
|
[/^scripts\/e2e\/npm-onboard-channel-agent-docker\.sh$/u, [pluginPrerelease]],
|
|
[
|
|
/^scripts\/e2e\/lib\/clawhub-fixture-server\.cjs$/u,
|
|
["clawhub-fixture-server", pluginPrerelease],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/codex-media-path\/jsonl-request-tail\.mts$/u,
|
|
["codex-media-path-client", "test/e2e/qa-lab/runtime/codex-auth-product-proof.e2e.test.ts"],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/lib\/codex-npm-plugin-live\/assertions\.mjs$/u,
|
|
["codex-install-assertions", dockerBuild],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/(?:lib\/openai-chat-tools\/scenario\.sh|openai-chat-tools-docker\.sh)$/u,
|
|
[openaiChatToolsE2e],
|
|
],
|
|
[/^scripts\/e2e\/lib\/openai-chat-tools\/write-config\.mjs$/u, [openaiChatToolsE2e]],
|
|
[
|
|
/^scripts\/e2e\/openai-web-search-minimal-docker\.sh$/u,
|
|
[
|
|
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
|
|
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
|
|
],
|
|
],
|
|
[
|
|
/^scripts\/e2e\/openwebui-docker\.sh$/u,
|
|
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts", "fixture-config"],
|
|
],
|
|
];
|
|
function resolveSemanticToolingTargets(changedPath: string) {
|
|
return SEMANTIC_TOOLING_TARGET_PATTERNS.flatMap(([pattern, owners]) =>
|
|
pattern.test(changedPath) ? resolveToolingTestOwnerTargets(...owners) : [],
|
|
);
|
|
}
|
|
|
|
function isWorkflowLintConfigPath(changedPath: string) {
|
|
return changedPath === ".github/actionlint.yaml";
|
|
}
|
|
|
|
function isGithubWorkflowOrActionYaml(changedPath: string) {
|
|
return (
|
|
/^\.github\/workflows\/[^/]+\.ya?ml$/u.test(changedPath) ||
|
|
/^\.github\/actions\/.+\.ya?ml$/u.test(changedPath)
|
|
);
|
|
}
|
|
|
|
function resolveGithubYamlGuardTargets(changedPath: string) {
|
|
if (isGithubWorkflowOrActionYaml(changedPath)) {
|
|
return GITHUB_YAML_PINNING_GUARD_TEST_TARGETS;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveDirectToolingReferenceTests(changedPath: string | string[], cwd: string) {
|
|
const changedPaths = typeof changedPath === "string" ? [changedPath] : changedPath;
|
|
const matches = listImportGraphGrepMatches(cwd, changedPaths, {
|
|
tooling: true,
|
|
testFilesOnly: true,
|
|
});
|
|
return changedPaths.flatMap((filePath) =>
|
|
(matches.get(filePath) ?? [])
|
|
.filter(
|
|
({ file, references }) =>
|
|
file !== "test/scripts/test-projects.test.ts" &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
isTestFileTarget(file) &&
|
|
references.has(filePath),
|
|
)
|
|
.map(({ file }) => file),
|
|
);
|
|
}
|
|
|
|
function hasToolingSourceOwner(changedPath: string, implementationPath: string): boolean {
|
|
const facts = getChangedPathFacts(changedPath);
|
|
return (
|
|
facts.surface === "rootTooling" ||
|
|
changedPath === "Dockerfile" ||
|
|
changedPath === ".crabbox.yaml" ||
|
|
changedPath.startsWith(".agents/") ||
|
|
isToolingScriptPath(implementationPath) ||
|
|
(facts.surface === "app" && /\/(?:fastlane|scripts)\//u.test(changedPath)) ||
|
|
(facts.surface === "extension" && /\/(?:scripts\/|package\.json$)/u.test(changedPath)) ||
|
|
(facts.surface === "rootTest" && changedPath.startsWith("test/e2e/qa-lab/"))
|
|
);
|
|
}
|
|
|
|
/** Inputs that retain the full maintainer-tooling family in automatic CI. */
|
|
export function isToolingTestOwnerPath(changedPath: string): boolean {
|
|
const implementationPath = changedPath.endsWith(".d.mts")
|
|
? changedPath.replace(/\.d\.mts$/u, ".mjs")
|
|
: changedPath;
|
|
const facts = getChangedPathFacts(changedPath);
|
|
return (
|
|
isToolingIsolatedTestFile(changedPath) ||
|
|
changedPath.startsWith("scripts/") ||
|
|
changedPath.startsWith("src/scripts/") ||
|
|
changedPath.startsWith("config/ci-") ||
|
|
changedPath.startsWith(".github/") ||
|
|
changedPath.startsWith("test/scripts/") ||
|
|
facts.surface === "rootGlobal" ||
|
|
facts.surface === "rootTest" ||
|
|
facts.surface === "testFixture" ||
|
|
facts.surface === "legacyRootAsset" ||
|
|
facts.surface === "unknown" ||
|
|
EXACT_TOOLING_TARGETS.has(implementationPath) ||
|
|
resolveSemanticToolingTargets(implementationPath).length > 0 ||
|
|
hasToolingSourceOwner(changedPath, implementationPath)
|
|
);
|
|
}
|
|
|
|
function resolveToolingTestTargets(
|
|
changedPath: string,
|
|
cwd = process.cwd(),
|
|
options: ChangedTestTargetOptions = {},
|
|
) {
|
|
if (
|
|
/^test\/scripts\/(?:ci-(?:checkout|git-owner|linux-git|platform-checkout|windows-process-census)\.test(?:-support)?\.ts|generated-publisher\.test-support\.ts|openclaw-performance-(?:workflow\.test(?:-support)?|git-lifecycle\.test)\.ts|plugin-release-git-lifecycle\.test\.ts|release-workflow-git-lifecycle\.test\.ts|fixtures\/(?:ci-platform-checkout\.mjs|ci-checkout-auth\.py|ci-windows-process-census\.(?:mjs|py)))$/u.test(
|
|
changedPath,
|
|
)
|
|
) {
|
|
return resolveToolingTestOwnerTargets(
|
|
"ci-git-owner",
|
|
"ci-linux-git",
|
|
"ci-platform-checkout",
|
|
"openclaw-performance-workflow",
|
|
"openclaw-performance-git-lifecycle",
|
|
"plugin-release-git-lifecycle",
|
|
"release-workflow-git-lifecycle",
|
|
workflowGuards,
|
|
);
|
|
}
|
|
if (changedPath.startsWith("test/scripts/") && isTestFileTarget(changedPath)) {
|
|
return [changedPath];
|
|
}
|
|
if (
|
|
!options.boundedOwners &&
|
|
BROAD_CHANGED_FALLBACK_PATTERNS.some((pattern) => pattern.test(changedPath))
|
|
) {
|
|
return null;
|
|
}
|
|
// Test-runner declarations still share their implementation owner. Script
|
|
// declaration sidecars are gone, so this no longer aliases scripts/*.d.mts.
|
|
const implementationPath =
|
|
!changedPath.startsWith("scripts/") && changedPath.endsWith(".d.mts")
|
|
? changedPath.replace(/\.d\.mts$/u, ".mjs")
|
|
: changedPath;
|
|
const githubYaml = isGithubWorkflowOrActionYaml(implementationPath);
|
|
const exactOwners = EXACT_TOOLING_TARGETS.get(implementationPath);
|
|
if (exactOwners && !githubYaml && !options.boundedOwners) {
|
|
return resolveToolingTestOwnerTargets(...exactOwners);
|
|
}
|
|
const boundedOwner = (target: string) =>
|
|
!options.boundedOwners || target !== TOOLING_VITEST_CONFIG;
|
|
const exactTargets = exactOwners
|
|
? resolveToolingTestOwnerTargets(...exactOwners).filter(boundedOwner)
|
|
: [];
|
|
const semanticTargets = resolveSemanticToolingTargets(implementationPath).filter(boundedOwner);
|
|
const facts = getChangedPathFacts(changedPath);
|
|
const toolingTestSource =
|
|
(changedPath.startsWith("test/scripts/") ||
|
|
(!options.boundedOwners && /^test\/vitest\/vitest\.[^/]+-paths\.mjs$/u.test(changedPath))) &&
|
|
TOOLING_IMPORTABLE_FILE_EXTENSIONS.some((ext) => implementationPath.endsWith(ext));
|
|
const hasToolingOwner =
|
|
exactTargets.length > 0 ||
|
|
semanticTargets.length > 0 ||
|
|
toolingTestSource ||
|
|
hasToolingSourceOwner(changedPath, implementationPath);
|
|
if (!hasToolingOwner) {
|
|
return null;
|
|
}
|
|
const crossOsReleaseTargets =
|
|
implementationPath === "scripts/openclaw-cross-os-release-checks.ts" ||
|
|
implementationPath.startsWith("scripts/lib/cross-os-release-checks/")
|
|
? ["test/scripts/openclaw-cross-os-release-checks.test.ts"]
|
|
: null;
|
|
const explicitTargets =
|
|
(changedPath === "Dockerfile"
|
|
? [
|
|
"src/docker-build-cache.test.ts",
|
|
"src/docker-image-digests.test.ts",
|
|
"src/dockerfile.test.ts",
|
|
"test/scripts/test-install-sh-docker.test.ts",
|
|
]
|
|
: changedPath === ".crabbox.yaml"
|
|
? ["test/scripts/package-acceptance-workflow.test.ts"]
|
|
: null) ??
|
|
crossOsReleaseTargets ??
|
|
resolveUpgradeSurvivorConfigRecipeTargets(implementationPath) ??
|
|
resolveDocsI18nBehaviorTargets(implementationPath) ??
|
|
resolveDocsI18nGoTargets(implementationPath) ??
|
|
resolveK8sManifestTargets(implementationPath) ??
|
|
resolveParallelsToolingTestTargets(implementationPath);
|
|
const githubYamlGuardTargets = resolveGithubYamlGuardTargets(implementationPath);
|
|
const conventionalTargets = resolveConventionalToolingTestTargets(implementationPath, cwd);
|
|
const hasDirectOwner = Boolean(
|
|
exactTargets.length ||
|
|
explicitTargets?.length ||
|
|
githubYamlGuardTargets?.length ||
|
|
semanticTargets.length ||
|
|
conventionalTargets?.length,
|
|
);
|
|
const importGraphResult =
|
|
!options.boundedOwners &&
|
|
!hasDirectOwner &&
|
|
(options.forceFullImportGraph ||
|
|
TOOLING_IMPORTABLE_FILE_EXTENSIONS.some((ext) => implementationPath.endsWith(ext)))
|
|
? options.forceFullImportGraph
|
|
? resolveAffectedTestsFromImportGraph(implementationPath, cwd, {
|
|
tooling: true,
|
|
forceFull: true,
|
|
resolveAliases: options.resolveAliases,
|
|
runtimeOnly: options.runtimeOnly,
|
|
})
|
|
: resolveAffectedTestsFromTargetedImportScan(implementationPath, cwd, {
|
|
tooling: true,
|
|
direct: !toolingTestSource,
|
|
})
|
|
: [];
|
|
if (toolingTestSource && importGraphResult === null) {
|
|
// Keep caller fallbacks; a partial literal reference cannot prove an opaque frontier.
|
|
return null;
|
|
}
|
|
const importGraphTargets = importGraphResult ?? [];
|
|
const referenceTargets =
|
|
githubYaml || (semanticTargets.length === 0 && !hasDirectOwner)
|
|
? resolveDirectToolingReferenceTests(implementationPath, cwd)
|
|
: [];
|
|
const targets = [
|
|
...(!hasDirectOwner && isRoutableChangedTarget(changedPath) && isTestFileTarget(changedPath)
|
|
? [changedPath]
|
|
: []),
|
|
...exactTargets,
|
|
...(explicitTargets ?? []),
|
|
...semanticTargets,
|
|
...(conventionalTargets ?? []),
|
|
...importGraphTargets,
|
|
...referenceTargets,
|
|
...(githubYamlGuardTargets ?? []),
|
|
// Root aliases also control native bundling; keep the existing tooling owners.
|
|
...(changedPath === "tsconfig.json" ? MERMAID_RENDERER_TEST_TARGETS : []),
|
|
];
|
|
if (targets.length > 0 || isWorkflowLintConfigPath(implementationPath)) {
|
|
return uniqueOrdered(targets);
|
|
}
|
|
return !options.boundedOwners &&
|
|
(isToolingScriptPath(implementationPath) || facts.surface === "rootTooling")
|
|
? [TOOLING_VITEST_CONFIG]
|
|
: null;
|
|
}
|
|
|
|
function shouldUseBroadChangedTargets(env = process.env) {
|
|
return parsePermissiveBooleanToken(env[BROAD_CHANGED_ENV_KEY]) === true;
|
|
}
|
|
|
|
export function isRoutableChangedTarget(changedPath: string) {
|
|
if (GENERATED_CHANGED_TEST_TARGET_PATTERNS.some((pattern) => pattern.test(changedPath))) {
|
|
return false;
|
|
}
|
|
if (changedPath.endsWith(".live.test.ts")) {
|
|
return false;
|
|
}
|
|
const surface = getChangedPathFacts(changedPath).surface;
|
|
return (
|
|
["source", "package", "extension", "rootTest"].includes(surface) ||
|
|
changedPath === "ui" ||
|
|
changedPath.startsWith("ui/") ||
|
|
["src", "test", "extensions", "packages"].includes(changedPath)
|
|
);
|
|
}
|
|
|
|
function resolveSiblingTestTarget(changedPath: string, cwd: string) {
|
|
if (!/\.[cm]?tsx?$/u.test(changedPath) || isTestFileTarget(changedPath)) {
|
|
return null;
|
|
}
|
|
const withoutExtension = changedPath.replace(/\.[cm]?tsx?$/u, "");
|
|
const sibling = `${withoutExtension}.test.ts`;
|
|
return fs.existsSync(path.join(cwd, sibling)) ? sibling : null;
|
|
}
|
|
|
|
function shouldCombineSiblingTestWithImportGraph(changedPath: string) {
|
|
const sourcePrefix = `${changedPath.replace(/\.[cm]?tsx?$/u, "")}.`;
|
|
return (
|
|
changedPath.startsWith("test/helpers/") ||
|
|
databaseWorkerExtensionTestFiles.some((file) => file.startsWith(sourcePrefix))
|
|
);
|
|
}
|
|
|
|
function shouldRouteChangedTargetWithoutImportGraph(changedPath: string) {
|
|
return changedPath.endsWith(".live.test.ts") || isControlUiSourcePath(changedPath);
|
|
}
|
|
|
|
function resolvePromptSnapshotFixtureTargets(changedPath: string) {
|
|
if (
|
|
!/^test\/fixtures\/agents\/prompt-snapshots\/.+\.(?:json|md(?:\.diff)?)$/u.test(changedPath)
|
|
) {
|
|
return null;
|
|
}
|
|
return ["test/scripts/prompt-snapshots.test.ts"];
|
|
}
|
|
|
|
function resolvePackageFixtureTargets(changedPath: string, cwd: string) {
|
|
const match = /^packages\/([^/]+)\/test\/fixtures\/([^/]+)\/.+$/u.exec(changedPath);
|
|
const packageName = match?.[1];
|
|
const fixtureFamily = match?.[2];
|
|
if (!packageName || !fixtureFamily) {
|
|
return null;
|
|
}
|
|
const owner = `packages/${packageName}/src/${fixtureFamily}.test.ts`;
|
|
return fs.existsSync(path.join(cwd, owner)) ? [owner] : null;
|
|
}
|
|
|
|
function resolveAppcastTargets(changedPath: string) {
|
|
return /^appcast(?:-(?:arm64|x86_64))?\.xml$/u.test(changedPath) ? APPCAST_TEST_TARGETS : null;
|
|
}
|
|
|
|
function resolveKovaSchemaTestTargets(changedPath: string) {
|
|
// The workflow fixture reads schema bytes, so imports cannot express this dependency.
|
|
return changedPath === "src/config/zod-schema.agent-defaults.ts" ||
|
|
changedPath === "src/config/zod-schema.agent-defaults-base.ts"
|
|
? ["test/scripts/openclaw-performance-workflow.test.ts"]
|
|
: [];
|
|
}
|
|
|
|
function resolvePreciseChangedTestTargets(
|
|
changedPath: string,
|
|
options: ChangedTestTargetOptions & { skipImportGraph?: boolean },
|
|
) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const pluginSdkInclude = resolvePluginSdkLightIncludePattern(changedPath);
|
|
const mappedTargets =
|
|
SOURCE_TEST_TARGETS.get(changedPath) ??
|
|
(pluginSdkInclude
|
|
? pluginSdkLightTestFiles.filter((file) => path.matchesGlob(file, pluginSdkInclude))
|
|
: null) ??
|
|
(/^extensions\/[^/]+\/openclaw\.plugin\.json$/u.test(changedPath)
|
|
? [changedPath, DOCS_CONFIG_EXAMPLES_TEST_TARGET]
|
|
: null) ??
|
|
resolveToolingTestTargets(changedPath, cwd, options) ??
|
|
resolveAppcastTargets(changedPath) ??
|
|
resolvePromptSnapshotFixtureTargets(changedPath) ??
|
|
resolvePackageFixtureTargets(changedPath, cwd);
|
|
if (mappedTargets) {
|
|
return mappedTargets;
|
|
}
|
|
if (isRoutableChangedTarget(changedPath) && isTestFileTarget(changedPath)) {
|
|
return [changedPath];
|
|
}
|
|
const siblingTest = resolveSiblingTestTarget(changedPath, cwd);
|
|
if (
|
|
siblingTest &&
|
|
!shouldCombineSiblingTestWithImportGraph(changedPath) &&
|
|
options.combineSiblingWithImportGraph !== true
|
|
) {
|
|
return [siblingTest];
|
|
}
|
|
if (!options.boundedOwners && shouldRouteChangedTargetWithoutImportGraph(changedPath)) {
|
|
return isControlUiSourcePath(changedPath) ? [changedPath] : null;
|
|
}
|
|
if (options.skipImportGraph === true) {
|
|
return options.boundedOwners && siblingTest ? [siblingTest] : null;
|
|
}
|
|
const facts = getChangedPathFacts(changedPath);
|
|
if (
|
|
facts.surface === "source" ||
|
|
facts.surface === "package" ||
|
|
facts.surface === "extension" ||
|
|
changedPath.startsWith("test/helpers/") ||
|
|
changedPath.startsWith("ui/src/") ||
|
|
changedPath.startsWith("ui/config/")
|
|
) {
|
|
const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd, {
|
|
forceFull: options.forceFullImportGraph === true,
|
|
resolveAliases: options.resolveAliases,
|
|
runtimeOnly: options.runtimeOnly,
|
|
});
|
|
if (affectedTests.length > 0) {
|
|
return siblingTest ? uniqueOrdered([siblingTest, ...affectedTests]) : affectedTests;
|
|
}
|
|
}
|
|
return siblingTest ? [siblingTest] : null;
|
|
}
|
|
|
|
function isDeletedChangedTestTarget(changedPath: string, cwd: string) {
|
|
return isTestFileTarget(changedPath) && !fs.existsSync(path.join(cwd, changedPath));
|
|
}
|
|
|
|
function resolveConventionalChangedOwnerTargets(
|
|
changedPath: string,
|
|
cwd: string,
|
|
maxDirectoryTests?: number,
|
|
) {
|
|
if (!isRoutableChangedTarget(changedPath) || changedPath.endsWith(".live.test.ts")) {
|
|
return [];
|
|
}
|
|
const stem = changedPath.replace(/\.[cm]?[jt]sx?$/u, "");
|
|
const files = listImportGraphFilesForCwd(cwd, { tooling: true });
|
|
if (maxDirectoryTests !== undefined) {
|
|
const directory = path.posix.dirname(changedPath);
|
|
const adjacent = files.filter(
|
|
(file) =>
|
|
path.posix.dirname(file) === directory &&
|
|
isTestFileTarget(file) &&
|
|
!file.endsWith(".live.test.ts"),
|
|
);
|
|
return adjacent.length <= maxDirectoryTests
|
|
? adjacent
|
|
: adjacent.filter((file) => file.startsWith(stem));
|
|
}
|
|
const siblings = files.filter(
|
|
(file) =>
|
|
file.startsWith(`${stem}.`) && isTestFileTarget(file) && !file.endsWith(".live.test.ts"),
|
|
);
|
|
if (siblings.length > 0) {
|
|
return siblings;
|
|
}
|
|
if (shouldKeepBroadChangedRun([changedPath])) {
|
|
return [];
|
|
}
|
|
const directory = path.posix.dirname(changedPath);
|
|
// Root leaves have no narrower conventional owner; their importers and smoke
|
|
// coverage must not turn into a whole-repository directory selection.
|
|
return [".", "src", "test", "extensions", "packages", "ui"].includes(directory)
|
|
? []
|
|
: [directory];
|
|
}
|
|
|
|
const TOOLING_TEST_OWNER_AREAS = ["scripts", "src/scripts", "test/scripts"];
|
|
|
|
function resolveChangedTestOwnerAreas(targets: string[], cwd: string): string[] {
|
|
return uniqueOrdered(
|
|
targets.flatMap((target) => {
|
|
if (RUNNABLE_VITEST_CONFIG_TARGETS.has(target) || isGlobTarget(target)) {
|
|
return [];
|
|
}
|
|
if (isExistingDirectoryTarget(target, cwd) || !isLikelyFileTarget(target)) {
|
|
return [target];
|
|
}
|
|
if (
|
|
target.startsWith("scripts/") ||
|
|
target.startsWith("src/scripts/") ||
|
|
target.startsWith("test/scripts/")
|
|
) {
|
|
return TOOLING_TEST_OWNER_AREAS;
|
|
}
|
|
const { surface } = getChangedPathFacts(target);
|
|
if (surface === "ui") {
|
|
return ["ui"];
|
|
}
|
|
const parts = target.split("/");
|
|
if (
|
|
(surface === "source" || surface === "package" || surface === "extension") &&
|
|
parts.length > 2
|
|
) {
|
|
return [parts.slice(0, 2).join("/")];
|
|
}
|
|
return [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function resolveBoundedChangedTestTargetPlan(
|
|
changedPaths: string[],
|
|
options: ChangedTestTargetOptions,
|
|
): ChangedTestTargetPlan {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const targets: string[] = [];
|
|
const ownerTargets: string[] = [];
|
|
const ownerAreas: string[] = [];
|
|
const graphOptions = {
|
|
tooling: true,
|
|
resolveAliases: options.resolveAliases,
|
|
runtimeOnly: options.runtimeOnly,
|
|
};
|
|
const onlyTestPaths = changedPaths.every(isTestFileTarget);
|
|
const testsHaveConsumers =
|
|
onlyTestPaths && hasImportGraphConsumers(changedPaths, cwd, graphOptions);
|
|
let graph: ImportGraph | undefined;
|
|
for (const changedPath of changedPaths) {
|
|
const mappedOwners = resolvePreciseChangedTestTargets(changedPath, {
|
|
...options,
|
|
skipImportGraph: true,
|
|
})?.flatMap((target) =>
|
|
!isTestFileTarget(target) &&
|
|
!isGlobTarget(target) &&
|
|
!RUNNABLE_VITEST_CONFIG_TARGETS.has(target) &&
|
|
!isExistingDirectoryTarget(target, cwd) &&
|
|
isLikelyFileTarget(target)
|
|
? resolveConventionalChangedOwnerTargets(target, cwd, options.aggressive?.maxDirectoryTests)
|
|
: [target],
|
|
);
|
|
const pluginPackageOwner =
|
|
getChangedPathFacts(changedPath).surface === "extension" &&
|
|
path.posix.basename(changedPath) === "package.json"
|
|
? resolveConventionalChangedOwnerTargets(
|
|
changedPath,
|
|
cwd,
|
|
options.aggressive?.maxDirectoryTests,
|
|
)
|
|
: [];
|
|
const explicitOwners = uniqueOrdered([...(mappedOwners ?? []), ...pluginPackageOwner]);
|
|
const conventionalOwners = resolveConventionalChangedOwnerTargets(
|
|
changedPath,
|
|
cwd,
|
|
options.aggressive?.maxDirectoryTests,
|
|
);
|
|
const owners = options.aggressive
|
|
? uniqueOrdered([
|
|
...explicitOwners,
|
|
...(isTestFileTarget(changedPath) ? [] : conventionalOwners),
|
|
]).filter((owner) => !RUNNABLE_VITEST_CONFIG_TARGETS.has(owner))
|
|
: explicitOwners.length > 0
|
|
? explicitOwners
|
|
: conventionalOwners;
|
|
const hasExplicitScope = explicitOwners.some(
|
|
(owner) => isGlobTarget(owner) || !isTestFileTarget(owner),
|
|
);
|
|
const areas = resolveChangedTestOwnerAreas(
|
|
hasExplicitScope ? owners : [changedPath, ...owners],
|
|
cwd,
|
|
);
|
|
const broadInput = shouldKeepBroadChangedRun([changedPath]);
|
|
if (
|
|
owners.length === 0 &&
|
|
areas.length === 0 &&
|
|
(broadInput || isToolingTestOwnerPath(changedPath))
|
|
) {
|
|
areas.push(...TOOLING_TEST_OWNER_AREAS);
|
|
}
|
|
let directImporters = new Set<string>();
|
|
let affectedTests: string[] = [];
|
|
if (!onlyTestPaths || testsHaveConsumers) {
|
|
// Deleted and non-source inputs share one resolution universe for the whole plan.
|
|
graph ??= getImportGraph(cwd, graphOptions, changedPaths);
|
|
directImporters = new Set(walkAffectedTestsFromImportGraph([changedPath], graph, 1));
|
|
const directCount = new Set(graph.reverseImports.get(changedPath)).size;
|
|
const depth = options.aggressive
|
|
? directCount < options.aggressive.maxDirectImporters
|
|
? 2
|
|
: 1
|
|
: undefined;
|
|
affectedTests = walkAffectedTestsFromImportGraph([changedPath], graph, depth);
|
|
}
|
|
// Direct readers keep their coverage without broadening the owner's transitive area.
|
|
const importers = affectedTests.filter(
|
|
(file) =>
|
|
options.aggressive ||
|
|
directImporters.has(file) ||
|
|
(!broadInput && owners.length === 0 && areas.length === 0) ||
|
|
areas.some((area) => isPathAtOrUnder(file, area)) ||
|
|
owners.some((owner) => owner === file || path.matchesGlob(file, owner)),
|
|
);
|
|
if (options.aggressive) {
|
|
options.onSelection?.({
|
|
rule: "explicit-owner",
|
|
input: changedPath,
|
|
targets: explicitOwners.filter((owner) => !RUNNABLE_VITEST_CONFIG_TARGETS.has(owner)),
|
|
});
|
|
options.onSelection?.({
|
|
rule: "conventional-owner",
|
|
input: changedPath,
|
|
targets: isTestFileTarget(changedPath) ? [] : conventionalOwners,
|
|
});
|
|
} else {
|
|
options.onSelection?.({
|
|
rule: explicitOwners.length > 0 ? "explicit-owner" : "conventional-owner",
|
|
input: changedPath,
|
|
targets: owners,
|
|
});
|
|
}
|
|
options.onSelection?.({ rule: "import-consumer", input: changedPath, targets: importers });
|
|
ownerTargets.push(...owners);
|
|
if (!isTestFileTarget(changedPath)) {
|
|
ownerAreas.push(...areas);
|
|
}
|
|
targets.push(...owners, ...importers);
|
|
if (CHANNEL_PLUGIN_SHAPE_PARITY_WIRING_PATHS.has(changedPath)) {
|
|
targets.push(CHANNEL_PLUGIN_SHAPE_PARITY_TEST_TARGET);
|
|
}
|
|
}
|
|
if (options.aggressive) {
|
|
const manifests = uniqueOrdered(
|
|
changedPaths
|
|
.filter((file) => !isTestFileTarget(file))
|
|
.flatMap((file) => {
|
|
const root = /^(?:packages|extensions)\/[^/]+\//u.exec(file)?.[0];
|
|
return root ? [`${root}package.json`] : file === "package.json" ? [file] : [];
|
|
}),
|
|
);
|
|
for (const manifest of manifests) {
|
|
const name = readImportGraphManifest(cwd, manifest).name;
|
|
if (typeof name !== "string") {
|
|
continue;
|
|
}
|
|
const consumers = resolveDependencyTestConsumers([{ root: ".", dependencies: [name] }], cwd, {
|
|
runtimeOnly: true,
|
|
// Resolved aliases share the module graph's hub cutoff. Traversing again
|
|
// from package readers would bypass it for mixed import styles.
|
|
direct: true,
|
|
}).tests;
|
|
targets.push(...consumers);
|
|
options.onSelection?.({ rule: "package-consumer", input: manifest, targets: consumers });
|
|
}
|
|
}
|
|
return {
|
|
mode: "targets",
|
|
ownerTargets: uniqueOrdered(ownerTargets),
|
|
ownerAreas: uniqueOrdered(ownerAreas),
|
|
targets: uniqueOrdered([
|
|
...targets,
|
|
...(options.watchMode ? [] : changedPaths.flatMap(resolveKovaSchemaTestTargets)),
|
|
]),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Maps changed repo paths to the smallest useful Vitest target plan.
|
|
*/
|
|
export function resolveChangedTestTargetPlan(
|
|
changedPaths: string[],
|
|
options: ChangedTestTargetOptions = {},
|
|
): ChangedTestTargetPlan {
|
|
if (changedPaths.length === 0) {
|
|
return { mode: "none", targets: [] };
|
|
}
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (options.boundedOwners) {
|
|
return resolveBoundedChangedTestTargetPlan(changedPaths, options);
|
|
}
|
|
const executableChangedPaths = changedPaths.filter(
|
|
(changedPath) => !isDeletedChangedTestTarget(changedPath, cwd),
|
|
);
|
|
const toolingTargets = resolveToolingChangedTestTargets(executableChangedPaths, cwd, options);
|
|
if (toolingTargets) {
|
|
return { mode: "targets", targets: toolingTargets };
|
|
}
|
|
const changedLanes = detectChangedLanes(executableChangedPaths);
|
|
const env = options.env ?? {};
|
|
const useBroadFallback = options.broad ?? shouldUseBroadChangedTargets(env);
|
|
const skipImportGraph = changedLanes.lanes.all && !useBroadFallback;
|
|
const targets = [];
|
|
const skippedBroadFallbackPaths = [];
|
|
for (const changedPath of executableChangedPaths) {
|
|
const needsPluginShapeParity = CHANNEL_PLUGIN_SHAPE_PARITY_WIRING_PATHS.has(changedPath);
|
|
const preciseTargets = resolvePreciseChangedTestTargets(changedPath, {
|
|
...options,
|
|
skipImportGraph,
|
|
});
|
|
if (preciseTargets) {
|
|
targets.push(...preciseTargets);
|
|
if (needsPluginShapeParity) {
|
|
targets.push(CHANNEL_PLUGIN_SHAPE_PARITY_TEST_TARGET);
|
|
}
|
|
continue;
|
|
}
|
|
const needsBroadFallback = shouldKeepBroadChangedRun([changedPath]) || changedLanes.lanes.all;
|
|
if (needsBroadFallback) {
|
|
if (useBroadFallback) {
|
|
return { mode: "broad", targets: [] };
|
|
}
|
|
skippedBroadFallbackPaths.push(changedPath);
|
|
continue;
|
|
}
|
|
if (isRoutableChangedTarget(changedPath)) {
|
|
targets.push(changedPath);
|
|
}
|
|
if (needsPluginShapeParity) {
|
|
targets.push(CHANNEL_PLUGIN_SHAPE_PARITY_TEST_TARGET);
|
|
}
|
|
}
|
|
if (
|
|
useBroadFallback &&
|
|
options.includeExtensionImpact !== false &&
|
|
changedLanes.extensionImpactFromCore
|
|
) {
|
|
targets.push("extensions");
|
|
}
|
|
const plan: ChangedTestTargetPlan = {
|
|
mode: "targets",
|
|
targets: uniqueOrdered([
|
|
...targets,
|
|
...(options.watchMode ? [] : executableChangedPaths.flatMap(resolveKovaSchemaTestTargets)),
|
|
]),
|
|
};
|
|
if (skippedBroadFallbackPaths.length > 0) {
|
|
plan.skippedBroadFallbackPaths = [...new Set(skippedBroadFallbackPaths)];
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
export function listFullExtensionVitestProjectConfigs() {
|
|
return (
|
|
fullSuiteVitestShards.find((shard) => shard.config === FULL_EXTENSIONS_VITEST_CONFIG)
|
|
?.projects ?? []
|
|
);
|
|
}
|
|
|
|
export function resolveChangedTargetArgs(
|
|
args: string[],
|
|
cwd = process.cwd(),
|
|
listChangedPaths: (baseRef: string, cwd: string) => string[] = listChangedPathsFromGit,
|
|
options: ChangedTestTargetOptions = {},
|
|
) {
|
|
const plan = resolveChangedTestTargetPlanForArgs(args, cwd, listChangedPaths, options);
|
|
if (!plan) {
|
|
return null;
|
|
}
|
|
if (plan.mode === "broad") {
|
|
return null;
|
|
}
|
|
return plan.targets;
|
|
}
|
|
|
|
export function resolveChangedTestTargetPlanForArgs(
|
|
args: string[],
|
|
cwd = process.cwd(),
|
|
listChangedPaths: (baseRef: string, cwd: string) => string[] = listChangedPathsFromGit,
|
|
options: ChangedTestTargetOptions = {},
|
|
) {
|
|
const baseRef = extractChangedBaseRef(args);
|
|
if (!baseRef) {
|
|
return null;
|
|
}
|
|
const changedPaths = listChangedPaths(baseRef, cwd);
|
|
return resolveChangedTestTargetPlan(changedPaths, {
|
|
cwd,
|
|
...options,
|
|
watchMode: parseTestProjectsArgs(args, cwd).watchMode,
|
|
});
|
|
}
|
|
|
|
function classifyTarget(arg: string, cwd: string, beforeDatabaseWorkerOwnership = false) {
|
|
const relative = toRepoRelativeTarget(arg, cwd);
|
|
if (!beforeDatabaseWorkerOwnership && databaseWorkerExtensionTestFiles.includes(relative)) {
|
|
return "extensionDatabaseWorkers";
|
|
}
|
|
const configTargetKind = resolveVitestConfigTargetKind(relative);
|
|
if (configTargetKind) {
|
|
return configTargetKind;
|
|
}
|
|
if (
|
|
gatewayPluginTestFiles.includes(relative) &&
|
|
(beforeDatabaseWorkerOwnership || !gatewayDatabaseWorkerTestFiles.includes(relative))
|
|
) {
|
|
return "gatewayMethods";
|
|
}
|
|
if (beforeDatabaseWorkerOwnership) {
|
|
const formerFastKind = databaseWorkerCoreFormerFastKinds.get(relative);
|
|
if (formerFastKind) {
|
|
return formerFastKind;
|
|
}
|
|
} else if (isDatabaseWorkerCoreTestFile(relative)) {
|
|
return "infra";
|
|
}
|
|
if (gatewayDatabaseWorkerTestFiles.includes(relative)) {
|
|
return beforeDatabaseWorkerOwnership ? "gateway" : "gatewayDatabaseWorkers";
|
|
}
|
|
if (isAgentsCoreIsolatedTestFile(relative)) {
|
|
return agentVitestProjectOwners.coreIsolated.kind;
|
|
}
|
|
if (isAgentsSpawnProductionBoundaryTestFile(relative)) {
|
|
return agentVitestProjectOwners.spawnProductionBoundary.kind;
|
|
}
|
|
if (isControlUiE2eTarget(relative)) {
|
|
return "uiE2e";
|
|
}
|
|
if (packageContractTestFiles.includes(relative)) {
|
|
return "packageContract";
|
|
}
|
|
if (isUiIsolatedTestFile(relative)) {
|
|
return "uiIsolated";
|
|
}
|
|
if (uiTimingTestFiles.includes(relative)) {
|
|
return "uiTiming";
|
|
}
|
|
if (isUiBrowserTestFile(relative)) {
|
|
return "uiBrowser";
|
|
}
|
|
if (isPathAtOrUnder(relative, "ui") || isPluginControlUiPath(relative)) {
|
|
return "ui";
|
|
}
|
|
if (tuiPtyTestFiles.includes(relative)) {
|
|
return "tuiPty";
|
|
}
|
|
if (relative.endsWith(".e2e.test.ts")) {
|
|
return "e2e";
|
|
}
|
|
if (
|
|
relative === "src/gateway/gateway.test.ts" ||
|
|
relative === "src/gateway/server.startup-matrix-migration.integration.test.ts"
|
|
) {
|
|
return "e2e";
|
|
}
|
|
const channelContractKind = resolveChannelContractTargetKind(relative);
|
|
if (channelContractKind) {
|
|
return channelContractKind;
|
|
}
|
|
if (relative.startsWith("src/plugins/contracts/")) {
|
|
return "contractsPlugin";
|
|
}
|
|
// These tests share stateful runner mocks and must keep the dedicated serial
|
|
// owner even when their contents also qualify for a unit-fast lane.
|
|
if (agentVitestProjectOwners.embeddedIncompleteTurn.include.includes(relative)) {
|
|
return agentVitestProjectOwners.embeddedIncompleteTurn.kind;
|
|
}
|
|
// Explicit isolation ownership wins over inferred unit-fast eligibility.
|
|
// Otherwise a thin wrapper can move a stateful tooling test into a shared worker.
|
|
if (isToolingIsolatedTestFile(relative)) {
|
|
return "toolingIsolated";
|
|
}
|
|
if (isCliProcessTestFile(relative)) {
|
|
return "cliProcess";
|
|
}
|
|
if (resolveUnitFastTimerTestIncludePattern(relative)) {
|
|
return "unitFastFakeTimers";
|
|
}
|
|
if (resolveUnitFastIsolatedTestIncludePattern(relative)) {
|
|
return "unitFastIsolated";
|
|
}
|
|
if (resolveUnitFastTestIncludePattern(relative)) {
|
|
return "unitFast";
|
|
}
|
|
if (relative === "extensions") {
|
|
return "extensionFull";
|
|
}
|
|
if (getChangedPathFacts(relative).surface === "extension") {
|
|
const extensionRoot = relative.split("/").slice(0, 2).join("/");
|
|
return VITEST_CONFIG_TARGET_KIND_BY_PATH.get(resolveExtensionTestConfig(extensionRoot))!;
|
|
}
|
|
if (isChannelSurfaceTestFile(relative)) {
|
|
return "channel";
|
|
}
|
|
if (isBoundaryTestFile(relative)) {
|
|
return "boundary";
|
|
}
|
|
if (relative === TOOLING_DOCKER_TEST_TARGET) {
|
|
return "toolingDocker";
|
|
}
|
|
if (
|
|
relative.startsWith("test/") ||
|
|
relative === "src/scripts" ||
|
|
relative.startsWith("src/scripts/") ||
|
|
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 "tooling";
|
|
}
|
|
if (isBundledPluginDependentUnitTestFile(relative)) {
|
|
return "bundled";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/channels")) {
|
|
return "channel";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/gateway")) {
|
|
return !beforeDatabaseWorkerOwnership &&
|
|
!isGlobTarget(relative) &&
|
|
isGatewayServerTestFile(relative)
|
|
? "gatewayServer"
|
|
: "gateway";
|
|
}
|
|
if (
|
|
isPathAtOrUnder(relative, "packages/gateway-client") ||
|
|
isPathAtOrUnder(relative, "packages/gateway-protocol")
|
|
) {
|
|
return "gatewayClient";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/hooks")) {
|
|
return "hooks";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/infra")) {
|
|
return "infra";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/config")) {
|
|
return "runtimeConfig";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/cron")) {
|
|
return "cron";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/daemon")) {
|
|
return "daemon";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/media-understanding")) {
|
|
return "mediaUnderstanding";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/media")) {
|
|
return "media";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/logging")) {
|
|
return "logging";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/plugin-sdk")) {
|
|
return isPluginSdkLightTarget(relative) ? "pluginSdkLight" : "pluginSdk";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/process")) {
|
|
return "process";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/secrets")) {
|
|
return "secrets";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/shared")) {
|
|
return "sharedCore";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/tui")) {
|
|
return "tui";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/acp")) {
|
|
return "acp";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/cli")) {
|
|
return "cli";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/commands")) {
|
|
return isCommandsLightTarget(relative) ? "commandLight" : "command";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/auto-reply")) {
|
|
return "autoReply";
|
|
}
|
|
if (isPathAtOrUnder(relative, agentVitestProjectOwners.all.root)) {
|
|
// Focused runs must preserve the full suite's isolated harness and hook-timeout contracts.
|
|
if (
|
|
relative === agentVitestProjectOwners.all.root ||
|
|
relative === AGENTS_EMBEDDED_AGENT_TEST_ROOT
|
|
) {
|
|
return agentVitestProjectOwners.all.kind;
|
|
}
|
|
if (agentVitestProjectOwners.embeddedOverflowCompaction.include.includes(relative)) {
|
|
return agentVitestProjectOwners.embeddedOverflowCompaction.kind;
|
|
}
|
|
if (isPathAtOrUnder(relative, agentVitestProjectOwners.embeddedRun.root)) {
|
|
return agentVitestProjectOwners.embeddedRun.kind;
|
|
}
|
|
if (isPathAtOrUnder(relative, AGENTS_EMBEDDED_AGENT_TEST_ROOT)) {
|
|
return isGlobTarget(relative)
|
|
? agentVitestProjectOwners.all.kind
|
|
: agentVitestProjectOwners.embedded.kind;
|
|
}
|
|
if (isPathAtOrUnder(relative, agentVitestProjectOwners.tools.root)) {
|
|
return agentVitestProjectOwners.tools.kind;
|
|
}
|
|
if (isGlobTarget(relative)) {
|
|
const owner =
|
|
relative.slice(agentVitestProjectOwners.all.root.length + 1).split("/", 1)[0] ?? "";
|
|
return isGlobTarget(owner)
|
|
? agentVitestProjectOwners.all.kind
|
|
: agentVitestProjectOwners.support.kind;
|
|
}
|
|
return isTestFileTarget(relative) &&
|
|
path.posix.dirname(relative) === agentVitestProjectOwners.core.root
|
|
? agentVitestProjectOwners.core.kind
|
|
: agentVitestProjectOwners.support.kind;
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/plugins")) {
|
|
return "plugin";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/utils")) {
|
|
return "utils";
|
|
}
|
|
if (isPathAtOrUnder(relative, "src/wizard")) {
|
|
return "wizard";
|
|
}
|
|
return "default";
|
|
}
|
|
|
|
function resolveLightLaneIncludePatterns(kind: string, targetArg: string, cwd: string) {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
if (kind === "unitFast") {
|
|
const includePattern = resolveUnitFastTestIncludePattern(relative);
|
|
return includePattern ? [includePattern] : null;
|
|
}
|
|
if (kind === "unitFastFakeTimers") {
|
|
const includePattern = resolveUnitFastTimerTestIncludePattern(relative);
|
|
return includePattern ? [includePattern] : null;
|
|
}
|
|
if (kind === "unitFastIsolated") {
|
|
const includePattern = resolveUnitFastIsolatedTestIncludePattern(relative);
|
|
return includePattern ? [includePattern] : null;
|
|
}
|
|
if (kind === "pluginSdkLight") {
|
|
const includePattern = resolvePluginSdkLightIncludePattern(relative);
|
|
return includePattern ? [includePattern] : null;
|
|
}
|
|
if (kind === "commandLight") {
|
|
const includePattern = resolveCommandsLightIncludePattern(relative);
|
|
return includePattern ? [includePattern] : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shouldUseWholeConfigTarget(kind: string, targetArg: string, cwd: string) {
|
|
if (isVitestConfigTargetForKind(kind, targetArg, cwd)) {
|
|
return true;
|
|
}
|
|
if (kind === "uiE2e") {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
return relative === "ui/src/test-helpers/control-ui-e2e.ts";
|
|
}
|
|
if (kind !== "ui") {
|
|
return false;
|
|
}
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
// Source files need whole-project coverage; existing directories already define a test scope.
|
|
if (isTestFileTarget(relative) || isExistingDirectoryTarget(targetArg, cwd)) {
|
|
return false;
|
|
}
|
|
if (isPluginControlUiPath(relative) && !isLikelyFileTarget(relative)) {
|
|
return false;
|
|
}
|
|
return isControlUiSourcePath(relative);
|
|
}
|
|
|
|
function createVitestArgs(
|
|
params: Pick<VitestRunPlan, "config" | "forwardedArgs" | "watchMode"> & {
|
|
env?: NodeJS.ProcessEnv;
|
|
},
|
|
) {
|
|
return [
|
|
"exec",
|
|
"node",
|
|
...resolveVitestNodeArgs(params.env),
|
|
resolveVitestCliEntry(),
|
|
...(params.watchMode ? [] : ["run"]),
|
|
"--config",
|
|
params.config,
|
|
...(params.config === UI_E2E_VITEST_CONFIG ? ["--configLoader", "runner"] : []),
|
|
...params.forwardedArgs,
|
|
];
|
|
}
|
|
|
|
export function createVitestPreflightPnpmArgs(config: string) {
|
|
if (config !== UI_E2E_VITEST_CONFIG) {
|
|
return null;
|
|
}
|
|
return ["exec", "node", "--import", "tsx", "scripts/ensure-playwright-chromium.mts"];
|
|
}
|
|
|
|
export function parseTestProjectsArgs(args: string[], cwd = process.cwd()) {
|
|
const forwardedArgs: string[] = [];
|
|
const nonTargetArgs: string[] = [];
|
|
const targetArgs: string[] = [];
|
|
let watchMode = false;
|
|
let passthrough = false;
|
|
|
|
for (let index = 0; index < args.length; index++) {
|
|
const arg = args[index]!;
|
|
if (arg === "--") {
|
|
// The project wrapper consumes separators; direct native and batch calls retain theirs.
|
|
passthrough = targetArgs.length > 0;
|
|
continue;
|
|
}
|
|
const watch = resolveBooleanModeFlag(args, index, "watch", "-w");
|
|
if (watch) {
|
|
watchMode = watch.value;
|
|
}
|
|
// Preserve bare wrapper --watch's existing omission of the named run command.
|
|
const next = args[index + 1];
|
|
if (!passthrough && arg === "--watch" && next !== "true" && next !== "false") {
|
|
continue;
|
|
}
|
|
forwardedArgs.push(arg);
|
|
if (vitestOptionConsumesNextArg(arg, next)) {
|
|
forwardedArgs.push(next!);
|
|
// Preserve operand occurrences even when their value also names a target.
|
|
nonTargetArgs.push(arg, next!);
|
|
index++;
|
|
} else if (!passthrough && isPathLikeTargetArg(arg, cwd)) {
|
|
targetArgs.push(arg);
|
|
} else {
|
|
nonTargetArgs.push(arg);
|
|
}
|
|
}
|
|
|
|
return { forwardedArgs, nonTargetArgs, targetArgs, watchMode };
|
|
}
|
|
|
|
export function buildVitestRunPlans(
|
|
args: string[],
|
|
cwd = process.cwd(),
|
|
listChangedPaths: (baseRef: string, cwd: string) => string[] = listChangedPathsFromGit,
|
|
options: ChangedTestTargetOptions = {},
|
|
) {
|
|
const {
|
|
forwardedArgs,
|
|
nonTargetArgs: remainingArgs,
|
|
targetArgs,
|
|
watchMode,
|
|
} = parseTestProjectsArgs(args, cwd);
|
|
const changedTargetArgs =
|
|
targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths, options) : null;
|
|
const requestedTargetArgs = changedTargetArgs ?? targetArgs;
|
|
const ownedTargets = explicitIncludeTargets(targetArgs, cwd);
|
|
if (
|
|
watchMode &&
|
|
requestedTargetArgs.some((target) => {
|
|
const relative = toRepoRelativeTarget(target, cwd);
|
|
return (
|
|
(isPathAtOrUnder(relative, "ui") || isPluginControlUiPath(relative)) &&
|
|
isGlobTarget(relative)
|
|
);
|
|
})
|
|
) {
|
|
throw new Error(
|
|
"watch mode with UI glob targets is not supported; use a literal test path, directory, or dedicated UI suite",
|
|
);
|
|
}
|
|
const activeTargetArgs = expandBroadToolingScriptTargets(
|
|
expandExplicitSourceTestTargets(requestedTargetArgs, cwd, watchMode),
|
|
cwd,
|
|
watchMode,
|
|
);
|
|
const activeForwardedArgs =
|
|
changedTargetArgs !== null ? stripChangedArgs(forwardedArgs) : forwardedArgs;
|
|
if (changedTargetArgs !== null && activeTargetArgs.length === 0) {
|
|
return [];
|
|
}
|
|
if (activeTargetArgs.length === 0) {
|
|
return [
|
|
{
|
|
config: DEFAULT_VITEST_CONFIG,
|
|
forwardedArgs: activeForwardedArgs,
|
|
includePatterns: null,
|
|
watchMode,
|
|
},
|
|
];
|
|
}
|
|
|
|
const nonTargetArgs = changedTargetArgs !== null ? activeForwardedArgs : remainingArgs;
|
|
const classifiedTargets = activeTargetArgs.map((targetArg) => ({
|
|
targetArg,
|
|
relative: toRepoRelativeTarget(targetArg, cwd),
|
|
kind: classifyTarget(targetArg, cwd),
|
|
}));
|
|
const gatewayProjectShards = (options.env ?? process.env).OPENCLAW_GATEWAY_PROJECT_SHARDS;
|
|
const hasGatewayAggregateTarget = classifiedTargets.some(
|
|
({ kind, relative }) =>
|
|
kind === "gateway" ||
|
|
(kind === "gatewayServer" &&
|
|
!isVitestConfigFileTarget(relative) &&
|
|
gatewayProjectShards === "0"),
|
|
);
|
|
const explicitConfigTargets = classifiedTargets.map(({ relative }) => relative);
|
|
const databaseWorkerPatterns = uniqueOrdered([
|
|
...requestedTargetArgs,
|
|
...activeTargetArgs,
|
|
]).flatMap((targetArg) => {
|
|
const relative = toRepoRelativeTarget(targetArg, cwd);
|
|
if (
|
|
!watchMode &&
|
|
(isOrdinaryAgentGlobTarget(relative) ||
|
|
(!isGlobTarget(relative) &&
|
|
isExistingDirectoryTarget(targetArg, cwd) &&
|
|
(isPathAtOrUnder(relative, "packages") ||
|
|
isPathAtOrUnder(relative, agentVitestProjectOwners.all.root))))
|
|
) {
|
|
// Expanded selections already contribute their existing leaves through activeTargetArgs.
|
|
return [];
|
|
}
|
|
return isTestFileTarget(relative) ||
|
|
isGlobTarget(relative) ||
|
|
isExistingDirectoryTarget(targetArg, cwd)
|
|
? [toScopedIncludePattern(targetArg, cwd)]
|
|
: [];
|
|
});
|
|
const impliedDatabaseWorkerTargets = databaseWorkerCoreTestFiles.filter((file) =>
|
|
databaseWorkerPatterns.some((pattern) => includePatternMatchesAnyFile(pattern, [file])),
|
|
);
|
|
const hasPackageFileTarget = classifiedTargets.some(
|
|
({ kind, relative }) =>
|
|
kind === "packageContract" && relative !== PACKAGE_CONTRACT_VITEST_CONFIG,
|
|
);
|
|
// The wrapper removes its separator; remaining positional filters can widen
|
|
// a package file run beyond the package inventory. Option operands cannot.
|
|
const hasE2eTarget =
|
|
classifiedTargets.some(({ kind }) => kind === "e2e") ||
|
|
(!explicitConfigTargets.includes(PACKAGE_CONTRACT_VITEST_CONFIG) &&
|
|
hasPackageFileTarget &&
|
|
collectVitestFileFilters(["run", ...nonTargetArgs]).length > 0);
|
|
if (
|
|
explicitConfigTargets.every(isVitestConfigFileTarget) &&
|
|
impliedDatabaseWorkerTargets.length === 0
|
|
) {
|
|
if (watchMode && explicitConfigTargets.length > 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",
|
|
);
|
|
}
|
|
return explicitConfigTargets
|
|
.filter((config) => !hasE2eTarget || config !== PACKAGE_CONTRACT_VITEST_CONFIG)
|
|
.filter(
|
|
(config) => !hasGatewayAggregateTarget || config !== GATEWAY_DATABASE_WORKERS_VITEST_CONFIG,
|
|
)
|
|
.flatMap((config) =>
|
|
createBoundedExtensionPlans(
|
|
{
|
|
config,
|
|
forwardedArgs: nonTargetArgs,
|
|
includePatterns: null,
|
|
watchMode,
|
|
},
|
|
cwd,
|
|
options.env,
|
|
),
|
|
);
|
|
}
|
|
|
|
const groupedTargets = new Map<string, string[]>();
|
|
for (const { targetArg, relative, kind: targetKind } of classifiedTargets) {
|
|
if (!watchMode && relative === AGENTS_EMBEDDED_AGENT_TEST_ROOT) {
|
|
// The recursive parent spans four harness owners; keep every isolated project intact.
|
|
for (const { kind, include: targets } of embeddedAgentVitestProjectOwners) {
|
|
const current = groupedTargets.get(kind) ?? [];
|
|
for (const target of targets) {
|
|
if (!current.includes(target)) {
|
|
current.push(target);
|
|
}
|
|
}
|
|
groupedTargets.set(kind, current);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// A requested Gateway aggregate already owns its child tests. Watch also
|
|
// keeps that aggregate; mixed E2E selections retain their serial build owner.
|
|
const kind =
|
|
(targetKind === "gatewayDatabaseWorkers" ||
|
|
(targetKind === "gatewayServer" && !isVitestConfigFileTarget(relative))) &&
|
|
(watchMode || hasGatewayAggregateTarget)
|
|
? "gateway"
|
|
: hasE2eTarget && targetKind === "packageContract"
|
|
? "e2e"
|
|
: targetKind;
|
|
const current = groupedTargets.get(kind) ?? [];
|
|
current.push(
|
|
...(hasE2eTarget && relative === PACKAGE_CONTRACT_VITEST_CONFIG
|
|
? packageContractTestFiles
|
|
: kind === "gateway" && relative === GATEWAY_DATABASE_WORKERS_VITEST_CONFIG
|
|
? gatewayDatabaseWorkerTestFiles
|
|
: [targetArg]),
|
|
);
|
|
groupedTargets.set(kind, current);
|
|
}
|
|
if (impliedDatabaseWorkerTargets.length > 0) {
|
|
const current = groupedTargets.get("infra") ?? [];
|
|
for (const target of impliedDatabaseWorkerTargets) {
|
|
if (!current.includes(target)) {
|
|
current.push(target);
|
|
}
|
|
}
|
|
groupedTargets.set("infra", current);
|
|
}
|
|
const toolingTargets = groupedTargets.get("tooling") ?? [];
|
|
if (
|
|
!watchMode &&
|
|
toolingTargets.some((targetArg) =>
|
|
includePatternMatchesAnyFile(toScopedIncludePattern(targetArg, cwd), [
|
|
TOOLING_DOCKER_TEST_TARGET,
|
|
]),
|
|
)
|
|
) {
|
|
const current = groupedTargets.get("toolingDocker") ?? [];
|
|
if (!current.includes(TOOLING_DOCKER_TEST_TARGET)) {
|
|
current.push(TOOLING_DOCKER_TEST_TARGET);
|
|
groupedTargets.set("toolingDocker", current);
|
|
}
|
|
}
|
|
const activeIncludePatterns = activeTargetArgs.map((target) =>
|
|
toScopedIncludePattern(target, cwd),
|
|
);
|
|
const impliedToolingIsolatedTargets = !watchMode
|
|
? toolingIsolatedTestFiles.filter((file) =>
|
|
activeIncludePatterns.some((pattern) => includePatternMatchesAnyFile(pattern, [file])),
|
|
)
|
|
: [];
|
|
if (impliedToolingIsolatedTargets.length > 0) {
|
|
const current = groupedTargets.get("toolingIsolated") ?? [];
|
|
for (const target of impliedToolingIsolatedTargets) {
|
|
if (!current.includes(target)) {
|
|
current.push(target);
|
|
}
|
|
}
|
|
groupedTargets.set("toolingIsolated", current);
|
|
}
|
|
const uiTargets = groupedTargets.get("ui") ?? [];
|
|
const uiIncludePatterns = uiTargets.map((target) => toScopedIncludePattern(target, cwd));
|
|
const impliedUiTimingTargets = uiTimingTestFiles.filter((file) =>
|
|
uiIncludePatterns.some((pattern) => includePatternMatchesAnyFile(pattern, [file])),
|
|
);
|
|
if (impliedUiTimingTargets.length > 0) {
|
|
groupedTargets.set("uiTiming", [
|
|
...new Set([...(groupedTargets.get("uiTiming") ?? []), ...impliedUiTimingTargets]),
|
|
]);
|
|
}
|
|
const broadUiTargets = uiTargets.filter(
|
|
(targetArg) => !isTestFileTarget(toRepoRelativeTarget(targetArg, cwd)),
|
|
);
|
|
if (broadUiTargets.length > 0) {
|
|
const browserTargets = listExplicitTestTargetFilesForCwd(cwd).filter(
|
|
(file) =>
|
|
isUiBrowserTestFile(file) &&
|
|
broadUiTargets.some(
|
|
(targetArg) =>
|
|
shouldUseWholeConfigTarget("ui", targetArg, cwd) ||
|
|
includePatternMatchesAnyFile(toScopedIncludePattern(targetArg, cwd), [file]),
|
|
),
|
|
);
|
|
if (browserTargets.length > 0) {
|
|
groupedTargets.set("uiBrowser", [
|
|
...new Set([...(groupedTargets.get("uiBrowser") ?? []), ...browserTargets]),
|
|
]);
|
|
}
|
|
}
|
|
const impliedUiIsolatedTargets = uiIsolatedTestFiles.filter((file) =>
|
|
uiIncludePatterns.some((pattern) => includePatternMatchesAnyFile(pattern, [file])),
|
|
);
|
|
if (impliedUiIsolatedTargets.length > 0) {
|
|
const current = groupedTargets.get("uiIsolated") ?? [];
|
|
for (const target of impliedUiIsolatedTargets) {
|
|
if (!current.includes(target)) {
|
|
current.push(target);
|
|
}
|
|
}
|
|
groupedTargets.set("uiIsolated", current);
|
|
}
|
|
// Source-child ownership can cross shared suites (for example state tests).
|
|
// Match every active target so broad selections cannot silently omit excluded children.
|
|
const impliedCliProcessTargets = cliProcessTestFiles.filter((file) =>
|
|
activeIncludePatterns.some((pattern) => includePatternMatchesAnyFile(pattern, [file])),
|
|
);
|
|
if (impliedCliProcessTargets.length > 0) {
|
|
const current = groupedTargets.get("cliProcess") ?? [];
|
|
for (const target of impliedCliProcessTargets) {
|
|
if (!current.includes(target)) {
|
|
current.push(target);
|
|
}
|
|
}
|
|
groupedTargets.set("cliProcess", current);
|
|
}
|
|
|
|
const impliedExtensionWorkerTargets = classifiedTargets
|
|
.filter(({ kind }) => kind === "extensionDatabaseWorkers")
|
|
.map(({ relative }) => relative);
|
|
const impliedWatchWorkerTargets = [
|
|
...impliedDatabaseWorkerTargets,
|
|
...impliedExtensionWorkerTargets,
|
|
];
|
|
const previousWatchKinds = watchMode
|
|
? new Set(classifiedTargets.map(({ targetArg }) => classifyTarget(targetArg, cwd, true)))
|
|
: new Set<string>();
|
|
if (watchMode && (groupedTargets.size > 1 || previousWatchKinds.size > 1)) {
|
|
if (impliedWatchWorkerTargets.length > 0 && previousWatchKinds.size === 1) {
|
|
const previousKind = [...previousWatchKinds][0]!;
|
|
const wholeOwner = classifiedTargets.some(({ targetArg }) =>
|
|
shouldUseWholeConfigTarget(previousKind, targetArg, cwd),
|
|
);
|
|
return [
|
|
{
|
|
config: "test/vitest/vitest.database-worker-watch.config.ts",
|
|
databaseWorkerWatchOwner: VITEST_CONFIG_BY_KIND[previousKind] ?? DEFAULT_VITEST_CONFIG,
|
|
databaseWorkerWatchTests: wholeOwner
|
|
? [...databaseWorkerCoreTestFiles, ...databaseWorkerExtensionTestFiles].filter(
|
|
(file) => classifyTarget(file, cwd, true) === previousKind,
|
|
)
|
|
: impliedWatchWorkerTargets,
|
|
forwardedArgs: nonTargetArgs,
|
|
includePatterns: wholeOwner
|
|
? null
|
|
: uniqueOrdered(activeTargetArgs.map((target) => toScopedIncludePattern(target, cwd))),
|
|
watchMode: true,
|
|
},
|
|
];
|
|
}
|
|
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 orderedKinds = Object.keys(VITEST_CONFIG_BY_KIND);
|
|
orderedKinds.splice(orderedKinds.indexOf("boundary"), 0, "default");
|
|
const plans: VitestRunPlan[] = [];
|
|
for (const kind of orderedKinds) {
|
|
const grouped = groupedTargets.get(kind);
|
|
if (!grouped || grouped.length === 0) {
|
|
continue;
|
|
}
|
|
if (kind === "extensionFull") {
|
|
const configs = watchMode
|
|
? [FULL_EXTENSIONS_VITEST_CONFIG]
|
|
: listFullExtensionVitestProjectConfigs();
|
|
for (const config of configs) {
|
|
const plan = {
|
|
config,
|
|
forwardedArgs: nonTargetArgs,
|
|
includePatterns: null,
|
|
watchMode,
|
|
};
|
|
plans.push(...createBoundedExtensionPlans(plan, cwd, options.env));
|
|
}
|
|
continue;
|
|
}
|
|
const config = VITEST_CONFIG_BY_KIND[kind] ?? DEFAULT_VITEST_CONFIG;
|
|
const useCliTargetArgs =
|
|
kind === "e2e" ||
|
|
kind === "packageContract" ||
|
|
grouped.every((targetArg) => isCanonicalAgentOwnerDirectoryTarget(targetArg, cwd)) ||
|
|
(kind === "default" &&
|
|
grouped.every((targetArg) => isTestFileTarget(toRepoRelativeTarget(targetArg, cwd))));
|
|
const useWholeConfigTarget = grouped.some((targetArg) =>
|
|
shouldUseWholeConfigTarget(kind, targetArg, cwd),
|
|
);
|
|
const scopedTargetArgs =
|
|
useCliTargetArgs && !useWholeConfigTarget ? uniqueOrdered(grouped) : [];
|
|
const forwardedPlanArgs = [...nonTargetArgs, ...scopedTargetArgs];
|
|
const unitCliIncludes =
|
|
kind === "default" &&
|
|
useCliTargetArgs &&
|
|
!watchMode &&
|
|
!options.env?.[INCLUDE_FILE_ENV_KEY]?.trim() &&
|
|
grouped.every((targetArg) => !path.isAbsolute(targetArg))
|
|
? uniqueOrdered(grouped.map((targetArg) => toRepoRelativeTarget(targetArg, cwd)))
|
|
: null;
|
|
// CI needs explicit selection metadata. Keep the CLI filters too: the unit
|
|
// config and runner use them for exclude and empty-selection policy.
|
|
const scopedUnitIncludes =
|
|
unitCliIncludes?.length &&
|
|
unitCliIncludes.every(
|
|
(file) =>
|
|
!isGlobTarget(file) && isUnitConfigTestFile(file) && isExistingFileTarget(file, cwd),
|
|
)
|
|
? unitCliIncludes
|
|
: null;
|
|
const includePatterns = useCliTargetArgs
|
|
? scopedUnitIncludes
|
|
: useWholeConfigTarget
|
|
? null
|
|
: uniqueOrdered(
|
|
grouped.flatMap((targetArg) => {
|
|
const lightLanePatterns = resolveLightLaneIncludePatterns(kind, targetArg, cwd);
|
|
return lightLanePatterns ?? [toScopedIncludePattern(targetArg, cwd)];
|
|
}),
|
|
);
|
|
const broadToolingScriptPlans = createBroadToolingScriptPlans({
|
|
config,
|
|
cwd,
|
|
forwardedArgs: forwardedPlanArgs,
|
|
includePatterns,
|
|
watchMode,
|
|
});
|
|
if (broadToolingScriptPlans) {
|
|
plans.push(...broadToolingScriptPlans);
|
|
continue;
|
|
}
|
|
const processRoots = EXTENSION_TEST_PROCESS_ROOTS.get(config);
|
|
const boundedExtensionRoots = grouped.flatMap((targetArg) => {
|
|
const root = toRepoRelativeTarget(targetArg, cwd);
|
|
return processRoots?.includes(root) && isExistingDirectoryTarget(targetArg, cwd)
|
|
? [root]
|
|
: [];
|
|
});
|
|
const boundedRootsCoverGroupedTargets = grouped.every((targetArg) => {
|
|
const relativeTarget = toRepoRelativeTarget(targetArg, cwd);
|
|
return boundedExtensionRoots.some(
|
|
(root) => relativeTarget === root || relativeTarget.startsWith(`${root}/`),
|
|
);
|
|
});
|
|
const boundedExtensionPlans =
|
|
kind === "extensionDatabaseWorkers" ||
|
|
(boundedExtensionRoots.length > 0 && boundedRootsCoverGroupedTargets)
|
|
? createBoundedExtensionPlans(
|
|
{
|
|
config,
|
|
forwardedArgs: forwardedPlanArgs,
|
|
includePatterns,
|
|
watchMode,
|
|
},
|
|
cwd,
|
|
options.env,
|
|
ownedTargets,
|
|
)
|
|
: null;
|
|
if (boundedExtensionPlans) {
|
|
plans.push(...boundedExtensionPlans);
|
|
continue;
|
|
}
|
|
plans.push({
|
|
config,
|
|
forwardedArgs: forwardedPlanArgs,
|
|
includePatterns,
|
|
watchMode,
|
|
});
|
|
}
|
|
return plans;
|
|
}
|
|
|
|
export function buildFullSuiteVitestRunPlans(args: string[], cwd = process.cwd()): VitestRunPlan[] {
|
|
const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd);
|
|
if (watchMode) {
|
|
return [
|
|
{
|
|
config: "vitest.config.ts",
|
|
forwardedArgs,
|
|
includePatterns: null,
|
|
watchMode,
|
|
},
|
|
];
|
|
}
|
|
const parallelShardCount = parsePositiveInt(
|
|
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL,
|
|
"OPENCLAW_TEST_PROJECTS_PARALLEL",
|
|
);
|
|
const expandToProjectConfigs =
|
|
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS === "1" ||
|
|
(parallelShardCount !== null && parallelShardCount > 1) ||
|
|
shouldExpandLocalFullSuiteShardsByDefault(process.env);
|
|
return fullSuiteVitestShards.flatMap((shard) => {
|
|
if (
|
|
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD === "1" &&
|
|
shard.config === FULL_EXTENSIONS_VITEST_CONFIG
|
|
) {
|
|
return [];
|
|
}
|
|
// The remote Testbox full gate runs every agentic and extension project in one process tree.
|
|
// Bound project and worker lifetimes before either aggregate reaches V8's heap limit.
|
|
const expandShard =
|
|
expandToProjectConfigs ||
|
|
(process.env.OPENCLAW_TESTBOX_REMOTE_RUN === "1" &&
|
|
(shard.config === FULL_AGENTIC_VITEST_CONFIG ||
|
|
shard.config === FULL_EXTENSIONS_VITEST_CONFIG));
|
|
const configs = expandShard ? shard.projects : [shard.config];
|
|
return configs.flatMap((config) => {
|
|
if (expandShard && targetArgs.length === 0) {
|
|
let chunks: string[][] | null = null;
|
|
if (config === AGENTS_CORE_VITEST_CONFIG) {
|
|
// A single non-isolated agents-core process grows until its worker can
|
|
// exit under the full-suite memory load. Bound each process lifetime.
|
|
chunks = splitTargetChunks(
|
|
listAgentsCoreFullSuiteTestTargets(cwd),
|
|
FULL_SUITE_AGENTS_CORE_TEST_TARGET_CHUNK_COUNT,
|
|
);
|
|
} else if (config === UNIT_FAST_VITEST_CONFIG) {
|
|
const targets = listUnitFastFullSuiteTestTargets();
|
|
const chunkCount = Math.ceil(
|
|
targets.length / FULL_SUITE_UNIT_FAST_TEST_TARGET_CHUNK_SIZE,
|
|
);
|
|
chunks = splitTargetChunks(targets, chunkCount);
|
|
} else if (config === UNIT_SRC_VITEST_CONFIG) {
|
|
// The 600+ file process can run for 20 minutes and has produced late,
|
|
// non-reproducible mock-transform failures under Bun. Bound the worker
|
|
// lifetime while preserving the complete file inventory.
|
|
const targets = listUnitSrcFullSuiteTestTargets(cwd);
|
|
const chunkCount = Math.ceil(targets.length / FULL_SUITE_UNIT_SRC_TEST_TARGET_CHUNK_SIZE);
|
|
chunks = splitTargetChunks(targets, chunkCount);
|
|
} else if (config === INFRA_VITEST_CONFIG) {
|
|
// Isolated infra files can share the scheduler without sharing fork state.
|
|
const targets = listInfraFullSuiteTestTargets(cwd);
|
|
chunks = splitTargetChunks(
|
|
targets,
|
|
Math.ceil(targets.length / FULL_SUITE_INFRA_TEST_TARGET_CHUNK_SIZE),
|
|
);
|
|
} else if (config === TOOLING_VITEST_CONFIG) {
|
|
// Tooling tests spawn package managers and native helpers. Keep native
|
|
// process lifetime short enough that unrelated files cannot crash together.
|
|
const targets = listToolingFullSuiteTestTargets(cwd);
|
|
const chunkCount = Math.ceil(targets.length / FULL_SUITE_TOOLING_TEST_TARGET_CHUNK_SIZE);
|
|
chunks = splitTargetChunks(targets, chunkCount);
|
|
} else if (config === GATEWAY_SERVER_VITEST_CONFIG) {
|
|
chunks = createGatewayServerTestTargetChunks(cwd);
|
|
} else {
|
|
const roots = EXTENSION_TEST_PROCESS_ROOTS.get(config);
|
|
if (roots) {
|
|
chunks = createExtensionTestProcessTargetChunks(config, roots, forwardedArgs, cwd);
|
|
}
|
|
}
|
|
if (chunks !== null) {
|
|
return chunks.map((targets) => ({
|
|
config,
|
|
forwardedArgs: [...forwardedArgs, ...targets],
|
|
timingTargets: targets,
|
|
includePatterns: null,
|
|
watchMode: false,
|
|
}));
|
|
}
|
|
}
|
|
return [
|
|
{
|
|
config,
|
|
forwardedArgs,
|
|
includePatterns: null,
|
|
watchMode: false,
|
|
},
|
|
];
|
|
});
|
|
});
|
|
}
|
|
|
|
function shouldUseLocalFullSuiteParallelByDefault(env = process.env) {
|
|
if (hasConservativeVitestWorkerBudget(env)) {
|
|
return false;
|
|
}
|
|
return env.OPENCLAW_TEST_PROJECTS_SERIAL !== "1" && !isCiLikeEnv(env);
|
|
}
|
|
|
|
function shouldExpandLocalFullSuiteShardsByDefault(env = process.env) {
|
|
return !isCiLikeEnv(env);
|
|
}
|
|
|
|
function parsePositiveInt(value: string | undefined, label: string) {
|
|
const text = value?.trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
if (!/^\d+$/u.test(text)) {
|
|
throw new Error(`${label} must be a positive integer; got: ${value}`);
|
|
}
|
|
const parsed = Number(text);
|
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
throw new Error(`${label} must be a positive integer; got: ${value}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function hasConservativeVitestWorkerBudget(env: NodeJS.ProcessEnv) {
|
|
const workerBudget = parsePositiveInt(
|
|
env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS,
|
|
env.OPENCLAW_VITEST_MAX_WORKERS === undefined
|
|
? "OPENCLAW_TEST_WORKERS"
|
|
: "OPENCLAW_VITEST_MAX_WORKERS",
|
|
);
|
|
return workerBudget !== null && workerBudget <= 1;
|
|
}
|
|
|
|
const FULL_EXTENSIONS_CONFIG = "test/vitest/vitest.full-extensions.config.ts";
|
|
const FULL_EXTENSIONS_MIN_HEAP_MB = 8192;
|
|
|
|
function ensureMaxOldSpaceSize(nodeOptions: string | undefined, minimumMb: number) {
|
|
const normalized = nodeOptions?.trim() ?? "";
|
|
const matches = Array.from(
|
|
normalized.matchAll(/(^|\s)--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)(?=\s|$)/gu),
|
|
);
|
|
const match = matches.at(-1);
|
|
if (!match) {
|
|
return [normalized, `--max-old-space-size=${minimumMb}`].filter(Boolean).join(" ");
|
|
}
|
|
const currentMb = Number(match[2]);
|
|
if (Number.isSafeInteger(currentMb) && currentMb >= minimumMb) {
|
|
return normalized;
|
|
}
|
|
const start = match.index;
|
|
const replacement = match[0].replace(/\d+$/u, String(minimumMb));
|
|
return `${normalized.slice(0, start)}${replacement}${normalized.slice(start + match[0].length)}`;
|
|
}
|
|
|
|
export function applyFullExtensionsHeapBudget<T extends VitestSpecShape>(
|
|
specs: T[],
|
|
params: { env?: NodeJS.ProcessEnv } = {},
|
|
): Array<Omit<T, "env"> & { env: NodeJS.ProcessEnv }> {
|
|
const baseEnv = params.env ?? {};
|
|
return specs.map((spec) =>
|
|
spec.config === FULL_EXTENSIONS_CONFIG
|
|
? {
|
|
...spec,
|
|
env: {
|
|
...spec.env,
|
|
NODE_OPTIONS: ensureMaxOldSpaceSize(
|
|
spec.env?.NODE_OPTIONS ?? baseEnv.NODE_OPTIONS,
|
|
FULL_EXTENSIONS_MIN_HEAP_MB,
|
|
),
|
|
},
|
|
}
|
|
: spec,
|
|
);
|
|
}
|
|
|
|
export function resolveParallelFullSuiteConcurrency(
|
|
specCount: number,
|
|
envInput?: NodeJS.ProcessEnv,
|
|
hostInfo?: VitestHostInfo,
|
|
) {
|
|
let env = envInput;
|
|
env ??= process.env;
|
|
const override = parsePositiveInt(
|
|
env.OPENCLAW_TEST_PROJECTS_PARALLEL,
|
|
"OPENCLAW_TEST_PROJECTS_PARALLEL",
|
|
);
|
|
if (override !== null) {
|
|
return Math.min(override, specCount);
|
|
}
|
|
if (env.OPENCLAW_TEST_PROJECTS_SERIAL === "1") {
|
|
return 1;
|
|
}
|
|
if (isCiLikeEnv(env)) {
|
|
return 1;
|
|
}
|
|
if (hasConservativeVitestWorkerBudget(env)) {
|
|
return 1;
|
|
}
|
|
if (
|
|
env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS !== "1" &&
|
|
!shouldUseLocalFullSuiteParallelByDefault(env)
|
|
) {
|
|
return 1;
|
|
}
|
|
return Math.min(resolveLocalFullSuiteProfile(env, hostInfo).shardParallelism, specCount);
|
|
}
|
|
|
|
export function applyParallelVitestCachePaths<T extends VitestSpecShape>(
|
|
specs: T[],
|
|
params: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
): Array<CacheAssignedSpec<T>> {
|
|
const baseEnv = params.env ?? process.env;
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const sharedRoot = baseEnv.OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT?.trim();
|
|
// Project callers historically supplied a root through PATH. ROOT makes CI's
|
|
// ownership explicit while a simultaneous PATH remains a caller-owned leaf.
|
|
const legacyRoot = sharedRoot ? undefined : baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim();
|
|
const cacheRoot = legacyRoot || resolveVitestCacheRoot(baseEnv, cwd);
|
|
const configSlots = new Map<string, number>();
|
|
return specs.map((spec) => {
|
|
const specCachePath = spec.env?.[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim();
|
|
if (
|
|
spec.cacheAssignment?.kind === "caller" ||
|
|
(spec.cacheAssignment?.kind !== "scheduler" && specCachePath && specCachePath !== legacyRoot)
|
|
) {
|
|
return { ...spec, cacheAssignment: spec.cacheAssignment ?? { kind: "caller" } };
|
|
}
|
|
const firstSlot = resolveVitestCacheSlotPath(cacheRoot, spec.config, 0, cwd);
|
|
const slot = configSlots.get(firstSlot) ?? 0;
|
|
configSlots.set(firstSlot, slot + 1);
|
|
return {
|
|
...spec,
|
|
cacheAssignment: { kind: "scheduler", root: cacheRoot },
|
|
env: {
|
|
...spec.env,
|
|
[FS_MODULE_CACHE_PATH_ENV_KEY]: resolveVitestCacheSlotPath(
|
|
cacheRoot,
|
|
spec.config,
|
|
slot,
|
|
cwd,
|
|
),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
export function applyDefaultVitestCachePaths<T extends WatchableVitestSpecShape>(
|
|
specs: T[],
|
|
params: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
): Array<CacheAssignedSpec<T>> {
|
|
if (specs.some((spec) => spec.watchMode)) {
|
|
return specs;
|
|
}
|
|
const baseEnv = params.env ?? process.env;
|
|
const oneConfig = specs.length <= 1 || specs.every((spec) => spec.config === specs[0]?.config);
|
|
// Before ROOT existed these serial callers owned PATH as an exact leaf.
|
|
if (
|
|
!baseEnv.OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT?.trim() &&
|
|
baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim() &&
|
|
oneConfig
|
|
) {
|
|
return specs;
|
|
}
|
|
if (process.platform === "win32" && oneConfig) {
|
|
return specs;
|
|
}
|
|
return applyParallelVitestCachePaths(specs, params);
|
|
}
|
|
|
|
export function applyDefaultVitestNoOutputTimeout<T extends WatchableVitestSpecShape>(
|
|
specs: T[],
|
|
params: { env?: NodeJS.ProcessEnv } = {},
|
|
): Array<Omit<T, "env"> & { env: NodeJS.ProcessEnv }> {
|
|
const baseEnv = params.env ?? process.env;
|
|
if (
|
|
Object.hasOwn(baseEnv, VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY) &&
|
|
Object.hasOwn(baseEnv, VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY)
|
|
) {
|
|
return specs;
|
|
}
|
|
return specs.map((spec) => {
|
|
if (spec.watchMode) {
|
|
return spec;
|
|
}
|
|
const env = spec.env ?? {};
|
|
const nextEnv = { ...env };
|
|
if (
|
|
!Object.hasOwn(baseEnv, VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY) &&
|
|
!Object.hasOwn(env, VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY)
|
|
) {
|
|
nextEnv[VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY] = resolveTestProjectsVitestNoOutputTimeoutMs(
|
|
spec.config,
|
|
);
|
|
}
|
|
if (
|
|
!Object.hasOwn(baseEnv, VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY) &&
|
|
!Object.hasOwn(env, VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY)
|
|
) {
|
|
nextEnv[VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY] =
|
|
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS;
|
|
}
|
|
return {
|
|
...spec,
|
|
env: nextEnv,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function createVitestRunSpecs(
|
|
args: string[],
|
|
params: { baseEnv?: NodeJS.ProcessEnv; cwd?: string } = {},
|
|
) {
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const baseEnv = params.baseEnv ?? process.env;
|
|
const ownedTargets = explicitIncludeTargets(parseTestProjectsArgs(args, cwd).targetArgs, cwd);
|
|
const plans = filterPlansForContractIncludeFile(
|
|
buildVitestRunPlans(args, cwd, listChangedPathsFromGit, { env: baseEnv }),
|
|
baseEnv,
|
|
ownedTargets,
|
|
);
|
|
const inheritedIncludes = plans.some(
|
|
(plan) =>
|
|
plan.includePatterns !== null && !ownsIncludeSelection(plan.includePatterns, ownedTargets),
|
|
)
|
|
? loadIncludePatternsForSpecFilter(baseEnv)
|
|
: null;
|
|
return plans.flatMap((originalPlan, index) => {
|
|
const plan =
|
|
originalPlan.includePatterns && inheritedIncludes
|
|
? {
|
|
...originalPlan,
|
|
includePatterns: resolveInheritedIncludeScope(
|
|
originalPlan.includePatterns,
|
|
inheritedIncludes,
|
|
ownedTargets,
|
|
),
|
|
}
|
|
: originalPlan;
|
|
if (inheritedIncludes && plan.includePatterns?.length === 0) {
|
|
return [];
|
|
}
|
|
const includeFilePath = plan.includePatterns
|
|
? path.join(os.tmpdir(), `openclaw-vitest-include-${randomUUID()}-${index}.json`)
|
|
: null;
|
|
return {
|
|
config: plan.config,
|
|
timingTargets: plan.timingTargets,
|
|
env:
|
|
includeFilePath || plan.databaseWorkerWatchOwner
|
|
? {
|
|
...baseEnv,
|
|
...(includeFilePath ? { [INCLUDE_FILE_ENV_KEY]: includeFilePath } : {}),
|
|
...(plan.databaseWorkerWatchOwner
|
|
? {
|
|
[DATABASE_WORKER_WATCH_OWNER_ENV_KEY]: plan.databaseWorkerWatchOwner,
|
|
[DATABASE_WORKER_WATCH_TESTS_ENV_KEY]: JSON.stringify(
|
|
plan.databaseWorkerWatchTests,
|
|
),
|
|
}
|
|
: {}),
|
|
}
|
|
: baseEnv,
|
|
includeFilePath,
|
|
includePatterns: plan.includePatterns,
|
|
pnpmArgs: createVitestArgs(plan),
|
|
preflightPnpmArgs: createVitestPreflightPnpmArgs(plan.config),
|
|
watchMode: plan.watchMode,
|
|
};
|
|
});
|
|
}
|
|
|
|
function loadIncludePatternsForSpecFilter(env: NodeJS.ProcessEnv) {
|
|
const filePath = env[INCLUDE_FILE_ENV_KEY]?.trim();
|
|
if (!filePath) {
|
|
return null;
|
|
}
|
|
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
return parsed.filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
}
|
|
|
|
function includePatternMatchesConfig(candidate: string, configPatterns: readonly string[]) {
|
|
return configPatterns.some(
|
|
(pattern) => path.matchesGlob(candidate, pattern) || path.matchesGlob(pattern, candidate),
|
|
);
|
|
}
|
|
|
|
function filterPlansForContractIncludeFile(
|
|
plans: VitestRunPlan[],
|
|
env: NodeJS.ProcessEnv,
|
|
ownedTargets?: ReadonlySet<string>,
|
|
) {
|
|
if (
|
|
!plans.some(
|
|
(plan) =>
|
|
CHANNEL_CONTRACT_CONFIG_PATTERNS.has(plan.config) &&
|
|
!ownsIncludeSelection(plan.includePatterns, ownedTargets),
|
|
)
|
|
) {
|
|
return plans;
|
|
}
|
|
const includePatterns = loadIncludePatternsForSpecFilter(env);
|
|
if (!includePatterns) {
|
|
return plans;
|
|
}
|
|
return plans.filter((plan) => {
|
|
if (plan.includePatterns?.some((pattern) => ownedTargets?.has(pattern))) {
|
|
return true;
|
|
}
|
|
const configPatterns = CHANNEL_CONTRACT_CONFIG_PATTERNS.get(plan.config);
|
|
if (!configPatterns) {
|
|
return true;
|
|
}
|
|
return includePatterns.some((candidate) =>
|
|
includePatternMatchesConfig(candidate, configPatterns),
|
|
);
|
|
});
|
|
}
|
|
|
|
function expandVitestIncludePatterns(includePatterns: string[], cwd: string) {
|
|
const candidateFiles = includePatterns.some(isGlobTarget)
|
|
? listExplicitTestTargetFilesForCwd(cwd).toSorted()
|
|
: [];
|
|
return uniqueOrdered(
|
|
includePatterns.flatMap((pattern) => {
|
|
if (!isGlobTarget(pattern)) {
|
|
return [pattern];
|
|
}
|
|
return candidateFiles.filter((file) => path.matchesGlob(file, pattern));
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function writeVitestIncludeFile(
|
|
filePath: string,
|
|
includePatterns: string[],
|
|
options: { cwd?: string; expandGlobs?: boolean } = {},
|
|
) {
|
|
// Shared Vitest projects intersect this file with their ownership globs.
|
|
// One-shot runs emit concrete paths; watch runs retain globs for new files.
|
|
const expandedPatterns =
|
|
options.expandGlobs === false
|
|
? includePatterns
|
|
: expandVitestIncludePatterns(includePatterns, options.cwd ?? process.cwd());
|
|
fs.writeFileSync(filePath, `${JSON.stringify(expandedPatterns, null, 2)}\n`);
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
const text = value;
|
|
if (text === "") {
|
|
return "''";
|
|
}
|
|
if (/^[A-Za-z0-9_./:=@%+-]+$/u.test(text)) {
|
|
return text;
|
|
}
|
|
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
}
|
|
|
|
function formatFailedShardRerunCommand(failure: FailedVitestShard) {
|
|
const includePatterns = failure.includePatterns ?? [];
|
|
if (includePatterns.length > 0) {
|
|
return ["pnpm", "test", ...includePatterns.map(shellQuote), "--", "--reporter=verbose"].join(
|
|
" ",
|
|
);
|
|
}
|
|
return [
|
|
"node",
|
|
"scripts/run-vitest.mjs",
|
|
"run",
|
|
"--config",
|
|
shellQuote(failure.config),
|
|
"--reporter=verbose",
|
|
].join(" ");
|
|
}
|
|
|
|
function formatFailedShardStatus(failure: FailedVitestShard) {
|
|
const details = [];
|
|
if (failure.code !== undefined && failure.code !== null) {
|
|
details.push(`exit ${failure.code}`);
|
|
}
|
|
if (failure.signal) {
|
|
details.push(`signal ${failure.signal}`);
|
|
}
|
|
if (failure.noOutputTimedOut) {
|
|
details.push("no-output timeout");
|
|
}
|
|
return details.length > 0 ? ` (${details.join(", ")})` : "";
|
|
}
|
|
|
|
export function formatFailedShardDigest(failures: FailedVitestShard[]) {
|
|
if (failures.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const orderedFailures = failures.toSorted((left, right) => {
|
|
const leftOrder = typeof left.order === "number" ? left.order : Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = typeof right.order === "number" ? right.order : Number.MAX_SAFE_INTEGER;
|
|
return leftOrder - rightOrder || left.config.localeCompare(right.config);
|
|
});
|
|
const shown = orderedFailures.slice(0, FAILED_SHARD_DIGEST_LIMIT);
|
|
const lines = [`[test] failed shard digest (${failures.length}):`];
|
|
for (const failure of shown) {
|
|
const includePatterns = failure.includePatterns ?? [];
|
|
const includes =
|
|
includePatterns.length > 0 ? ` includes=${includePatterns.map(shellQuote).join(",")}` : "";
|
|
lines.push(`[test] - ${failure.config}${formatFailedShardStatus(failure)}${includes}`);
|
|
lines.push(`[test] rerun: ${formatFailedShardRerunCommand(failure)}`);
|
|
}
|
|
if (shown.length < failures.length) {
|
|
lines.push(`[test] - ... ${failures.length - shown.length} more failed shard(s) omitted`);
|
|
}
|
|
return lines;
|
|
}
|