mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
204 lines
8.6 KiB
TypeScript
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,
|
|
);
|
|
}
|