mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 19:36:39 +01:00
1458 lines
48 KiB
JavaScript
1458 lines
48 KiB
JavaScript
import path from "node:path";
|
|
import { isUnitConfigTestFile } from "../../vitest.unit-paths.mjs";
|
|
import {
|
|
loadChannelTimingManifest,
|
|
loadExtensionTimingManifest,
|
|
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 parseBooleanLike = (value, fallback = false) => {
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") {
|
|
return true;
|
|
}
|
|
if (normalized === "false" || normalized === "0" || normalized === "") {
|
|
return false;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
|
|
const sumKnownManifestDurationsMs = (manifest) =>
|
|
Object.values(manifest.files ?? {}).reduce((totalMs, entry) => totalMs + entry.durationMs, 0);
|
|
|
|
const resolveDynamicShardCount = ({
|
|
estimatedDurationMs,
|
|
fileCount,
|
|
targetDurationMs,
|
|
targetFilesPerShard,
|
|
minShards,
|
|
maxShards,
|
|
}) => {
|
|
const durationDriven =
|
|
Number.isFinite(targetDurationMs) && targetDurationMs > 0
|
|
? Math.ceil(estimatedDurationMs / targetDurationMs)
|
|
: 1;
|
|
const fileDriven =
|
|
Number.isFinite(targetFilesPerShard) && targetFilesPerShard > 0
|
|
? Math.ceil(fileCount / targetFilesPerShard)
|
|
: 1;
|
|
return clamp(Math.max(minShards, durationDriven, fileDriven), minShards, maxShards);
|
|
};
|
|
|
|
const createShardMatrixEntries = ({ checkNamePrefix, runtime, task, command, shardCount }) =>
|
|
Array.from({ length: shardCount }, (_, index) => ({
|
|
check_name: `${checkNamePrefix}-${String(index + 1)}`,
|
|
runtime,
|
|
task,
|
|
command,
|
|
shard_index: index + 1,
|
|
shard_count: shardCount,
|
|
}));
|
|
|
|
const parseChangedExtensionsMatrix = (value) => {
|
|
if (typeof value === "object" && value !== null && Array.isArray(value.include)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (parsed && typeof parsed === "object" && Array.isArray(parsed.include)) {
|
|
return parsed;
|
|
}
|
|
} catch {}
|
|
}
|
|
return { include: [] };
|
|
};
|
|
|
|
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 extensionTimingManifest = loadExtensionTimingManifest();
|
|
const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest();
|
|
return {
|
|
env,
|
|
runtime,
|
|
executionBudget,
|
|
catalog,
|
|
unitTimingManifest,
|
|
channelTimingManifest,
|
|
extensionTimingManifest,
|
|
unitMemoryHotspotManifest,
|
|
};
|
|
};
|
|
|
|
const resolveCIManifestScope = (scope = {}, env = process.env) => ({
|
|
eventName: scope.eventName ?? env.GITHUB_EVENT_NAME ?? "pull_request",
|
|
docsOnly: parseBooleanLike(scope.docsOnly ?? env.OPENCLAW_CI_DOCS_ONLY, false),
|
|
docsChanged: parseBooleanLike(scope.docsChanged ?? env.OPENCLAW_CI_DOCS_CHANGED, false),
|
|
runNode: parseBooleanLike(scope.runNode ?? env.OPENCLAW_CI_RUN_NODE, true),
|
|
runMacos: parseBooleanLike(scope.runMacos ?? env.OPENCLAW_CI_RUN_MACOS, true),
|
|
runAndroid: parseBooleanLike(scope.runAndroid ?? env.OPENCLAW_CI_RUN_ANDROID, true),
|
|
runWindows: parseBooleanLike(scope.runWindows ?? env.OPENCLAW_CI_RUN_WINDOWS, true),
|
|
runSkillsPython: parseBooleanLike(
|
|
scope.runSkillsPython ?? env.OPENCLAW_CI_RUN_SKILLS_PYTHON,
|
|
true,
|
|
),
|
|
hasChangedExtensions: parseBooleanLike(
|
|
scope.hasChangedExtensions ?? env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS,
|
|
false,
|
|
),
|
|
changedExtensionsMatrix: parseChangedExtensionsMatrix(
|
|
scope.changedExtensionsMatrix ?? env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX,
|
|
),
|
|
runChangedSmoke: parseBooleanLike(
|
|
scope.runChangedSmoke ?? env.OPENCLAW_CI_RUN_CHANGED_SMOKE,
|
|
true,
|
|
),
|
|
});
|
|
|
|
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") {
|
|
return (file) =>
|
|
context.channelTimingManifest.files[file]?.durationMs ??
|
|
context.channelTimingManifest.defaultDurationMs;
|
|
}
|
|
if (config === "vitest.extensions.config.ts") {
|
|
return (file) =>
|
|
context.extensionTimingManifest.files[file]?.durationMs ??
|
|
context.extensionTimingManifest.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 splitFilesByBalancedDurationBudget = (files, targetDurationMs, estimateDurationMs) => {
|
|
if (!Number.isFinite(targetDurationMs) || targetDurationMs <= 0 || files.length <= 1) {
|
|
return [files];
|
|
}
|
|
const totalDurationMs = files.reduce((sum, file) => sum + estimateDurationMs(file), 0);
|
|
const batchCount = clamp(Math.ceil(totalDurationMs / targetDurationMs), 1, files.length);
|
|
const originalOrder = new Map(files.map((file, index) => [file, index]));
|
|
return packFilesByDuration(files, batchCount, estimateDurationMs).map((batch) =>
|
|
[...batch].toSorted(
|
|
(left, right) => (originalOrder.get(left) ?? 0) - (originalOrder.get(right) ?? 0),
|
|
),
|
|
);
|
|
};
|
|
|
|
const resolveUnitFastBatchTargetMs = ({ context, selectedSurfaceSet, unitOnlyRun }) => {
|
|
const defaultTargetMs = context.executionBudget.unitFastBatchTargetMs;
|
|
if (
|
|
!unitOnlyRun &&
|
|
selectedSurfaceSet.size > 1 &&
|
|
!context.runtime.isCI &&
|
|
context.runtime.memoryBand === "high"
|
|
) {
|
|
return Math.max(defaultTargetMs, 75_000);
|
|
}
|
|
return defaultTargetMs;
|
|
};
|
|
|
|
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 === "channels") {
|
|
return budget.channelSharedWorkers ?? budget.unitSharedWorkers;
|
|
}
|
|
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,
|
|
extensionTimingManifest,
|
|
} = context;
|
|
const noIsolateArgs = context.noIsolateArgs;
|
|
const selectedSurfaces = buildRequestedSurfaces(request, env);
|
|
const selectedSurfaceSet = new Set(selectedSurfaces);
|
|
const unitOnlyRun = selectedSurfaceSet.size === 1 && selectedSurfaceSet.has("unit");
|
|
const channelsOnlyRun = selectedSurfaceSet.size === 1 && selectedSurfaceSet.has("channels");
|
|
const extensionsOnlyRun = selectedSurfaceSet.size === 1 && selectedSurfaceSet.has("extensions");
|
|
|
|
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 estimateExtensionDurationMs = (file) =>
|
|
extensionTimingManifest.files[file]?.durationMs ?? extensionTimingManifest.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 = resolveUnitFastBatchTargetMs({
|
|
context,
|
|
selectedSurfaceSet,
|
|
unitOnlyRun,
|
|
});
|
|
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: unitOnlyRun ? undefined : "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,
|
|
estimatedDurationMs: estimateUnitDurationMs(file),
|
|
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,
|
|
estimatedDurationMs: files.reduce((sum, file) => sum + estimateUnitDurationMs(file), 0),
|
|
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,
|
|
estimatedDurationMs: estimateUnitDurationMs(file),
|
|
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("channels")) {
|
|
for (const file of catalog.channelIsolatedFiles) {
|
|
units.push(
|
|
createExecutionUnit(context, {
|
|
id: `${path.basename(file, ".test.ts")}-channels-isolated`,
|
|
surface: "channels",
|
|
isolate: true,
|
|
estimatedDurationMs: estimateChannelDurationMs(file),
|
|
args: [
|
|
"vitest",
|
|
"run",
|
|
"--config",
|
|
"vitest.channels.config.ts",
|
|
"--pool=forks",
|
|
...noIsolateArgs,
|
|
file,
|
|
],
|
|
reasons: ["channels-isolated-rule"],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
estimatedDurationMs: estimateExtensionDurationMs(file),
|
|
args: ["vitest", "run", "--config", "vitest.extensions.config.ts", "--pool=forks", file],
|
|
reasons: ["extensions-isolated-manifest"],
|
|
}),
|
|
);
|
|
}
|
|
const extensionBatches = splitFilesByBalancedDurationBudget(
|
|
extensionSharedCandidateFiles,
|
|
extensionsBatchTargetMs,
|
|
estimateExtensionDurationMs,
|
|
);
|
|
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: extensionsOnlyRun ? undefined : "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")) {
|
|
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: channelsOnlyRun ? undefined : "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 estimateUnitDurationMs = (file) =>
|
|
context.unitTimingManifest.files[file]?.durationMs ??
|
|
context.unitTimingManifest.defaultDurationMs;
|
|
const estimateChannelDurationMs = (file) =>
|
|
context.channelTimingManifest.files[file]?.durationMs ??
|
|
context.channelTimingManifest.defaultDurationMs;
|
|
const defaultTargetedUnitBatchTargetMs = 12_000;
|
|
const targetedUnitBatchTargetMs = parseEnvNumber(
|
|
context.env,
|
|
"OPENCLAW_TEST_TARGETED_UNIT_BATCH_TARGET_MS",
|
|
defaultTargetedUnitBatchTargetMs,
|
|
);
|
|
const defaultTargetedChannelsBatchTargetMs = 11_000;
|
|
const targetedChannelsBatchTargetMs = parseEnvNumber(
|
|
context.env,
|
|
"OPENCLAW_TEST_TARGETED_CHANNELS_BATCH_TARGET_MS",
|
|
defaultTargetedChannelsBatchTargetMs,
|
|
);
|
|
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],
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
classification.surface === "unit" &&
|
|
uniqueFilters.length > 4 &&
|
|
targetedUnitBatchTargetMs > 0
|
|
) {
|
|
const estimatedTotalDurationMs = uniqueFilters.reduce(
|
|
(totalMs, file) => totalMs + estimateUnitDurationMs(file),
|
|
0,
|
|
);
|
|
if (estimatedTotalDurationMs > targetedUnitBatchTargetMs) {
|
|
return splitFilesByBalancedDurationBudget(
|
|
uniqueFilters,
|
|
targetedUnitBatchTargetMs,
|
|
estimateUnitDurationMs,
|
|
).map((batch, batchIndex) =>
|
|
createExecutionUnit(context, {
|
|
...createTargetedUnit(context, classification, batch),
|
|
id: `unit-batch-${String(batchIndex + 1)}`,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
classification.surface === "channels" &&
|
|
uniqueFilters.length > 4 &&
|
|
targetedChannelsBatchTargetMs > 0
|
|
) {
|
|
const estimatedTotalDurationMs = uniqueFilters.reduce(
|
|
(totalMs, file) => totalMs + estimateChannelDurationMs(file),
|
|
0,
|
|
);
|
|
if (estimatedTotalDurationMs > targetedChannelsBatchTargetMs) {
|
|
return splitFilesByBalancedDurationBudget(
|
|
uniqueFilters,
|
|
targetedChannelsBatchTargetMs,
|
|
estimateChannelDurationMs,
|
|
).map((batch, batchIndex) =>
|
|
createExecutionUnit(context, {
|
|
...createTargetedUnit(context, classification, batch),
|
|
id: `channels-batch-${String(batchIndex + 1)}`,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
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 function buildCIExecutionManifest(scopeInput = {}, options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const scope = resolveCIManifestScope(scopeInput, env);
|
|
const context = createPlannerContext({ mode: "ci", profile: null }, { ...options, env });
|
|
const isPullRequest = scope.eventName === "pull_request";
|
|
const isPush = scope.eventName === "push";
|
|
const nodeEligible = !scope.docsOnly && scope.runNode;
|
|
const macosEligible = !scope.docsOnly && isPullRequest && scope.runMacos;
|
|
const windowsEligible = !scope.docsOnly && scope.runWindows;
|
|
const androidEligible = !scope.docsOnly && scope.runAndroid;
|
|
const docsEligible = scope.docsChanged;
|
|
const skillsPythonEligible = !scope.docsOnly && (isPush || scope.runSkillsPython);
|
|
const extensionFastEligible = nodeEligible && scope.hasChangedExtensions;
|
|
|
|
const channelCandidateFiles = context.catalog.allKnownTestFiles.filter((file) =>
|
|
context.catalog.channelTestPrefixes.some((prefix) => file.startsWith(prefix)),
|
|
);
|
|
const unitShardCount = resolveDynamicShardCount({
|
|
estimatedDurationMs: sumKnownManifestDurationsMs(context.unitTimingManifest),
|
|
fileCount: context.catalog.allKnownUnitFiles.length,
|
|
targetDurationMs: 30_000,
|
|
targetFilesPerShard: 80,
|
|
minShards: 1,
|
|
maxShards: 4,
|
|
});
|
|
const channelShardCount = resolveDynamicShardCount({
|
|
estimatedDurationMs: sumKnownManifestDurationsMs(context.channelTimingManifest),
|
|
fileCount: channelCandidateFiles.length,
|
|
targetDurationMs: 90_000,
|
|
targetFilesPerShard: 150,
|
|
minShards: 1,
|
|
maxShards: 4,
|
|
});
|
|
const windowsShardCount = resolveDynamicShardCount({
|
|
estimatedDurationMs: sumKnownManifestDurationsMs(context.unitTimingManifest),
|
|
fileCount: context.catalog.allKnownUnitFiles.length,
|
|
targetDurationMs: 12_000,
|
|
targetFilesPerShard: 30,
|
|
minShards: 1,
|
|
maxShards: 6,
|
|
});
|
|
const macosNodeShardCount = resolveDynamicShardCount({
|
|
estimatedDurationMs: sumKnownManifestDurationsMs(context.unitTimingManifest),
|
|
fileCount: context.catalog.allKnownUnitFiles.length,
|
|
targetDurationMs: 12_000,
|
|
targetFilesPerShard: 30,
|
|
minShards: 1,
|
|
maxShards: 9,
|
|
});
|
|
const bunShardCount = resolveDynamicShardCount({
|
|
estimatedDurationMs: sumKnownManifestDurationsMs(context.unitTimingManifest),
|
|
fileCount: context.catalog.allKnownUnitFiles.length,
|
|
targetDurationMs: 30_000,
|
|
targetFilesPerShard: 80,
|
|
minShards: 1,
|
|
maxShards: 4,
|
|
});
|
|
|
|
const checksFastInclude = nodeEligible
|
|
? [
|
|
{
|
|
check_name: "checks-fast-extensions",
|
|
runtime: "node",
|
|
task: "extensions",
|
|
command: "pnpm test:extensions",
|
|
},
|
|
{
|
|
check_name: "checks-fast-contracts-protocol",
|
|
runtime: "node",
|
|
task: "contracts-protocol",
|
|
command: "pnpm test:contracts\npnpm protocol:check",
|
|
},
|
|
]
|
|
: [];
|
|
const checksInclude = nodeEligible
|
|
? [
|
|
...createShardMatrixEntries({
|
|
checkNamePrefix: "checks-node-test",
|
|
runtime: "node",
|
|
task: "test",
|
|
command: "pnpm test",
|
|
shardCount: unitShardCount,
|
|
}),
|
|
...createShardMatrixEntries({
|
|
checkNamePrefix: "checks-node-channels",
|
|
runtime: "node",
|
|
task: "channels",
|
|
command: "pnpm test:channels",
|
|
shardCount: channelShardCount,
|
|
}),
|
|
...(isPush
|
|
? [
|
|
{
|
|
check_name: "checks-node-compat-node22",
|
|
runtime: "node",
|
|
task: "compat-node22",
|
|
node_version: "22.x",
|
|
cache_key_suffix: "node22",
|
|
command: [
|
|
"pnpm build",
|
|
"pnpm ui:build",
|
|
"node openclaw.mjs --help",
|
|
"node openclaw.mjs status --json --timeout 1",
|
|
"pnpm test:build:singleton",
|
|
].join("\n"),
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
: [];
|
|
const checksWindowsInclude = windowsEligible
|
|
? createShardMatrixEntries({
|
|
checkNamePrefix: "checks-windows-node-test",
|
|
runtime: "node",
|
|
task: "test",
|
|
command: "pnpm test",
|
|
shardCount: windowsShardCount,
|
|
})
|
|
: [];
|
|
const macosNodeInclude = macosEligible
|
|
? createShardMatrixEntries({
|
|
checkNamePrefix: "macos-node",
|
|
runtime: "node",
|
|
task: "test",
|
|
command: "pnpm test",
|
|
shardCount: macosNodeShardCount,
|
|
})
|
|
: [];
|
|
const androidInclude = androidEligible
|
|
? [
|
|
{
|
|
check_name: "android-test-play",
|
|
task: "test-play",
|
|
command: "./gradlew --no-daemon :app:testPlayDebugUnitTest",
|
|
},
|
|
{
|
|
check_name: "android-test-third-party",
|
|
task: "test-third-party",
|
|
command: "./gradlew --no-daemon :app:testThirdPartyDebugUnitTest",
|
|
},
|
|
{
|
|
check_name: "android-build-play",
|
|
task: "build-play",
|
|
command: "./gradlew --no-daemon :app:assemblePlayDebug",
|
|
},
|
|
{
|
|
check_name: "android-build-third-party",
|
|
task: "build-third-party",
|
|
command: "./gradlew --no-daemon :app:assembleThirdPartyDebug",
|
|
},
|
|
]
|
|
: [];
|
|
const bunChecksInclude = createShardMatrixEntries({
|
|
checkNamePrefix: "bun-checks",
|
|
runtime: "bun",
|
|
task: "test",
|
|
command: "bunx vitest run --config vitest.unit.config.ts",
|
|
shardCount: bunShardCount,
|
|
});
|
|
const extensionFastInclude = extensionFastEligible
|
|
? scope.changedExtensionsMatrix.include.map((entry) => ({
|
|
check_name: `extension-fast-${entry.extension}`,
|
|
extension: entry.extension,
|
|
}))
|
|
: [];
|
|
|
|
const jobs = {
|
|
buildArtifacts: { enabled: nodeEligible, needsDistArtifacts: false },
|
|
checksFast: { enabled: checksFastInclude.length > 0, matrix: { include: checksFastInclude } },
|
|
checks: { enabled: checksInclude.length > 0, matrix: { include: checksInclude } },
|
|
extensionFast: {
|
|
enabled: extensionFastInclude.length > 0,
|
|
matrix: { include: extensionFastInclude },
|
|
},
|
|
check: { enabled: !scope.docsOnly },
|
|
checkAdditional: { enabled: !scope.docsOnly },
|
|
buildSmoke: { enabled: nodeEligible },
|
|
checkDocs: { enabled: docsEligible },
|
|
skillsPython: { enabled: skillsPythonEligible },
|
|
checksWindows: {
|
|
enabled: checksWindowsInclude.length > 0,
|
|
matrix: { include: checksWindowsInclude },
|
|
},
|
|
macosNode: { enabled: macosNodeInclude.length > 0, matrix: { include: macosNodeInclude } },
|
|
macosSwift: { enabled: macosEligible },
|
|
android: { enabled: androidInclude.length > 0, matrix: { include: androidInclude } },
|
|
bunChecks: { enabled: bunChecksInclude.length > 0, matrix: { include: bunChecksInclude } },
|
|
installSmoke: { enabled: !scope.docsOnly && scope.runChangedSmoke },
|
|
};
|
|
|
|
return {
|
|
runtimeProfile: context.runtime.runtimeProfileName,
|
|
scope,
|
|
shardCounts: {
|
|
unit: unitShardCount,
|
|
channels: channelShardCount,
|
|
windows: windowsShardCount,
|
|
macosNode: macosNodeShardCount,
|
|
bun: bunShardCount,
|
|
},
|
|
jobs,
|
|
requiredCheckNames: [
|
|
...checksFastInclude.map((entry) => entry.check_name),
|
|
...checksInclude.map((entry) => entry.check_name),
|
|
...checksWindowsInclude.map((entry) => entry.check_name),
|
|
...macosNodeInclude.map((entry) => entry.check_name),
|
|
...(macosEligible ? ["macos-swift"] : []),
|
|
...androidInclude.map((entry) => entry.check_name),
|
|
...extensionFastInclude.map((entry) => entry.check_name),
|
|
...bunChecksInclude.map((entry) => entry.check_name),
|
|
"check",
|
|
"check-additional",
|
|
"build-smoke",
|
|
...(docsEligible ? ["check-docs"] : []),
|
|
...(skillsPythonEligible ? ["skills-python"] : []),
|
|
...(nodeEligible ? ["build-artifacts"] : []),
|
|
],
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|