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

424 lines
15 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
isNonImpactful,
filterImpactfulChanges,
forcesBroad,
isTsconfig,
tsconfigForcesBroad,
isBackendConfig,
configForcesBroad,
classifyManifestChange,
dropDevDepOnlyDeps,
overrideTargetName,
changedOverrideTargets,
} from './changes.js';
describe('isNonImpactful', () => {
it.each([
'.claude/skills/foo.md',
'.claude/agents/bar.json',
'.vscode/settings.json',
'.idea/workspace.xml',
'.editorconfig',
'.gitattributes',
'README.mdx',
'packages/cli/CHANGELOG.md',
'LICENSE',
'cspell.json',
'packages/x/project-words.dic',
'docs/images/diagram.png',
'assets/logo.svg',
'scripts/release/build.mjs',
'turbo.json',
'biome.jsonc',
'packages/testing/test-impact/vitest.config.ts',
'.eslintrc.js',
])('treats %s as non-impactful', (file) => {
expect(isNonImpactful(file)).toBe(true);
});
it.each([
// Real product source — must NOT be ignored
'packages/nodes-base/nodes/If/If.node.ts',
'packages/frontend/editor-ui/src/App.vue',
// Surgical: real *.json source data is NOT a "config file"
'packages/nodes-base/nodes/Slack/Slack.node.json',
'packages/@n8n/i18n/src/locales/en.json',
// Dependency changes — handled by devDep classifier / dep-graph, not ignored
'pnpm-lock.yaml',
'packages/cli/package.json',
// Container + patches affect runtime / a dependency — never ignored
'docker/images/n8n/Dockerfile',
'patches/some-dep.patch',
// tsconfig can carry module-resolution keys → routed via tsconfigForcesBroad
'tsconfig.json',
'packages/cli/tsconfig.build.json',
])('treats %s as impactful', (file) => {
expect(isNonImpactful(file)).toBe(false);
});
});
describe('filterImpactfulChanges', () => {
it('drops non-impactful paths, keeps real source', () => {
expect(
filterImpactfulChanges([
'packages/nodes-base/nodes/If/If.node.ts',
'.claude/skills/x.md',
'scripts/y.mjs',
'turbo.json',
]),
).toEqual(['packages/nodes-base/nodes/If/If.node.ts']);
});
it('returns empty when every change is non-impactful (→ caller skips)', () => {
expect(filterImpactfulChanges(['.claude/x.md', 'scripts/y.mjs', '.editorconfig'])).toEqual([]);
});
it('is a no-op when nothing is ignored', () => {
const files = ['packages/cli/src/a.ts', 'pnpm-lock.yaml', 'docker/Dockerfile'];
expect(filterImpactfulChanges(files)).toEqual(files);
});
});
describe('forcesBroad', () => {
it.each([
'docker/images/n8n/Dockerfile',
'docker/compose/base.yml',
'packages/testing/containers/services/n8n.ts',
'packages/cli/Dockerfile',
'Dockerfile.dev',
'packages/cli/worker.Dockerfile',
'packages/nodes-base/credentials/MicrosoftOAuth2Api.credentials.ts',
// Module registry: decides which modules load at boot, so a default flip
// changes behaviour in specs that never execute this file.
'packages/@n8n/backend-common/src/modules/module-registry.ts',
'packages/@n8n/backend-common/src/modules/modules.config.ts',
])('treats %s as runtime-defining (force broad)', (file) => {
expect(forcesBroad(file)).toBe(true);
});
it.each([
'packages/cli/src/server.ts',
'packages/testing/playwright/tests/e2e/x.spec.ts',
'packages/nodes-base/nodes/If/If.node.ts',
// A unit test beside the registry can't change the runtime — stays scoped.
'packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts',
// Per-module descriptors are NOT the registry: high churn, and they can't
// change which modules are enabled by default.
'packages/cli/src/modules/instance-ai/instance-ai.module.ts',
])('does not force broad for %s', (file) => {
expect(forcesBroad(file)).toBe(false);
});
});
describe('isTsconfig', () => {
it.each(['tsconfig.json', 'packages/cli/tsconfig.build.json', 'a/tsconfig.go.json'])(
'recognises %s',
(file) => {
expect(isTsconfig(file)).toBe(true);
},
);
it.each(['tsconfig.ts', 'packages/cli/package.json', 'config.json'])(
'does not match %s',
(file) => {
expect(isTsconfig(file)).toBe(false);
},
);
});
describe('tsconfigForcesBroad', () => {
const ts = (compilerOptions = {}, extra = {}) => JSON.stringify({ compilerOptions, ...extra });
it('forces broad when compilerOptions.paths changes', () => {
const before = ts({ paths: { 'esprima-next': ['./x'] } });
const after = ts({ paths: { 'n8n-workflow': ['./src/index.ts'], 'esprima-next': ['./x'] } });
expect(tsconfigForcesBroad(before, after)).toBe(true);
});
it('forces broad when baseUrl changes', () => {
expect(tsconfigForcesBroad(ts({ baseUrl: '.' }), ts({ baseUrl: './src' }))).toBe(true);
});
it('forces broad when moduleResolution changes', () => {
expect(
tsconfigForcesBroad(ts({ moduleResolution: 'node' }), ts({ moduleResolution: 'bundler' })),
).toBe(true);
});
it('forces broad when customConditions changes', () => {
const before = ts({ customConditions: ['development'] });
const after = ts({ customConditions: ['production'] });
expect(tsconfigForcesBroad(before, after)).toBe(true);
});
it('forces broad when extends changes', () => {
const before = ts({}, { extends: '@n8n/typescript-config/modern/tsconfig.json' });
const after = ts({}, { extends: '@n8n/typescript-config/modern/tsconfig.go.json' });
expect(tsconfigForcesBroad(before, after)).toBe(true);
});
it('does NOT force broad for a resolution-neutral flag change', () => {
const before = ts({ strict: true, paths: { a: ['./a'] } });
const after = ts({ strict: false, target: 'ES2022', paths: { a: ['./a'] } });
expect(tsconfigForcesBroad(before, after)).toBe(false);
});
it('tolerates comments and trailing commas', () => {
const before = '{\n // base\n "compilerOptions": { "paths": { "a": ["./a"] } },\n}';
const after = '{\n // base\n "compilerOptions": { "paths": { "a": ["./b"] } },\n}';
expect(tsconfigForcesBroad(before, after)).toBe(true);
});
it('forces broad when content is unparseable (conservative)', () => {
expect(tsconfigForcesBroad('{ not json', '{ still not')).toBe(true);
});
});
const pkg = (deps = {}, devDeps = {}, extra = {}) =>
JSON.stringify({ name: 'x', dependencies: deps, devDependencies: devDeps, ...extra });
describe('classifyManifestChange', () => {
it('runtime when a dependencies version moves', () => {
expect(classifyManifestChange(pkg({ axios: '1.0.0' }), pkg({ axios: '1.1.0' }))).toBe(
'runtime',
);
});
it('runtime when a runtime dep is added', () => {
expect(classifyManifestChange(pkg(), pkg({ lodash: '4.0.0' }))).toBe('runtime');
});
it('runtime for peer/optional dependency changes', () => {
const before = JSON.stringify({ peerDependencies: { react: '18' } });
const after = JSON.stringify({ peerDependencies: { react: '19' } });
expect(classifyManifestChange(before, after)).toBe('runtime');
});
it('devDep-only when only devDependencies move', () => {
expect(
classifyManifestChange(
pkg({ axios: '1' }, { vitest: '1' }),
pkg({ axios: '1' }, { vitest: '2' }),
),
).toBe('devDep-only');
});
it('none when no dependency section changes', () => {
expect(
classifyManifestChange(pkg({}, {}, { version: '1.0.0' }), pkg({}, {}, { version: '1.0.1' })),
).toBe('none');
});
it('does not throw on unparseable content', () => {
expect(classifyManifestChange('not json', pkg({}, { vitest: '1' }))).toBe('devDep-only');
});
});
describe('dropDevDepOnlyDeps (safety-critical)', () => {
const diff = (before: string, after: string) => ({ before, after });
const devOnly = diff(pkg({}, { vitest: '1' }), pkg({}, { vitest: '2' }));
const runtime = diff(pkg({ axios: '1' }), pkg({ axios: '2' }));
it('drops lockfile + manifest when the change is devDep-only', () => {
const files = ['pnpm-lock.yaml', 'packages/cli/package.json', 'packages/cli/src/a.ts'];
expect(dropDevDepOnlyDeps(files, { 'packages/cli/package.json': devOnly })).toEqual([
'packages/cli/src/a.ts',
]);
});
it('KEEPS everything when a runtime dependency changed', () => {
const files = ['pnpm-lock.yaml', 'packages/cli/package.json'];
expect(dropDevDepOnlyDeps(files, { 'packages/cli/package.json': runtime })).toEqual(files);
});
it('KEEPS when a changed manifest has no supplied diff (conservative → runtime)', () => {
const files = ['pnpm-lock.yaml', 'packages/cli/package.json'];
expect(dropDevDepOnlyDeps(files, {})).toEqual(files);
});
it('KEEPS a lockfile-only (transitive) bump with no changed manifest', () => {
const files = ['pnpm-lock.yaml', 'packages/cli/src/a.ts'];
expect(dropDevDepOnlyDeps(files, {})).toEqual(files);
});
it('mixed devDep-only + runtime manifests → keeps all', () => {
const files = ['pnpm-lock.yaml', 'a/package.json', 'b/package.json'];
expect(
dropDevDepOnlyDeps(files, { 'a/package.json': devOnly, 'b/package.json': runtime }),
).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);
});
});
describe('isBackendConfig', () => {
it.each([
'packages/@n8n/config/src/configs/ai.config.ts',
'packages/@n8n/config/src/configs/logging.config.ts',
])('matches %s', (file) => {
expect(isBackendConfig(file)).toBe(true);
});
it.each([
'packages/@n8n/config/src/configs/__tests__/ai.config.test.ts',
'packages/@n8n/config/src/decorators.ts',
'packages/cli/src/config/index.ts',
])('does not match %s', (file) => {
expect(isBackendConfig(file)).toBe(false);
});
});
describe('configForcesBroad', () => {
const cfg = (body: string) =>
`import { Config, Env } from '../decorators';\n@Config\nexport class C {\n${body}\n}`;
const enabled = "\t@Env('N8N_AI_ENABLED')\n\tenabled: boolean = false;";
it('forces broad when a default value changes', () => {
const after = cfg(enabled.replace('= false', '= true'));
expect(configForcesBroad(cfg(enabled), after)).toBe(true);
});
it('forces broad when an env var is renamed', () => {
const after = cfg(enabled.replace('N8N_AI_ENABLED', 'N8N_AI_ON'));
expect(configForcesBroad(cfg(enabled), after)).toBe(true);
});
it('forces broad when a field is removed', () => {
expect(configForcesBroad(cfg(enabled), cfg(''))).toBe(true);
});
it('forces broad when an optional field gains a default', () => {
const before = cfg('\tthreshold?: number;');
const after = cfg('\tthreshold?: number = 5;');
expect(configForcesBroad(before, after)).toBe(true);
});
it('does NOT force broad for a new field (additive)', () => {
const after = cfg(`${enabled}\n\n\t@Env('N8N_AI_TIMEOUT')\n\ttimeout: number = 60;`);
expect(configForcesBroad(cfg(enabled), after)).toBe(false);
});
it('does NOT force broad for a comment-only edit', () => {
const before = cfg(`\t/** Old wording. */\n${enabled}`);
const after = cfg(`\t/** New wording, same default. */\n${enabled}`);
expect(configForcesBroad(before, after)).toBe(false);
});
it('does NOT force broad for reformatted whitespace', () => {
const before = cfg('\tmaxSize: number = 50 * 1024 * 1024;');
const after = cfg('\tmaxSize: number =\t50 * 1024 * 1024;');
expect(configForcesBroad(before, after)).toBe(false);
});
});