mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
perf(ci): overlap bounded Telegram test processes
Admit two source-only singleton processes on measured Linux capacity while preserving file isolation, worker ceilings, cache ownership and focused failure draining. Keep serial fallback prices until exact parallel measurements qualify. The constrained 199-case comparison improved from 184.40s to 147.31s; three qualified replays completed in 146.13-149.34s with at most 4.51 GiB aggregate memory. Runner classes and row counts remain unchanged. Linux owner, planner, changed-file and Knip proof plus focused fixture corrections and P2 review completed.
This commit is contained in:
parent
fa90c83d1a
commit
116a1571b9
13 changed files with 405 additions and 47 deletions
|
|
@ -487,7 +487,16 @@ These are intentionally guarded by the `ci-workflow-guards`,
|
|||
remain intact.
|
||||
The canonical shard executor admits two CI children only with at least eight
|
||||
available CPUs and 24 GiB actual memory; otherwise it admits one. Inner project
|
||||
parallelism stays one and each overlapping child keeps two Vitest workers.
|
||||
parallelism stays one during outer overlap and each overlapping child keeps two Vitest workers.
|
||||
A serial changed-extension envelope may overlap two source-only Telegram database-worker
|
||||
singleton processes, retaining two workers per process, only with scheduler-owned caches,
|
||||
at least two actual CPUs and 7.5 GiB effective memory (the smaller of physical memory and
|
||||
a positive finite process constraint). Require self-hosted Linux, an explicit current-target
|
||||
receipt (`FROZEN_TARGET=false`), and one unchanged Node invocation from the canonical
|
||||
runtime selector; `bun-compatible` can still select Node. Other shapes, runtimes and
|
||||
unknown/insufficient capacity retain one inner process. Use the actual inner-cap receipt
|
||||
in timing identity; parallel envelopes never supply serial singleton or wrapper-overhead
|
||||
prices. Keep conservative serial placement until qualified exact parallel measurements exist.
|
||||
Gateway methods use four workers in serial, non-frozen self-hosted jobs with
|
||||
at least eight actual CPUs and 28 GiB memory, with the existing two-worker
|
||||
fallback elsewhere. Keep its worker-specific timing identity and require
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ Median rates are estimates, not elapsed-time guarantees. Before the Codex fixtur
|
|||
|
||||
GitHub-hosted core storage/state stripes also have a 64-file admission ceiling. In [run 35477045216](https://github.com/openclaw/openclaw/actions/runs/35477045216), a 203-file serial stripe continued passing tests until the one-hour job deadline; its 196-file sibling completed in 2,867 test seconds. The compact split owner bounds each hosted prerequisite-preserving stripe independently of older timing estimates, and the existing distinct-family rule prevents reassembly in one job. Blacksmith and hybrid retain their existing measured partitioning. Every file remains covered once, with fork isolation, serial file execution, worker limits, deadlines, and the 90-row compact cap. This is a workload bound, not a measured new runtime; hosted CI must establish the resulting duration.
|
||||
|
||||
Eligible Blacksmith and hybrid compact bins with multiple ordinary groups request the existing 32-vCPU runner with two child-process slots. Initial packing retains the 360-second aggregate budget and completes Gateway serialization, runtime placement, and worker pinning before compaction. Only jobs already admitted on the 32-class with two child-process slots are then repacked into 500-second bins. Those jobs may share capacity across logical runner classes while each child retains its original runner metadata and worker ceiling. Existing serial jobs retain their membership, ordering, estimates, and worker limits; widening their initial packing can concentrate slow Gateway work even when their final estimate remains below 360 seconds. Compatible parallel groups can fill the new budget without the ten-group cutoff retained by serial jobs. Blacksmith serial jobs retain their 200/276-second budgets; hybrid serial jobs retain 210 seconds. Exclusive jobs retain 150 seconds by default. Only complete ordinary hybrid bins of non-build CLI groups may use 250 seconds and share split siblings; every child must still fit 150 seconds. Groups above their existing serial cap stay alone. Exclusive groups, original single groups, dist descriptors and jobs with runtime preparation remain serial. Hybrid exclusive and dist bins retain their existing prerequisite sharing. The shard executor admits at most two processes only when the actual host has at least eight available CPUs and 24 GiB of memory; smaller capacity admits one. Each overlapping child keeps two Vitest workers, including a singleton left after compaction; inner project parallelism remains one. Commands files follow the measured worker allocation in serial self-hosted rows, with a two-worker fallback for hosted, constrained, frozen, or overlapping execution; the existing non-isolated fork runner retains file-boundary cleanup. The primary `github` profile stays serial at 210 seconds. Preflight records the actual row count for each source revision; canonical inventory comparisons must preserve every original child plan and test input. Native elapsed-time, memory and cleanup evidence must establish the actual effect.
|
||||
Eligible Blacksmith and hybrid compact bins with multiple ordinary groups request the existing 32-vCPU runner with two child-process slots. Initial packing retains the 360-second aggregate budget and completes Gateway serialization, runtime placement, and worker pinning before compaction. Only jobs already admitted on the 32-class with two child-process slots are then repacked into 500-second bins. Those jobs may share capacity across logical runner classes while each child retains its original runner metadata and worker ceiling. Existing serial jobs retain their membership, ordering, estimates, and worker limits; widening their initial packing can concentrate slow Gateway work even when their final estimate remains below 360 seconds. Compatible parallel groups can fill the new budget without the ten-group cutoff retained by serial jobs. Blacksmith serial jobs retain their 200/276-second budgets; hybrid serial jobs retain 210 seconds. Exclusive jobs retain 150 seconds by default. Only complete ordinary hybrid bins of non-build CLI groups may use 250 seconds and share split siblings; every child must still fit 150 seconds. Groups above their existing serial cap stay alone. Exclusive groups, original single groups, dist descriptors and jobs with runtime preparation remain serial. Hybrid exclusive and dist bins retain their existing prerequisite sharing. The shard executor admits at most two processes only when the actual host has at least eight available CPUs and 24 GiB of memory; smaller capacity admits one. Each overlapping child keeps two Vitest workers, including a singleton left after compaction; inner project parallelism remains one during outer overlap. Serial source-only Telegram database-worker envelopes have the narrower [inner-process exception](/ci/routing-costs#telegram-process-overlap). Commands files follow the measured worker allocation in serial self-hosted rows, with a two-worker fallback for hosted, constrained, frozen, or overlapping execution; the existing non-isolated fork runner retains file-boundary cleanup. The primary `github` profile stays serial at 210 seconds. Preflight records the actual row count for each source revision; canonical inventory comparisons must preserve every original child plan and test input. Native elapsed-time, memory and cleanup evidence must establish the actual effect.
|
||||
|
||||
The `github` compact planner can place smaller ordinary groups on an already-required stronger logical runner. Each emitted job retains its strongest capacity owner, serial execution, original child processes and worker limits, the 210-second budget, and the ten-group limit. SDK and plugin runtime consumers are separated from ordinary files before packing; the SDK light project intersects shared include lists with its own inventory. Parent-derived fractional costs are rounded only at the emitted job boundary, so rounding small consumers cannot create a redundant runtime build. The shared packer can exchange groups to fill compatible capacity while rechecking complete replacements. Runtime-only hosted groups may share their strongest prerequisite within the existing 210-second serial budget; the build itself costs 160–166 seconds and is charged once per job. No-build exclusive groups keep their 150-second limit. Stranded tooling tails are sized against remaining compatible capacity before normal admission is reapplied. Exclusive, dist, oversized, and runtime-preparing bins retain their separate constraints.
|
||||
|
||||
|
|
@ -485,11 +485,12 @@ output ownership is explicit. Use a console reporter such as `--reporter=dot`
|
|||
with `--coverage.enabled=false`, or the existing multi-invocation JSON report
|
||||
owner. Unresolved config outputs and GitHub summary reporters stay serial;
|
||||
explicit parallel overrides retain their caller-owned output policy.
|
||||
Selected specs and file membership remain unchanged. Explicit
|
||||
`OPENCLAW_TEST_PROJECTS_PARALLEL` and `OPENCLAW_TEST_PROJECTS_SERIAL` settings keep
|
||||
their existing behavior. Automatic admission stops after a failure or unverified
|
||||
join and drains already admitted work; bounded scheduling does not establish that a
|
||||
full protected proof fits its lease or workflow deadline.
|
||||
Selected specs and file membership remain unchanged. Explicit local parallel
|
||||
settings and full-suite continuation retain their existing behavior. Focused CI
|
||||
shards stop admission after failure even when inner processes overlap, unless
|
||||
ordinary joined failures have explicit continuation enabled. Automatic admission
|
||||
also stops after an unverified join and drains admitted work; bounded scheduling
|
||||
does not establish that a full protected proof fits its lease or workflow deadline.
|
||||
|
||||
Ordinary compact Gateway bins retain their 300-second predicted test budget.
|
||||
Formerly parallel bins keep their two-worker ceiling through the job environment,
|
||||
|
|
|
|||
|
|
@ -67,10 +67,35 @@ qualifiers must not discard the existing owner measurement.
|
|||
|
||||
Process-bounded plugin owners also retain per-file invocation prices across
|
||||
envelope changes. These prices have distinct keys from whole-envelope walls;
|
||||
shared wrapper overhead is charged once. Only complete successful receipts
|
||||
shared wrapper overhead is charged once. Only complete successful serial receipts
|
||||
with one declared file per process qualify. Other process owners and missing
|
||||
measurements keep their existing estimates.
|
||||
|
||||
### Telegram process overlap
|
||||
|
||||
Source-only Telegram database-worker envelopes with at least two singleton
|
||||
processes can overlap two inner processes on current self-hosted Linux runners.
|
||||
Admission requires an explicit current target (`FROZEN_TARGET=false`), one unchanged
|
||||
Node invocation from the canonical runtime selector (including `bun-compatible`),
|
||||
two workers, one outer plan, scheduler-owned caches, at least two actual CPUs, and
|
||||
7.5 GiB of effective memory. Effective memory is the smaller
|
||||
of physical memory and a positive finite process constraint. Other selections or
|
||||
insufficient capacity retain one inner process. Each test file keeps its own
|
||||
process, and focused CI still stops admitting new work after failure while
|
||||
joining admitted peers.
|
||||
|
||||
The executor reports actual inner concurrency, including serial fallback. Its
|
||||
value participates in the existing environment-bound timing identity. Parallel
|
||||
receipts supply only the complete envelope wall; overlapping file durations are
|
||||
never summed and subtracted to derive wrapper overhead. Until that exact policy
|
||||
has qualified measurements, placement retains the conservative serial estimate.
|
||||
|
||||
A 199-case envelope measured 184.40 seconds serially and 147.31 seconds with two
|
||||
processes on a Linux Testbox constrained to two CPUs and 7.65 GiB. Three further
|
||||
qualified replays took 147.46, 146.13 and 149.34 seconds. Peak aggregate memory
|
||||
stayed below 4.51 GiB, with no OOM events and the same cases. Whole-row budgets
|
||||
still need qualification from naturally occurring CI runs.
|
||||
|
||||
Storage balancing additionally uses 36 config-scoped module-work hints from an
|
||||
unchanged two-worker replay of the exact tested merge. Setup, collection, and
|
||||
suite elapsed time guide placement; the longest file is never divided by the
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { StringDecoder } from "node:string_decoder";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { decodeNodeTestGroups } from "./lib/ci-node-test-groups-codec.mts";
|
||||
import type { CiTestRuntimeSelection } from "./lib/ci-test-runtime.mts";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { isConstrainedCiCheckHost, isExclusiveCiTestConfig } from "./lib/local-check-runtime.mts";
|
||||
import { parsePositiveInt, readPositiveEnvInt } from "./lib/numeric-options.mjs";
|
||||
|
|
@ -36,7 +37,7 @@ import {
|
|||
import type { VitestWorkerRun } from "./lib/vitest-worker-run.mts";
|
||||
|
||||
// CI admits at most two plans only when the actual host has room. Each plan
|
||||
// keeps inner test-projects parallelism 1; runner labels cannot establish capacity.
|
||||
// normally keeps inner parallelism 1; qualified singleton envelopes have a separate admission.
|
||||
const FS_MODULE_CACHE_ROOT_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT";
|
||||
const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH";
|
||||
const FS_MODULE_CACHE_WRITER_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER";
|
||||
|
|
@ -77,6 +78,8 @@ function reportCiResourceSnapshot(phase: "start" | "end") {
|
|||
export type ShardTargetPlan = { kind: "target"; name: string; target: string };
|
||||
type ShardGroupConfig = {
|
||||
configs: string[];
|
||||
requiresDist?: boolean;
|
||||
pretestBuildMode?: "runtime" | "private-qa";
|
||||
fallbackMaxWorkers?: number;
|
||||
minTotalMemoryBytes?: number;
|
||||
env?: Record<string, unknown> | null;
|
||||
|
|
@ -592,7 +595,7 @@ export async function runShardPlans(plans: ShardPlan[], options: RunShardOptions
|
|||
concurrency = Math.min(plans.length, 1);
|
||||
}
|
||||
// Final plan admission owns both compiler and child worker budgets.
|
||||
const admittedPlans = plans.map((entry): ShardPlan => {
|
||||
let admittedPlans = plans.map((entry): ShardPlan => {
|
||||
if (entry.kind !== "group" || entry.plan.fallbackMaxWorkers === undefined) {
|
||||
return entry;
|
||||
}
|
||||
|
|
@ -618,6 +621,81 @@ export async function runShardPlans(plans: ShardPlan[], options: RunShardOptions
|
|||
},
|
||||
};
|
||||
});
|
||||
const preparedRuntimeSelections = new Map<ShardPlan, CiTestRuntimeSelection[]>();
|
||||
const overlapRequests = admittedPlans.filter(
|
||||
(entry): entry is ShardGroupPlan =>
|
||||
entry.kind === "group" &&
|
||||
entry.name.startsWith("changed-extensions-config") &&
|
||||
entry.plan.env?.OPENCLAW_TEST_PROJECTS_PARALLEL === "2",
|
||||
);
|
||||
if (overlapRequests.length > 0) {
|
||||
const constrained = process.constrainedMemory?.() ?? 0;
|
||||
const memory = hostResources
|
||||
? Math.min(
|
||||
hostResources.totalMemoryBytes,
|
||||
Number.isFinite(constrained) && constrained > 0
|
||||
? constrained
|
||||
: hostResources.totalMemoryBytes,
|
||||
)
|
||||
: 0;
|
||||
const hostAdmitted =
|
||||
ci &&
|
||||
process.platform === "linux" &&
|
||||
baseEnv.RUNNER_ENVIRONMENT === "self-hosted" &&
|
||||
baseEnv.FROZEN_TARGET === "false" &&
|
||||
hostResources !== null &&
|
||||
Number.isFinite(hostResources.logicalCpuCount) &&
|
||||
hostResources.logicalCpuCount >= 2 &&
|
||||
Number.isFinite(memory) &&
|
||||
memory >= 7.5 * 1024 ** 3 &&
|
||||
concurrency === 1 &&
|
||||
Boolean(workerOwner) &&
|
||||
!callerCacheLeaf;
|
||||
const canOverlap = hostAdmitted
|
||||
? (await import("./lib/extension-test-plan.mts")).canOverlapTelegramSingletonProcesses
|
||||
: undefined;
|
||||
admittedPlans = admittedPlans.map((entry) => {
|
||||
if (entry.kind !== "group" || !overlapRequests.includes(entry)) {
|
||||
return entry;
|
||||
}
|
||||
const env = prepareChildEnv(entry, baseEnv);
|
||||
const shapeAdmitted =
|
||||
canOverlap !== undefined &&
|
||||
entry.plan.configs.length === 1 &&
|
||||
!entry.plan.requiresDist &&
|
||||
entry.plan.pretestBuildMode === undefined &&
|
||||
env.OPENCLAW_VITEST_MAX_WORKERS === "2" &&
|
||||
[inheritedEnv[VITEST_EXTRA_ARGS_ENV_KEY], env[VITEST_EXTRA_ARGS_ENV_KEY]].every(
|
||||
(args) => !args?.trim() || args.trim() === "[]",
|
||||
) &&
|
||||
canOverlap(entry.plan.configs[0]!, entry.plan.includePatterns);
|
||||
const selections: CiTestRuntimeSelection[] = shapeAdmitted
|
||||
? (runtimeOwner?.resolveCiTestRuntimeSelections(
|
||||
{ ...entry.plan, env, vitestArgs: [] },
|
||||
policy,
|
||||
) ?? [{ runtime: "node" }])
|
||||
: [];
|
||||
const [selected] = selections;
|
||||
const admitted =
|
||||
selections.length === 1 &&
|
||||
selected?.runtime === "node" &&
|
||||
selected.configs === undefined &&
|
||||
selected.includePatterns === undefined &&
|
||||
selected.includeAfterShard === undefined &&
|
||||
selected.env === undefined;
|
||||
const prepared: ShardGroupPlan = {
|
||||
...entry,
|
||||
plan: {
|
||||
...entry.plan,
|
||||
env: { ...entry.plan.env, OPENCLAW_TEST_PROJECTS_PARALLEL: admitted ? "2" : "1" },
|
||||
},
|
||||
};
|
||||
if (admitted) {
|
||||
preparedRuntimeSelections.set(prepared, selections);
|
||||
}
|
||||
return prepared;
|
||||
});
|
||||
}
|
||||
const scratchDir = options.scratchDir ?? mkdtempSync(join(tmpdir(), "openclaw-node-shard-"));
|
||||
const persistentCacheRoot =
|
||||
baseEnv[FS_MODULE_CACHE_ROOT_ENV_KEY]?.trim() || baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim();
|
||||
|
|
@ -678,14 +756,15 @@ export async function runShardPlans(plans: ShardPlan[], options: RunShardOptions
|
|||
const value = parseJsonEnv(env, VITEST_EXTRA_ARGS_ENV_KEY, []);
|
||||
return isStringArray(value) ? value : [];
|
||||
});
|
||||
const selections = runtimeOwner?.resolveCiTestRuntimeSelections(
|
||||
{
|
||||
...(entry.kind === "target" ? { targets: [entry.target] } : entry.plan),
|
||||
env: prepareChildEnv(entry, baseEnv),
|
||||
vitestArgs: vitestExtraArgs,
|
||||
},
|
||||
policy,
|
||||
) ?? [{ runtime: "node" as const }];
|
||||
const selections = preparedRuntimeSelections.get(entry) ??
|
||||
runtimeOwner?.resolveCiTestRuntimeSelections(
|
||||
{
|
||||
...(entry.kind === "target" ? { targets: [entry.target] } : entry.plan),
|
||||
env: prepareChildEnv(entry, baseEnv),
|
||||
vitestArgs: vitestExtraArgs,
|
||||
},
|
||||
policy,
|
||||
) ?? [{ runtime: "node" as const }];
|
||||
const [nodeSelection, bunSelection] = selections;
|
||||
// Only the proven UI partition has native sharding after discovery.
|
||||
// Run it once on Bun and reuse that owner's facts, never its algorithm.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { isCiProofTestFile } from "./ci-proof-test-inventory.mts";
|
||||
import { readCompactGroupTimings } from "./ci-test-timings.mts";
|
||||
import {
|
||||
canOverlapTelegramSingletonProcesses,
|
||||
createExtensionTestTimingKey,
|
||||
DATABASE_WORKER_CONFIG,
|
||||
DATABASE_WORKER_TEST_JOB_FILE_LIMIT,
|
||||
|
|
@ -142,19 +143,22 @@ export function createChangedExtensionConfigShards(
|
|||
const partitionSeconds = Math.ceil(
|
||||
estimateExtensionTestCost(config, testFiles.length, testFiles) / chunks.length,
|
||||
);
|
||||
return chunks.map((includePatterns, index) =>
|
||||
Object.assign(
|
||||
return chunks.map((includePatterns, index) => {
|
||||
const env = canOverlapTelegramSingletonProcesses(config, includePatterns)
|
||||
? { OPENCLAW_VITEST_MAX_WORKERS: "2", OPENCLAW_TEST_PROJECTS_PARALLEL: "2" }
|
||||
: undefined;
|
||||
return Object.assign(
|
||||
{
|
||||
config,
|
||||
pretestBuildMode: splitProcesses
|
||||
? mergeVitestPretestBuildModes(includePatterns.map((file) => buildModes.get(file)))
|
||||
: configBuildMode,
|
||||
predictedSeconds: splitProcesses
|
||||
? estimateExtensionTestCost(config, includePatterns.length, includePatterns)
|
||||
? estimateExtensionTestCost(config, includePatterns.length, includePatterns, env)
|
||||
: partitionSeconds,
|
||||
},
|
||||
splitProcesses
|
||||
? { includePatterns }
|
||||
? { includePatterns, ...(env ? { env } : {}) }
|
||||
: chunks.length > 1
|
||||
? {
|
||||
// Counts size jobs only. Vitest owns the complete config inventory,
|
||||
|
|
@ -169,8 +173,8 @@ export function createChangedExtensionConfigShards(
|
|||
// Native-sharded configs keep their process contract while every shard
|
||||
// consumes the same selected inventory before Vitest partitions it.
|
||||
!splitProcesses && runtimeFiltered ? { includePatterns: testFiles } : {},
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
return plans.map(
|
||||
({ config, env, includePatterns, pretestBuildMode, predictedSeconds }, index) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type RuntimePlacementTiming,
|
||||
} from "./ci-test-timings-schema.mts";
|
||||
import {
|
||||
canOverlapTelegramSingletonProcesses,
|
||||
createExtensionTestTimingKey,
|
||||
splitExtensionTestProcessTargets,
|
||||
} from "./extension-test-plan.mts";
|
||||
|
|
@ -503,10 +504,46 @@ function readCompactLog(
|
|||
selectedKey.slice(selectedKey.lastIndexOf("#include-")),
|
||||
);
|
||||
}
|
||||
const singletonLog = group && singletonLogs.get(group.shard_name);
|
||||
const parallelReceipts = (singletonLog?.lines ?? []).flatMap((outputLine) => {
|
||||
const receipt = /\[shard:[^\]]+\] \[test\] inner parallelism ([1-9]\d*)$/u.exec(outputLine);
|
||||
return receipt ? [Number(receipt[1])] : [];
|
||||
});
|
||||
const requestedParallel = group?.env?.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const innerParallelism =
|
||||
extensionGroup && (requestedParallel === "2" || parallelReceipts.length > 0)
|
||||
? parallelReceipts.length === 1 &&
|
||||
(parallelReceipts[0] === 1 || parallelReceipts[0] === 2)
|
||||
? parallelReceipts[0]
|
||||
: undefined
|
||||
: 1;
|
||||
const parallelQualified =
|
||||
innerParallelism !== 2 ||
|
||||
(workerCeiling === 2 &&
|
||||
group?.configs.length === 1 &&
|
||||
!runtimeModes.has(key) &&
|
||||
canOverlapTelegramSingletonProcesses(group.configs[0]!, selectedFiles));
|
||||
const sampledEnv: Record<string, string> = {
|
||||
...group?.env,
|
||||
...(typeof workerCeiling === "number"
|
||||
? { OPENCLAW_VITEST_MAX_WORKERS: String(workerCeiling) }
|
||||
: {}),
|
||||
};
|
||||
if (extensionGroup) {
|
||||
delete sampledEnv.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
if (innerParallelism === 2) {
|
||||
sampledEnv.OPENCLAW_TEST_PROJECTS_PARALLEL = "2";
|
||||
}
|
||||
}
|
||||
let exactKey: string | undefined;
|
||||
if (group && !splitTiming && typeof workerCeiling === "number" && hasExactSelection) {
|
||||
const env = { ...group.env, OPENCLAW_VITEST_MAX_WORKERS: String(workerCeiling) };
|
||||
if (extensionGroup && group.configs.length === 1) {
|
||||
const env = sampledEnv;
|
||||
if (
|
||||
extensionGroup &&
|
||||
group.configs.length === 1 &&
|
||||
innerParallelism !== undefined &&
|
||||
parallelQualified
|
||||
) {
|
||||
exactKey = createExtensionTestTimingKey(group.configs[0]!, selectedFiles, env);
|
||||
} else if (workerCeiling === 2 && key.endsWith("#file-parallel-2")) {
|
||||
// PR descriptors prove this selected inventory, never an unsplit family total.
|
||||
|
|
@ -519,12 +556,12 @@ function readCompactLog(
|
|||
}).timingKeys[0];
|
||||
}
|
||||
}
|
||||
const singletonLog = group && singletonLogs.get(group.shard_name);
|
||||
if (
|
||||
extensionGroup &&
|
||||
exactKey &&
|
||||
group &&
|
||||
singletonLog?.key === key &&
|
||||
innerParallelism === 1 &&
|
||||
!runtimeModes.has(key)
|
||||
) {
|
||||
const invocations = readSingletonExtensionInvocations(
|
||||
|
|
@ -536,7 +573,7 @@ function readCompactLog(
|
|||
const total = [...invocations.values()].reduce((sum, value) => sum + value, 0);
|
||||
const overhead = (Date.parse(timestamp) - started) / 1000 - total;
|
||||
if (overhead >= 0) {
|
||||
const env = { ...group.env, OPENCLAW_VITEST_MAX_WORKERS: String(workerCeiling) };
|
||||
const env = sampledEnv;
|
||||
for (const [file, duration] of invocations) {
|
||||
recordSample(
|
||||
samples[profile],
|
||||
|
|
@ -574,16 +611,15 @@ function readCompactLog(
|
|||
// An unsplit PR key does not prove that its full owner inventory ran.
|
||||
recordSample(samples[profile], measuredKey, (Date.parse(timestamp) - started) / 1000);
|
||||
}
|
||||
if (group && workerCeiling !== null) {
|
||||
if (
|
||||
group &&
|
||||
workerCeiling !== null &&
|
||||
(!extensionGroup || (innerParallelism !== undefined && parallelQualified))
|
||||
) {
|
||||
const observation = {
|
||||
configs: group.configs,
|
||||
env: Object.fromEntries(
|
||||
Object.entries({
|
||||
...group.env,
|
||||
...(workerCeiling === undefined
|
||||
? {}
|
||||
: { OPENCLAW_VITEST_MAX_WORKERS: String(workerCeiling) }),
|
||||
}).toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
Object.entries(sampledEnv).toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
),
|
||||
includePatterns: group.includePatterns?.toSorted(),
|
||||
pretestBuildMode: runtimeModes.get(key),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { isRuntimePlacementIncludePatterns } from "./ci-test-timings-schema.mts"
|
|||
import { readCompactGroupTimings } from "./ci-test-timings.mts";
|
||||
import { GIT_LS_FILES_MAX_BUFFER_BYTES } from "./list-test-files.mts";
|
||||
import { parsePositiveInt } from "./numeric-options.mjs";
|
||||
import { resolveVitestPretestBuildMode } from "./vitest-build-prerequisites.mts";
|
||||
import { createCompactSplitTimingGeneration } from "./vitest-shard-metadata.mts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..", "..");
|
||||
|
|
@ -350,6 +351,24 @@ export function splitExtensionTestProcessTargets(config: string, targets: string
|
|||
: [uniqueSortedTargets(targets)];
|
||||
}
|
||||
|
||||
/** Only the qualified Telegram native-worker cohort may overlap singleton processes. */
|
||||
export function canOverlapTelegramSingletonProcesses(
|
||||
config: string,
|
||||
files: readonly string[] | null | undefined,
|
||||
): boolean {
|
||||
if (
|
||||
config !== DATABASE_WORKER_CONFIG ||
|
||||
!isRuntimePlacementIncludePatterns(files) ||
|
||||
files.length < 2 ||
|
||||
!files.every((file) => file.startsWith("extensions/telegram/")) ||
|
||||
resolveVitestPretestBuildMode([{ configs: [config], includePatterns: files }]) !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const processes = splitExtensionTestProcessTargets(config, [...files]);
|
||||
return processes.length === files.length && processes.every((process) => process.length === 1);
|
||||
}
|
||||
|
||||
/** Split an extension config's test files into CI envelopes without changing process lifetime. */
|
||||
export function splitExtensionTestJobTargets(config: string, targets: string[]) {
|
||||
if (config === DATABASE_WORKER_CONFIG) {
|
||||
|
|
@ -438,9 +457,13 @@ export function createExtensionTestTimingKey(
|
|||
) {
|
||||
return undefined;
|
||||
}
|
||||
const timingEnv: Record<string, string> = { ...env };
|
||||
if (timingEnv.OPENCLAW_TEST_PROJECTS_PARALLEL === "1") {
|
||||
delete timingEnv.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
}
|
||||
return createCompactSplitTimingGeneration({
|
||||
configs: [config],
|
||||
env,
|
||||
env: timingEnv,
|
||||
parentShardName: `extension-test:${config}#workers-${env.OPENCLAW_VITEST_MAX_WORKERS}${kind === "envelope" ? "" : `#${kind}`}`,
|
||||
stripes: [files],
|
||||
}).timingKeys[0];
|
||||
|
|
@ -450,7 +473,19 @@ export function estimateExtensionTestCost(
|
|||
config: string,
|
||||
testFileCount: number,
|
||||
files: readonly string[] = [],
|
||||
env: Readonly<Record<string, string>> = { OPENCLAW_VITEST_MAX_WORKERS: "2" },
|
||||
) {
|
||||
if (env.OPENCLAW_TEST_PROJECTS_PARALLEL === "2") {
|
||||
const key =
|
||||
files.length === testFileCount ? createExtensionTestTimingKey(config, files, env) : undefined;
|
||||
const measured = key ? readCompactGroupTimings("blacksmith")[key] : undefined;
|
||||
if (measured !== undefined) {
|
||||
return Math.max(1, Math.ceil(measured));
|
||||
}
|
||||
// Retain the serial estimate until this policy has an exact measurement.
|
||||
const { OPENCLAW_TEST_PROJECTS_PARALLEL: _parallel, ...serialEnv } = env;
|
||||
return estimateExtensionTestCost(config, testFileCount, files, serialEnv);
|
||||
}
|
||||
const serialReference = EXTENSION_TEST_SERIAL_REFERENCE_COST_MULTIPLIERS[config];
|
||||
const multiplier =
|
||||
serialReference !== undefined && testFileCount <= 1
|
||||
|
|
@ -467,7 +502,7 @@ export function estimateExtensionTestCost(
|
|||
? files.filter((file) => file.startsWith("extensions/codex/src/app-server/")).length
|
||||
: 0;
|
||||
const key =
|
||||
files.length === testFileCount ? createExtensionTestTimingKey(config, files) : undefined;
|
||||
files.length === testFileCount ? createExtensionTestTimingKey(config, files, env) : undefined;
|
||||
const timings = readCompactGroupTimings("blacksmith");
|
||||
const measured = key ? timings[key] : undefined;
|
||||
const processes = key ? splitExtensionTestProcessTargets(config, [...files]) : [];
|
||||
|
|
@ -475,12 +510,7 @@ export function estimateExtensionTestCost(
|
|||
if (processes.length === files.length && processes.every((process) => process.length === 1)) {
|
||||
let observed = false;
|
||||
for (const file of files) {
|
||||
const fileKey = createExtensionTestTimingKey(
|
||||
config,
|
||||
[file],
|
||||
undefined,
|
||||
"singleton-invocation",
|
||||
)!;
|
||||
const fileKey = createExtensionTestTimingKey(config, [file], env, "singleton-invocation")!;
|
||||
const fileSeconds = timings[fileKey];
|
||||
observed ||= fileSeconds !== undefined;
|
||||
singletonSeconds += Math.max(
|
||||
|
|
@ -490,7 +520,7 @@ export function estimateExtensionTestCost(
|
|||
: multiplier,
|
||||
);
|
||||
}
|
||||
const overheadKey = createExtensionTestTimingKey(config, [], undefined, "wrapper-overhead");
|
||||
const overheadKey = createExtensionTestTimingKey(config, [], env, "wrapper-overhead");
|
||||
singletonSeconds = observed
|
||||
? singletonSeconds + (overheadKey ? (timings[overheadKey] ?? 0) : 0)
|
||||
: 0;
|
||||
|
|
|
|||
|
|
@ -532,6 +532,10 @@ export async function runTestProjects(
|
|||
targetArgs.length === 0 &&
|
||||
changedTargetArgs === null &&
|
||||
!runSpecs.some((spec) => spec.watchMode);
|
||||
const focusedCiShard =
|
||||
!isFullSuiteRun &&
|
||||
isCiLikeEnv(baseEnv) &&
|
||||
Boolean(baseEnv.OPENCLAW_VITEST_SHARD_NAME?.trim());
|
||||
const isExplicitParallelMultiConfigRun =
|
||||
Boolean(baseEnv.OPENCLAW_TEST_PROJECTS_PARALLEL) &&
|
||||
runSpecs.length > 1 &&
|
||||
|
|
@ -560,6 +564,9 @@ export async function runTestProjects(
|
|||
: isParallelShardRun
|
||||
? resolveParallelFullSuiteConcurrency(runSpecs.length, baseEnv)
|
||||
: 1;
|
||||
if (focusedCiShard) {
|
||||
console.error(`[test] inner parallelism ${concurrency}`);
|
||||
}
|
||||
if (automatic) {
|
||||
console.error(
|
||||
`[test] running ${runSpecs.length} exact-target plans with parallelism ${concurrency} and joined exclusive barriers`,
|
||||
|
|
@ -595,9 +602,7 @@ export async function runTestProjects(
|
|||
termination,
|
||||
automatic,
|
||||
baseEnv.OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE === "1",
|
||||
!isFullSuiteRun &&
|
||||
isCiLikeEnv(baseEnv) &&
|
||||
Boolean(baseEnv.OPENCLAW_VITEST_SHARD_NAME?.trim()),
|
||||
focusedCiShard,
|
||||
);
|
||||
if (concurrency === 1 && termination.signal) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -3228,6 +3228,7 @@ describe("CI changed Node test plan", () => {
|
|||
`2026-09-26T00:00:00Z OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: ${encodeNodeTestGroups(groups)}`,
|
||||
...groups.flatMap((group, index) => [
|
||||
`2026-09-26T00:0${index * 4}:00Z [shard:${group.shard_name}] begin`,
|
||||
`2026-09-26T00:0${index * 4}:01Z [shard:${group.shard_name}] [test] inner parallelism 2`,
|
||||
`2026-09-26T00:0${index * 4 + 3}:12Z [shard:${group.shard_name}] end (exit 0)`,
|
||||
]),
|
||||
].join("\n"),
|
||||
|
|
|
|||
|
|
@ -1061,6 +1061,93 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "qualified bun-compatible Node", expected: "2" },
|
||||
{ name: "explicit Node policy", policy: "node", expected: "2" },
|
||||
{ name: "one CPU", cpus: 1, expected: "1" },
|
||||
{ name: "physical memory", gib: 7.49, expected: "1" },
|
||||
{ name: "cgroup memory", gib: 32, constrainedGiB: 7.49, expected: "1" },
|
||||
{ name: "unlimited cgroup", constrainedGiB: 0, expected: "2" },
|
||||
{ name: "nonfinite cgroup", constrainedGiB: Number.POSITIVE_INFINITY, expected: "2" },
|
||||
{ name: "outer overlap", cpus: 8, gib: 24, outer: 2, expected: "1" },
|
||||
{ name: "hosted runner", hosted: true, expected: "1" },
|
||||
{ name: "frozen target", frozen: true, expected: "1" },
|
||||
{ name: "unknown target", unknownTarget: true, expected: "1" },
|
||||
{ name: "portable host", portable: true, expected: "1" },
|
||||
{ name: "mixed config", mixed: true, expected: "1" },
|
||||
{ name: "one file", singleton: true, expected: "1" },
|
||||
{ name: "different config", otherConfig: true, expected: "1" },
|
||||
{ name: "runtime build", runtime: true, expected: "1" },
|
||||
{ name: "runtime consumer", runtimeConsumer: true, expected: "1" },
|
||||
{ name: "dist build", dist: true, expected: "1" },
|
||||
{ name: "one worker", workers: "1", expected: "1" },
|
||||
{ name: "caller cache", callerLeaf: true, expected: "1" },
|
||||
])("admits inner singleton overlap from actual $name facts", async (scenario) => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue(scenario.portable ? "darwin" : "linux");
|
||||
vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(true);
|
||||
vi.spyOn(os, "availableParallelism").mockReturnValue(scenario.cpus ?? 2);
|
||||
vi.spyOn(os, "totalmem").mockReturnValue((scenario.gib ?? 7.65) * 1024 ** 3);
|
||||
vi.spyOn(process, "constrainedMemory").mockReturnValue(
|
||||
(scenario.constrainedGiB ?? 7.65) * 1024 ** 3,
|
||||
);
|
||||
const scratchDir = makeScratchDir();
|
||||
const files = [
|
||||
"extensions/telegram/src/telegram-ingress-spool.test.ts",
|
||||
"extensions/telegram/src/webhook.test.ts",
|
||||
];
|
||||
const includes = scenario.singleton
|
||||
? files.slice(0, 1)
|
||||
: scenario.mixed
|
||||
? [files[0]!, memoryTarget]
|
||||
: scenario.runtimeConsumer
|
||||
? ["extensions/telegram/src/bot.create-telegram-bot.native-pipeline.test.ts", files[1]!]
|
||||
: files;
|
||||
const groups = Array.from({ length: scenario.outer ?? 1 }, (_, index) => ({
|
||||
configs: [
|
||||
scenario.otherConfig
|
||||
? "test/vitest/vitest.extension-telegram.config.ts"
|
||||
: "test/vitest/vitest.extension-database-workers.config.ts",
|
||||
],
|
||||
shard_name: `changed-extensions-config-${index + 1}`,
|
||||
includePatterns: includes,
|
||||
requiresDist: scenario.dist ?? false,
|
||||
...(scenario.runtime ? { pretestBuildMode: "runtime" } : {}),
|
||||
env: {
|
||||
OPENCLAW_VITEST_MAX_WORKERS: scenario.workers ?? "2",
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "2",
|
||||
},
|
||||
}));
|
||||
const runtimes: Array<string | undefined> = [];
|
||||
const runChild = vi.fn(async (_args, env) => {
|
||||
runtimes.push(env.OPENCLAW_VITEST_RUNTIME);
|
||||
expect(env.OPENCLAW_TEST_PROJECTS_PARALLEL).toBe(scenario.expected);
|
||||
expect(env.OPENCLAW_VITEST_MAX_WORKERS).toBe(scenario.workers ?? "2");
|
||||
expect(JSON.parse(readFileSync(env.OPENCLAW_VITEST_INCLUDE_FILE, "utf8"))).toEqual(includes);
|
||||
return 0;
|
||||
});
|
||||
await expect(
|
||||
runShardPlans(resolveShardPlans({ OPENCLAW_NODE_TEST_GROUPS_JSON: JSON.stringify(groups) }), {
|
||||
concurrency: scenario.outer ?? 1,
|
||||
env: {
|
||||
CI: "1",
|
||||
RUNNER_ENVIRONMENT: scenario.hosted ? "github-hosted" : "self-hosted",
|
||||
FROZEN_TARGET: scenario.unknownTarget ? undefined : scenario.frozen ? "true" : "false",
|
||||
OPENCLAW_CI_TEST_RUNTIME_POLICY: scenario.policy ?? "bun-compatible",
|
||||
...(scenario.callerLeaf
|
||||
? {
|
||||
OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT: scratchDir,
|
||||
OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: path.join(scratchDir, "caller"),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
scratchDir,
|
||||
runChild,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(runChild).toHaveBeenCalledTimes(groups.length);
|
||||
expect(runtimes).toEqual(groups.map(() => "node"));
|
||||
});
|
||||
|
||||
it("builds child env with per-plan cache isolation, includes, and env overlays", () => {
|
||||
const scratchDir = makeScratchDir();
|
||||
const entry = {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,47 @@ describe("native singleton invocation timings", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"records only the parallel envelope wall (interleaved=%s)",
|
||||
(interleaved) => {
|
||||
const env = { OPENCLAW_VITEST_MAX_WORKERS: "2", OPENCLAW_TEST_PROJECTS_PARALLEL: "2" };
|
||||
let text = invocationLog()
|
||||
.replace(encodeNodeTestGroups([descriptor]), encodeNodeTestGroups([{ ...descriptor, env }]))
|
||||
.replace(
|
||||
line(0, "begin"),
|
||||
[line(0, "begin"), line(0, "[test] inner parallelism 2")].join("\n"),
|
||||
);
|
||||
if (interleaved) {
|
||||
text = text
|
||||
.replace(line(11, `[test] starting ${config}`) + "\n", "")
|
||||
.replace(
|
||||
line(2, "RUN v5.0.1 /checkout"),
|
||||
[line(2, `[test] starting ${config}`), line(2, "RUN v5.0.1 /checkout")].join("\n"),
|
||||
);
|
||||
}
|
||||
expect(refit(text)).toEqual({ [createExtensionTestTimingKey(config, files, env)!]: 34 });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["fallback", "missing", "contradictory"])(
|
||||
"qualifies requested overlap using its %s receipt",
|
||||
(kind) => {
|
||||
const env = { OPENCLAW_VITEST_MAX_WORKERS: "2", OPENCLAW_TEST_PROJECTS_PARALLEL: "2" };
|
||||
const receipt =
|
||||
kind === "missing"
|
||||
? []
|
||||
: kind === "fallback"
|
||||
? [line(0, "[test] inner parallelism 1")]
|
||||
: [line(0, "[test] inner parallelism 1"), line(0, "[test] inner parallelism 2")];
|
||||
const text = invocationLog()
|
||||
.replace(encodeNodeTestGroups([descriptor]), encodeNodeTestGroups([{ ...descriptor, env }]))
|
||||
.replace(line(0, "begin"), [line(0, "begin"), ...receipt].join("\n"));
|
||||
const observed = refit(text);
|
||||
expect(observed[createExtensionTestTimingKey(config, files, env)!]).toBeUndefined();
|
||||
expect(observed).toEqual(kind === "fallback" ? refit(invocationLog()) : {});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an outer singleton wall distinct from its invocation and shared overhead", () => {
|
||||
const only = [files[0]!];
|
||||
const text = [
|
||||
|
|
|
|||
|
|
@ -43,6 +43,20 @@ describe("extension executable test plans", () => {
|
|||
);
|
||||
timings[key(workerConfig, files)!] = 60;
|
||||
expect(extensionTestPlan.estimateExtensionTestCost(workerConfig, 2, files)).toBe(60);
|
||||
const parallelEnv = { OPENCLAW_VITEST_MAX_WORKERS: "2", OPENCLAW_TEST_PROJECTS_PARALLEL: "2" };
|
||||
const parallelKey = key(workerConfig, files, parallelEnv)!;
|
||||
expect(parallelKey).not.toBe(key(workerConfig, files));
|
||||
expect(key(workerConfig, files, { ...parallelEnv, OPENCLAW_TEST_PROJECTS_PARALLEL: "1" })).toBe(
|
||||
key(workerConfig, files),
|
||||
);
|
||||
expect(extensionTestPlan.estimateExtensionTestCost(workerConfig, 2, files, parallelEnv)).toBe(
|
||||
60,
|
||||
);
|
||||
timings[parallelKey] = 25;
|
||||
expect(extensionTestPlan.estimateExtensionTestCost(workerConfig, 2, files, parallelEnv)).toBe(
|
||||
25,
|
||||
);
|
||||
expect(extensionTestPlan.estimateExtensionTestCost(workerConfig, 2, files)).toBe(60);
|
||||
|
||||
samples.mockReturnValue({
|
||||
[key(telegramConfig, [files[0]!], undefined, "singleton-invocation")!]: 100,
|
||||
|
|
@ -76,6 +90,29 @@ describe("extension executable test plans", () => {
|
|||
expect(extensionTestPlan.estimateExtensionTestCost(workerConfig, 1, only)).toBe(22);
|
||||
});
|
||||
|
||||
it("requests overlap only for selected Telegram singleton envelopes", async () => {
|
||||
const { createChangedExtensionConfigShards } =
|
||||
await import("../../scripts/lib/ci-extension-test-shards.mts");
|
||||
const files = [
|
||||
"extensions/telegram/src/telegram-ingress-spool.test.ts",
|
||||
"extensions/telegram/src/telegram-ingress-drain.test.ts",
|
||||
"extensions/telegram/src/webhook.test.ts",
|
||||
];
|
||||
vi.spyOn(extensionTestPlan, "listExtensionTestFilesForRoots").mockReturnValue(files);
|
||||
const shards = createChangedExtensionConfigShards(["extensions/telegram"], {
|
||||
targets: new Set(files),
|
||||
includeReleaseOnlyRuntimeTests: true,
|
||||
});
|
||||
expect(shards).toHaveLength(1);
|
||||
expect(shards[0]).toMatchObject({
|
||||
configs: [workerConfig],
|
||||
env: { OPENCLAW_VITEST_MAX_WORKERS: "2", OPENCLAW_TEST_PROJECTS_PARALLEL: "2" },
|
||||
planConcurrency: 1,
|
||||
requiresDist: false,
|
||||
});
|
||||
expect(shards[0]!.includePatterns?.toSorted()).toEqual(files.toSorted());
|
||||
});
|
||||
|
||||
it.each(["git", "filesystem"])("reads each candidate checkout's %s plugin inventory", (kind) => {
|
||||
const root = "extensions/fixture";
|
||||
for (const snapshot of ["before", "after"]) {
|
||||
|
|
|
|||
|
|
@ -583,6 +583,7 @@ describe("packed CI config continuation", () => {
|
|||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
vi.stubEnv("CI", "1");
|
||||
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", "1");
|
||||
vi.stubEnv("OPENCLAW_VITEST_SHARD_NAME", "serial-fixture");
|
||||
vi.stubEnv("OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT", "");
|
||||
vi.stubEnv("OPENCLAW_VITEST_FS_MODULE_CACHE_PATH", "");
|
||||
vi.stubEnv("OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE", scenario.enabled ? "1" : "");
|
||||
|
|
@ -613,6 +614,7 @@ describe("packed CI config continuation", () => {
|
|||
} else {
|
||||
await running;
|
||||
}
|
||||
expect(console.error).toHaveBeenCalledWith("[test] inner parallelism 1");
|
||||
expect(selected).toEqual(configs.slice(0, scenario.expected));
|
||||
if (scenario.signaled) {
|
||||
expect(exit).toHaveBeenCalledWith("SIGTERM");
|
||||
|
|
@ -1178,6 +1180,7 @@ describe("cache lease completion", () => {
|
|||
}
|
||||
expect(commands.reader).toHaveBeenCalledTimes(expected);
|
||||
if (focused) {
|
||||
expect(console.error).toHaveBeenCalledWith("[test] inner parallelism 2");
|
||||
const selected = selections.flat();
|
||||
expect(
|
||||
selections.every((selection) => Array.isArray(selection) && selection.length === 1),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue