n8n/packages/testing/test-impact/src/select.ts
Declan Carroll a0f350ed28
ci: Force broad E2E selection for boot-time default changes (no-changelog) (#36333)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:59:03 +00:00

204 lines
8.6 KiB
TypeScript

/**
* `select` handler: changed files + impact map → spec list.
*
* The file-system-aware wrapper around the pure {@link resolveImpact} resolver.
* This is where the FAIL-OPEN safety contract lives — every failure mode
* (missing map, unreadable map, corrupt JSON, empty map) must degrade to
* `mode: 'broad'` so the caller runs the full suite, never an empty one.
*
* Extracted from {@link runSelect} in `cli.ts` so the contract can be
* exhaustively unit-tested without spawning a subprocess.
*/
import * as fs from 'node:fs';
import {
changedRuntimeDepsFromManifests,
classifyManifestChange,
configForcesBroad,
dropDevDepOnlyDeps,
filterImpactfulChanges,
forcesBroad,
isBackendConfig,
isTsconfig,
stripDependencyFiles,
tsconfigForcesBroad,
} from './changes.js';
import type { WorkspaceImporters } from './dep-graph.js';
import {
type ImpactMap,
type InternedImpactMap,
type ResolveResult,
decodeImpactMap,
} from './impact-map.js';
import { CoverageMapStrategy } from './select/coverage-map-strategy.js';
import { DependencyGraphStrategy } from './select/dep-graph-strategy.js';
import { selectImpactedTests } from './select/pipeline.js';
import type { SelectionStrategy } from './select/strategy.js';
/** before/after content of changed files, keyed by path — the caller reads these
* from git. A file absent here can't be classified, so it stays broad. */
export type FileDiffs = Record<string, { before: string; after: string }>;
export interface SelectTestsInput {
/** Changed files (file paths). */
changedFiles: string[];
/** Path to the impact map JSON. Missing/unreadable → fail-open broad. */
mapFile?: string;
/** Path to a newline/comma separated list of every known spec. */
allSpecsFile?: string;
/** Changed package.json diffs. When supplied, a devDependencies-only manifest
* change drops the lockfile + manifests from selection — a devDep can't reach
* the runtime bundle the E2E suite exercises. */
manifests?: FileDiffs;
/** Changed tsconfig diffs, fed to {@link tsconfigForcesBroad}. */
tsconfigs?: FileDiffs;
/** Changed `@n8n/config` diffs, fed to {@link configForcesBroad}. */
configs?: FileDiffs;
/** Workspace package dir → runtime dependency names it declares (parsed from
* pnpm-lock.yaml's `importers`). With `manifests`, a changed runtime dep is
* walked to its declaring packages and scoped via the map instead
* of forcing broad. */
lockfileImporters?: WorkspaceImporters;
/** Package names reachable from the deployed packages' runtime deps (see
* `runtimeClosure` in dep-graph). Classifies `pnpm.overrides` changes;
* omitted → an override change stays broad. */
runtimeClosure?: ReadonlySet<string>;
}
export interface SelectTestsResult extends ResolveResult {
/** Set when the map could not be loaded; the result is broad as a safety. */
failOpen?: string;
}
function parseSpecList(raw: string): string[] {
return raw
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
function loadMap(mapFile: string | undefined): { map: ImpactMap; failOpen?: string } {
if (!mapFile) return { map: {}, failOpen: 'no --map provided' };
if (!fs.existsSync(mapFile)) return { map: {}, failOpen: `map not found: ${mapFile}` };
try {
const parsed: unknown = JSON.parse(fs.readFileSync(mapFile, 'utf8'));
// Interned form ({specs, files}) is decoded; a plain ImpactMap is used as-is.
const isInterned =
typeof parsed === 'object' && parsed !== null && 'specs' in parsed && 'files' in parsed;
const map = isInterned ? decodeImpactMap(parsed as InternedImpactMap) : (parsed as ImpactMap);
// An empty map carries no coverage data — a build/data failure, not a set
// of genuinely-uncovered changes. Flag it as fail-open so selection runs
// broad rather than declaring every change uncovered and skipping E2E.
if (Object.keys(map).length === 0) return { map, failOpen: 'empty map (no coverage data)' };
return { map };
} catch (error) {
return { map: {}, failOpen: `unreadable map: ${String(error)}` };
}
}
/**
* Resolve changed files → the specs that must run. Routes each change to one of
* three outcomes: BROAD (infra failure, runtime-defining change, or an
* unattributable dependency), SCOPED (mapped source / attributable dep), or
* declared UNCOVERED (no covering spec → not run; unit + sanity are the net).
*/
export function selectTests(input: SelectTestsInput): SelectTestsResult {
const allSpecs = input.allSpecsFile
? parseSpecList(fs.readFileSync(input.allSpecsFile, 'utf8'))
: undefined;
const { map, failOpen } = loadMap(input.mapFile);
const broad = (files: string[]): SelectTestsResult => ({
specs: allSpecs ?? [],
unmapped: files,
mode: 'broad',
...(failOpen ? { failOpen } : {}),
});
let impactful = filterImpactfulChanges(input.changedFiles);
// Fail-open: an unusable/empty map is an infra failure, not a coverage gap.
if (failOpen) return broad(impactful);
// Container image / harness changes define the runtime for EVERY spec and
// aren't in the map → run the suite (low-churn, so broad is cheap).
const forcing = impactful.filter(forcesBroad);
if (forcing.length > 0) return broad(forcing);
// Content-aware classifiers: a change of this kind forces broad only when the
// diff proves it can move behaviour. An unsupplied diff (local dev, no base
// ref) can't prove anything, so it forces broad.
const forcingByContent = (
matches: (f: string) => boolean,
diffs: FileDiffs | undefined,
classify: (before: string, after: string) => boolean,
): string[] =>
impactful.filter((f) => {
if (!matches(f)) return false;
const diff = diffs?.[f];
return !diff || classify(diff.before, diff.after);
});
const forcingTsconfig = forcingByContent(isTsconfig, input.tsconfigs, tsconfigForcesBroad);
if (forcingTsconfig.length > 0) return broad(forcingTsconfig);
// A resolution-neutral tsconfig resolves to the same modules → drop it.
impactful = impactful.filter((f) => !isTsconfig(f));
// A changed config default moves behaviour in specs that never execute the
// file; an additive one can't, and falls through to the map.
const forcingConfig = forcingByContent(isBackendConfig, input.configs, configForcesBroad);
if (forcingConfig.length > 0) return broad(forcingConfig);
// devDependency-only change can't reach the runtime bundle → dropped. Same for
// a `pnpm.overrides` pin whose target is outside the runtime closure.
if (input.manifests) {
impactful = dropDevDepOnlyDeps(impactful, input.manifests, input.runtimeClosure);
}
// Runtime-dep change: walk it to the packages that declare it and drop
// the dep files from the coverage path.
const strategies: SelectionStrategy[] = [];
if (input.lockfileImporters && input.manifests) {
const runtimeDeps = changedRuntimeDepsFromManifests(input.manifests);
if (runtimeDeps.length > 0) {
// Stripping the lockfile is intentional: the dep-graph scopes each changed
// declared dep to its importers, which covers that dep's transitive subtree
// (the subtree is reached only THROUGH the dep). What it does NOT cover is
// UNRELATED transitive churn co-occurring in the same lockfile (e.g. a
// `pnpm dedupe` alongside the bump) — the deferred transitive-closure case,
// backstopped by the nightly full E2E run. A lockfile-only change (no
// manifest) never reaches here and stays broad.
impactful = stripDependencyFiles(impactful);
strategies.push(
new DependencyGraphStrategy(map, input.lockfileImporters, runtimeDeps, { allSpecs }),
);
}
}
// A dep change the walk couldn't scope stays broad (never declared uncovered):
// a lockfile bump, or a runtime manifest still present. A package.json with no
// manifest metadata (e.g. local dev with no base ref) can't be classified, so
// it's treated conservatively as runtime → broad. A classified non-dep manifest
// change (version / scripts) falls through and is declared like source.
const lockfileRemains = impactful.includes('pnpm-lock.yaml');
const unscopedRuntimeManifest = impactful.some((f) => {
if (!/(^|\/)package\.json$/.test(f)) return false;
const manifest = input.manifests?.[f];
if (!manifest) return true;
// An `override` still present here failed the closure check → unproven,
// treat like runtime.
const kind = classifyManifestChange(manifest.before, manifest.after);
return kind === 'runtime' || kind === 'override';
});
if (lockfileRemains || unscopedRuntimeManifest) return broad(impactful);
// Source files: no covering spec → declared uncovered (surfaced, not run);
// running its package's specs verifies nothing.
strategies.unshift(new CoverageMapStrategy(map, { allSpecs, onUncovered: 'declare' }));
return selectImpactedTests(
impactful.map((file) => ({ file })),
strategies,
);
}