mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
1173 lines
47 KiB
TypeScript
1173 lines
47 KiB
TypeScript
import { existsSync, lstatSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { pluginContractPatterns } from "../../test/vitest/vitest.contracts-paths.mjs";
|
|
import { tuiPtyTestFiles } from "../../test/vitest/vitest.test-shards.mjs";
|
|
import {
|
|
controlUiE2eTestGlobs,
|
|
isControlUiSourcePath,
|
|
isPluginControlUiPath,
|
|
isUiBrowserTestFile,
|
|
isUiTestTarget,
|
|
uiE2eRealGatewayTestFiles,
|
|
} from "../../test/vitest/vitest.ui-paths.mjs";
|
|
import { isBoundaryTestFile } from "../../test/vitest/vitest.unit-paths.mjs";
|
|
import { detectChangedLanes } from "../changed-lanes.mts";
|
|
import {
|
|
detectChangedScope,
|
|
isCiDocumentationPath as isDocumentationPath,
|
|
isNodeTestDataOnlyPath,
|
|
} from "../ci-changed-scope.mjs";
|
|
import {
|
|
buildVitestRunPlans,
|
|
CHANNEL_CONTRACT_CONFIG_PATTERNS,
|
|
CONTRACTS_PLUGIN_VITEST_CONFIG,
|
|
E2E_VITEST_CONFIG,
|
|
hasImportGraphImpactOnTargets,
|
|
isTestFileTarget,
|
|
listRunnableVitestConfigTargets,
|
|
resolveAffectedTestsFromImportGraph,
|
|
resolveChangedTestTargetPlan,
|
|
UI_E2E_VITEST_CONFIG,
|
|
} from "../test-projects.test-support.mts";
|
|
import { listAvailableExtensionIds } from "./changed-extensions.mts";
|
|
import { getChangedPathFacts, isTestOnlyPath } from "./changed-path-facts.mjs";
|
|
import {
|
|
createChangedExtensionConfigShards,
|
|
createChangedExtensionConfigShardsForPaths,
|
|
packChangedExtensionConfigShards,
|
|
resolveChangedExtensionRoots,
|
|
} from "./ci-extension-test-shards.mts";
|
|
import {
|
|
canSplitWholeConfigGroup,
|
|
listNodeTestConfigFiles,
|
|
listWholeConfigFiles,
|
|
} from "./ci-node-test-inventory.mts";
|
|
import {
|
|
createUiRealGatewayTestShards,
|
|
createSelectedNodeTestShardBundles,
|
|
packNodeTestGroups,
|
|
nodeTestConfigRequiresCanonicalMetadata,
|
|
resolveCanonicalNodeTestConfig,
|
|
isCanonicalNodeTestConfig,
|
|
isRuntimeTestFileIncluded,
|
|
SOURCE_CHANNEL_TEST_POLICY,
|
|
type NodeTestShardGroup,
|
|
type RuntimeTestSelection,
|
|
} from "./ci-node-test-plan.mts";
|
|
import { isPolicyTestOwnedPath, resolvePolicyTestTargets } from "./ci-policy-test-watch.mts";
|
|
import {
|
|
isCiProofTestFile,
|
|
isPrExemptRuntimeTestFile,
|
|
isReleaseOnlyRuntimeTestFile,
|
|
listPrExemptRuntimeTestFiles,
|
|
PR_PROTECTED_RUNTIME_TEST_FILES,
|
|
} from "./ci-proof-test-inventory.mts";
|
|
import {
|
|
readCompactGroupTimings,
|
|
readRepoE2eFileTimings,
|
|
readToolingFileTimings,
|
|
} from "./ci-test-timings.mts";
|
|
import {
|
|
listExtensionTestFilesForRoots,
|
|
resolveExtensionTestConfig,
|
|
} from "./extension-test-plan.mts";
|
|
import { buildPluginSdkEntrySources, publicPluginSdkEntrypoints } from "./plugin-sdk-entries.mts";
|
|
import {
|
|
mergeVitestPretestBuildModes,
|
|
resolveVitestPretestBuildMode,
|
|
type VitestPretestBuildMode,
|
|
} from "./vitest-build-prerequisites.mts";
|
|
import {
|
|
createCompactSplitTimingGeneration,
|
|
estimateVitestTestFileSeconds,
|
|
estimateVitestToolingFileSeconds,
|
|
VITEST_PRETEST_BUILD_SECONDS,
|
|
} from "./vitest-shard-metadata.mts";
|
|
|
|
type ChangedNodeTestShard = {
|
|
checkName: string;
|
|
configs: string[];
|
|
groups?: NodeTestShardGroup[];
|
|
env?: Record<string, string>;
|
|
includePatterns?: string[];
|
|
planConcurrency?: number;
|
|
predictedSeconds?: number;
|
|
predictedTestSeconds?: number;
|
|
pretestBuildMode?: VitestPretestBuildMode;
|
|
requiresDist: boolean;
|
|
runner: string;
|
|
shardName: string;
|
|
targets?: string[];
|
|
timeoutMinutes?: number;
|
|
};
|
|
type CwdOptions = { cwd?: string };
|
|
type PlanDiagnostic = (reason: string) => void;
|
|
type ChangedTargetValidation = {
|
|
baseRef?: string;
|
|
dedicatedCoreTypeChecks?: boolean;
|
|
dedicatedNativeChecks?: { macos: boolean; ios: boolean; android: boolean };
|
|
onFallback?: PlanDiagnostic;
|
|
};
|
|
const BROWSER_EXTENSION_E2E_TEST_FILE =
|
|
"extensions/browser/chrome-extension/bootstrap.chromium.test.ts";
|
|
|
|
/** Ordinary UI unit entries retain their unit owner; fixtures and their consumers retain E2E. */
|
|
export function hasUiE2eAffectingChange(
|
|
changedPaths: string[],
|
|
options: CwdOptions & { family?: "control-ui" | "browser-extension" | "real-gateway" } = {},
|
|
) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
|
|
return true;
|
|
}
|
|
const relevantPaths = changedPaths.filter(
|
|
(file) =>
|
|
!isDocumentationPath(file) &&
|
|
getChangedPathFacts(file).surface !== "rootGlobal" &&
|
|
file !== "test/vitest/vitest.shared.config.ts" &&
|
|
file !== "tsconfig.json",
|
|
);
|
|
if (relevantPaths.length === 0) {
|
|
return false;
|
|
}
|
|
if (options.family === "browser-extension") {
|
|
const workflowInputs = relevantPaths.filter((file) => file.startsWith(".github/"));
|
|
return (
|
|
relevantPaths.some((file) => file.startsWith("extensions/browser/chrome-extension/")) ||
|
|
(workflowInputs.length > 0 && detectChangedScope(workflowInputs).runUiTests) ||
|
|
hasImportGraphImpactOnTargets(
|
|
relevantPaths,
|
|
[
|
|
BROWSER_EXTENSION_E2E_TEST_FILE,
|
|
"test/vitest/vitest.e2e.config.ts",
|
|
"scripts/ensure-playwright-chromium.mts",
|
|
],
|
|
cwd,
|
|
{ tooling: true, resolveAliases: true },
|
|
)
|
|
);
|
|
}
|
|
if (options.family === "real-gateway") {
|
|
return (
|
|
relevantPaths.some(
|
|
(file) =>
|
|
!isTestOnlyPath(file) &&
|
|
(file.startsWith("src/gateway/") || file.startsWith("packages/gateway-protocol/")),
|
|
) ||
|
|
hasImportGraphImpactOnTargets(
|
|
relevantPaths,
|
|
[
|
|
...uiE2eRealGatewayTestFiles,
|
|
...new Set(
|
|
createUiRealGatewayTestShards([]).flatMap((shard) =>
|
|
shard.groups.flatMap((group) => group.configs),
|
|
),
|
|
),
|
|
],
|
|
cwd,
|
|
{ tooling: true, resolveAliases: true },
|
|
)
|
|
);
|
|
}
|
|
// The extension bootstrap has a separate Chromium job; its source tree is
|
|
// not a Control UI owner. Shared imports can still select either family.
|
|
const controlUiPaths = relevantPaths.filter(
|
|
(file) => !file.startsWith("extensions/browser/chrome-extension/"),
|
|
);
|
|
if (controlUiPaths.length === 0 || !detectChangedScope(controlUiPaths).runUiTests) {
|
|
return hasImportGraphImpactOnTargets(
|
|
controlUiPaths,
|
|
(file) =>
|
|
!isTestOnlyPath(file) &&
|
|
(isControlUiSourcePath(file) ||
|
|
file.startsWith("packages/gateway-protocol/") ||
|
|
file === "src/gateway/control-ui.ts"),
|
|
cwd,
|
|
{ tooling: true, resolveAliases: true },
|
|
);
|
|
}
|
|
if (
|
|
relevantPaths.some(
|
|
(file) =>
|
|
!file.startsWith("ui/src/") ||
|
|
!isUiTestTarget(file) ||
|
|
/\.(?:browser|node)\.test\.ts$/u.test(file) ||
|
|
/(?:^|\/)(?:e2e|test-helpers|test-fixtures|fixtures|__fixtures__)(?:\/|$)/u.test(file) ||
|
|
path.posix.normalize(file) !== file ||
|
|
!existsSync(path.join(cwd, file)) ||
|
|
!lstatSync(path.join(cwd, file)).isFile(),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
// Test entry names are not enough: a fixture or application may import one.
|
|
// Reuse the canonical import graph rather than orphaning that consumer's proof.
|
|
return hasImportGraphImpactOnTargets(
|
|
relevantPaths,
|
|
(file) =>
|
|
isUiBrowserTestFile(file) ||
|
|
uiE2eRealGatewayTestFiles.includes(file) ||
|
|
controlUiE2eTestGlobs.some((pattern) => path.matchesGlob(file, pattern)),
|
|
cwd,
|
|
{ tooling: true, resolveAliases: true },
|
|
);
|
|
}
|
|
|
|
const DEFAULT_NODE_TEST_RUNNER = "blacksmith-8vcpu-ubuntu-2404";
|
|
// 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;
|
|
const PR_NODE_TEST_SECONDS = 150;
|
|
const MAX_CHANGED_EXTENSION_FALLBACK_JOBS = 50;
|
|
// 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 BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
|
|
const TUI_PTY_ASSERTION_TEST = "src/tui/tui-pty-harness-assertion-test-support.test.ts";
|
|
const publicPluginSdkEntrySources = Object.values(
|
|
buildPluginSdkEntrySources(publicPluginSdkEntrypoints),
|
|
);
|
|
|
|
// Inputs `build:ci-artifacts` consumes: runtime/plugin/package sources plus
|
|
// the build pipeline itself, including shared declaration publication and cache owners.
|
|
// Built-artifact test inputs below also require this lane even though they do
|
|
// not change the bytes under test.
|
|
const BUILD_INPUT_RE =
|
|
/^(?:src|extensions|packages)\/|^(?:openclaw\.mjs|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$|^tsconfig[^/]*\.json$|^tsdown(?:\.[^/]+)?\.config\.ts$|^scripts\/(?:build-[^/]+|runtime-postbuild\.mts|tsdown-build\.mts|write-(?:plugin-sdk|unified)-entry-dts\.ts)$|^scripts\/lib\/(?:copy-assets\.ts|plugin-sdk-entries\.mts|(?:build-artifact-cache|compiler-input-snapshot|declaration-stage|tsdown-[^/]+)\.mts)$/u;
|
|
const BUILT_ARTIFACT_TEST_INPUTS = new Set([
|
|
"extensions/browser/chrome-extension/relay-key.test-support.ts",
|
|
"extensions/browser/src/browser/extension-install.native-host.e2e.test.ts",
|
|
"extensions/browser/src/browser/extension-install.test-support.ts",
|
|
]);
|
|
|
|
/**
|
|
* True when a changed path can influence built dist/packaging bytes: a
|
|
* non-test build-input source, build pipeline, or built-artifact test input.
|
|
* Diffs entirely outside that set (ordinary tests, repo scripts, workflows) let the
|
|
* manifest skip the build-artifacts lane.
|
|
*/
|
|
export function hasBuildArtifactAffectingChange(changedPaths: string[]) {
|
|
return changedPaths.some(
|
|
(changedPath) =>
|
|
BUILT_ARTIFACT_TEST_INPUTS.has(changedPath) ||
|
|
(BUILD_INPUT_RE.test(changedPath) &&
|
|
!isTestOnlyPath(changedPath) &&
|
|
!isDocumentationPath(changedPath)),
|
|
);
|
|
}
|
|
|
|
// QA-owned surfaces that keep the smoke lane on PRs and main: 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;
|
|
|
|
/**
|
|
* Broad runtime changes deliberately wait for manual/release validation;
|
|
* automatic PR and main runs select the same QA-owned surfaces.
|
|
*/
|
|
export function hasQaSmokeAffectingChange(changedPaths: string[]) {
|
|
return changedPaths.some((changedPath) => QA_SMOKE_SURFACE_RE.test(changedPath));
|
|
}
|
|
|
|
// UI artifacts and their own build/performance policy retain explicit ownership;
|
|
// workspace packages use their direct UI import edges instead of a global fallback.
|
|
const CONTROL_UI_PERFORMANCE_SURFACE_RE =
|
|
/^ui\/|^extensions\/[^/]+\/browser(?:\/|$)|^tsconfig\.ui\.json$|^scripts\/(?:check-control-ui-(?:performance(?:-base)?|precompressed-assets)\.mts|ui\.(?:mts|js)|lib\/ci-changed-node-test-plan\.mts)$|^config\/control-ui-startup-budget-baseline\.json$|^\.github\/workflows\/ci\.yml$/u;
|
|
|
|
export function hasControlUiPerformanceAffectingChange(
|
|
changedPaths: string[],
|
|
options: CwdOptions = {},
|
|
) {
|
|
const sources = changedPaths.filter((file) => !isTestOnlyPath(file));
|
|
if (sources.some((file) => CONTROL_UI_PERFORMANCE_SURFACE_RE.test(file))) {
|
|
return true;
|
|
}
|
|
return hasImportGraphImpactOnTargets(
|
|
sources,
|
|
(file) =>
|
|
(!isTestOnlyPath(file) &&
|
|
(getChangedPathFacts(file).surface === "ui" || isPluginControlUiPath(file))) ||
|
|
/^scripts\/check-control-ui-(?:performance(?:-base)?|precompressed-assets)\.mts$/u.test(file),
|
|
options.cwd ?? process.cwd(),
|
|
{ tooling: true, resolveAliases: true, runtimeOnly: true },
|
|
);
|
|
}
|
|
|
|
// 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";
|
|
|
|
const GLOBAL_NODE_TEST_INPUT_RE =
|
|
/^(?:pnpm-workspace\.yaml|\.npmrc|node-version\.mjs|tsconfig(?:\.[^/]+)?\.json|vitest\.config\.ts|test\/setup(?:\.shared|\.extensions|-openclaw-runtime)?\.ts|test\/vitest\/vitest\.(?:shared\.config|scoped-config|performance-config)\.ts|scripts\/run-vitest\.(?:mjs|mts)|scripts\/test-projects\.mts|scripts\/lib\/vitest-process-env\.mts|\.github\/actions\/(?:setup-node-env|setup-pnpm-store-cache)\/action\.yml)$|^patches\//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 isIndependentlyCheckedDocumentation(changedPath: string, cwd: string) {
|
|
if (
|
|
changedPath !== changedPath.trim() ||
|
|
path.posix.normalize(changedPath) !== changedPath ||
|
|
!isNodeTestDataOnlyPath(changedPath)
|
|
) {
|
|
return false;
|
|
}
|
|
// Docs/instruction routing owns these pages; packaged templates retain runtime proof.
|
|
// Missing pages are deletions, but symlinks (including dangling ones) are not pages.
|
|
const entry = lstatSync(path.join(cwd, changedPath), { throwIfNoEntry: false });
|
|
return entry === undefined || entry.isFile();
|
|
}
|
|
|
|
export function resolveReleaseFastLaneScope(
|
|
changedPaths: readonly string[] | null,
|
|
options: CwdOptions = {},
|
|
): { eligible: true } | { eligible: false; reason: string } {
|
|
if (!changedPaths?.length) {
|
|
return { eligible: false, reason: "missing changed paths" };
|
|
}
|
|
const globalInput = changedPaths.find((file) => GLOBAL_NODE_TEST_INPUT_RE.test(file));
|
|
if (globalInput) {
|
|
return { eligible: false, reason: `global execution or resolution input: ${globalInput}` };
|
|
}
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const outsideScope = changedPaths.find(
|
|
(file) =>
|
|
!file.startsWith(".github/workflows/") &&
|
|
!file.startsWith("scripts/") &&
|
|
!file.startsWith("test/scripts/") &&
|
|
!/^\.agents\/skills\/release-[^/]+\//u.test(file) &&
|
|
file !== "docs/reference/RELEASING.md" &&
|
|
!isIndependentlyCheckedDocumentation(file, cwd),
|
|
);
|
|
return outsideScope === undefined
|
|
? { eligible: true }
|
|
: { eligible: false, reason: `outside the release tooling scope: ${outsideScope}` };
|
|
}
|
|
|
|
/** The fixed cross-area smoke travels through the same exact-file owners as changed tests. */
|
|
const PR_SMOKE_TEST_FILES = [
|
|
"test/gateway-rpc-exporters.test.ts",
|
|
"src/config/io.load-async.test.ts",
|
|
"src/config/io.compat.test.ts",
|
|
"src/config/utility-model-separation-migration.io.test.ts",
|
|
"src/plugins/loader.runtime-registry.test.ts",
|
|
"test/qa-channel-message-tool-delivery.test.ts",
|
|
];
|
|
const protectedRuntimeTestFiles = new Set(PR_PROTECTED_RUNTIME_TEST_FILES);
|
|
|
|
/** Resolve owner areas and transitive consumers once, before projecting platform jobs. */
|
|
export function resolveChangedNodeTestTargets(
|
|
changedPaths: string[],
|
|
options: CwdOptions & ChangedTargetValidation & RuntimeTestSelection = {},
|
|
): string[] {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const paths = changedPaths.filter((file) => !isIndependentlyCheckedDocumentation(file, cwd));
|
|
const targetPlan = resolveChangedTestTargetPlan(paths, {
|
|
cwd,
|
|
broad: false,
|
|
boundedOwners: true,
|
|
combineSiblingWithImportGraph: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
includeExtensionImpact: false,
|
|
});
|
|
if (paths.length > 0 && targetPlan.mode !== "targets") {
|
|
throw new Error(`Unresolved changed-owner test plan: ${targetPlan.mode}`);
|
|
}
|
|
const ownsFile = (file: string) =>
|
|
targetPlan.ownerAreas?.some((area) => file.startsWith(`${area}/`)) === true;
|
|
// Dependency and global build inputs reach every runtime area. Keep the
|
|
// observed regression inventory without restoring the whole runtime suite.
|
|
const globalProtection = paths.some((file) => getChangedPathFacts(file).surface === "rootGlobal");
|
|
const affectedProtectedTests = globalProtection
|
|
? []
|
|
: resolveAffectedTestsFromImportGraph(paths, cwd, {
|
|
tooling: true,
|
|
forceFull: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
}).filter((file) => protectedRuntimeTestFiles.has(file));
|
|
const ownerOptIns = [
|
|
...PR_PROTECTED_RUNTIME_TEST_FILES.filter((file) => globalProtection || ownsFile(file)),
|
|
...affectedProtectedTests,
|
|
...listPrExemptRuntimeTestFiles(cwd).filter(ownsFile),
|
|
];
|
|
const owners = [
|
|
...new Set([
|
|
...targetPlan.targets,
|
|
...ownerOptIns,
|
|
...paths.filter(
|
|
(file) =>
|
|
listRunnableVitestConfigTargets().includes(file) && isCanonicalNodeTestConfig(file),
|
|
),
|
|
...(paths.some((file) => listRunnableVitestConfigTargets().includes(file))
|
|
? ["test/vitest-projects-config.test.ts"]
|
|
: []),
|
|
...resolvePolicyTestTargets(paths),
|
|
]),
|
|
];
|
|
let inventory: string[] | undefined;
|
|
const allFiles = () =>
|
|
(inventory ??= listExtensionTestFilesForRoots(
|
|
["src", "test", "extensions", "packages", "ui"],
|
|
cwd,
|
|
));
|
|
const optInTargets = new Set([...paths, ...PR_SMOKE_TEST_FILES, ...ownerOptIns]);
|
|
const files = owners.flatMap((target) => {
|
|
if (isTestFileTarget(target)) {
|
|
optInTargets.add(target);
|
|
return [target];
|
|
}
|
|
if (listRunnableVitestConfigTargets().includes(target)) {
|
|
// Explicit config owners retain their inventory, while shared config hubs
|
|
// resolve to the selector/guard owners above rather than all config users.
|
|
const ownedFiles =
|
|
path.resolve(cwd) === process.cwd()
|
|
? (listNodeTestConfigFiles(target) ??
|
|
allFiles().filter((file) =>
|
|
buildVitestRunPlans([file], cwd).some(
|
|
(plan) =>
|
|
resolveCanonicalNodeTestConfig(file, plan.config) === target ||
|
|
plan.config === target,
|
|
),
|
|
))
|
|
: [];
|
|
if (paths.includes(target)) {
|
|
for (const file of ownedFiles) {
|
|
optInTargets.add(file);
|
|
}
|
|
}
|
|
return ownedFiles;
|
|
}
|
|
const entry = lstatSync(path.join(cwd, target), { throwIfNoEntry: false });
|
|
if (entry?.isDirectory()) {
|
|
return listExtensionTestFilesForRoots([target], cwd);
|
|
}
|
|
// Mapped globs are owner contracts; ordinary source names are not test globs.
|
|
return target.includes("*") ? allFiles().filter((file) => path.matchesGlob(file, target)) : [];
|
|
});
|
|
return [...new Set([...files, ...PR_SMOKE_TEST_FILES])]
|
|
.filter(
|
|
(file) =>
|
|
isTestFileTarget(file) &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
(optInTargets.has(file) ||
|
|
((options.includePrExemptRuntimeTests !== false || !isPrExemptRuntimeTestFile(file)) &&
|
|
(options.includeReleaseOnlyRuntimeTests !== false ||
|
|
!isReleaseOnlyRuntimeTestFile(file)))) &&
|
|
lstatSync(path.join(cwd, file), { throwIfNoEntry: false })?.isFile(),
|
|
)
|
|
.toSorted();
|
|
}
|
|
|
|
function resolvePreciseChangedTargets(targets: readonly string[], cwd: string) {
|
|
return targets.map((target) => ({
|
|
target,
|
|
plans: buildVitestRunPlans([target], cwd).map((targetPlan) => {
|
|
const config =
|
|
path.resolve(cwd) === process.cwd()
|
|
? resolveCanonicalNodeTestConfig(target, targetPlan.config)
|
|
: undefined;
|
|
return config && config !== targetPlan.config
|
|
? Object.assign({}, targetPlan, { config, includePatterns: [target], forwardedArgs: [] })
|
|
: targetPlan;
|
|
}),
|
|
}));
|
|
}
|
|
|
|
function createChangedTargetShards(
|
|
targets: NonNullable<ReturnType<typeof resolvePreciseChangedTargets>>,
|
|
names: { checkName: string; shardName: string },
|
|
rowBudget?: number,
|
|
) {
|
|
const timings = { ...readRepoE2eFileTimings(), ...readToolingFileTimings("blacksmith") };
|
|
const buildModeOf = (chunk: typeof targets) =>
|
|
chunk.some(({ plans }) => plans.some((plan) => plan.config === E2E_VITEST_CONFIG))
|
|
? "private-qa"
|
|
: resolveVitestPretestBuildMode([{ includePatterns: chunk.map(({ target }) => target) }]);
|
|
const targetChunks: (typeof targets)[] = [];
|
|
if (rowBudget !== undefined) {
|
|
const byPolicy = new Map<string, { targets: typeof targets; seconds: number; rows: number }>();
|
|
for (const entry of targets) {
|
|
const key = JSON.stringify([
|
|
buildModeOf([entry]),
|
|
SERIAL_CHANGED_TARGET_RE.test(entry.target),
|
|
]);
|
|
const partition = byPolicy.get(key) ?? { targets: [], seconds: 0, rows: 1 };
|
|
partition.targets.push(entry);
|
|
partition.seconds += timings[entry.target] ?? 20;
|
|
byPolicy.set(key, partition);
|
|
}
|
|
const partitions = [...byPolicy.values()];
|
|
if (partitions.length > rowBudget) {
|
|
throw new Error(
|
|
`${partitions.length} execution policies exceed the changed-target row budget of ${rowBudget}`,
|
|
);
|
|
}
|
|
// Highest-averages apportionment preserves at least one row per policy
|
|
// without spreading build preparation or serial admission across other work.
|
|
for (let remaining = rowBudget - partitions.length; remaining > 0; remaining -= 1) {
|
|
const eligible = partitions.filter((partition) => partition.rows < partition.targets.length);
|
|
if (eligible.length === 0) {
|
|
break;
|
|
}
|
|
const next = eligible.reduce((best, partition) =>
|
|
partition.seconds / (partition.rows + 1) > best.seconds / (best.rows + 1)
|
|
? partition
|
|
: best,
|
|
);
|
|
next.rows += 1;
|
|
}
|
|
for (const partition of partitions) {
|
|
const bins: { targets: typeof targets; seconds: number }[] = Array.from(
|
|
{ length: partition.rows },
|
|
() => ({ targets: [], seconds: 0 }),
|
|
);
|
|
for (const entry of partition.targets.toSorted(
|
|
(a, b) => (timings[b.target] ?? 20) - (timings[a.target] ?? 20),
|
|
)) {
|
|
const bin = bins.reduce((best, candidate) =>
|
|
candidate.seconds < best.seconds ? candidate : best,
|
|
);
|
|
bin.targets.push(entry);
|
|
bin.seconds += timings[entry.target] ?? 20;
|
|
}
|
|
targetChunks.push(...bins.map((bin) => bin.targets));
|
|
}
|
|
} else {
|
|
let pendingChunk: typeof targets = [];
|
|
let seconds = 0;
|
|
for (const entry of targets) {
|
|
const cost = timings[entry.target] ?? 20;
|
|
if (
|
|
pendingChunk.length &&
|
|
(pendingChunk.length >= CHANGED_NODE_TEST_TARGETS_PER_JOB ||
|
|
seconds + cost > PR_NODE_TEST_SECONDS)
|
|
) {
|
|
targetChunks.push(pendingChunk);
|
|
pendingChunk = [];
|
|
seconds = 0;
|
|
}
|
|
pendingChunk.push(entry);
|
|
seconds += cost;
|
|
}
|
|
if (pendingChunk.length) {
|
|
targetChunks.push(pendingChunk);
|
|
}
|
|
}
|
|
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.map(({ target }) => target),
|
|
predictedSeconds: Math.ceil(
|
|
chunk.reduce((sum, { target }) => sum + (timings[target] ?? 20), 0),
|
|
),
|
|
};
|
|
const pretestBuildMode = buildModeOf(chunk);
|
|
if (pretestBuildMode) {
|
|
shard.pretestBuildMode = pretestBuildMode;
|
|
}
|
|
if (chunk.some(({ target }) => SERIAL_CHANGED_TARGET_RE.test(target))) {
|
|
shard.planConcurrency = 1;
|
|
}
|
|
return shard;
|
|
});
|
|
}
|
|
|
|
/** Narrow canonical envelopes without changing their workers, routing, or isolation. */
|
|
function boundChangedNodeRows(
|
|
shards: ChangedNodeTestShard[],
|
|
selectedTargets: readonly string[],
|
|
runnerBackend?: string,
|
|
cwd = process.cwd(),
|
|
): ChangedNodeTestShard[] {
|
|
const profile = runnerBackend === "github" ? "github" : "blacksmith";
|
|
const fileTimings = { ...readRepoE2eFileTimings(), ...readToolingFileTimings(profile) };
|
|
const groupTimings = readCompactGroupTimings(profile);
|
|
const selected = new Set(selectedTargets);
|
|
return shards.flatMap((shard) => {
|
|
if (!shard.groups) {
|
|
const files = shard.includePatterns;
|
|
const testSeconds = (row: ChangedNodeTestShard) =>
|
|
row.predictedSeconds === undefined
|
|
? undefined
|
|
: Math.max(
|
|
0,
|
|
row.predictedSeconds -
|
|
(row.pretestBuildMode ? VITEST_PRETEST_BUILD_SECONDS[row.pretestBuildMode] : 0),
|
|
);
|
|
if (
|
|
!shard.requiresDist &&
|
|
(testSeconds(shard) ?? 0) > PR_NODE_TEST_SECONDS &&
|
|
files &&
|
|
files.length > 1 &&
|
|
files.every((file) => getChangedPathFacts(file).surface === "extension")
|
|
) {
|
|
const split = (targets: string[]): ReturnType<typeof createChangedExtensionConfigShards> =>
|
|
createChangedExtensionConfigShards(resolveChangedExtensionRoots(targets), {
|
|
cwd,
|
|
targets: new Set(targets),
|
|
}).flatMap((row) => {
|
|
const selectedFiles = row.includePatterns;
|
|
if (
|
|
testSeconds(row)! <= PR_NODE_TEST_SECONDS ||
|
|
!selectedFiles ||
|
|
selectedFiles.length < 2
|
|
) {
|
|
return [row];
|
|
}
|
|
const middle = Math.ceil(selectedFiles.length / 2);
|
|
return split(selectedFiles.slice(0, middle)).concat(split(selectedFiles.slice(middle)));
|
|
});
|
|
return split(files).map((row, index) =>
|
|
Object.assign({}, row, {
|
|
checkName: `${shard.checkName}-${index + 1}`,
|
|
shardName: `${shard.shardName}-${index + 1}`,
|
|
predictedTestSeconds: testSeconds(row),
|
|
}),
|
|
);
|
|
}
|
|
return [shard];
|
|
}
|
|
const canonicalTestSeconds = shard.predictedTestSeconds ?? shard.predictedSeconds;
|
|
if (canonicalTestSeconds === undefined || canonicalTestSeconds <= PR_NODE_TEST_SECONDS) {
|
|
return [shard];
|
|
}
|
|
const pieces = shard.groups.flatMap((group) => {
|
|
// Static boundary guards are fixed correctness gates, outside runtime selection.
|
|
if (group.configs.includes(BOUNDARY_NODE_TEST_CONFIG)) {
|
|
return [{ group, seconds: 0 }];
|
|
}
|
|
const estimatedGroup =
|
|
groupTimings[group.timing_key ?? group.shard_name] ??
|
|
groupTimings[group.shard_name] ??
|
|
shard.predictedSeconds;
|
|
const atomic =
|
|
group.requiresDist ||
|
|
group.configs.length > 1 ||
|
|
!canSplitWholeConfigGroup(group.shard_name);
|
|
const files =
|
|
(atomic
|
|
? (group.includePatterns ??
|
|
group.configs.flatMap((config) => listNodeTestConfigFiles(config) ?? []))
|
|
: group.includePatterns?.filter((file) => selected.has(file))) ??
|
|
selectedTargets.filter((file) =>
|
|
buildVitestRunPlans([file]).some((plan) =>
|
|
group.configs.includes(
|
|
resolveCanonicalNodeTestConfig(file, plan.config) ?? plan.config,
|
|
),
|
|
),
|
|
);
|
|
if (files.length === 0) {
|
|
return atomic ? [{ group, seconds: estimatedGroup ?? canonicalTestSeconds }] : [];
|
|
}
|
|
// Retain a conservative share of the original envelope when file timings
|
|
// are absent. These are admission estimates; the replay reports cold wall separately.
|
|
const originalCount =
|
|
listWholeConfigFiles(group.shard_name)?.length ??
|
|
group.configs.reduce(
|
|
(count, config) => count + (listNodeTestConfigFiles(config)?.length ?? 0),
|
|
0,
|
|
);
|
|
const groupFileSeconds = originalCount > 0 ? (estimatedGroup ?? 0) / originalCount : 0;
|
|
const costOf = (file: string) =>
|
|
Math.max(
|
|
groupFileSeconds,
|
|
fileTimings[file] ??
|
|
(group.configs[0] === "test/vitest/vitest.tooling.config.ts"
|
|
? estimateVitestToolingFileSeconds(file)
|
|
: estimateVitestTestFileSeconds(file)),
|
|
);
|
|
// An atomic group keeps its complete process owner, but does not make
|
|
// unrelated groups in the same canonical row indivisible.
|
|
if (atomic) {
|
|
return [{ group, seconds: Math.ceil(files.reduce((sum, file) => sum + costOf(file), 0)) }];
|
|
}
|
|
const chunks: string[][] = [];
|
|
let chunk: string[] = [];
|
|
let seconds = 0;
|
|
for (const file of files) {
|
|
const cost = costOf(file);
|
|
if (chunk.length && seconds + cost > PR_NODE_TEST_SECONDS) {
|
|
chunks.push(chunk);
|
|
chunk = [];
|
|
seconds = 0;
|
|
}
|
|
chunk.push(file);
|
|
seconds += cost;
|
|
}
|
|
if (chunk.length) {
|
|
chunks.push(chunk);
|
|
}
|
|
const { timingKeys } = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: group.env,
|
|
parentShardName: `changed-${group.shard_name}`,
|
|
stripes: chunks,
|
|
});
|
|
return chunks.map((includePatterns, index) => ({
|
|
group: { ...group, includePatterns, timing_key: timingKeys[index] },
|
|
seconds: Math.ceil(includePatterns.reduce((sum, file) => sum + costOf(file), 0)),
|
|
}));
|
|
});
|
|
const testSeconds = (entries: readonly (typeof pieces)[number][]) => {
|
|
const slots = Array.from({ length: shard.planConcurrency ?? 1 }, () => 0);
|
|
for (const entry of entries) {
|
|
const slot = slots.indexOf(Math.min(...slots));
|
|
slots[slot]! += entry.seconds;
|
|
}
|
|
return Math.ceil(Math.max(...slots));
|
|
};
|
|
const bins = packNodeTestGroups(
|
|
pieces,
|
|
(bin, piece) =>
|
|
((shard.planConcurrency ?? 1) === 1 ||
|
|
bin.every((entry) => entry.group.shard_name !== piece.group.shard_name)) &&
|
|
testSeconds([...bin, piece]) <= PR_NODE_TEST_SECONDS,
|
|
true,
|
|
);
|
|
return bins.map((bin, index) =>
|
|
Object.assign({}, shard, {
|
|
checkName: bins.length === 1 ? shard.checkName : `${shard.checkName}-${index + 1}`,
|
|
shardName: bins.length === 1 ? shard.shardName : `${shard.shardName}-${index + 1}`,
|
|
groups: bin.map(({ group }) => group),
|
|
requiresDist: bin.some(({ group }) => group.requiresDist),
|
|
pretestBuildMode: mergeVitestPretestBuildModes([
|
|
shard.pretestBuildMode,
|
|
...bin.map(({ group }) => group.pretestBuildMode),
|
|
]),
|
|
predictedSeconds: bin.reduce((seconds, piece) => seconds + piece.seconds, 0),
|
|
predictedTestSeconds: testSeconds(bin),
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* True when core or shared runtime changes can affect extension consumers beyond
|
|
* the changed extension paths.
|
|
*/
|
|
export function hasCoreExtensionImpact(changedPaths: string[], options: CwdOptions = {}) {
|
|
// Planner policy has its own selector/guard tests. Running every plugin for
|
|
// a planner edit adds no runtime-consumer coverage; hourly/release plans
|
|
// and the complete-inventory planner assertions retain that proof.
|
|
if (
|
|
changedPaths.some(
|
|
(changedPath) =>
|
|
GLOBAL_NODE_TEST_INPUT_RE.test(changedPath) ||
|
|
getChangedPathFacts(changedPath).surface === "rootGlobal",
|
|
)
|
|
) {
|
|
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, {
|
|
resolveAliases: true,
|
|
}))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Covers changed extensions plus the full core-impact blast radius when precise
|
|
* planning falls back. See #124412.
|
|
*/
|
|
export function createChangedExtensionFallbackShards(
|
|
changedPaths: string[],
|
|
options: CwdOptions & RuntimeTestSelection = {},
|
|
): ChangedNodeTestShard[] {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const shards = hasCoreExtensionImpact(changedPaths, { cwd })
|
|
? createChangedExtensionConfigShards(
|
|
listAvailableExtensionIds(cwd).map((extensionId) => `extensions/${extensionId}`),
|
|
{ ...options, changedPaths, fullConfigInventory: true, cwd },
|
|
)
|
|
: createChangedExtensionConfigShardsForPaths(changedPaths, cwd, { ...options, changedPaths });
|
|
const jobs = packChangedExtensionConfigShards(shards);
|
|
if (jobs.length > MAX_CHANGED_EXTENSION_FALLBACK_JOBS) {
|
|
throw new Error(
|
|
`changed plugin fallback exceeds ${MAX_CHANGED_EXTENSION_FALLBACK_JOBS} jobs (${jobs.length} planned)`,
|
|
);
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
/**
|
|
* Builds bounded PR jobs from precise changed-test targets.
|
|
* Missing input is an error for the caller; changed inputs never widen to a full suite.
|
|
*/
|
|
export function createChangedNodeTestShards(
|
|
changedPaths: string[],
|
|
options: CwdOptions &
|
|
ChangedTargetValidation & {
|
|
runnerBackend?: string;
|
|
compactNodeJobCap?: number;
|
|
releaseFastLane?: boolean;
|
|
includeReleaseOnlyToolingShards?: boolean;
|
|
includeReleaseOnlyRuntimeTests?: boolean;
|
|
includePrExemptRuntimeTests?: boolean;
|
|
dedicatedContractShards?: readonly { task: string; includePatterns: readonly string[] }[];
|
|
dedicatedBuildArtifacts?: boolean;
|
|
dedicatedUiE2e?: boolean;
|
|
dedicatedUiTests?: boolean;
|
|
selectedTestTargets?: readonly string[];
|
|
dedicatedMaxLinesRatchet?: boolean;
|
|
} = {},
|
|
): ChangedNodeTestShard[] | null {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const fallback = (reason: string) => {
|
|
options.onFallback?.(reason);
|
|
return null;
|
|
};
|
|
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
|
|
return fallback("missing changed paths");
|
|
}
|
|
|
|
if (
|
|
changedPaths.some(
|
|
(file) =>
|
|
file !== file.trim() ||
|
|
file.includes("\\") ||
|
|
path.posix.normalize(file) !== file ||
|
|
path.isAbsolute(file) ||
|
|
file.startsWith("../"),
|
|
)
|
|
) {
|
|
return fallback("invalid changed path");
|
|
}
|
|
|
|
const selectedTargets =
|
|
options.selectedTestTargets ?? resolveChangedNodeTestTargets(changedPaths, options);
|
|
const livePaths = changedPaths.filter((file) => existsSync(path.join(cwd, file)));
|
|
const policyTargets = new Set(resolvePolicyTestTargets(changedPaths));
|
|
const resolvedTargetPlans = resolvePreciseChangedTargets(selectedTargets, cwd);
|
|
if (resolvedTargetPlans.some(({ plans }) => plans.length === 0)) {
|
|
return fallback("selected test lacks an executable owner");
|
|
}
|
|
const targetPlans = resolvedTargetPlans;
|
|
// Resolve every changed source first, then defer only named complete proofs.
|
|
// Filtering inputs earlier would hide an unresolved companion or helper.
|
|
const runtimeSelection = {
|
|
changedPaths: [...livePaths, ...selectedTargets],
|
|
includeReleaseOnlyRuntimeTests: options.includeReleaseOnlyRuntimeTests,
|
|
includePrExemptRuntimeTests: options.includePrExemptRuntimeTests,
|
|
};
|
|
const changedBuildArtifacts =
|
|
options.dedicatedBuildArtifacts === true && hasBuildArtifactAffectingChange(changedPaths);
|
|
const prTargetPlans: typeof targetPlans = [];
|
|
for (const entry of targetPlans) {
|
|
const { target, plans } = entry;
|
|
if (
|
|
isCiProofTestFile(target) ||
|
|
(options.dedicatedBuildArtifacts === false && tuiPtyTestFiles.includes(target)) ||
|
|
(!PR_SMOKE_TEST_FILES.includes(target) &&
|
|
!policyTargets.has(target) &&
|
|
!isRuntimeTestFileIncluded(target, runtimeSelection, cwd))
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
(options.dedicatedUiTests && isUiTestTarget(target)) ||
|
|
(options.dedicatedUiE2e &&
|
|
(target === BROWSER_EXTENSION_E2E_TEST_FILE ||
|
|
plans.every((plan) => plan.config === UI_E2E_VITEST_CONFIG)))
|
|
) {
|
|
continue;
|
|
}
|
|
const separateContract = plans.some(
|
|
(plan) =>
|
|
plan.config === CONTRACTS_PLUGIN_VITEST_CONFIG ||
|
|
CHANNEL_CONTRACT_CONFIG_PATTERNS.has(plan.config),
|
|
);
|
|
const extensionOwner =
|
|
target.startsWith("extensions/") &&
|
|
plans.some((plan) => plan.config === resolveExtensionTestConfig(target));
|
|
const uncoveredChannels =
|
|
!changedBuildArtifacts &&
|
|
plans.every((plan) => plan.config === SOURCE_CHANNEL_TEST_POLICY.config);
|
|
if (
|
|
changedPaths.includes(target) ||
|
|
path.resolve(cwd) !== process.cwd() ||
|
|
separateContract ||
|
|
extensionOwner ||
|
|
uncoveredChannels ||
|
|
(options.dedicatedBuildArtifacts === false && target === TUI_PTY_ASSERTION_TEST) ||
|
|
plans.every(
|
|
(plan) =>
|
|
!nodeTestConfigRequiresCanonicalMetadata(plan.config) &&
|
|
((plan.includePatterns?.length === 1 && plan.includePatterns[0] === target) ||
|
|
(plan.forwardedArgs.length === 1 && plan.forwardedArgs[0] === target)),
|
|
) ||
|
|
plans.every((plan) => resolveCanonicalNodeTestConfig(target, plan.config))
|
|
) {
|
|
prTargetPlans.push(entry);
|
|
continue;
|
|
}
|
|
// Fully enumerated automatic suites exclude files outside their inventory.
|
|
// General E2E and browser projects retain their separate execution owners.
|
|
if (plans.every((plan) => resolveCanonicalNodeTestConfig(target, plan.config) === null)) {
|
|
continue;
|
|
}
|
|
return fallback(
|
|
`unresolved related test owner: ${target} (${plans.map((plan) => plan.config).join(", ")})`,
|
|
);
|
|
}
|
|
const canonicalTargets = prTargetPlans
|
|
.filter(({ target }) => !target.startsWith("extensions/"))
|
|
// The PTY artifact descriptor only admits process proofs. Its source assertion
|
|
// helper keeps the exact-file TUI config without requiring the built CLI.
|
|
.filter(
|
|
({ target }) =>
|
|
options.dedicatedBuildArtifacts !== false || target !== TUI_PTY_ASSERTION_TEST,
|
|
)
|
|
.filter(
|
|
({ plans }) =>
|
|
plans.every((plan) => plan.includePatterns) &&
|
|
plans.every(
|
|
(plan) =>
|
|
plan.config !== BOUNDARY_NODE_TEST_CONFIG && plan.config !== "ui/vitest.config.ts",
|
|
) &&
|
|
(prTargetPlans.length > 96 ||
|
|
plans.some(({ config }) => nodeTestConfigRequiresCanonicalMetadata(config))) &&
|
|
plans.every((plan) => isCanonicalNodeTestConfig(plan.config)),
|
|
)
|
|
.map(({ target }) => target);
|
|
// Canonical shard inventories describe this checkout, never a caller's
|
|
// synthetic or alternate source root with coincidentally matching paths.
|
|
const canonicalShards = canonicalTargets.length
|
|
? path.resolve(cwd) === process.cwd()
|
|
? createSelectedNodeTestShardBundles(canonicalTargets, {
|
|
runnerBackend: options.runnerBackend,
|
|
onFallback: options.onFallback,
|
|
// These exact targets already passed deferral above, including explicit policy watches.
|
|
includeReleaseOnlyRuntimeTests: true,
|
|
includePrExemptRuntimeTests: true,
|
|
})
|
|
: null
|
|
: [];
|
|
if (canonicalShards === null) {
|
|
return fallback("test targets lack canonical shard metadata");
|
|
}
|
|
const artifactBoundaryOwned =
|
|
changedBuildArtifacts || canonicalShards.some((shard) => shard.requiresDist);
|
|
const boundaryShards = artifactBoundaryOwned ? [] : [createBoundaryShard()];
|
|
const channelTargets = new Set(
|
|
options.dedicatedBuildArtifacts === false
|
|
? prTargetPlans
|
|
.filter(({ plans }) =>
|
|
plans.every((plan) => plan.config === SOURCE_CHANNEL_TEST_POLICY.config),
|
|
)
|
|
.map(({ target }) => target)
|
|
: [],
|
|
);
|
|
const channelShards: ChangedNodeTestShard[] =
|
|
!artifactBoundaryOwned && channelTargets.size > 0
|
|
? [
|
|
{
|
|
checkName: "checks-node-changed-channels",
|
|
configs: [SOURCE_CHANNEL_TEST_POLICY.config],
|
|
includePatterns: [...channelTargets],
|
|
env: { ...SOURCE_CHANNEL_TEST_POLICY.env },
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: "changed-channels",
|
|
},
|
|
]
|
|
: [];
|
|
// CI supplies the suite owners it emits. Validate every changed path first,
|
|
// then subtract covered plans; local runs and unselected owners keep their targets.
|
|
const targets = prTargetPlans
|
|
.filter(({ target }) => !canonicalTargets.includes(target))
|
|
.filter(({ target }) => !channelTargets.has(target))
|
|
.filter(
|
|
({ target, plans }) =>
|
|
!target.startsWith("extensions/") ||
|
|
isPluginControlUiPath(target) ||
|
|
plans.some((plan) => !plan.includePatterns),
|
|
)
|
|
.filter(
|
|
({ plans }) =>
|
|
!options.dedicatedUiE2e || !plans.every(({ config }) => config === UI_E2E_VITEST_CONFIG),
|
|
)
|
|
.filter(
|
|
({ target, plans }) =>
|
|
!plans.every((plan) => {
|
|
const plugin = plan.config === CONTRACTS_PLUGIN_VITEST_CONFIG;
|
|
const patterns = plugin
|
|
? pluginContractPatterns
|
|
: CHANNEL_CONTRACT_CONFIG_PATTERNS.get(plan.config);
|
|
return (
|
|
!plan.watchMode &&
|
|
plan.forwardedArgs.length === 0 &&
|
|
plan.includePatterns?.every((pattern) => pattern === target) &&
|
|
// Artifact plans and local boundary rows both execute the complete suite.
|
|
(((artifactBoundaryOwned || boundaryShards.length > 0) &&
|
|
plan.config === BOUNDARY_NODE_TEST_CONFIG &&
|
|
plan.includePatterns.length > 0 &&
|
|
isBoundaryTestFile(target)) ||
|
|
(patterns?.some((pattern) => path.matchesGlob(target, pattern)) &&
|
|
options.dedicatedContractShards?.some(
|
|
(shard) =>
|
|
shard.task === (plugin ? "contracts-plugins" : "contracts-channels") &&
|
|
shard.includePatterns.includes(target),
|
|
)))
|
|
);
|
|
}),
|
|
);
|
|
|
|
const otherShards = [
|
|
...channelShards,
|
|
...canonicalShards.map((shard) => Object.assign({}, shard, { configs: [] })),
|
|
...packChangedExtensionConfigShards(
|
|
createChangedExtensionConfigShards(
|
|
resolveChangedExtensionRoots(
|
|
prTargetPlans
|
|
.filter(
|
|
({ target, plans }) =>
|
|
target.startsWith("extensions/") &&
|
|
!isPluginControlUiPath(target) &&
|
|
plans.every((plan) => plan.includePatterns),
|
|
)
|
|
.map(({ target }) => target),
|
|
),
|
|
{
|
|
cwd,
|
|
targets: new Set(prTargetPlans.map(({ target }) => target)),
|
|
},
|
|
),
|
|
),
|
|
];
|
|
// Native browser files run in checks-ui, including precise changed-file plans.
|
|
const changedTargets = targets.filter(({ target }) => !isUiBrowserTestFile(target));
|
|
const names = { checkName: "checks-node-changed", shardName: "changed" };
|
|
const shards = [
|
|
...otherShards,
|
|
...createChangedTargetShards(changedTargets, names),
|
|
...boundaryShards,
|
|
];
|
|
// Covered source targets keep build-artifacts ownership even with no Node rows.
|
|
const bounded = boundChangedNodeRows(shards, selectedTargets, options.runnerBackend, cwd);
|
|
// Time-based splitting must not make an admitted owner plan exceed the matrix
|
|
// budget. Retain its compact rows, including plugin work, without widening scope.
|
|
const cap = options.compactNodeJobCap;
|
|
if (cap === undefined || bounded.filter((shard) => !shard.requiresDist).length <= cap) {
|
|
return bounded;
|
|
}
|
|
const otherNonDistRows = [...otherShards, ...boundaryShards].filter(
|
|
(shard) => !shard.requiresDist,
|
|
).length;
|
|
if (shards.filter((shard) => !shard.requiresDist).length <= cap || otherNonDistRows >= cap) {
|
|
return shards;
|
|
}
|
|
// Only target chunks may grow to fit the remaining rows; all other owners
|
|
// keep their compact execution envelopes and the workflow owns final admission.
|
|
return [
|
|
...otherShards,
|
|
...createChangedTargetShards(changedTargets, names, cap - otherNonDistRows),
|
|
...boundaryShards,
|
|
];
|
|
}
|