feat: Skip E2E for pnpm.overrides pins provably outside the runtime closure (no-changelog) (#35540)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Declan Carroll
2026-08-06 09:57:37 +01:00
committed by GitHub
parent cc98292be0
commit 1aa604d265
9 changed files with 590 additions and 22 deletions
+15 -5
View File
@@ -22,6 +22,7 @@ import {
distributeShards,
selectTests,
changedRuntimeDepsFromManifests,
changedOverrideTargets,
} from '@n8n/test-impact';
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -78,7 +79,7 @@ import {
formatMethodUsageIndexJSON,
} from './core/method-usage-analyzer.js';
import { createProject } from './core/project-loader.js';
import { readLockfileImporters } from './core/read-lockfile-importers.js';
import { readLockfileImporters, readRuntimeClosure } from './core/read-lockfile-importers.js';
import { readManifestDiffs, readTsconfigDiffs } from './core/read-manifest-diffs.js';
import { toJSON, toConsole } from './core/reporter.js';
import { filterToFailedSpecs } from './core/retry-filter.js';
@@ -726,10 +727,18 @@ function runSelect(options: CliOptions): void {
// Only parse the (large) lockfile when a RUNTIME dependency actually changed —
// the only case the dep-graph selector (389) acts on. A devDep-only manifest
// change would parse it for nothing.
const lockfileImporters =
manifests && changedRuntimeDepsFromManifests(manifests).length > 0
? readLockfileImporters()
: undefined;
const runtimeDepsChanged = manifests
? changedRuntimeDepsFromManifests(manifests).length > 0
: false;
const lockfileImporters = runtimeDepsChanged ? readLockfileImporters() : undefined;
// The closure classifies override pins; a runtime-dep change already forces
// broad, so don't compute it then.
const overridesChanged =
!runtimeDepsChanged &&
Object.values(manifests ?? {}).some(({ before, after }) => {
const targets = changedOverrideTargets(before, after);
return targets === null || targets.length > 0;
});
const result = selectTests({
changedFiles,
mapFile: options.mapFile,
@@ -737,6 +746,7 @@ function runSelect(options: CliOptions): void {
manifests,
tsconfigs,
lockfileImporters,
runtimeClosure: overridesChanged ? readRuntimeClosure() : undefined,
});
console.log(JSON.stringify(result));
}
@@ -8,27 +8,44 @@
* lockfile) returns `{}`, which makes the dep-graph selector contribute nothing
* (the change then resolves through the coverage map alone — fail-open).
*/
import { RUNTIME_SECTIONS } from '@n8n/test-impact';
import {
RUNTIME_SECTIONS,
runtimeClosure,
type LockfileImporters,
type LockfileSnapshots,
} from '@n8n/test-impact';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse } from 'yaml';
import { getGitRoot } from '../utils/git-operations.js';
type ImporterSections = Record<string, Record<string, unknown> | undefined>;
type Lockfile = {
importers?: LockfileImporters;
snapshots?: LockfileSnapshots;
};
export function readLockfileImporters(): Record<string, string[]> {
/**
* Closure seed: `packages/cli` is the `n8n` package the E2E container runs;
* its workspace `link:` edges cover the rest. See {@link runtimeClosure} for
* why this must not be "every importer".
*/
const DEPLOY_ROOTS = ['packages/cli'] as const;
/** Parse the lockfile once, or `undefined` on any failure (fail-open). */
function readLockfile(): Lockfile | undefined {
const lockPath = join(getGitRoot(process.cwd()), 'pnpm-lock.yaml');
if (!existsSync(lockPath)) return {};
let doc: { importers?: Record<string, ImporterSections> };
if (!existsSync(lockPath)) return undefined;
try {
doc = parse(readFileSync(lockPath, 'utf8')) as typeof doc;
return parse(readFileSync(lockPath, 'utf8')) as Lockfile;
} catch {
return {};
return undefined;
}
const importers = doc?.importers ?? {};
}
function importersFrom(doc: Lockfile): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const [dir, sections] of Object.entries(importers)) {
for (const [dir, sections] of Object.entries(doc.importers ?? {})) {
const names = new Set<string>();
for (const section of RUNTIME_SECTIONS) {
const deps = sections?.[section];
@@ -38,3 +55,21 @@ export function readLockfileImporters(): Record<string, string[]> {
}
return out;
}
export function readLockfileImporters(): Record<string, string[]> {
const doc = readLockfile();
return doc ? importersFrom(doc) : {};
}
/**
* Runtime closure for classifying `pnpm.overrides` changes. `undefined` on any
* read failure, which keeps them broad.
*/
export function readRuntimeClosure(): ReadonlySet<string> | undefined {
const doc = readLockfile();
if (!doc?.snapshots || !doc.importers) return undefined;
return runtimeClosure(doc.importers, doc.snapshots, {
deployRoots: DEPLOY_ROOTS,
runtimeSections: RUNTIME_SECTIONS,
});
}
@@ -8,6 +8,8 @@ import {
tsconfigForcesBroad,
classifyManifestChange,
dropDevDepOnlyDeps,
overrideTargetName,
changedOverrideTargets,
} from './changes.js';
describe('isNonImpactful', () => {
@@ -228,3 +230,123 @@ describe('dropDevDepOnlyDeps (safety-critical)', () => {
).toEqual(files);
});
});
const ovr = (overrides: Record<string, string>, deps = {}, devDeps = {}) =>
JSON.stringify({ name: 'x', dependencies: deps, devDependencies: devDeps, pnpm: { overrides } });
describe('overrideTargetName', () => {
it.each([
['@vitest/browser@<4.1.10', '@vitest/browser'],
['brace-expansion@5', 'brace-expansion'],
['node-gyp>undici', 'undici'],
['@babel/traverse', '@babel/traverse'],
['undici@7', 'undici'],
['a>b>@scope/c@^1.0.0', '@scope/c'],
['@n8n/typeorm>@sentry/node', '@sentry/node'],
// `>` inside a version range is not a parent separator
['pkg@>=2.0.0', 'pkg'],
['pkg@>2', 'pkg'],
['pkg@1||>2', 'pkg'],
['pkg@^1||>=2.0.0', 'pkg'],
['pkg@>=1||>=2', 'pkg'],
['a@1>b@>=2', 'b'],
['a@=1.2.3>b', 'b'],
['pkg@1=>2', 'pkg'],
])('%s → %s', (selector, expected) => {
expect(overrideTargetName(selector)).toBe(expected);
});
it('returns null when no package name can be extracted', () => {
expect(overrideTargetName('>=2.0.0')).toBeNull();
});
});
describe('changedOverrideTargets', () => {
it('returns the target when a pin is added', () => {
expect(changedOverrideTargets(ovr({}), ovr({ 'ws@<8.21.1': '8.21.1' }))).toEqual(['ws']);
});
it('returns the target when a pin is removed', () => {
expect(changedOverrideTargets(ovr({ 'ws@<8.21.1': '8.21.1' }), ovr({}))).toEqual(['ws']);
});
it('returns the target when a pin version changes', () => {
expect(
changedOverrideTargets(
ovr({ 'brace-expansion@5': '5.0.7' }),
ovr({ 'brace-expansion@5': '5.0.9' }),
),
).toEqual(['brace-expansion']);
});
it('de-duplicates selectors pinning the same package', () => {
expect(
changedOverrideTargets(ovr({}), ovr({ 'undici@7': '7.29.0', 'node-gyp>undici': '7.29.0' })),
).toEqual(['undici']);
});
it('ignores untouched pins', () => {
const same = ovr({ 'ws@<8.21.1': '8.21.1' });
expect(changedOverrideTargets(same, same)).toEqual([]);
});
});
describe('classifyManifestChange — overrides', () => {
it('override when only a pnpm.overrides pin moves', () => {
expect(classifyManifestChange(ovr({}), ovr({ 'ws@1': '1.0.1' }))).toBe('override');
});
it('runtime wins over a co-occurring override change', () => {
expect(
classifyManifestChange(ovr({}, { axios: '1' }), ovr({ 'ws@1': '1.0.1' }, { axios: '2' })),
).toBe('runtime');
});
it('override wins over a co-occurring devDependencies change', () => {
expect(
classifyManifestChange(
ovr({}, {}, { vitest: '1' }),
ovr({ 'ws@1': '1.0.1' }, {}, { vitest: '2' }),
),
).toBe('override');
});
});
describe('dropDevDepOnlyDeps — overrides (safety-critical)', () => {
const files = ['pnpm-lock.yaml', 'package.json'];
const overrideDiff = (target: string) => ({
'package.json': { before: ovr({}), after: ovr({ [`${target}@<2`]: '2.0.0' }) },
});
const closure = new Set(['ajv', 'fast-uri']);
it('drops when every override target is outside the runtime closure', () => {
expect(dropDevDepOnlyDeps(files, overrideDiff('@vitest/browser'), closure)).toEqual([]);
});
it('KEEPS when the override target is inside the runtime closure', () => {
expect(dropDevDepOnlyDeps(files, overrideDiff('fast-uri'), closure)).toEqual(files);
});
it('KEEPS when no closure is supplied', () => {
expect(dropDevDepOnlyDeps(files, overrideDiff('@vitest/browser'), undefined)).toEqual(files);
});
it('KEEPS when the closure is empty (broken walk, not proof)', () => {
expect(dropDevDepOnlyDeps(files, overrideDiff('@vitest/browser'), new Set())).toEqual(files);
});
it('KEEPS when any one of several targets is inside the closure', () => {
const manifests = {
'package.json': {
before: ovr({}),
after: ovr({ '@vitest/browser@<2': '2.0.0', 'fast-uri@<4': '4.0.0' }),
},
};
expect(dropDevDepOnlyDeps(files, manifests, closure)).toEqual(files);
});
it('KEEPS a runtime-section change even when its override target is outside the closure', () => {
const manifests = {
'package.json': {
before: ovr({}, { axios: '1' }),
after: ovr({ '@vitest/browser@<2': '2.0.0' }, { axios: '2' }),
},
};
expect(dropDevDepOnlyDeps(files, manifests, closure)).toEqual(files);
});
it('KEEPS when a changed selector cannot be attributed to a package', () => {
const manifests = {
'package.json': { before: ovr({}), after: ovr({ '>=2.0.0': '2.0.0' }) },
};
expect(dropDevDepOnlyDeps(files, manifests, closure)).toEqual(files);
});
});
+90 -3
View File
@@ -78,7 +78,7 @@ export function forcesBroad(file: string): boolean {
}
/** A package.json change classified by which dependency sections moved. */
export type ManifestChangeKind = 'runtime' | 'devDep-only' | 'none';
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. */
@@ -115,18 +115,83 @@ function sectionChanged(before: ManifestJson, after: ManifestJson, section: stri
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.
* 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';
}
@@ -217,14 +282,21 @@ export function stripDependencyFiles(files: string[]): string[] {
* 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.
* - 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;
@@ -232,6 +304,21 @@ export function dropDevDepOnlyDeps(
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);
}
@@ -5,7 +5,13 @@ import {
changedRuntimeDepsFromManifests,
stripDependencyFiles,
} from './changes.js';
import { dependentDirs } from './dep-graph.js';
import {
dependentDirs,
runtimeClosure,
snapshotKeyToName,
type LockfileImporters,
type LockfileSnapshots,
} from './dep-graph.js';
import type { ImpactMap } from './impact-map.js';
import { DependencyGraphStrategy } from './select/dep-graph-strategy.js';
@@ -111,3 +117,98 @@ describe('DependencyGraphStrategy', () => {
expect(r).toEqual({ specs: [], unmapped: [], mode: 'scoped' });
});
});
describe('snapshotKeyToName', () => {
it.each([
['ajv@8.18.0', 'ajv'],
['@scope/pkg@1.0.0', '@scope/pkg'],
// peer suffix carries `@`s — must not confuse the version separator
['@vitest/coverage-v8@4.1.9(vitest@4.1.9)', '@vitest/coverage-v8'],
['vite@5.0.0(sass@1.98.0)(terser@5.0.0)', 'vite'],
['plain-name', 'plain-name'],
])('%s → %s', (key, expected) => {
expect(snapshotKeyToName(key)).toBe(expected);
});
});
describe('runtimeClosure', () => {
// cli links core (runtime) → ajv → fast-uri; test-utils declares vitest but
// is only reachable via devDependencies, so it must stay out.
const importers: LockfileImporters = {
'packages/cli': {
dependencies: {
'n8n-core': { specifier: 'workspace:*', version: 'link:../core' },
axios: { specifier: '^1.0.0', version: '1.0.0' },
},
devDependencies: {
'test-utils': { specifier: 'workspace:*', version: 'link:../test-utils' },
},
},
'packages/core': {
dependencies: { ajv: { specifier: '^8.0.0', version: '8.18.0' } },
},
'packages/test-utils': {
dependencies: { vitest: { specifier: '^4.0.0', version: '4.1.9' } },
},
};
const snapshots: LockfileSnapshots = {
'ajv@8.18.0': { dependencies: { 'fast-uri': '3.1.3' } },
'fast-uri@3.1.3': {},
'axios@1.0.0': {},
'vitest@4.1.9': { dependencies: { '@vitest/browser': '4.1.9' } },
'@vitest/browser@4.1.9': {},
};
const opts = {
deployRoots: ['packages/cli'],
runtimeSections: ['dependencies', 'optionalDependencies'],
};
it('follows workspace link: edges from the deploy roots', () => {
const closure = runtimeClosure(importers, snapshots, opts);
expect(closure.has('ajv')).toBe(true);
expect(closure.has('axios')).toBe(true);
});
it('reaches a dep only present via a transitive snapshot edge (the fast-uri shape)', () => {
expect(runtimeClosure(importers, snapshots, opts).has('fast-uri')).toBe(true);
});
it('excludes deps of a workspace package reachable only via devDependencies (the vitest shape)', () => {
const closure = runtimeClosure(importers, snapshots, opts);
expect(closure.has('vitest')).toBe(false);
expect(closure.has('@vitest/browser')).toBe(false);
});
it('with empty snapshots the closure is just the declared external roots', () => {
expect([...runtimeClosure(importers, {}, opts)].sort()).toEqual(['ajv', 'axios']);
});
it('roots the real package behind an npm: alias in an importer (the zod-from-json-schema shape)', () => {
const aliased: LockfileImporters = {
'packages/cli': {
dependencies: {
'zod-v3': {
specifier: 'npm:zod-from-json-schema@^0.0.5',
version: 'zod-from-json-schema@0.0.5',
},
},
},
};
const snaps: LockfileSnapshots = {
'zod-from-json-schema@0.0.5': { dependencies: { zod: '3.25.76' } },
};
const closure = runtimeClosure(aliased, snaps, opts);
expect(closure.has('zod-from-json-schema')).toBe(true);
expect(closure.has('zod')).toBe(true);
});
it('follows an aliased dep inside a snapshot (the string-width-cjs shape)', () => {
const snaps: LockfileSnapshots = {
'ajv@8.18.0': { dependencies: { 'string-width-cjs': 'string-width@4.2.3' } },
'string-width@4.2.3': { dependencies: { 'emoji-regex': '8.0.0' } },
};
const closure = runtimeClosure(importers, snaps, opts);
expect(closure.has('string-width')).toBe(true);
expect(closure.has('emoji-regex')).toBe(true);
});
it('unknown deploy root → empty closure, no throw', () => {
expect(
runtimeClosure(importers, snapshots, { ...opts, deployRoots: ['packages/nope'] }).size,
).toBe(0);
});
});
@@ -18,3 +18,141 @@ export function dependentDirs(deps: string[], importers: WorkspaceImporters): st
}
return dirs.sort();
}
/**
* pnpm-lock.yaml `snapshots`: resolved-package key → its dependency sections.
* Keys carry a version and, for peer-resolved packages, a suffix — e.g.
* `ajv@8.18.0`, `@vitest/coverage-v8@4.1.9(vitest@4.1.9)`.
*/
export type LockfileSnapshots = Record<
string,
Record<string, Record<string, string> | undefined> | undefined
>;
/**
* Package name from a snapshot key. The peer suffix must be stripped BEFORE
* looking for the version separator, because it contains `@` of its own —
* otherwise `@vitest/coverage-v8@4.1.9(vitest@4.1.9)` resolves to the wrong
* name, the variant is never visited, and the closure silently under-includes.
*/
export function snapshotKeyToName(key: string): string {
const base = key.replace(/\(.*$/, '');
const at = base.lastIndexOf('@');
return at > 0 ? base.slice(0, at) : base;
}
/**
* Real package behind an aliased dependency value (`string-width@4.2.3`), or
* null for a plain version. Aliases resolve under the REAL package's snapshot
* key, so a walk following only the alias name never visits them.
*/
function aliasedDepName(value: string): string | null {
const name = snapshotKeyToName(value);
return name === value.replace(/\(.*$/, '') ? null : name;
}
/** A dependency entry in pnpm-lock.yaml's `importers`. */
type ImporterEntry = { specifier?: string; version?: string } | string;
/** pnpm-lock.yaml `importers`: workspace dir → dependency sections. */
export type LockfileImporters = Record<
string,
Record<string, Record<string, ImporterEntry> | undefined> | undefined
>;
const LINK_PREFIX = 'link:';
/** The `link:` target of a workspace dependency entry, or undefined if external. */
function workspaceLinkTarget(entry: ImporterEntry): string | undefined {
const candidates =
typeof entry === 'string' ? [entry] : [entry?.specifier ?? '', entry?.version ?? ''];
const link = candidates.find((v) => v.startsWith(LINK_PREFIX));
return link?.slice(LINK_PREFIX.length);
}
/** Resolve a `link:../foo` target, relative to the declaring package's dir. */
function resolveLink(fromDir: string, target: string): string {
const segments = `${fromDir}/${target}`.split('/');
const out: string[] = [];
for (const segment of segments) {
if (segment === '' || segment === '.') continue;
if (segment === '..') out.pop();
else out.push(segment);
}
return out.join('/');
}
/**
* External dependency names the *deployed* workspace packages declare, following
* workspace `link:` edges through RUNTIME sections only. Runtime-only edges keep
* dev-only workspace packages out: `@n8n/backend-test-utils` declares `vitest`
* in `dependencies` but is itself reachable only via `devDependencies`.
*/
function deployedExternalDeps(
importers: LockfileImporters,
deployRoots: readonly string[],
runtimeSections: readonly string[],
): Set<string> {
const visited = new Set<string>();
const external = new Set<string>();
const queue = [...deployRoots];
while (queue.length > 0) {
const dir = queue.pop();
if (dir === undefined || visited.has(dir)) continue;
visited.add(dir);
const sections = importers[dir];
if (!sections) continue;
for (const section of runtimeSections) {
for (const [name, entry] of Object.entries(sections[section] ?? {})) {
const link = workspaceLinkTarget(entry);
if (link !== undefined) {
queue.push(resolveLink(dir, link));
continue;
}
external.add(name);
const version = typeof entry === 'string' ? entry : (entry?.version ?? '');
const real = aliasedDepName(version);
if (real !== null) external.add(real);
}
}
}
return external;
}
/**
* Every package name transitively reachable from the deploy roots' runtime
* dependencies — a name OUTSIDE this set cannot reach the bundle E2E exercises.
* Errs toward including: over-inclusion costs a missed optimisation,
* under-inclusion would skip tests for a live runtime change.
*/
export function runtimeClosure(
importers: LockfileImporters,
snapshots: LockfileSnapshots,
opts: { deployRoots: readonly string[]; runtimeSections: readonly string[] },
): Set<string> {
const { deployRoots, runtimeSections } = opts;
const variantsByName = new Map<string, string[]>();
for (const key of Object.keys(snapshots)) {
const name = snapshotKeyToName(key);
const existing = variantsByName.get(name);
if (existing) existing.push(key);
else variantsByName.set(name, [key]);
}
const closure = new Set<string>();
const queue = [...deployedExternalDeps(importers, deployRoots, runtimeSections)];
while (queue.length > 0) {
const name = queue.pop();
if (name === undefined || closure.has(name)) continue;
closure.add(name);
for (const key of variantsByName.get(name) ?? []) {
for (const section of runtimeSections) {
for (const [dep, version] of Object.entries(snapshots[key]?.[section] ?? {})) {
if (!closure.has(dep)) queue.push(dep);
const real = aliasedDepName(version);
if (real !== null && !closure.has(real)) queue.push(real);
}
}
}
}
return closure;
}
+8 -1
View File
@@ -17,11 +17,18 @@ export {
dropDevDepOnlyDeps,
changedRuntimeDeps,
changedRuntimeDepsFromManifests,
changedOverrideTargets,
stripDependencyFiles,
RUNTIME_SECTIONS,
type ManifestChangeKind,
} from './changes.js';
export { dependentDirs, type WorkspaceImporters } from './dep-graph.js';
export {
dependentDirs,
runtimeClosure,
type WorkspaceImporters,
type LockfileImporters,
type LockfileSnapshots,
} from './dep-graph.js';
export type { DiscoveredSpec } from './types.js';
// Strategy + Pipeline selection layer.
@@ -297,3 +297,61 @@ describe('selectTests — fail-open contract', () => {
});
});
});
describe('selectTests — pnpm.overrides changes', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'select-ovr-')));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const ALL_SPECS = ['tests/e2e/a.spec.ts', 'tests/e2e/b.spec.ts'];
const changedFiles = ['package.json', 'pnpm-lock.yaml'];
const setup = (target: string) => {
const map: ImpactMap = { 'packages/cli/src/x.ts': { '10': ['tests/e2e/a.spec.ts'] } };
const mapFile = path.join(tempDir, 'map.json');
fs.writeFileSync(mapFile, JSON.stringify(map));
const allSpecsFile = path.join(tempDir, 'all-specs.txt');
fs.writeFileSync(allSpecsFile, ALL_SPECS.join('\n'));
const manifests = {
'package.json': {
before: JSON.stringify({ pnpm: { overrides: {} } }),
after: JSON.stringify({ pnpm: { overrides: { [`${target}@<2`]: '2.0.0' } } }),
},
};
return { mapFile, allSpecsFile, manifests };
};
it('target outside the runtime closure → scoped with no specs (skip)', () => {
const result = selectTests({
changedFiles,
...setup('@vitest/browser'),
runtimeClosure: new Set(['ajv', 'fast-uri']),
});
expect(result.mode).toBe('scoped');
expect(result.specs).toEqual([]);
});
it('target inside the runtime closure → broad', () => {
const result = selectTests({
changedFiles,
...setup('fast-uri'),
runtimeClosure: new Set(['ajv', 'fast-uri']),
});
expect(result.mode).toBe('broad');
expect(result.specs).toEqual([...ALL_SPECS].sort());
});
it('no runtime closure supplied → broad (cannot prove dev-only)', () => {
const result = selectTests({
changedFiles,
...setup('@vitest/browser'),
});
expect(result.mode).toBe('broad');
});
});
+13 -3
View File
@@ -54,6 +54,10 @@ export interface SelectTestsInput {
* 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 {
@@ -127,8 +131,11 @@ export function selectTests(input: SelectTestsInput): SelectTestsResult {
impactful = impactful.filter((f) => !isTsconfig(f));
}
// devDependency-only change can't reach the runtime bundle → dropped.
if (input.manifests) impactful = dropDevDepOnlyDeps(impactful, input.manifests);
// 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.
@@ -160,7 +167,10 @@ export function selectTests(input: SelectTestsInput): SelectTestsResult {
if (!/(^|\/)package\.json$/.test(f)) return false;
const manifest = input.manifests?.[f];
if (!manifest) return true;
return classifyManifestChange(manifest.before, manifest.after) === 'runtime';
// 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);