mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 10:22:32 +00:00
* test: stabilize ci and local vitest workers * test: introduce planner-backed test runner * test: address planner review follow-ups * test: derive planner budgets from host capabilities * test: restore planner filter helper import * test: align planner explain output with execution * test: keep low profile as serial alias * test: restrict explicit planner file targets * test: clean planner exits and pnpm launch * test: tighten wrapper flag validation * ci: gate heavy fanout on check * test: key shard assignments by unit identity * ci(bun): shard vitest lanes further * test: restore ci overlap and stabilize planner tests * test: relax planner output worker assertions * test: reset plugin runtime state in optional tools suite * ci: split macos node and swift jobs * test: honor no-isolate top-level concurrency budgets * ci: fix macos swift format lint * test: cap max-profile top-level concurrency * ci: shard macos node checks * ci: use four macos node shards * test: normalize explain targets before classification
1024 lines
33 KiB
JavaScript
1024 lines
33 KiB
JavaScript
import path from "node:path";
|
|
import { isUnitConfigTestFile } from "../../vitest.unit-paths.mjs";
|
|
import {
|
|
loadChannelTimingManifest,
|
|
loadUnitMemoryHotspotManifest,
|
|
loadUnitTimingManifest,
|
|
packFilesByDuration,
|
|
packFilesByDurationWithBaseLoads,
|
|
selectUnitHeavyFileGroups,
|
|
} from "../test-runner-manifest.mjs";
|
|
import { loadTestCatalog, normalizeRepoPath } from "./catalog.mjs";
|
|
import { resolveExecutionBudget, resolveRuntimeCapabilities } from "./runtime-profile.mjs";
|
|
import {
|
|
countExplicitEntryFilters,
|
|
getExplicitEntryFilters,
|
|
parsePassthroughArgs,
|
|
SINGLE_RUN_ONLY_FLAGS,
|
|
} from "./vitest-args.mjs";
|
|
|
|
const parseEnvNumber = (env, name, fallback) => {
|
|
const parsed = Number.parseInt(env[name] ?? "", 10);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
};
|
|
|
|
const normalizeSurfaces = (values = []) => [
|
|
...new Set(
|
|
values
|
|
.flatMap((value) => String(value).split(","))
|
|
.map((value) => value.trim())
|
|
.filter(Boolean),
|
|
),
|
|
];
|
|
|
|
const EXPLICIT_PLAN_SURFACES = new Set(["unit", "extensions", "channels", "gateway"]);
|
|
|
|
const validateExplicitSurfaces = (surfaces) => {
|
|
const invalidSurfaces = surfaces.filter((surface) => !EXPLICIT_PLAN_SURFACES.has(surface));
|
|
if (invalidSurfaces.length > 0) {
|
|
throw new Error(
|
|
`Unsupported --surface value(s): ${invalidSurfaces.join(", ")}. Supported surfaces: unit, extensions, channels, gateway.`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const buildRequestedSurfaces = (request, env) => {
|
|
const explicit = normalizeSurfaces(request.surfaces ?? []);
|
|
if (explicit.length > 0) {
|
|
validateExplicitSurfaces(explicit);
|
|
return explicit;
|
|
}
|
|
const surfaces = [];
|
|
const skipDefaultRuns = env.OPENCLAW_TEST_SKIP_DEFAULT === "1";
|
|
if (!skipDefaultRuns) {
|
|
surfaces.push("unit");
|
|
}
|
|
if (env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1") {
|
|
surfaces.push("extensions");
|
|
}
|
|
if (env.OPENCLAW_TEST_INCLUDE_CHANNELS === "1") {
|
|
surfaces.push("channels");
|
|
}
|
|
if (env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1") {
|
|
surfaces.push("gateway");
|
|
}
|
|
return surfaces;
|
|
};
|
|
|
|
const createPlannerContext = (request, options = {}) => {
|
|
const env = options.env ?? process.env;
|
|
const runtime = resolveRuntimeCapabilities(env, {
|
|
mode: request.mode ?? null,
|
|
profile: request.profile ?? null,
|
|
cpuCount: options.cpuCount,
|
|
totalMemoryBytes: options.totalMemoryBytes,
|
|
platform: options.platform,
|
|
loadAverage: options.loadAverage,
|
|
nodeVersion: options.nodeVersion,
|
|
});
|
|
const executionBudget = resolveExecutionBudget(runtime);
|
|
const catalog = options.catalog ?? loadTestCatalog();
|
|
const unitTimingManifest = loadUnitTimingManifest();
|
|
const channelTimingManifest = loadChannelTimingManifest();
|
|
const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest();
|
|
return {
|
|
env,
|
|
runtime,
|
|
executionBudget,
|
|
catalog,
|
|
unitTimingManifest,
|
|
channelTimingManifest,
|
|
unitMemoryHotspotManifest,
|
|
};
|
|
};
|
|
|
|
const estimateEntryFilesDurationMs = (entry, files, context) => {
|
|
const estimateDurationMs = resolveEntryTimingEstimator(entry, context);
|
|
if (!estimateDurationMs) {
|
|
return files.length * 1_000;
|
|
}
|
|
return files.reduce((totalMs, file) => totalMs + estimateDurationMs(file), 0);
|
|
};
|
|
|
|
const resolveEntryTimingEstimator = (entry, context) => {
|
|
const configIndex = entry.args.findIndex((arg) => arg === "--config");
|
|
const config = configIndex >= 0 ? (entry.args[configIndex + 1] ?? "") : "";
|
|
if (config === "vitest.unit.config.ts") {
|
|
return (file) =>
|
|
context.unitTimingManifest.files[file]?.durationMs ??
|
|
context.unitTimingManifest.defaultDurationMs;
|
|
}
|
|
if (config === "vitest.channels.config.ts" || config === "vitest.extensions.config.ts") {
|
|
return (file) =>
|
|
context.channelTimingManifest.files[file]?.durationMs ??
|
|
context.channelTimingManifest.defaultDurationMs;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const splitFilesByDurationBudget = (files, targetDurationMs, estimateDurationMs) => {
|
|
if (!Number.isFinite(targetDurationMs) || targetDurationMs <= 0 || files.length <= 1) {
|
|
return [files];
|
|
}
|
|
|
|
const batches = [];
|
|
let currentBatch = [];
|
|
let currentDurationMs = 0;
|
|
|
|
for (const file of files) {
|
|
const durationMs = estimateDurationMs(file);
|
|
if (currentBatch.length > 0 && currentDurationMs + durationMs > targetDurationMs) {
|
|
batches.push(currentBatch);
|
|
currentBatch = [];
|
|
currentDurationMs = 0;
|
|
}
|
|
currentBatch.push(file);
|
|
currentDurationMs += durationMs;
|
|
}
|
|
|
|
if (currentBatch.length > 0) {
|
|
batches.push(currentBatch);
|
|
}
|
|
|
|
return batches;
|
|
};
|
|
|
|
const resolveMaxWorkersForUnit = (unit, context) => {
|
|
const overrideWorkers = Number.parseInt(context.env.OPENCLAW_TEST_WORKERS ?? "", 10);
|
|
const resolvedOverride =
|
|
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
|
if (resolvedOverride) {
|
|
return resolvedOverride;
|
|
}
|
|
const budget = context.executionBudget;
|
|
if (unit.isolate) {
|
|
return budget.unitIsolatedWorkers;
|
|
}
|
|
if (unit.id.startsWith("unit-heavy-")) {
|
|
return budget.unitHeavyWorkers;
|
|
}
|
|
if (unit.surface === "extensions") {
|
|
return budget.extensionWorkers;
|
|
}
|
|
if (unit.surface === "gateway") {
|
|
return budget.gatewayWorkers;
|
|
}
|
|
return budget.unitSharedWorkers;
|
|
};
|
|
|
|
const formatPerFileEntryName = (owner, file) => {
|
|
const baseName = path
|
|
.basename(file)
|
|
.replace(/\.live\.test\.ts$/u, "")
|
|
.replace(/\.e2e\.test\.ts$/u, "")
|
|
.replace(/\.test\.ts$/u, "");
|
|
return `${owner}-${baseName}`;
|
|
};
|
|
|
|
const createExecutionUnit = (context, config) => {
|
|
const unit = {
|
|
id: config.id,
|
|
surface: config.surface,
|
|
isolate: Boolean(config.isolate),
|
|
pool: config.pool ?? "forks",
|
|
args: config.args,
|
|
env: config.env,
|
|
includeFiles: config.includeFiles,
|
|
serialPhase: config.serialPhase,
|
|
fixedShardIndex: config.fixedShardIndex,
|
|
estimatedDurationMs: config.estimatedDurationMs,
|
|
timeoutMs: config.timeoutMs,
|
|
reasons: config.reasons ?? [],
|
|
};
|
|
unit.maxWorkers = resolveMaxWorkersForUnit(unit, context);
|
|
return unit;
|
|
};
|
|
|
|
const withIncludeFileEnv = (context, unitId, files) => ({
|
|
OPENCLAW_VITEST_INCLUDE_FILE: context.writeTempJsonArtifact(unitId, files),
|
|
});
|
|
|
|
const resolveUnitHeavyFileGroups = (context) => {
|
|
const { env, runtime, executionBudget, catalog, unitTimingManifest, unitMemoryHotspotManifest } =
|
|
context;
|
|
const heavyUnitFileLimit = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT",
|
|
runtime.intentProfile === "max"
|
|
? Math.max(executionBudget.heavyUnitFileLimit, 90)
|
|
: executionBudget.heavyUnitFileLimit,
|
|
);
|
|
const heavyUnitLaneCount = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_HEAVY_UNIT_LANES",
|
|
runtime.intentProfile === "max"
|
|
? Math.max(executionBudget.heavyUnitLaneCount, 6)
|
|
: executionBudget.heavyUnitLaneCount,
|
|
);
|
|
const heavyUnitMinDurationMs = parseEnvNumber(env, "OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200);
|
|
const memoryHeavyUnitFileLimit = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_MEMORY_HEAVY_UNIT_FILE_LIMIT",
|
|
executionBudget.memoryHeavyUnitFileLimit,
|
|
);
|
|
const memoryHeavyUnitMinDeltaKb = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_MEMORY_HEAVY_UNIT_MIN_KB",
|
|
unitMemoryHotspotManifest.defaultMinDeltaKb,
|
|
);
|
|
return {
|
|
heavyUnitLaneCount,
|
|
...selectUnitHeavyFileGroups({
|
|
candidates: catalog.allKnownUnitFiles,
|
|
behaviorOverrides: catalog.unitBehaviorOverrideSet,
|
|
timedLimit: heavyUnitFileLimit,
|
|
timedMinDurationMs: heavyUnitMinDurationMs,
|
|
memoryLimit: memoryHeavyUnitFileLimit,
|
|
memoryMinDeltaKb: memoryHeavyUnitMinDeltaKb,
|
|
timings: unitTimingManifest,
|
|
hotspots: unitMemoryHotspotManifest,
|
|
}),
|
|
};
|
|
};
|
|
|
|
const buildDefaultUnits = (context, request) => {
|
|
const { env, executionBudget, catalog, unitTimingManifest, channelTimingManifest } = context;
|
|
const noIsolateArgs = context.noIsolateArgs;
|
|
const selectedSurfaces = buildRequestedSurfaces(request, env);
|
|
const selectedSurfaceSet = new Set(selectedSurfaces);
|
|
|
|
const {
|
|
heavyUnitLaneCount,
|
|
memoryHeavyFiles: memoryHeavyUnitFiles,
|
|
timedHeavyFiles: timedHeavyUnitFiles,
|
|
} = resolveUnitHeavyFileGroups(context);
|
|
const unitMemoryIsolatedFiles = [...memoryHeavyUnitFiles].filter(
|
|
(file) => !catalog.unitBehaviorOverrideSet.has(file),
|
|
);
|
|
const unitSchedulingOverrideSet = new Set([
|
|
...catalog.unitBehaviorOverrideSet,
|
|
...memoryHeavyUnitFiles,
|
|
]);
|
|
const unitFastExcludedFiles = [
|
|
...new Set([
|
|
...unitSchedulingOverrideSet,
|
|
...timedHeavyUnitFiles,
|
|
...catalog.channelIsolatedFiles,
|
|
]),
|
|
];
|
|
const estimateUnitDurationMs = (file) =>
|
|
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
|
|
const estimateChannelDurationMs = (file) =>
|
|
channelTimingManifest.files[file]?.durationMs ?? channelTimingManifest.defaultDurationMs;
|
|
const unitFastCandidateFiles = catalog.allKnownUnitFiles.filter(
|
|
(file) => !new Set(unitFastExcludedFiles).has(file),
|
|
);
|
|
const extensionSharedCandidateFiles = catalog.allKnownTestFiles.filter(
|
|
(file) => file.startsWith("extensions/") && !catalog.extensionForkIsolatedFileSet.has(file),
|
|
);
|
|
const channelSharedCandidateFiles = catalog.allKnownTestFiles.filter(
|
|
(file) =>
|
|
catalog.channelTestPrefixes.some((prefix) => file.startsWith(prefix)) &&
|
|
!catalog.channelIsolatedFileSet.has(file),
|
|
);
|
|
const defaultExtensionsBatchTargetMs = executionBudget.extensionsBatchTargetMs;
|
|
const extensionsBatchTargetMs = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_EXTENSIONS_BATCH_TARGET_MS",
|
|
defaultExtensionsBatchTargetMs,
|
|
);
|
|
const defaultUnitFastLaneCount = executionBudget.unitFastLaneCount;
|
|
const unitFastLaneCount = Math.max(
|
|
1,
|
|
parseEnvNumber(env, "OPENCLAW_TEST_UNIT_FAST_LANES", defaultUnitFastLaneCount),
|
|
);
|
|
const defaultUnitFastBatchTargetMs = executionBudget.unitFastBatchTargetMs;
|
|
const unitFastBatchTargetMs = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS",
|
|
defaultUnitFastBatchTargetMs,
|
|
);
|
|
const defaultChannelsBatchTargetMs = executionBudget.channelsBatchTargetMs;
|
|
const channelsBatchTargetMs = parseEnvNumber(
|
|
env,
|
|
"OPENCLAW_TEST_CHANNELS_BATCH_TARGET_MS",
|
|
defaultChannelsBatchTargetMs,
|
|
);
|
|
const unitFastBuckets =
|
|
unitFastLaneCount > 1
|
|
? packFilesByDuration(unitFastCandidateFiles, unitFastLaneCount, estimateUnitDurationMs)
|
|
: [unitFastCandidateFiles];
|
|
const units = [];
|
|
|
|
if (selectedSurfaceSet.has("unit")) {
|
|
for (const [laneIndex, files] of unitFastBuckets.entries()) {
|
|
const laneName =
|
|
unitFastBuckets.length === 1 ? "unit-fast" : `unit-fast-${String(laneIndex + 1)}`;
|
|
const recycledBatches = splitFilesByDurationBudget(
|
|
files,
|
|
unitFastBatchTargetMs,
|
|
estimateUnitDurationMs,
|
|
);
|
|
for (const [batchIndex, batch] of recycledBatches.entries()) {
|
|
if (batch.length === 0) {
|
|
continue;
|
|
}
|
|
const unitId =
|
|
recycledBatches.length === 1 ? laneName : `${laneName}-batch-${String(batchIndex + 1)}`;
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: unitId,
|
|
surface: "unit",
|
|
isolate: false,
|
|
serialPhase: "unit-fast",
|
|
includeFiles: batch,
|
|
estimatedDurationMs: estimateEntryFilesDurationMs(
|
|
{ args: ["vitest", "run", "--config", "vitest.unit.config.ts"] },
|
|
batch,
|
|
context,
|
|
),
|
|
env: withIncludeFileEnv(
|
|
context,
|
|
`vitest-unit-fast-include-${String(laneIndex + 1)}-${String(batchIndex + 1)}`,
|
|
batch,
|
|
),
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.unit.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
],
|
|
reasons: ["unit-fast-shared"],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const file of catalog.unitForkIsolatedFiles) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: `unit-${path.basename(file, ".test.ts")}-isolated`,
|
|
surface: "unit",
|
|
isolate: true,
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.unit.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
file,
|
|
],
|
|
reasons: ["unit-isolated-manifest"],
|
|
}),
|
|
);
|
|
}
|
|
|
|
const heavyUnitBuckets = packFilesByDuration(
|
|
timedHeavyUnitFiles,
|
|
heavyUnitLaneCount,
|
|
estimateUnitDurationMs,
|
|
);
|
|
for (const [index, files] of heavyUnitBuckets.entries()) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: `unit-heavy-${String(index + 1)}`,
|
|
surface: "unit",
|
|
isolate: false,
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.unit.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
...files,
|
|
],
|
|
reasons: ["unit-timed-heavy"],
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const file of unitMemoryIsolatedFiles) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: `unit-${path.basename(file, ".test.ts")}-memory-isolated`,
|
|
surface: "unit",
|
|
isolate: true,
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.unit.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
file,
|
|
],
|
|
reasons: ["unit-memory-isolated"],
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (catalog.unitThreadPinnedFiles.length > 0) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: "unit-pinned",
|
|
surface: "unit",
|
|
isolate: false,
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.unit.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
...catalog.unitThreadPinnedFiles,
|
|
],
|
|
reasons: ["unit-pinned-manifest"],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (selectedSurfaceSet.has("extensions")) {
|
|
for (const file of catalog.extensionForkIsolatedFiles) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: `extensions-${path.basename(file, ".test.ts")}-isolated`,
|
|
surface: "extensions",
|
|
isolate: true,
|
|
args: ["vitest", "run", "--config", "vitest.extensions.config.ts", "--pool=forks", file],
|
|
reasons: ["extensions-isolated-manifest"],
|
|
}),
|
|
);
|
|
}
|
|
const extensionBatches = splitFilesByDurationBudget(
|
|
extensionSharedCandidateFiles,
|
|
extensionsBatchTargetMs,
|
|
estimateChannelDurationMs,
|
|
);
|
|
for (const [batchIndex, batch] of extensionBatches.entries()) {
|
|
if (batch.length === 0) {
|
|
continue;
|
|
}
|
|
const unitId =
|
|
extensionBatches.length === 1 ? "extensions" : `extensions-batch-${String(batchIndex + 1)}`;
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: unitId,
|
|
surface: "extensions",
|
|
isolate: false,
|
|
serialPhase: "extensions",
|
|
includeFiles: batch,
|
|
estimatedDurationMs: estimateEntryFilesDurationMs(
|
|
{ args: ["vitest", "run", "--config", "vitest.extensions.config.ts"] },
|
|
batch,
|
|
context,
|
|
),
|
|
env: withIncludeFileEnv(
|
|
context,
|
|
`vitest-extensions-include-${String(batchIndex + 1)}`,
|
|
batch,
|
|
),
|
|
args: ["vitest", "run", "--config", "vitest.extensions.config.ts", ...noIsolateArgs],
|
|
reasons: ["extensions-shared"],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (selectedSurfaceSet.has("channels")) {
|
|
for (const file of catalog.channelIsolatedFiles) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: `${path.basename(file, ".test.ts")}-channels-isolated`,
|
|
surface: "channels",
|
|
isolate: true,
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.channels.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
file,
|
|
],
|
|
reasons: ["channels-isolated-rule"],
|
|
}),
|
|
);
|
|
}
|
|
const channelBatches = splitFilesByDurationBudget(
|
|
channelSharedCandidateFiles,
|
|
channelsBatchTargetMs,
|
|
estimateChannelDurationMs,
|
|
);
|
|
for (const [batchIndex, batch] of channelBatches.entries()) {
|
|
if (batch.length === 0) {
|
|
continue;
|
|
}
|
|
const unitId =
|
|
channelBatches.length === 1 ? "channels" : `channels-batch-${String(batchIndex + 1)}`;
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: unitId,
|
|
surface: "channels",
|
|
isolate: false,
|
|
serialPhase: "channels",
|
|
includeFiles: batch,
|
|
estimatedDurationMs: estimateEntryFilesDurationMs(
|
|
{ args: ["vitest", "run", "--config", "vitest.channels.config.ts"] },
|
|
batch,
|
|
context,
|
|
),
|
|
env: withIncludeFileEnv(
|
|
context,
|
|
`vitest-channels-include-${String(batchIndex + 1)}`,
|
|
batch,
|
|
),
|
|
args: ["vitest", "run", "--config", "vitest.channels.config.ts", ...noIsolateArgs],
|
|
reasons: ["channels-shared"],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (selectedSurfaceSet.has("gateway")) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: "gateway",
|
|
surface: "gateway",
|
|
isolate: false,
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.gateway.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
],
|
|
reasons: ["gateway-surface"],
|
|
}),
|
|
);
|
|
}
|
|
|
|
return { units, unitMemoryIsolatedFiles };
|
|
};
|
|
|
|
const createTargetedUnit = (context, classification, filters) => {
|
|
const owner = classification.legacyBasePinned ? "base-pinned" : classification.surface;
|
|
const unitId =
|
|
filters.length === 1 && (classification.isolated || owner === "base-pinned")
|
|
? `${formatPerFileEntryName(owner, filters[0])}${classification.isolated ? "-isolated" : ""}`
|
|
: classification.isolated
|
|
? `${owner}-isolated`
|
|
: owner;
|
|
const args = (() => {
|
|
if (owner === "unit") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.unit.config.ts",
|
|
"--pool=forks",
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
if (owner === "base-pinned") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.config.ts",
|
|
"--pool=forks",
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
if (owner === "extensions") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.extensions.config.ts",
|
|
...(classification.isolated ? ["--pool=forks"] : []),
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
if (owner === "gateway") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.gateway.config.ts",
|
|
"--pool=forks",
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
if (owner === "channels") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.channels.config.ts",
|
|
...(classification.isolated ? ["--pool=forks"] : []),
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
if (owner === "live") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.live.config.ts",
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
if (owner === "e2e") {
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.e2e.config.ts",
|
|
...context.noIsolateArgs,
|
|
...filters,
|
|
];
|
|
}
|
|
return [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.config.ts",
|
|
...context.noIsolateArgs,
|
|
...(classification.isolated ? ["--pool=forks"] : []),
|
|
...filters,
|
|
];
|
|
})();
|
|
return createExecutionUnit(context, {
|
|
id: unitId,
|
|
surface: classification.legacyBasePinned ? "base" : classification.surface,
|
|
isolate: classification.isolated || owner === "base-pinned",
|
|
args,
|
|
reasons: classification.reasons,
|
|
});
|
|
};
|
|
|
|
const buildTargetedUnits = (context, request) => {
|
|
if (request.fileFilters.length === 0) {
|
|
return [];
|
|
}
|
|
const unitMemoryIsolatedFiles = request.unitMemoryIsolatedFiles ?? [];
|
|
const groups = request.fileFilters.reduce((acc, fileFilter) => {
|
|
const matchedFiles = context.catalog.resolveFilterMatches(fileFilter);
|
|
if (matchedFiles.length === 0) {
|
|
const classification = context.catalog.classifyTestFile(normalizeRepoPath(fileFilter), {
|
|
unitMemoryIsolatedFiles,
|
|
});
|
|
const key = `${classification.legacyBasePinned ? "base-pinned" : classification.surface}:${
|
|
classification.isolated ? "isolated" : "default"
|
|
}`;
|
|
const files = acc.get(key) ?? { classification, files: [] };
|
|
files.files.push(normalizeRepoPath(fileFilter));
|
|
acc.set(key, files);
|
|
return acc;
|
|
}
|
|
for (const matchedFile of matchedFiles) {
|
|
const classification = context.catalog.classifyTestFile(matchedFile, {
|
|
unitMemoryIsolatedFiles,
|
|
});
|
|
const key = `${classification.legacyBasePinned ? "base-pinned" : classification.surface}:${
|
|
classification.isolated ? "isolated" : "default"
|
|
}`;
|
|
const files = acc.get(key) ?? { classification, files: [] };
|
|
files.files.push(matchedFile);
|
|
acc.set(key, files);
|
|
}
|
|
return acc;
|
|
}, new Map());
|
|
return Array.from(groups.values()).flatMap(({ classification, files }) => {
|
|
const uniqueFilters = [...new Set(files)];
|
|
if (classification.isolated || classification.legacyBasePinned) {
|
|
return uniqueFilters.map((file) =>
|
|
createTargetedUnit(
|
|
context,
|
|
context.catalog.classifyTestFile(file, {
|
|
unitMemoryIsolatedFiles,
|
|
}),
|
|
[file],
|
|
),
|
|
);
|
|
}
|
|
return [createTargetedUnit(context, classification, uniqueFilters)];
|
|
});
|
|
};
|
|
|
|
const rebuildEntryArgsWithFilters = (entryArgs, filters) => {
|
|
const baseArgs = entryArgs.slice(0, 2);
|
|
const { optionArgs } = parsePassthroughArgs(entryArgs.slice(2));
|
|
return [...baseArgs, ...optionArgs, ...filters];
|
|
};
|
|
|
|
const createPinnedShardUnit = (context, unit, files, fixedShardIndex) => {
|
|
const nextUnit = createExecutionUnit(context, {
|
|
...unit,
|
|
id: `${unit.id}-shard-${String(fixedShardIndex)}`,
|
|
fixedShardIndex,
|
|
estimatedDurationMs: estimateEntryFilesDurationMs(unit, files, context),
|
|
includeFiles:
|
|
Array.isArray(unit.includeFiles) && unit.includeFiles.length > 0 ? files : undefined,
|
|
env:
|
|
Array.isArray(unit.includeFiles) && unit.includeFiles.length > 0
|
|
? {
|
|
...unit.env,
|
|
OPENCLAW_VITEST_INCLUDE_FILE: context.writeTempJsonArtifact(
|
|
`${unit.id}-shard-${String(fixedShardIndex)}-include`,
|
|
files,
|
|
),
|
|
}
|
|
: unit.env,
|
|
args:
|
|
Array.isArray(unit.includeFiles) && unit.includeFiles.length > 0
|
|
? rebuildEntryArgsWithFilters(unit.args, [])
|
|
: rebuildEntryArgsWithFilters(unit.args, files),
|
|
});
|
|
nextUnit.fixedShardIndex = fixedShardIndex;
|
|
return nextUnit;
|
|
};
|
|
|
|
const expandUnitsAcrossTopLevelShards = (context, units) => {
|
|
if (context.configuredShardCount === null || context.shardCount <= 1) {
|
|
return units;
|
|
}
|
|
return units.flatMap((unit) => {
|
|
const estimateDurationMs = resolveEntryTimingEstimator(unit, context);
|
|
if (!estimateDurationMs || unit.fixedShardIndex !== undefined) {
|
|
return [unit];
|
|
}
|
|
const candidateFiles =
|
|
Array.isArray(unit.includeFiles) && unit.includeFiles.length > 0
|
|
? unit.includeFiles
|
|
: getExplicitEntryFilters(unit.args);
|
|
if (candidateFiles.length <= 1) {
|
|
return [unit];
|
|
}
|
|
const effectiveShardCount = Math.min(
|
|
context.shardCount,
|
|
Math.max(1, candidateFiles.length - 1),
|
|
);
|
|
if (effectiveShardCount <= 1) {
|
|
return [unit];
|
|
}
|
|
const buckets = packFilesByDurationWithBaseLoads(
|
|
candidateFiles,
|
|
effectiveShardCount,
|
|
estimateDurationMs,
|
|
);
|
|
return buckets.flatMap((files, bucketIndex) =>
|
|
files.length > 0 ? [createPinnedShardUnit(context, unit, files, bucketIndex + 1)] : [],
|
|
);
|
|
});
|
|
};
|
|
|
|
const estimateTopLevelEntryDurationMs = (unit, context) => {
|
|
if (Number.isFinite(unit.estimatedDurationMs) && unit.estimatedDurationMs > 0) {
|
|
return unit.estimatedDurationMs;
|
|
}
|
|
const filters = getExplicitEntryFilters(unit.args);
|
|
if (filters.length === 0) {
|
|
return context.unitTimingManifest.defaultDurationMs;
|
|
}
|
|
return filters.reduce((totalMs, file) => {
|
|
if (isUnitConfigTestFile(file)) {
|
|
return (
|
|
totalMs +
|
|
(context.unitTimingManifest.files[file]?.durationMs ??
|
|
context.unitTimingManifest.defaultDurationMs)
|
|
);
|
|
}
|
|
if (context.catalog.channelTestPrefixes.some((prefix) => file.startsWith(prefix))) {
|
|
return totalMs + 3_000;
|
|
}
|
|
if (file.startsWith("extensions/")) {
|
|
return totalMs + 2_000;
|
|
}
|
|
return totalMs + 1_000;
|
|
}, 0);
|
|
};
|
|
|
|
const buildTopLevelSingleShardAssignments = (context, units) => {
|
|
if (context.shardIndexOverride === null || context.shardCount <= 1) {
|
|
return new WeakMap();
|
|
}
|
|
|
|
const entriesNeedingAssignment = units.filter((unit) => {
|
|
if (unit.fixedShardIndex !== undefined) {
|
|
return false;
|
|
}
|
|
const explicitFilterCount = countExplicitEntryFilters(unit.args);
|
|
if (explicitFilterCount === null) {
|
|
return false;
|
|
}
|
|
const effectiveShardCount = Math.min(context.shardCount, Math.max(1, explicitFilterCount - 1));
|
|
return effectiveShardCount <= 1;
|
|
});
|
|
|
|
const assignmentMap = new WeakMap();
|
|
const pinnedShardLoadsMs = Array.from({ length: context.shardCount }, () => 0);
|
|
for (const unit of units) {
|
|
if (unit.fixedShardIndex === undefined) {
|
|
continue;
|
|
}
|
|
const shardArrayIndex = unit.fixedShardIndex - 1;
|
|
if (shardArrayIndex < 0 || shardArrayIndex >= pinnedShardLoadsMs.length) {
|
|
continue;
|
|
}
|
|
pinnedShardLoadsMs[shardArrayIndex] += estimateTopLevelEntryDurationMs(unit, context);
|
|
}
|
|
const buckets = packFilesByDurationWithBaseLoads(
|
|
entriesNeedingAssignment,
|
|
context.shardCount,
|
|
(unit) => estimateTopLevelEntryDurationMs(unit, context),
|
|
pinnedShardLoadsMs,
|
|
);
|
|
for (const [bucketIndex, bucket] of buckets.entries()) {
|
|
for (const unit of bucket) {
|
|
assignmentMap.set(unit, bucketIndex + 1);
|
|
}
|
|
}
|
|
return assignmentMap;
|
|
};
|
|
|
|
export const formatExecutionUnitSummary = (unit) =>
|
|
`${unit.id} filters=${String(countExplicitEntryFilters(unit.args) || "all")} maxWorkers=${String(
|
|
unit.maxWorkers ?? "default",
|
|
)} surface=${unit.surface} isolate=${unit.isolate ? "yes" : "no"} pool=${unit.pool}`;
|
|
|
|
export function explainExecutionTarget(request, options = {}) {
|
|
const context = createPlannerContext(request, options);
|
|
context.noIsolateArgs =
|
|
context.env.OPENCLAW_TEST_ISOLATE === "1" || context.env.OPENCLAW_TEST_ISOLATE === "true"
|
|
? []
|
|
: context.env.OPENCLAW_TEST_NO_ISOLATE !== "0" &&
|
|
context.env.OPENCLAW_TEST_NO_ISOLATE !== "false"
|
|
? ["--isolate=false"]
|
|
: [];
|
|
const [target] = request.fileFilters;
|
|
const matchedFiles = context.catalog.resolveFilterMatches(target);
|
|
const normalizedTarget = matchedFiles[0] ?? normalizeRepoPath(target);
|
|
const { memoryHeavyFiles } = resolveUnitHeavyFileGroups(context);
|
|
const unitMemoryIsolatedFiles = [...memoryHeavyFiles].filter(
|
|
(file) => !context.catalog.unitBehaviorOverrideSet.has(file),
|
|
);
|
|
const classification = context.catalog.classifyTestFile(normalizedTarget, {
|
|
unitMemoryIsolatedFiles,
|
|
});
|
|
const targetedUnit = createTargetedUnit(context, classification, [normalizedTarget]);
|
|
return {
|
|
runtimeProfile: context.runtime.runtimeProfileName,
|
|
intentProfile: context.runtime.intentProfile,
|
|
memoryBand: context.runtime.memoryBand,
|
|
loadBand: context.runtime.loadBand,
|
|
file: classification.file,
|
|
surface: classification.legacyBasePinned ? "base" : classification.surface,
|
|
isolate: targetedUnit.isolate,
|
|
pool: targetedUnit.pool,
|
|
maxWorkers: targetedUnit.maxWorkers,
|
|
reasons: classification.reasons,
|
|
args: targetedUnit.args,
|
|
};
|
|
}
|
|
|
|
export function buildExecutionPlan(request, options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const explicitFileFilters = (request.fileFilters ?? []).map((value) => normalizeRepoPath(value));
|
|
const { fileFilters: passthroughFileFilters, optionArgs } = parsePassthroughArgs(
|
|
request.passthroughArgs ?? [],
|
|
);
|
|
const fileFilters = [...explicitFileFilters, ...passthroughFileFilters];
|
|
const passthroughMetadataFlags = new Set(["-h", "--help", "--listTags", "--clearCache"]);
|
|
const passthroughMetadataOnly =
|
|
(request.passthroughArgs ?? []).length > 0 &&
|
|
fileFilters.length === 0 &&
|
|
optionArgs.every((arg) => {
|
|
if (!arg.startsWith("-")) {
|
|
return false;
|
|
}
|
|
const [flag] = arg.split("=", 1);
|
|
return passthroughMetadataFlags.has(flag);
|
|
});
|
|
const passthroughRequiresSingleRun = optionArgs.some((arg) => {
|
|
if (!arg.startsWith("-")) {
|
|
return false;
|
|
}
|
|
const [flag] = arg.split("=", 1);
|
|
return SINGLE_RUN_ONLY_FLAGS.has(flag);
|
|
});
|
|
const context = createPlannerContext(
|
|
{
|
|
...request,
|
|
fileFilters,
|
|
passthroughOptionArgs: optionArgs,
|
|
},
|
|
options,
|
|
);
|
|
context.noIsolateArgs =
|
|
env.OPENCLAW_TEST_ISOLATE === "1" || env.OPENCLAW_TEST_ISOLATE === "true"
|
|
? []
|
|
: env.OPENCLAW_TEST_NO_ISOLATE !== "0" && env.OPENCLAW_TEST_NO_ISOLATE !== "false"
|
|
? ["--isolate=false"]
|
|
: [];
|
|
context.writeTempJsonArtifact =
|
|
options.writeTempJsonArtifact ??
|
|
(() => {
|
|
throw new Error("buildExecutionPlan requires writeTempJsonArtifact for include-file units");
|
|
});
|
|
|
|
const shardOverride = Number.parseInt(env.OPENCLAW_TEST_SHARDS ?? "", 10);
|
|
context.configuredShardCount =
|
|
Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null;
|
|
context.shardCount = context.configuredShardCount ?? (context.runtime.isWindowsCi ? 2 : 1);
|
|
const shardIndexOverride = Number.parseInt(env.OPENCLAW_TEST_SHARD_INDEX ?? "", 10);
|
|
context.shardIndexOverride =
|
|
Number.isFinite(shardIndexOverride) && shardIndexOverride > 0 ? shardIndexOverride : null;
|
|
|
|
if (context.shardIndexOverride !== null && context.shardCount <= 1) {
|
|
throw new Error(
|
|
`OPENCLAW_TEST_SHARD_INDEX=${String(context.shardIndexOverride)} requires OPENCLAW_TEST_SHARDS>1.`,
|
|
);
|
|
}
|
|
if (context.shardIndexOverride !== null && context.shardIndexOverride > context.shardCount) {
|
|
throw new Error(
|
|
`OPENCLAW_TEST_SHARD_INDEX=${String(context.shardIndexOverride)} exceeds OPENCLAW_TEST_SHARDS=${String(context.shardCount)}.`,
|
|
);
|
|
}
|
|
|
|
const defaultPlanning = buildDefaultUnits(context, { ...request, fileFilters });
|
|
let units = defaultPlanning.units;
|
|
const targetedUnits = buildTargetedUnits(context, {
|
|
...request,
|
|
fileFilters,
|
|
unitMemoryIsolatedFiles: defaultPlanning.unitMemoryIsolatedFiles,
|
|
});
|
|
if (context.configuredShardCount !== null && context.shardCount > 1) {
|
|
units = expandUnitsAcrossTopLevelShards(context, units);
|
|
}
|
|
const selectedUnits = targetedUnits.length > 0 ? targetedUnits : units;
|
|
const topLevelSingleShardAssignments = buildTopLevelSingleShardAssignments(context, units);
|
|
const parallelGatewayEnabled =
|
|
env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" ||
|
|
(!context.runtime.isCI && context.executionBudget.gatewayWorkers > 1);
|
|
const keepGatewaySerial =
|
|
context.runtime.isWindowsCi ||
|
|
env.OPENCLAW_TEST_SERIAL_GATEWAY === "1" ||
|
|
context.runtime.intentProfile === "serial" ||
|
|
!parallelGatewayEnabled;
|
|
const parallelUnits = keepGatewaySerial
|
|
? selectedUnits.filter((unit) => unit.surface !== "gateway")
|
|
: selectedUnits;
|
|
const serialUnits = keepGatewaySerial
|
|
? selectedUnits.filter((unit) => unit.surface === "gateway")
|
|
: [];
|
|
const serialPrefixUnits = parallelUnits.filter((unit) => unit.serialPhase);
|
|
const deferredParallelUnits = parallelUnits.filter((unit) => !unit.serialPhase);
|
|
const topLevelParallelEnabled = context.executionBudget.topLevelParallelEnabled;
|
|
const baseTopLevelParallelLimit =
|
|
context.noIsolateArgs.length > 0
|
|
? context.executionBudget.topLevelParallelLimitNoIsolate
|
|
: context.executionBudget.topLevelParallelLimitIsolated;
|
|
const defaultTopLevelParallelLimit = baseTopLevelParallelLimit;
|
|
const topLevelParallelLimit = Math.max(
|
|
1,
|
|
parseEnvNumber(env, "OPENCLAW_TEST_TOP_LEVEL_CONCURRENCY", defaultTopLevelParallelLimit),
|
|
);
|
|
const deferredRunConcurrency = context.executionBudget.deferredRunConcurrency;
|
|
|
|
return {
|
|
runtimeCapabilities: context.runtime,
|
|
executionBudget: context.executionBudget,
|
|
passthroughOptionArgs: optionArgs,
|
|
passthroughRequiresSingleRun,
|
|
passthroughMetadataOnly,
|
|
fileFilters,
|
|
allUnits: units,
|
|
selectedUnits,
|
|
targetedUnits,
|
|
parallelUnits,
|
|
serialUnits,
|
|
serialPrefixUnits,
|
|
deferredParallelUnits,
|
|
topLevelParallelEnabled,
|
|
topLevelParallelLimit,
|
|
deferredRunConcurrency,
|
|
keepGatewaySerial,
|
|
shardCount: context.shardCount,
|
|
shardIndexOverride: context.shardIndexOverride,
|
|
topLevelSingleShardAssignments,
|
|
};
|
|
}
|