n8n/packages/testing/test-impact/src/changes.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

368 lines
15 KiB
TypeScript

/**
* Classify changed files for impact selection.
*
* Some changed paths cannot affect the runtime the E2E suite exercises — repo
* tooling, docs, editor config. Left in the changed-set they have no map entry,
* so they hit the unmapped → broad fallback and drag an otherwise-scoped PR to
* the full suite. Dropping them before selection is a pure win: they can't run
* a real test regardless, so we only ever decline to run things that can't run.
*
* SURGICAL by design: only *named* config files are ignored — never all
* `*.json` / `*.yaml`, because node descriptions, i18n catalogues and test
* fixtures are real source data the map legitimately keys on.
*
* NOT ignored (deliberately): `docker`/Dockerfiles (define the container the
* E2E suite runs in), `pnpm-lock.yaml`/`package.json` (dependency changes —
* handled by the devDep classifier / dep-graph selector), `patches/**`
* (edits a dependency's code = a dependency change), and `tsconfig*.json`
* (can carry module-resolution keys — see {@link tsconfigForcesBroad}).
*/
const NON_IMPACTFUL: Array<(f: string) => boolean> = [
// Agent / editor / repo tooling
(f) => f.startsWith('.claude/'),
(f) => /^\.(vscode|idea)\//.test(f),
(f) =>
/(^|\/)\.(editorconfig|gitattributes|gitignore|npmrc|nvmrc|prettierignore|eslintignore)$/.test(
f,
),
// Docs + dictionaries + doc assets
(f) => /\.mdx?$/.test(f) || /(^|\/)(LICENSE|CHANGELOG\.md)$/.test(f),
(f) => /cspell|\.dic$/i.test(f),
(f) => /^(docs|assets)\/.*\.(png|jpe?g|gif|svg|webp)$/.test(f),
// Repo automation scripts (not shipped, not exercised by E2E)
(f) => f.startsWith('scripts/'),
// Named build / lint / test-runner config (NOT all json/yaml). `tsconfig*` is
// deliberately absent — it's routed through tsconfigForcesBroad instead.
(f) =>
/(^|\/)(turbo\.json|biome\.jsonc?|vitest\.config\.[cm]?[jt]s|\.eslintrc[\w.]*|\.prettierrc[\w.]*)$/.test(
f,
),
];
/** True if a changed file cannot affect the runtime the E2E suite exercises. */
export function isNonImpactful(file: string): boolean {
return NON_IMPACTFUL.some((matches) => matches(file));
}
/**
* Drop non-impactful paths from a changed-file set so they don't force a broad
* selection. Apply this once, at the entry, before any selector sees the files.
*/
export function filterImpactfulChanges(files: string[]): string[] {
return files.filter((file) => !isNonImpactful(file));
}
/**
* Paths that define the runtime the WHOLE E2E suite executes in — the container
* image and the container harness. The coverage map can't attribute these (a
* change here can reach any spec), so they force a broad run rather than being
* declared uncovered. This is a small, low-churn set, so broad is cheap here.
*
* Credential definitions belong here for a different reason: they're declarative
* metadata loaded into the registry once at boot, so no code in them re-executes
* attributably to a spec and the runtime coverage map never records them. Absent
* from the map, a credential change would be declared uncovered and skip its
* covering specs, so we force broad instead.
*
* The module registry is the same boot-time class, but worse: it IS in the map,
* so a scoped result looks correct. Boot code is attributed only to specs that
* start their own container — i.e. the specs that already enable the module via
* `N8N_ENABLED_MODULES` and so can't observe a default flip. The specs that
* break assert the off-branch, and no hit-coverage map reaches a branch that
* stopped executing. Churn is low, so broad is cheap here too.
*/
const FORCES_BROAD: Array<(f: string) => boolean> = [
(f) => f.startsWith('docker/'),
(f) => /(^|\/)Dockerfile(\.|$)|\.Dockerfile$/.test(f),
(f) => f.startsWith('packages/testing/containers/'),
(f) => f.startsWith('packages/nodes-base/credentials/'),
(f) => /^packages\/@n8n\/backend-common\/src\/modules\/[^/]+\.ts$/.test(f),
];
/** True if a changed file defines the E2E runtime → the whole suite must run. */
export function forcesBroad(file: string): boolean {
return FORCES_BROAD.some((matches) => matches(file));
}
/** A package.json change classified by which dependency sections moved. */
export type ManifestChangeKind = 'runtime' | 'devDep-only' | 'override' | 'none';
type ManifestJson = Record<string, Record<string, string> | undefined>;
/** package.json sections whose changes can reach the runtime bundle. */
export const RUNTIME_SECTIONS = [
'dependencies',
'optionalDependencies',
'peerDependencies',
] as const;
function parseManifest(raw: string): ManifestJson {
try {
return JSON.parse(raw) as ManifestJson;
} catch {
return {};
}
}
/** Dependency names added / removed / version-changed in one manifest section. */
function changedKeysInSection(
before: ManifestJson,
after: ManifestJson,
section: string,
): string[] {
const b = before[section] ?? {};
const a = after[section] ?? {};
const changed: string[] = [];
for (const key of new Set([...Object.keys(b), ...Object.keys(a)])) {
if (b[key] !== a[key]) changed.push(key);
}
return changed;
}
function sectionChanged(before: ManifestJson, after: ManifestJson, section: string): boolean {
return changedKeysInSection(before, after, section).length > 0;
}
/** `pnpm.overrides` selectors changed between two manifests, as written. */
function changedOverrideSelectors(before: ManifestJson, after: ManifestJson): string[] {
const b = (before.pnpm as { overrides?: Record<string, string> } | undefined)?.overrides ?? {};
const a = (after.pnpm as { overrides?: Record<string, string> } | undefined)?.overrides ?? {};
const changed: string[] = [];
for (const key of new Set([...Object.keys(b), ...Object.keys(a)])) {
if (b[key] !== a[key]) changed.push(key);
}
return changed;
}
const NPM_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
/**
* The package an override selector pins (`node-gyp>undici` → `undici`,
* `@vitest/browser@<4.1.10` → `@vitest/browser`), or `null` when the selector
* can't be parsed with confidence — callers must then stay broad.
*/
export function overrideTargetName(selector: string): string | null {
// A range `>` (`pkg@>2`, `pkg@1||>2`) follows `@` or `|`, touches whitespace,
// or is `>=` — never a parent>child separator.
let child = selector;
for (let i = selector.length - 1; i > 0; i--) {
if (selector[i] !== '>') continue;
const prev = selector[i - 1];
const next = selector[i + 1] ?? '';
if (
prev === '@' ||
prev === '|' ||
prev === '=' ||
next === '=' ||
/\s/.test(prev) ||
/\s/.test(next)
) {
continue;
}
child = selector.slice(i + 1);
break;
}
child = child.trim();
// `> 0` keeps a leading scope `@` intact when stripping the version range.
const at = child.lastIndexOf('@');
const name = at > 0 ? child.slice(0, at) : child;
return NPM_PACKAGE_NAME.test(name) ? name : null;
}
/**
* Packages whose `pnpm.overrides` pin was added, removed or changed. `null`
* when any changed selector fails to parse — the diff can't be attributed.
*/
export function changedOverrideTargets(before: string, after: string): string[] | null {
const selectors = changedOverrideSelectors(parseManifest(before), parseManifest(after));
const names = new Set<string>();
for (const selector of selectors) {
const name = overrideTargetName(selector);
if (name === null) return null;
names.add(name);
}
return [...names];
}
/**
* Classify a package.json change by the dependency sections it touched:
* - `runtime` — a runtime section (dependencies / optional / peer) moved, so
* it can reach the bundle the E2E suite exercises.
* - `override` — a `pnpm.overrides` pin moved; whether it reaches the
* runtime bundle takes a closure check (see {@link dropDevDepOnlyDeps}).
* - `devDep-only` — only devDependencies moved → cannot reach the runtime bundle.
* - `none` — no dependency section moved (scripts / version / engines / …).
* Unparseable content is treated as an empty manifest. Checked most-impactful
* first: a mixed devDep+override change classifies as `override`.
*/
export function classifyManifestChange(before: string, after: string): ManifestChangeKind {
const b = parseManifest(before);
const a = parseManifest(after);
if (RUNTIME_SECTIONS.some((s) => sectionChanged(b, a, s))) return 'runtime';
if (changedOverrideSelectors(b, a).length > 0) return 'override';
return sectionChanged(b, a, 'devDependencies') ? 'devDep-only' : 'none';
}
/**
* Names of the *runtime* dependencies (dependencies / optional / peer) that
* changed between two manifests — the input to the dep-graph selector,
* which walks each name to the workspace packages that declare it. devDeps are
* excluded (they can't reach the runtime bundle).
*/
export function changedRuntimeDeps(before: string, after: string): string[] {
const b = parseManifest(before);
const a = parseManifest(after);
const names = new Set<string>();
for (const section of RUNTIME_SECTIONS) {
for (const name of changedKeysInSection(b, a, section)) names.add(name);
}
return [...names];
}
/** {@link changedRuntimeDeps} unioned across every changed manifest. */
export function changedRuntimeDepsFromManifests(
manifests: Record<string, { before: string; after: string }>,
): string[] {
const names = new Set<string>();
for (const { before, after } of Object.values(manifests)) {
for (const name of changedRuntimeDeps(before, after)) names.add(name);
}
return [...names];
}
const isManifest = (f: string): boolean => /(^|\/)package\.json$/.test(f);
const isLockfile = (f: string): boolean => f === 'pnpm-lock.yaml';
export const isTsconfig = (f: string): boolean => /(^|\/)tsconfig([.\w-]*)\.json$/.test(f);
/** compilerOptions keys that change which module a bare import resolves to. */
const TSCONFIG_RESOLUTION_KEYS = [
'paths',
'baseUrl',
'moduleResolution',
'customConditions',
] as const;
/** Remove block and line comments. A comment-only edit must not look like a
* change. The `[^:]` guard keeps `http://` intact. */
function stripComments(raw: string): string {
return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
}
/** Tolerant parse for tsconfig (allows comments + trailing commas). Null when
* unparseable, which the caller treats as "force broad". */
function parseTsconfig(raw: string): Record<string, unknown> | null {
if (!raw.trim()) return null;
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
try {
const stripped = stripComments(raw).replace(/,(\s*[}\]])/g, '$1');
return JSON.parse(stripped) as Record<string, unknown>;
} catch {
return null;
}
}
}
/**
* True when a tsconfig change touches a resolution key (`paths`/`baseUrl`/
* `moduleResolution`/`customConditions`/`extends`), which re-points imports for
* every spec and can't be attributed in the coverage map → force the full
* suite. A type-check-only edit (`strict`, `target`, …) resolves to the same
* modules and is non-impactful.
*/
export function tsconfigForcesBroad(before: string, after: string): boolean {
const b = parseTsconfig(before);
const a = parseTsconfig(after);
if (!b || !a) return true;
if (JSON.stringify(b.extends) !== JSON.stringify(a.extends)) return true;
const bco = (b.compilerOptions ?? {}) as Record<string, unknown>;
const aco = (a.compilerOptions ?? {}) as Record<string, unknown>;
return TSCONFIG_RESOLUTION_KEYS.some((k) => JSON.stringify(bco[k]) !== JSON.stringify(aco[k]));
}
export const isBackendConfig = (f: string): boolean =>
/^packages\/@n8n\/config\/src\/configs\/[^/]+\.ts$/.test(f);
/** Every field default (`name=initializer`, empty when the field has none) and
* every `@Env` binding — the two inputs that decide a config's runtime value. */
function configDefaults(source: string): Set<string> {
const src = stripComments(source);
const defaults = new Set<string>();
for (const [, env] of src.matchAll(/@Env\(\s*['"]([^'"]+)['"]/g)) {
defaults.add(`env:${env}`);
}
for (const [, field, init] of src.matchAll(
/^\s*(?:readonly\s+)?([\w$]+)[?!]?\s*:\s*[^=;\n]+?(?:=\s*([^;\n]+))?;/gm,
)) {
defaults.add(`${field}=${(init ?? '').trim().replace(/\s+/g, ' ')}`);
}
return defaults;
}
/**
* True when a config change alters an EXISTING default — a field's fallback
* value, or the env var it reads. Config classes are built once with the DI
* container, so their defaults are unattributable (1 of ~57 files is in the
* map) and a changed default moves behaviour in specs that never touch the
* file. A purely additive change can't: the code reading the new field is in
* the same PR and IS mapped. That split matters at ~130 commits/90d.
*/
export function configForcesBroad(before: string, after: string): boolean {
const next = configDefaults(after);
return [...configDefaults(before)].some((d) => !next.has(d));
}
/** Remove the lockfile + every package.json from a changed-file set. */
export function stripDependencyFiles(files: string[]): string[] {
return files.filter((f) => !isLockfile(f) && !isManifest(f));
}
/**
* Drop the lockfile + manifests from a changed-file set when the dependency
* change is provably devDependencies-only — a devDep can't reach the runtime
* bundle, so it must not force broad. `manifests` maps each changed package.json
* path to its before/after content (the caller reads these from git).
*
* An override pins a TRANSITIVE package, which no declared section mentions —
* `fast-uri` (reaches runtime via `ajv`) and `@vitest/browser` (dev-only) look
* identical there. Only `runtimeClosure` membership can tell them apart.
*
* Conservative by construction — never drops without positive evidence:
* - any runtime-section change → keep everything (real dep change);
* - a changed package.json with no supplied diff → treated as runtime;
* - a lockfile change with no changed package.json at all (transitive bump) → kept;
* - an override change with a missing/empty closure, an unparseable selector,
* or any target inside the closure → kept.
*/
export function dropDevDepOnlyDeps(
files: string[],
manifests: Record<string, { before: string; after: string }>,
runtimeClosure?: ReadonlySet<string>,
): string[] {
const changedManifests = files.filter(isManifest);
if (changedManifests.length === 0) return files;
const kinds = changedManifests.map((f) =>
manifests[f] ? classifyManifestChange(manifests[f].before, manifests[f].after) : 'runtime',
);
if (kinds.includes('runtime')) return files;
if (kinds.includes('override')) {
// An empty closure is a broken walk, not proof nothing reaches runtime.
if (!runtimeClosure || runtimeClosure.size === 0) return files;
const targets = new Set<string>();
for (const f of changedManifests) {
if (!manifests[f]) continue;
const names = changedOverrideTargets(manifests[f].before, manifests[f].after);
if (names === null) return files;
for (const name of names) targets.add(name);
}
if (targets.size === 0 || [...targets].some((t) => runtimeClosure.has(t))) return files;
return stripDependencyFiles(files);
}
if (!kinds.includes('devDep-only')) return files;
return stripDependencyFiles(files);
}