mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
0e8faacd71
* fix(scripts): size the tsdown heap from the build's own cgroup budget The build heap probe only read the cgroup root (/sys/fs/cgroup/memory.max and the v1 equivalent). Those files exist only when the process runs in a namespaced container cgroup; under systemd the budget lives on the process's own slice, and the v2 root carries no limit at all. So every systemd-managed build found no limit, fell back to /proc/meminfo MemTotal, and took the full 12288 MB default heap regardless of its actual budget. Observed on a 15.4 GiB host: openclaw-main-update.service ran tsdown with NODE_OPTIONS=--max-old-space-size=12288 while its user@999.service slice was bounded at 5 GiB, reaching 3.2 GB RSS and 6.25 GB peak before the host began OOM-killing unrelated services. Resolve the limit from /proc/self/cgroup and walk that chain instead, reading memory.high alongside memory.max (memory.high throttles reclaim rather than failing allocation, so a heap above it stalls the build instead of OOM-ing), and take the tightest bound found. Root paths stay as the container fallback, and an explicitly injected path list still disables detection. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): resolve the build heap budget from the v1 memory controller too The slice walk only accepted the unified 0:: record, so a legacy or hybrid systemd host fell back to the root probe and kept taking host memory. One resolver now walks both hierarchies leaf-to-root, which makes the static root list its own depth-0 case and removes it. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): read cgroup controller mounts instead of assuming their paths v1 controllers can be co-mounted at the cgroup root, where memory.limit_in_bytes sits under the slice with no per-controller directory, so the hardcoded /sys/fs/cgroup/memory probe missed the budget and the build took the full 12288MB default. Mount points now come from mountinfo. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): translate cgroup records through the mount root mountinfo field 4 is the subtree a cgroupfs mount exposes. Under a container mount the /proc/self/cgroup record stays host-absolute, so walking it verbatim probed paths below the visible mount and the build fell back to host memory. Records now translate through the mount root before the walk. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): skip cgroup mounts that cannot represent this process Falling back to the mount root for a record outside the mount's subtree sized the build from an unrelated cgroup: an inherited namespace clamped the heap to the 2048MB floor from a foreign 1GiB limit. Non-representable mounts are now skipped, and the blind root probe only runs when no memory record exists. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): keep every cgroup mount view, not just the last one seen One hierarchy can be visible through several mounts and only some expose a subtree containing this process. Retaining only the last view dropped the budget whenever a non-representable bind view came later, sending the build back to host MemTotal. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): decode octal-escaped mountinfo paths before matching cgroups ClawSweeper P2 on7e64ad61f7: the cgroup resolver compared mountinfo's mount root and mount point verbatim. The kernel escapes space, tab, newline, and backslash in those two fields, so any cgroup mounted under such a path never matched, the bounded slice was missed, and heap sizing silently fell back to host memory. Decode both fields before matching. The decoder lives in scripts/lib beside the other shared script helpers rather than inline, so the scripts program has one copy rather than a new ad hoc one. Regression test fails pre-fix: a v2 mount at "/sys/fs/cgroup\040dir" with a 5 GiB memory.high yields --max-old-space-size=12288 (host fallback) before the fix and 4352 after. Follow-up, deliberately not bundled here: src/infra/sqlite-wal.ts, src/commands/doctor-state-integrity.ts, and src/plugins/bundled-source-overlays.ts each carry their own private copy of this same decoder. Consolidating all four into @openclaw/normalization-core is the right end state, but it touches a shared package plus three core modules and belongs in its own reviewable change. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): resolve cgroup-namespace-relative records to their mount ClawSweeper P1 ond6fe49dd3f: inside a cgroup namespace /proc/self/cgroup reports the namespace root ("0::/") while mountinfo field 4 stays the host subtree the cgroupfs was mounted from ("/docker/<id>"). relativeCgroupPath then found no prefix match and returned null; because a memory record had already been seen, the root probe was skipped and the build fell back to host MemTotal. A constrained container therefore missed its own budget entirely. That namespace root is exactly what the mount exposes at its mount point, so it resolves to "/" rather than failing closed. Regression test fails pre-fix: a "0::/" record against a /docker/2f1a9c mount root with a 5 GiB memory.max yields --max-old-space-size=12288 before the fix and 4352 after. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): reject inherited cgroup mount views instead of guessing ClawSweeper P1 onb4d200c5d2: the previous commit resolved a namespace-relative record against any mount root, including the inherited views cgroup_namespaces(7) documents, whose field-4 root reads "/..". Which cgroup such a view exposes is not derivable from mountinfo, so probing it can size the build from an unrelated cgroup's limit. Reject non-canonical mount roots outright. An undecidable view now falls back to host sizing, which is current main's behavior, rather than silently adopting the wrong budget. Regression test covers the "/.." inherited mount: it must yield host MemTotal sizing, not the 5 GiB limit sitting behind that mount. Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(scripts): fail closed on namespace-root records against non-root mounts ClawSweeper P1 on731d3bbc8e: a "0::/" record does not prove that a mount rooted at some other subtree exposes this process's cgroup. Resolving that pair could cap the build heap from an unrelated cgroup's limit. Return no mapping for it. An undecidable pair now falls back to host sizing, which is current main's behavior, so the failure mode is a missed optimisation rather than a wrong budget. The "/.." inherited-mount rejection stays; this covers the broader ambiguous mapping it did not. The namespace-relative test is repointed accordingly: an unrelated mounted subtree must yield host sizing, not that subtree's limit. Net production change: none (4 lines swapped). Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> * fix(build): cap tsdown heap to the real budget and refuse hosts that cannot build The 2048MB floor was applied on top of a discovered cgroup limit, so a small container was handed a heap larger than it could honour. Measured in real cgroups, that does not OOM-kill, it thrashes: a 1500MiB container sat pinned at its ceiling for 10 minutes with oom_kill at 0, never finished the second of eleven invocations, and starved every other process on the host. Cap to the discovered budget, then refuse up front when that budget cannot hold the build. The threshold is the whole-build peak, not a single pass: a full eleven-invocation build peaks at 4730MiB, so a 5GiB slice completes while 4GiB and 2816MiB slices are both killed partway through the third invocation. The refusal runs before any output is cleaned, so a host that cannot rebuild does not also lose the build it has. * fix(build): harden tsdown heap admission * fix(build): guard the default tsdown plan * fix(build): preserve runtime-only Docker builds * fix(build): admit only declaration cache misses * fix(build): scope heap admission to real budgets * fix(build): guard direct unified declarations * fix(build): guard the canonical tsdown config * fix(build): satisfy cache planning lint * fix(gateway): release empty orphan leases * fix(build): cap cgroup budget by host memory * fix(build): serialize the canonical tsdown config * test(build): freeze host memory fixtures * fix(build): honor cgroup v1 soft limits * fix(build): respect cgroup v1 hierarchy mode * fix(build): admit unified runtime plans * fix(build): admit every unified runtime path * fix(build): collect repeated tsdown filters * fix(build): ignore cgroup v1 soft limits * fix(build): use explicit heap override as opt-in * refactor(build): simplify memory admission * fix(build): harden constrained build recovery * fix(ci): prebuild runtime before real CLI shards * fix(build): honor runtime-only runner environment * fix(ci): satisfy tooling shard lint
507 lines
20 KiB
TypeScript
507 lines
20 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { detectChangedLanes } from "../changed-lanes.mts";
|
|
import {
|
|
buildVitestRunPlans,
|
|
findUnmatchedExplicitTestTargets,
|
|
hasImportGraphImpactOnTargets,
|
|
isTestFileTarget,
|
|
isTestSupportFileTarget,
|
|
resolveChangedTestTargetPlan,
|
|
} from "../test-projects.test-support.mts";
|
|
import { listAvailableExtensionIds } from "./changed-extensions.mts";
|
|
import {
|
|
createNodeTestShards,
|
|
isPolicyTestOwnedPath,
|
|
resolvePolicyTestTargets,
|
|
} from "./ci-node-test-plan.mts";
|
|
import {
|
|
listExtensionTestFilesForRoots,
|
|
resolveExtensionTestConfig,
|
|
shouldSplitExtensionTestProcesses,
|
|
splitExtensionTestJobTargets,
|
|
} from "./extension-test-plan.mts";
|
|
import { buildPluginSdkEntrySources, publicPluginSdkEntrypoints } from "./plugin-sdk-entries.mts";
|
|
|
|
type ChangedNodeTestShard = {
|
|
checkName: string;
|
|
configs: string[];
|
|
includePatterns?: string[];
|
|
planConcurrency?: number;
|
|
pretestBuildMode?: "private-qa" | "runtime";
|
|
requiresDist: boolean;
|
|
runner: string;
|
|
shardName: string;
|
|
targets?: string[];
|
|
};
|
|
type CwdOptions = { cwd?: string };
|
|
|
|
const DEFAULT_NODE_TEST_RUNNER = "blacksmith-8vcpu-ubuntu-2404";
|
|
const MAX_CHANGED_NODE_TEST_TARGETS = 96;
|
|
// Each target runs in its own child process (isolation contract), so bound the
|
|
// serial tail per job; the shard runner overlaps two children at a time.
|
|
const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12;
|
|
// Memory Core targets perform real SQLite/indexing work. Two concurrent Vitest
|
|
// processes starve each other on 4-vCPU runners and push otherwise healthy
|
|
// integration tests past the global timeout.
|
|
const SERIAL_CHANGED_TARGET_RE = /^extensions\/memory-core\//u;
|
|
const PRETEST_RUNTIME_BUILD_TARGETS = new Set([
|
|
"test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts",
|
|
]);
|
|
const PRETEST_PRIVATE_QA_BUILD_TARGETS = new Set([
|
|
"extensions/qa-lab/src/suite-process-lifecycle.test.ts",
|
|
]);
|
|
const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
|
|
const DOCKER_SEED_LANE_ORDER = [
|
|
"mcp-channels",
|
|
"cron-mcp-cleanup",
|
|
"mcp-code-mode-gateway",
|
|
] as const;
|
|
type DockerSeedLane = (typeof DOCKER_SEED_LANE_ORDER)[number];
|
|
const DOCKER_SEED_LANES_BY_PATH: Readonly<Record<string, readonly DockerSeedLane[]>> = {
|
|
".github/workflows/ci.yml": DOCKER_SEED_LANE_ORDER,
|
|
"scripts/e2e/cron-mcp-cleanup-seed.ts": ["cron-mcp-cleanup"],
|
|
"scripts/e2e/docker-openai-seed.ts": DOCKER_SEED_LANE_ORDER,
|
|
"scripts/e2e/lib/mcp-code-mode-probe-server.ts": ["mcp-code-mode-gateway"],
|
|
"scripts/e2e/mcp-channels-seed.ts": ["mcp-channels"],
|
|
"scripts/e2e/mcp-code-mode-gateway-seed.ts": ["mcp-code-mode-gateway"],
|
|
"scripts/lib/ci-changed-node-test-plan.mts": DOCKER_SEED_LANE_ORDER,
|
|
};
|
|
const publicPluginSdkEntrySources = Object.values(
|
|
buildPluginSdkEntrySources(publicPluginSdkEntrypoints),
|
|
);
|
|
|
|
const fullNodeTestShards = createNodeTestShards({
|
|
includeReleaseOnlyPluginShards: false,
|
|
});
|
|
const configsRequiringFullSuiteMetadata = new Set(
|
|
fullNodeTestShards
|
|
.filter((shard) => shard.env || shard.shardName.startsWith("core-tooling"))
|
|
.flatMap((shard) => shard.configs),
|
|
);
|
|
const splitNodeTestConfigs = new Set(
|
|
fullNodeTestShards.filter((shard) => shard.includePatterns).flatMap((shard) => shard.configs),
|
|
);
|
|
|
|
export function resolveChangedDockerSeedLanes(changedPaths: string[]) {
|
|
const selected = new Set<DockerSeedLane>();
|
|
for (const changedPath of changedPaths) {
|
|
const normalizedPath = changedPath.replaceAll("\\", "/");
|
|
for (const lane of DOCKER_SEED_LANES_BY_PATH[normalizedPath] ?? []) {
|
|
selected.add(lane);
|
|
}
|
|
}
|
|
return DOCKER_SEED_LANE_ORDER.filter((lane) => selected.has(lane));
|
|
}
|
|
|
|
function isTestOnlyPath(changedPath: string) {
|
|
return (
|
|
isTestFileTarget(changedPath) ||
|
|
isTestSupportFileTarget(changedPath) ||
|
|
changedPath.startsWith("test/")
|
|
);
|
|
}
|
|
|
|
// Inputs `build:ci-artifacts` consumes: runtime/plugin/package sources plus
|
|
// the build pipeline itself (mirrors the build-all cache key in ci.yml).
|
|
// Paths outside this set — repo scripts, workflows, qa scenarios, docs mixes —
|
|
// cannot change dist or bundled plugin asset bytes.
|
|
const BUILD_INPUT_RE =
|
|
/^(?:src|extensions|packages)\/|^(?:openclaw\.mjs|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$|^tsconfig[^/]*\.json$|^scripts\/(?:build-[^/]+|runtime-postbuild\.mts|write-plugin-sdk-entry-dts\.ts)$|^scripts\/lib\/(?:copy-assets\.ts|plugin-sdk-entries\.mts)$/u;
|
|
|
|
/**
|
|
* True when a changed path can influence built dist/packaging bytes: a
|
|
* non-test build-input source or the build pipeline itself. Diffs entirely
|
|
* outside that set (tests, repo scripts, workflows, qa scenarios) let the
|
|
* manifest skip the build-artifacts lane.
|
|
*/
|
|
export function hasBuildArtifactAffectingChange(changedPaths: string[]) {
|
|
return changedPaths.some(
|
|
(changedPath) => BUILD_INPUT_RE.test(changedPath) && !isTestOnlyPath(changedPath),
|
|
);
|
|
}
|
|
|
|
// QA-owned surfaces that keep the smoke lane on pull requests: the qa-lab
|
|
// harness and scenario data, the two channels the smoke profile drives
|
|
// (matrix, telegram), the packaged-CLI docker packaging scripts, and the QA
|
|
// lane's own orchestration (this planner, the CI workflow, composite
|
|
// actions) — changes to the gate must not be able to skip the gated lane.
|
|
const QA_SMOKE_SURFACE_RE =
|
|
/^(?:extensions\/(?:matrix|qa-lab|telegram)|qa)\/|^scripts\/(?:build-all\.mts|package-openclaw-for-docker\.mts)$|^scripts\/lib\/ci-changed-node-test-plan\.mts$|^\.github\/(?:workflows\/ci\.yml$|actions\/)/u;
|
|
|
|
/**
|
|
* True when a pull request diff touches a QA-owned smoke surface. Broad
|
|
* runtime changes (src/ui/packages/dependency manifests) deliberately no
|
|
* longer select the smoke lane on pull requests: every canonical `main` push
|
|
* and release validation still runs the full profile set, so runtime
|
|
* regressions surface one push later instead of taxing every PR with the
|
|
* six-part smoke matrix (~5 hosted-runner minutes each).
|
|
*/
|
|
export function hasQaSmokeAffectingChange(changedPaths: string[]) {
|
|
return changedPaths.some((changedPath) => QA_SMOKE_SURFACE_RE.test(changedPath));
|
|
}
|
|
|
|
// Surfaces the prompt-snapshot check exercises outside its generator's
|
|
// relative import graph: the snapshot fixtures and generator scripts, the
|
|
// codex extension (its test API loads through a dynamic bundled-plugin module
|
|
// id the graph walk cannot see), and the gate's own orchestration — changes
|
|
// to the gate must not be able to skip the gated lane.
|
|
const PROMPT_SNAPSHOT_SURFACE_RE =
|
|
/^(?:test\/(?:helpers\/agents|fixtures\/agents\/prompt-snapshots)|extensions\/codex|packages)\/|^scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.[cm]?[jt]s)$|^scripts\/lib\/ci-changed-node-test-plan\.mts$|^\.github\/(?:workflows\/ci\.yml$|actions\/)|^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/u;
|
|
// The generator renders real prompt-layer stacks, so its runtime blast radius
|
|
// is the snapshot helper's import graph (auto-reply prompts, channel typing,
|
|
// plugin-sdk agent harness, codex catalog fixtures).
|
|
const PROMPT_SNAPSHOT_ENTRY = "test/helpers/agents/happy-path-prompt-snapshots.ts";
|
|
|
|
// The fallback planner and chunk-policy owner are part of the gate surface; changes to the
|
|
// gate must not be able to skip the gated lane (#124412).
|
|
const CORE_EXTENSION_IMPACT_SURFACE_RE =
|
|
/^scripts\/lib\/(?:changed-extensions|ci-changed-node-test-plan|extension-test-plan)\.mts$/u;
|
|
|
|
/**
|
|
* True when a changed path can influence generated prompt snapshots: it
|
|
* touches the snapshot surface directly, or the generator's import graph
|
|
* reaches it. Diffs outside both cannot change generator output, so the
|
|
* manifest may skip the check lane.
|
|
*/
|
|
export function hasPromptSnapshotAffectingChange(changedPaths: string[], options: CwdOptions = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (changedPaths.some((changedPath) => PROMPT_SNAPSHOT_SURFACE_RE.test(changedPath))) {
|
|
return true;
|
|
}
|
|
const sourcePaths = changedPaths.filter(
|
|
(changedPath) => changedPath.startsWith("src/") && !isTestFileTarget(changedPath),
|
|
);
|
|
if (sourcePaths.length === 0) {
|
|
return false;
|
|
}
|
|
// Deleted sources cannot be graphed; fail safe to running the check.
|
|
if (sourcePaths.some((changedPath) => !existsSync(path.join(cwd, changedPath)))) {
|
|
return true;
|
|
}
|
|
return hasImportGraphImpactOnTargets(sourcePaths, [PROMPT_SNAPSHOT_ENTRY], cwd);
|
|
}
|
|
|
|
// The lifecycle proof crosses dynamic Gateway method registration, doctor
|
|
// migrations, shared session coordination, the public session SDK, and the
|
|
// built CLI. Keep those owners on the direct surface; use the import graph only
|
|
// inside the embedded-runner neighborhood, whose session reachability is not
|
|
// apparent from filenames.
|
|
const SQLITE_SESSION_LIFECYCLE_PREFIX_RE =
|
|
/^(?:src\/(?:agents\/(?:sessions\/|[^/]*(?:session|transcript|compaction)[^/]*)|commands\/doctor-session-|config\/sessions\/|gateway\/(?:agent-turn\/agent-session-persist|server-chat\.(?:load-gateway-session-row|persist-session-lifecycle)|server-methods\/sessions|server\.sessions|session-|sessions-)|plugin-sdk\/session-|sessions\/|state\/openclaw-agent-(?:db|schema))|\.github\/actions\/setup-node-env\/)/u;
|
|
const SQLITE_SESSION_LIFECYCLE_EXACT_RE =
|
|
/^(?:src\/config\/sessions\.ts|test\/helpers\/(?:openclaw-test-instance|sqlite-sessions-transcripts-flip-proof(?:-assertions)?)\.ts|test\/scripts\/(?:sqlite-sessions-transcripts-flip-proof(?:\.built-cli)?\.e2e\.test|vitest-e2e-global-setup\.test)\.ts|test\/vitest\/vitest\.e2e\.(?:config|global-setup)\.ts|scripts\/lib\/ci-changed-node-test-plan\.mts|\.github\/workflows\/ci\.yml|openclaw\.mjs|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/u;
|
|
const SQLITE_SESSION_LIFECYCLE_ENTRY =
|
|
"test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts";
|
|
const SQLITE_SESSION_LIFECYCLE_IMPORT_CANDIDATE_RE = /^src\/agents\/embedded-agent-runner\/run\//u;
|
|
|
|
/**
|
|
* True when a changed path touches a SQLite session lifecycle owner or reaches
|
|
* the proof from the embedded-runner neighborhood.
|
|
*/
|
|
export function hasSqliteSessionLifecycleAffectingChange(
|
|
changedPaths: string[],
|
|
options: CwdOptions = {},
|
|
) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (
|
|
changedPaths.some(
|
|
(changedPath) =>
|
|
(!isTestFileTarget(changedPath) && SQLITE_SESSION_LIFECYCLE_PREFIX_RE.test(changedPath)) ||
|
|
SQLITE_SESSION_LIFECYCLE_EXACT_RE.test(changedPath),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
const sourcePaths = changedPaths.filter(
|
|
(changedPath) =>
|
|
SQLITE_SESSION_LIFECYCLE_IMPORT_CANDIDATE_RE.test(changedPath) &&
|
|
!isTestFileTarget(changedPath),
|
|
);
|
|
// Deleted sources cannot be graphed; fail safe to running the lifecycle proof.
|
|
if (sourcePaths.some((changedPath) => !existsSync(path.join(cwd, changedPath)))) {
|
|
return true;
|
|
}
|
|
if (sourcePaths.length === 0) {
|
|
return false;
|
|
}
|
|
return hasImportGraphImpactOnTargets(sourcePaths, [SQLITE_SESSION_LIFECYCLE_ENTRY], cwd);
|
|
}
|
|
|
|
function createBoundaryShard() {
|
|
// Boundary tests scan the source tree (including test files) and build
|
|
// their own fixtures; they do not consume the built dist artifact. When the
|
|
// build-artifacts lane is skipped, this shard keeps that coverage.
|
|
return {
|
|
checkName: "checks-node-changed-boundary",
|
|
configs: [BOUNDARY_NODE_TEST_CONFIG],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: "changed-boundary",
|
|
};
|
|
}
|
|
|
|
function resolvePreciseChangedTargets(
|
|
changedPaths: string[],
|
|
cwd: string,
|
|
additionalTargets: string[] = [],
|
|
) {
|
|
const resolveTargetPlan = (paths: string[]) =>
|
|
resolveChangedTestTargetPlan(paths, {
|
|
broad: true,
|
|
combineSiblingWithImportGraph: true,
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
includeExtensionImpact: false,
|
|
});
|
|
const plan =
|
|
changedPaths.length > 0
|
|
? resolveTargetPlan(changedPaths)
|
|
: { mode: "targets" as const, targets: [] };
|
|
// Aggregate resolution must not let one precise path hide another path that
|
|
// contributes no tests. Partial plans silently drop coverage.
|
|
if (
|
|
changedPaths.some((changedPath) => {
|
|
const changedPathPlan = resolveTargetPlan([changedPath]);
|
|
return changedPathPlan.mode !== "targets" || changedPathPlan.targets.length === 0;
|
|
}) ||
|
|
plan.mode !== "targets"
|
|
) {
|
|
return null;
|
|
}
|
|
const targets = [...new Set([...plan.targets, ...additionalTargets])];
|
|
if (
|
|
targets.length > MAX_CHANGED_NODE_TEST_TARGETS ||
|
|
targets.some(
|
|
(target) =>
|
|
/^test\/vitest\/vitest\.full-.*\.config\.ts$/u.test(target) ||
|
|
splitNodeTestConfigs.has(target),
|
|
) ||
|
|
targets.some(
|
|
(target) =>
|
|
!isTestFileTarget(target) || findUnmatchedExplicitTestTargets([target], cwd).length > 0,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const targetPlans = targets.map((target) => ({
|
|
plans: buildVitestRunPlans([target], cwd),
|
|
target,
|
|
}));
|
|
if (
|
|
targetPlans.some(
|
|
({ plans }) => plans.length === 0 || plans.some((targetPlan) => !targetPlan.includePatterns),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
// Preserve special shard setup (for example Go and TUI PTY coverage) by using
|
|
// the compact plan until targeted jobs can carry per-config prerequisites.
|
|
if (
|
|
targetPlans.some(({ plans }) =>
|
|
plans.some(({ config }) => configsRequiringFullSuiteMetadata.has(config)),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return targetPlans.map(({ target }) => target);
|
|
}
|
|
|
|
function createChangedTargetShards(
|
|
targets: string[],
|
|
names: { checkName: string; shardName: string },
|
|
) {
|
|
const targetChunks: string[][] = [];
|
|
for (let offset = 0; offset < targets.length; offset += CHANGED_NODE_TEST_TARGETS_PER_JOB) {
|
|
targetChunks.push(targets.slice(offset, offset + CHANGED_NODE_TEST_TARGETS_PER_JOB));
|
|
}
|
|
return targetChunks.map((chunk, index) => {
|
|
const suffix = targetChunks.length === 1 ? "" : `-${index + 1}`;
|
|
const shard: ChangedNodeTestShard = {
|
|
checkName: `${names.checkName}${suffix}`,
|
|
configs: [],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: `${names.shardName}${suffix}`,
|
|
targets: chunk,
|
|
};
|
|
if (chunk.some((target) => PRETEST_PRIVATE_QA_BUILD_TARGETS.has(target))) {
|
|
shard.pretestBuildMode = "private-qa";
|
|
} else if (chunk.some((target) => PRETEST_RUNTIME_BUILD_TARGETS.has(target))) {
|
|
shard.pretestBuildMode = "runtime";
|
|
}
|
|
if (chunk.some((target) => SERIAL_CHANGED_TARGET_RE.test(target))) {
|
|
shard.planConcurrency = 1;
|
|
}
|
|
return shard;
|
|
});
|
|
}
|
|
|
|
function resolveChangedExtensionRoots(changedPaths: string[]) {
|
|
return [
|
|
...new Set(
|
|
changedPaths.flatMap((changedPath) => {
|
|
const [, extensionId] = changedPath.split("/");
|
|
return extensionId ? [`extensions/${extensionId}`] : [];
|
|
}),
|
|
),
|
|
];
|
|
}
|
|
|
|
function createChangedExtensionConfigShards(extensionRoots: string[]) {
|
|
const rootsByConfig = new Map<string, string[]>();
|
|
for (const root of extensionRoots) {
|
|
const config = resolveExtensionTestConfig(root);
|
|
rootsByConfig.set(config, [...(rootsByConfig.get(config) ?? []), root]);
|
|
}
|
|
const plans: Array<{ config: string; includePatterns?: string[]; roots: string[] }> = [
|
|
...rootsByConfig,
|
|
].flatMap(([config, roots]) => {
|
|
const testFiles = shouldSplitExtensionTestProcesses(config)
|
|
? listExtensionTestFilesForRoots(roots)
|
|
: [];
|
|
const chunks = testFiles.length > 0 ? splitExtensionTestJobTargets(config, testFiles) : [roots];
|
|
return chunks.length > 1
|
|
? chunks.map((includePatterns) => ({ config, includePatterns, roots }))
|
|
: [{ config, roots }];
|
|
});
|
|
return plans.map(({ config, includePatterns, roots }, index) => {
|
|
const suffix = plans.length === 1 ? "" : `-${index + 1}`;
|
|
const shard: ChangedNodeTestShard = {
|
|
checkName: `checks-node-changed-extensions-config${suffix}`,
|
|
configs: [config],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: `changed-extensions-config${suffix}`,
|
|
};
|
|
if (roots.includes("extensions/qa-lab")) {
|
|
shard.pretestBuildMode = "private-qa";
|
|
}
|
|
if (includePatterns) {
|
|
shard.includePatterns = includePatterns;
|
|
}
|
|
if (roots.some((root) => SERIAL_CHANGED_TARGET_RE.test(`${root}/`))) {
|
|
shard.planConcurrency = 1;
|
|
}
|
|
return shard;
|
|
});
|
|
}
|
|
|
|
function createChangedExtensionConfigShardsForPaths(changedPaths: string[], cwd: string) {
|
|
const relevantPaths = changedPaths.filter(
|
|
(changedPath) =>
|
|
changedPath.startsWith("extensions/") &&
|
|
(existsSync(path.join(cwd, changedPath)) || !isTestFileTarget(changedPath)),
|
|
);
|
|
return createChangedExtensionConfigShards(resolveChangedExtensionRoots(relevantPaths));
|
|
}
|
|
|
|
/**
|
|
* True when core or fallback-gate changes can affect extension consumers beyond
|
|
* the changed extension paths.
|
|
*/
|
|
export function hasCoreExtensionImpact(changedPaths: string[], options: CwdOptions = {}) {
|
|
if (changedPaths.some((changedPath) => CORE_EXTENSION_IMPACT_SURFACE_RE.test(changedPath))) {
|
|
return true;
|
|
}
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const regularLivePaths = changedPaths.filter(
|
|
(changedPath) =>
|
|
existsSync(path.join(cwd, changedPath)) &&
|
|
!changedPath.startsWith("extensions/") &&
|
|
!isPolicyTestOwnedPath(changedPath),
|
|
);
|
|
return (
|
|
detectChangedLanes(changedPaths).extensionImpactFromCore ||
|
|
(regularLivePaths.some((changedPath) => changedPath.startsWith("src/")) &&
|
|
hasImportGraphImpactOnTargets(regularLivePaths, publicPluginSdkEntrySources, cwd))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Covers changed extensions plus the full core-impact blast radius when precise
|
|
* planning falls back. See #124412.
|
|
*/
|
|
export function createChangedExtensionFallbackShards(
|
|
changedPaths: string[],
|
|
options: CwdOptions = {},
|
|
): ChangedNodeTestShard[] {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (hasCoreExtensionImpact(changedPaths, { cwd })) {
|
|
return createChangedExtensionConfigShards(
|
|
listAvailableExtensionIds().map((extensionId) => `extensions/${extensionId}`),
|
|
);
|
|
}
|
|
return createChangedExtensionConfigShardsForPaths(changedPaths, cwd);
|
|
}
|
|
|
|
/**
|
|
* Builds bounded PR jobs from precise changed-test targets.
|
|
* Null means the caller must fail safe to the compact full-suite plan.
|
|
*/
|
|
export function createChangedNodeTestShards(
|
|
changedPaths: string[],
|
|
options: CwdOptions = {},
|
|
): ChangedNodeTestShard[] | null {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const livePaths: string[] = [];
|
|
const deletedPaths: string[] = [];
|
|
for (const changedPath of changedPaths) {
|
|
(existsSync(path.join(cwd, changedPath)) ? livePaths : deletedPaths).push(changedPath);
|
|
}
|
|
// Deleted test files cannot regress runtime behavior, so they never block
|
|
// targeting. Deleted source files cannot be import-graphed from the merged
|
|
// tree and no live-path heuristic proves their consumers are covered, so
|
|
// any source deletion keeps the full-suite plan.
|
|
if (deletedPaths.some((deletedPath) => !isTestFileTarget(deletedPath))) {
|
|
return null;
|
|
}
|
|
|
|
const policyTargetsByPath = new Map(
|
|
livePaths
|
|
.filter((changedPath) => !changedPath.startsWith("extensions/"))
|
|
.map((changedPath) => [changedPath, resolvePolicyTestTargets([changedPath])]),
|
|
);
|
|
const regularLivePaths = livePaths.filter(
|
|
(changedPath) => !changedPath.startsWith("extensions/") && !isPolicyTestOwnedPath(changedPath),
|
|
);
|
|
|
|
// Workspace package consumers often use package specifiers, which the
|
|
// relative import graph cannot connect back to the changed package source.
|
|
if (changedPaths.some((changedPath) => changedPath.startsWith("packages/"))) {
|
|
return null;
|
|
}
|
|
|
|
// Package-specifier consumers are invisible to the relative import graph.
|
|
// Fail safe when a core change reaches a public SDK entrypoint indirectly.
|
|
if (hasCoreExtensionImpact(changedPaths, { cwd })) {
|
|
return null;
|
|
}
|
|
|
|
const targets = resolvePreciseChangedTargets(
|
|
regularLivePaths,
|
|
cwd,
|
|
[...policyTargetsByPath.values()].flat(),
|
|
);
|
|
if (targets === null) {
|
|
return null;
|
|
}
|
|
|
|
// Boundary-config targets run as regular nondist targets: the boundary
|
|
// suite scans the checked-out tree and never consumes the built dist.
|
|
const shards = [
|
|
...createChangedExtensionConfigShardsForPaths(livePaths, cwd),
|
|
...createChangedTargetShards(targets, {
|
|
checkName: "checks-node-changed",
|
|
shardName: "changed",
|
|
}),
|
|
...(hasBuildArtifactAffectingChange(changedPaths) ? [] : [createBoundaryShard()]),
|
|
];
|
|
return shards.length > 0 ? shards : null;
|
|
}
|