test: Janitor cleanup — scope-lockdown, additive-only impact narrowing, metrics refresh (no-changelog) (#26476)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-03-04 14:37:19 +00:00 committed by GitHub
parent 72081560e3
commit bcd18a6bfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1283 additions and 696 deletions

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ import {
showOrchestrateHelp,
} from './cli/index.js';
import { setConfig, getConfig, defineConfig, type JanitorConfig } from './config.js';
import { diffFileMethods } from './core/ast-diff-analyzer.js';
import {
generateBaseline,
saveBaseline,
@ -176,9 +177,17 @@ async function runImpact(options: CliOptions): Promise<void> {
return;
}
// Compute AST diffs for additive-only narrowing
const diffs = changedFiles
.filter((f) => f.endsWith('.ts') && !f.endsWith('.spec.ts'))
.map((f) => {
const abs = path.isAbsolute(f) ? f : path.resolve(config.rootDir, f);
return diffFileMethods(abs);
});
// Analyze impact
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(changedFiles);
const result = analyzer.analyze(changedFiles, diffs);
// Output
if (options.json) {
@ -447,8 +456,15 @@ async function runOrchestrate(options: CliOptions): Promise<void> {
console.error('Impact: No changed files detected. Returning empty orchestration.');
specs = [];
} else {
const diffs = changedFiles
.filter((f) => f.endsWith('.ts') && !f.endsWith('.spec.ts'))
.map((f) => {
const abs = path.isAbsolute(f) ? f : path.resolve(config.rootDir, f);
return diffFileMethods(abs);
});
const impactAnalyzer = new ImpactAnalyzer(project);
const impactResult = impactAnalyzer.analyze(changedFiles);
const impactResult = impactAnalyzer.analyze(changedFiles, diffs);
const affectedSet = new Set(impactResult.affectedTests);
const totalBefore = specs.length;
specs = specs.filter((s) => affectedSet.has(s.path));

View file

@ -1,6 +1,7 @@
import { Project } from 'ts-morph';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { FileDiffResult } from './ast-diff-analyzer.js';
import { ImpactAnalyzer } from './impact-analyzer.js';
import { setConfig, resetConfig, defineConfig } from '../config.js';
@ -27,9 +28,9 @@ vi.mock('../utils/paths.js', async () => {
};
});
// Mock fs for file reading in findTestsUsingProperties
vi.mock('fs', async () => {
const actual = await vi.importActual('fs');
// Mock node:fs for file reading in findConsumersUsingProperties
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
return {
...actual,
readFileSync: (filePath: string) => mockReadFileSync(filePath),
@ -540,6 +541,256 @@ test('standalone', () => {});
expect(result.affectedTests).not.toContain('tests/standalone.spec.ts');
});
it('resolves multi-hop chain through composable (page → facade → composable → facade → test)', () => {
// The MFA case: MfaLoginPage → facade(mfaLogin) → MfaComposer uses .mfaLogin.*
// → facade(mfaComposer) → test uses .mfaComposer.*
// Page being changed
project.createSourceFile(
'/test-root/pages/MfaLoginPage.ts',
`
export class MfaLoginPage {
async enterCode(code: string) {}
}
`,
);
// Composable that uses the page via facade property
project.createSourceFile(
'/test-root/composables/MfaComposer.ts',
`
export class MfaComposer {
async loginWithMfa() {
await this.n8n.mfaLogin.enterCode('123456');
}
}
`,
);
// Facade exposes both the page and the composable
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { MfaLoginPage } from './MfaLoginPage';
import { MfaComposer } from '../composables/MfaComposer';
export class AppPage {
readonly page: Page;
readonly mfaLogin: MfaLoginPage;
readonly mfaComposer: MfaComposer;
}
`,
);
// Mock: return composable + test files when searching all .ts files
mockFindFilesRecursive.mockReturnValue([
'/test-root/composables/MfaComposer.ts',
'/test-root/tests/mfa.spec.ts',
'/test-root/tests/login.spec.ts',
]);
mockReadFileSync.mockImplementation((filePath) => {
const p = String(filePath);
if (p.includes('MfaComposer.ts')) {
return 'await this.n8n.mfaLogin.enterCode("123456");';
}
if (p.includes('mfa.spec.ts')) {
return 'await n8n.mfaComposer.loginWithMfa();';
}
if (p.includes('login.spec.ts')) {
return 'await n8n.login.signIn();'; // unrelated
}
return '';
});
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/MfaLoginPage.ts']);
// mfa.spec.ts should be found via the multi-hop chain
expect(result.affectedTests).toContain('tests/mfa.spec.ts');
// login.spec.ts doesn't use mfaLogin or mfaComposer
expect(result.affectedTests).not.toContain('tests/login.spec.ts');
});
it('finds tests via both direct property usage and indirect composable chain', () => {
// Same page used directly by one test AND through a composable by another
project.createSourceFile(
'/test-root/pages/SettingsPage.ts',
`
export class SettingsPage {
async openApiKeys() {}
}
`,
);
project.createSourceFile(
'/test-root/composables/SetupComposer.ts',
`
export class SetupComposer {
async completeSetup() {
await this.n8n.settings.openApiKeys();
}
}
`,
);
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { SettingsPage } from './SettingsPage';
import { SetupComposer } from '../composables/SetupComposer';
export class AppPage {
readonly page: Page;
readonly settings: SettingsPage;
readonly setupComposer: SetupComposer;
}
`,
);
mockFindFilesRecursive.mockReturnValue([
'/test-root/composables/SetupComposer.ts',
'/test-root/tests/settings-direct.spec.ts',
'/test-root/tests/setup-flow.spec.ts',
'/test-root/tests/unrelated.spec.ts',
]);
mockReadFileSync.mockImplementation((filePath) => {
const p = String(filePath);
if (p.includes('SetupComposer.ts')) {
return 'await this.n8n.settings.openApiKeys();';
}
if (p.includes('settings-direct.spec.ts')) {
return 'await n8n.settings.openApiKeys();'; // direct usage
}
if (p.includes('setup-flow.spec.ts')) {
return 'await n8n.setupComposer.completeSetup();'; // indirect via composable
}
return '';
});
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/SettingsPage.ts']);
// Both direct and indirect consumers found
expect(result.affectedTests).toContain('tests/settings-direct.spec.ts');
expect(result.affectedTests).toContain('tests/setup-flow.spec.ts');
expect(result.affectedTests).not.toContain('tests/unrelated.spec.ts');
});
it('traces non-facade intermediary via import graph', () => {
// Helper file not on the facade — should fall back to import tracing
project.createSourceFile(
'/test-root/pages/NodePage.ts',
`
export class NodePage {
async configureNode() {}
}
`,
);
// Helper that uses NodePage via facade property but is NOT on the facade itself
project.createSourceFile(
'/test-root/helpers/nodeHelper.ts',
`
export function setupNode(n8n: any) {
return n8n.node.configureNode();
}
`,
);
// Test imports the helper directly
project.createSourceFile(
'/test-root/tests/node-helper.spec.ts',
`
import { setupNode } from '../helpers/nodeHelper';
test('configures node', () => {});
`,
);
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { NodePage } from './NodePage';
export class AppPage {
readonly page: Page;
readonly node: NodePage;
}
`,
);
// findConsumersUsingProperties finds the helper (it references .node.)
mockFindFilesRecursive.mockReturnValue(['/test-root/helpers/nodeHelper.ts']);
mockReadFileSync.mockImplementation((filePath) => {
const p = String(filePath);
if (p.includes('nodeHelper.ts')) {
return 'return n8n.node.configureNode();';
}
return '';
});
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/NodePage.ts']);
// Helper is not on facade, so import-trace picks up the test
expect(result.affectedTests).toContain('tests/node-helper.spec.ts');
});
it('does not match unrelated composables with similar but distinct property names', () => {
// Ensure .node doesn't match .nodePanel (word boundary check)
project.createSourceFile(
'/test-root/pages/NodePage.ts',
`
export class NodePage {
async openNode() {}
}
`,
);
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { NodePage } from './NodePage';
export class AppPage {
readonly page: Page;
readonly node: NodePage;
}
`,
);
mockFindFilesRecursive.mockReturnValue([
'/test-root/tests/node-panel.spec.ts',
'/test-root/tests/node.spec.ts',
]);
mockReadFileSync.mockImplementation((filePath) => {
const p = String(filePath);
if (p.includes('node-panel.spec.ts')) {
return 'await n8n.nodePanel.search("HTTP");'; // nodePanel, not node
}
if (p.includes('node.spec.ts')) {
return 'await n8n.node.openNode();';
}
return '';
});
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/NodePage.ts']);
expect(result.affectedTests).toContain('tests/node.spec.ts');
expect(result.affectedTests).not.toContain('tests/node-panel.spec.ts');
});
it('handles page not exposed on facade (falls back to camelCase)', () => {
// If a page isn't in the facade, we fall back to camelCase property name
project.createSourceFile(
@ -571,6 +822,183 @@ export class AppPage {
});
});
describe('Additive-Only Narrowing', () => {
it('skips dependency tracing when all changes are additive', () => {
// Shared service imported by a test — but only new methods were added
project.createSourceFile(
'/test-root/services/api-helper.ts',
`
export class ApiHelper {
async getWorkflows() {}
async newMethod() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/api.spec.ts',
`
import { ApiHelper } from '../services/api-helper';
test('uses api', () => {});
`,
);
const diffs: FileDiffResult[] = [
{
filePath: '/test-root/services/api-helper.ts',
changedMethods: [
{ className: 'ApiHelper', methodName: 'newMethod', changeType: 'added' },
],
isNewFile: false,
isDeletedFile: false,
parseTimeMs: 0,
},
];
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['services/api-helper.ts'], diffs);
// No transitive tests affected — the change is purely additive
expect(result.affectedTests).toEqual([]);
});
it('traces normally when a method is modified', () => {
project.createSourceFile(
'/test-root/services/api-helper.ts',
`
export class ApiHelper {
async getWorkflows() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/api.spec.ts',
`
import { ApiHelper } from '../services/api-helper';
test('uses api', () => {});
`,
);
const diffs: FileDiffResult[] = [
{
filePath: '/test-root/services/api-helper.ts',
changedMethods: [
{ className: 'ApiHelper', methodName: 'getWorkflows', changeType: 'modified' },
],
isNewFile: false,
isDeletedFile: false,
parseTimeMs: 0,
},
];
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['services/api-helper.ts'], diffs);
expect(result.affectedTests).toContain('tests/api.spec.ts');
});
it('traces normally when changes are mixed (added + modified)', () => {
project.createSourceFile(
'/test-root/services/api-helper.ts',
`
export class ApiHelper {
async getWorkflows() {}
async newMethod() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/api.spec.ts',
`
import { ApiHelper } from '../services/api-helper';
test('uses api', () => {});
`,
);
const diffs: FileDiffResult[] = [
{
filePath: '/test-root/services/api-helper.ts',
changedMethods: [
{ className: 'ApiHelper', methodName: 'newMethod', changeType: 'added' },
{ className: 'ApiHelper', methodName: 'getWorkflows', changeType: 'modified' },
],
isNewFile: false,
isDeletedFile: false,
parseTimeMs: 0,
},
];
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['services/api-helper.ts'], diffs);
expect(result.affectedTests).toContain('tests/api.spec.ts');
});
it('traces normally when no diffs are provided (backwards-compatible)', () => {
project.createSourceFile(
'/test-root/services/api-helper.ts',
`
export class ApiHelper {
async getWorkflows() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/api.spec.ts',
`
import { ApiHelper } from '../services/api-helper';
test('uses api', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
// No diffs parameter — full tracing as before
const result = analyzer.analyze(['services/api-helper.ts']);
expect(result.affectedTests).toContain('tests/api.spec.ts');
});
it('traces conservatively when changedMethods is empty', () => {
// Empty changedMethods means the file changed but no method-level changes detected
// (e.g., import reordering, comments). We should still trace conservatively.
project.createSourceFile(
'/test-root/services/api-helper.ts',
`
export class ApiHelper {
async getWorkflows() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/api.spec.ts',
`
import { ApiHelper } from '../services/api-helper';
test('uses api', () => {});
`,
);
const diffs: FileDiffResult[] = [
{
filePath: '/test-root/services/api-helper.ts',
changedMethods: [],
isNewFile: false,
isDeletedFile: false,
parseTimeMs: 0,
},
];
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['services/api-helper.ts'], diffs);
// Empty changedMethods = conservative, still traces
expect(result.affectedTests).toContain('tests/api.spec.ts');
});
});
describe('Edge Cases', () => {
it('handles file not found gracefully', () => {
const analyzer = new ImpactAnalyzer(project);

View file

@ -9,9 +9,15 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { type Project, type SourceFile } from 'ts-morph';
import { type FileDiffResult } from './ast-diff-analyzer.js';
import { FacadeResolver } from './facade-resolver.js';
import { getRootDir, findFilesRecursive, getRelativePath, isTestFile } from '../utils/paths.js';
function isAdditiveOnly(diff: FileDiffResult): boolean {
if (diff.changedMethods.length === 0) return false;
return diff.changedMethods.every((m) => m.changeType === 'added');
}
export interface ImpactResult {
changedFiles: string[];
affectedFiles: string[];
@ -32,13 +38,22 @@ export class ImpactAnalyzer {
}
/**
* Given a list of changed files, determine which test files are affected
* Given a list of changed files, determine which test files are affected.
* When diffs are provided, files with only additive changes (new methods)
* skip dependency tracing new exports can't break existing consumers.
*/
analyze(changedFiles: string[]): ImpactResult {
analyze(changedFiles: string[], diffs?: FileDiffResult[]): ImpactResult {
const absolutePaths = changedFiles.map((f) =>
path.isAbsolute(f) ? f : path.join(this.root, f),
);
const diffMap = new Map<string, FileDiffResult>();
if (diffs) {
for (const diff of diffs) {
diffMap.set(diff.filePath, diff);
}
}
const affectedSet = new Set<string>();
const graph: Record<string, string[]> = {};
@ -57,6 +72,13 @@ export class ImpactAnalyzer {
affectedSet.add(filePath);
}
// If we have diff info and all changes are additive, skip dependency tracing.
// New exports can't break existing consumers.
const diff = diffMap.get(filePath);
if (diff && isAdditiveOnly(diff)) {
continue;
}
// Find property names this file exposes (for property-based search)
const propertyNames = this.extractPropertyNames(sourceFile);
@ -136,8 +158,8 @@ export class ImpactAnalyzer {
// If we hit a facade, stop import tracing and switch to property search
if (this.facade.isFacade(depPath)) {
// Find tests that actually USE the property, not just import the facade
const testsUsingProperty = this.findTestsUsingProperties(propertyNames);
// Resolve through multi-hop chains (page → facade → composable → test)
const testsUsingProperty = this.resolvePropertyToTests(propertyNames, visited);
dependents.push(...testsUsingProperty);
continue;
}
@ -157,29 +179,33 @@ export class ImpactAnalyzer {
}
/**
* Find test files that actually use the given property names
* Uses grep-style search for .propertyName. patterns
* Find all .ts files that use the given property names via text search.
* Searches the entire project root (not just tests/) to catch composables,
* helpers, and other intermediaries between the facade and tests.
*/
private findTestsUsingProperties(propertyNames: string[]): string[] {
private findConsumersUsingProperties(propertyNames: string[]): string[] {
if (propertyNames.length === 0) {
return [];
}
const testsDir = path.join(this.root, 'tests');
const matchingTests = new Set<string>();
const matchingFiles = new Set<string>();
const facadePath = this.facade.getFacadePath();
// Build regex pattern to match property access: .logsPanel. or .logsPanel)
const patterns = propertyNames.map((name) => new RegExp(`\\.${name}[.)]`));
// Word-boundary regex: matches .name followed by any non-identifier char
const patterns = propertyNames.map((name) => new RegExp(`\\.${name}(?![a-zA-Z0-9_])`));
// Recursively find all test files
const testFiles = findFilesRecursive(testsDir, '.spec.ts');
// Search all .ts files in the project root
const allFiles = findFilesRecursive(this.root, '.ts');
for (const file of allFiles) {
// Skip the facade itself — it declares properties, doesn't consume them
if (file === facadePath) continue;
for (const testFile of testFiles) {
try {
const content = fs.readFileSync(testFile, 'utf-8');
const content = fs.readFileSync(file, 'utf-8');
for (const pattern of patterns) {
if (pattern.test(content)) {
matchingTests.add(testFile);
matchingFiles.add(file);
break;
}
}
@ -188,7 +214,62 @@ export class ImpactAnalyzer {
}
}
return Array.from(matchingTests);
return Array.from(matchingFiles);
}
/**
* Resolve property names to affected test files, following multi-hop chains.
*
* When a page changes and we hit the facade, the consumers might be:
* 1. Test files add directly to results
* 2. Composables/helpers on the facade extract THEIR property names, recurse
* 3. Non-facade files import-trace via findAllDependents
*
* Example chain: MfaLoginPage facade(mfaLogin) MfaComposer(mfaLogin.*)
* facade(mfaComposer) test(n8n.mfaComposer.*)
*/
private resolvePropertyToTests(
propertyNames: string[],
visited: Set<string>,
resolvedConsumers: Set<string> = new Set(),
): string[] {
const consumers = this.findConsumersUsingProperties(propertyNames);
const tests: string[] = [];
for (const consumer of consumers) {
// Guard against cyclic property chains (A→B→A)
if (resolvedConsumers.has(consumer)) continue;
resolvedConsumers.add(consumer);
const relativePath = getRelativePath(consumer);
if (isTestFile(relativePath)) {
tests.push(consumer);
continue;
}
// Non-test consumer (composable, helper, etc.)
const sourceFile = this.project.getSourceFile(consumer);
if (!sourceFile) continue;
// Check if this file is exposed on the facade
const consumerPropertyNames = this.extractPropertyNames(sourceFile);
if (consumerPropertyNames.length > 0) {
// File is on the facade — recurse with its property names
const transitiveTests = this.resolvePropertyToTests(
consumerPropertyNames,
visited,
resolvedConsumers,
);
tests.push(...transitiveTests);
} else {
// Not on the facade — fall back to import tracing
const dependents = this.findAllDependents(sourceFile, visited, []);
tests.push(...dependents);
}
}
return tests;
}
}

View file

@ -1,7 +1,7 @@
{
"version": 1,
"generated": "2026-02-24T22:17:42.106Z",
"totalViolations": 552,
"generated": "2026-03-03T13:03:42.179Z",
"totalViolations": 553,
"violations": {
"pages/AIAssistantPage.ts": [
{
@ -145,14 +145,6 @@
"hash": "c904517ef2df"
}
],
"pages/MfaLoginPage.ts": [
{
"rule": "scope-lockdown",
"line": 8,
"message": "MfaLoginPage: Ambiguous page - add a container (for scoped components) or a navigation method (for top-level pages)",
"hash": "65f89f81ef90"
}
],
"pages/MfaSetupModal.ts": [
{
"rule": "scope-lockdown",
@ -1224,55 +1216,55 @@
"tests/e2e/ai/langchain-memory.spec.ts": [
{
"rule": "selector-purity",
"line": 92,
"line": 93,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'Define below' }).cl...",
"hash": "39e5d44a50a7"
},
{
"rule": "selector-purity",
"line": 92,
"line": 93,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'Define below' })",
"hash": "5c42d41d0397"
},
{
"rule": "selector-purity",
"line": 93,
"line": 94,
"message": "Chained locator call in test: n8n.ndv.getParameterInput('sessionKey').locator('input')",
"hash": "4860c40ad30b"
},
{
"rule": "selector-purity",
"line": 107,
"line": 108,
"message": "Chained locator call in test: n8n.ndv.getAiOutputModeToggle().locator('[role=\"radio\"]')",
"hash": "712bf333eaaf"
},
{
"rule": "selector-purity",
"line": 115,
"line": 116,
"message": "Chained locator call in test: n8n.ndv.getOutputPanel().getByTestId('node-error-message')",
"hash": "9436eb5e9511"
},
{
"rule": "selector-purity",
"line": 118,
"line": 119,
"message": "Chained locator call in test: n8n.ndv.getOutputPanel().getByTestId('node-error-message')",
"hash": "9436eb5e9511"
},
{
"rule": "selector-purity",
"line": 169,
"line": 170,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'Define below' }).cl...",
"hash": "39e5d44a50a7"
},
{
"rule": "selector-purity",
"line": 169,
"line": 170,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'Define below' })",
"hash": "5c42d41d0397"
},
{
"rule": "selector-purity",
"line": 170,
"line": 171,
"message": "Chained locator call in test: n8n.ndv.getParameterInput('text').locator('textarea')",
"hash": "986a0637e45b"
}
@ -1294,13 +1286,13 @@
"tests/e2e/ai/workflow-builder.spec.ts": [
{
"rule": "selector-purity",
"line": 81,
"line": 82,
"message": "Direct page locator call: n8n.page.getByRole('button', { name: 'Execute and refine' })",
"hash": "ba2cdfe0e07b"
},
{
"rule": "selector-purity",
"line": 115,
"line": 113,
"message": "Direct page locator call: n8n.page.getByText('Task aborted')",
"hash": "beba4221d694"
}
@ -1450,19 +1442,19 @@
"tests/e2e/credentials/crud.spec.ts": [
{
"rule": "selector-purity",
"line": 347,
"line": 346,
"message": "Direct page locator call: n8n.page.locator('.el-overlay').first()",
"hash": "6e0141ec89e6"
},
{
"rule": "selector-purity",
"line": 347,
"line": 346,
"message": "Direct page locator call: n8n.page.locator('.el-overlay')",
"hash": "4668d190d0af"
},
{
"rule": "api-purity",
"line": 300,
"line": 299,
"message": "Raw API call detected: fetch(",
"hash": "b52df352569e"
}
@ -2036,55 +2028,55 @@
"tests/e2e/settings/environments/source-control.spec.ts": [
{
"rule": "selector-purity",
"line": 56,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'main' })",
"hash": "8e36dd6e7c35"
},
{
"rule": "selector-purity",
"line": 75,
"line": 57,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'main' })",
"hash": "8e36dd6e7c35"
},
{
"rule": "selector-purity",
"line": 76,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'main' })",
"hash": "8e36dd6e7c35"
},
{
"rule": "selector-purity",
"line": 77,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'development' })",
"hash": "4157d0d852a3"
},
{
"rule": "selector-purity",
"line": 77,
"line": 78,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'staging' })",
"hash": "5ea1e1189ae1"
},
{
"rule": "selector-purity",
"line": 78,
"line": 79,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'production' })",
"hash": "13bbd087b456"
},
{
"rule": "selector-purity",
"line": 79,
"line": 80,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'development' }).cli...",
"hash": "2d47edbe9c6a"
},
{
"rule": "selector-purity",
"line": 79,
"line": 80,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'development' })",
"hash": "4157d0d852a3"
},
{
"rule": "api-purity",
"line": 83,
"line": 84,
"message": "Raw API call detected: request.get(",
"hash": "5128d500e46f"
},
{
"rule": "api-purity",
"line": 92,
"line": 93,
"message": "Raw API call detected: request.get(",
"hash": "5128d500e46f"
}
@ -2174,7 +2166,7 @@
"tests/e2e/source-control/pull.spec.ts": [
{
"rule": "selector-purity",
"line": 94,
"line": 95,
"message": "Chained locator call in test: n8n.canvas.tagsManagerModal.getTable().getByText('pull-te...",
"hash": "9cd079655a6b"
}
@ -2182,55 +2174,55 @@
"tests/e2e/workflows/checklist/production-checklist.spec.ts": [
{
"rule": "selector-purity",
"line": 67,
"line": 68,
"message": "Direct page locator call: n8n.page.getByTestId('workflow-settings-dialog')",
"hash": "6dff31c05187"
},
{
"rule": "selector-purity",
"line": 68,
"line": 69,
"message": "Direct page locator call: n8n.page.getByTestId('workflow-settings-error-workflow')",
"hash": "e86d143e5094"
},
{
"rule": "selector-purity",
"line": 83,
"line": 86,
"message": "Direct page locator call: n8n.page.getByTestId('workflow-settings-dialog')",
"hash": "6dff31c05187"
},
{
"rule": "selector-purity",
"line": 95,
"line": 98,
"message": "Chained locator call in test: n8n.canvas.getProductionChecklistActionItem().first().get...",
"hash": "b99bf85bffb8"
},
{
"rule": "selector-purity",
"line": 98,
"line": 101,
"message": "Direct page locator call: n8n.page.locator('body').click({ position: { x: 0, y: 0 } })",
"hash": "82ea0a8120b9"
},
{
"rule": "selector-purity",
"line": 98,
"line": 101,
"message": "Direct page locator call: n8n.page.locator('body')",
"hash": "6667df4502c4"
},
{
"rule": "selector-purity",
"line": 136,
"line": 139,
"message": "Direct page locator call: n8n.page.getByTestId('workflow-settings-dialog')",
"hash": "6dff31c05187"
},
{
"rule": "selector-purity",
"line": 142,
"line": 145,
"message": "Chained locator call in test: n8n.canvas .getProductionChecklistActionItem() .first() ....",
"hash": "049e1b469b18"
},
{
"rule": "selector-purity",
"line": 159,
"line": 162,
"message": "Direct page locator call: n8n.page.locator('.el-message-box')",
"hash": "a41c304c373a"
}
@ -2386,19 +2378,19 @@
"tests/e2e/workflows/editor/canvas/canvas-zoom.spec.ts": [
{
"rule": "selector-purity",
"line": 222,
"line": 241,
"message": "Chained locator call in test: n8n.canvas.nodeByName('n8n').getByTestId('overflow-node-b...",
"hash": "3d500494797e"
},
{
"rule": "selector-purity",
"line": 223,
"line": 242,
"message": "Direct page locator call: n8n.page.getByTestId('context-menu-item-open').click()",
"hash": "803edc6e2d15"
},
{
"rule": "selector-purity",
"line": 223,
"line": 242,
"message": "Direct page locator call: n8n.page.getByTestId('context-menu-item-open')",
"hash": "049003f6550b"
}
@ -2444,19 +2436,19 @@
"tests/e2e/workflows/editor/execution/execution.spec.ts": [
{
"rule": "selector-purity",
"line": 149,
"line": 150,
"message": "Direct page locator call: n8n.page.getByTestId('copy-input').click()",
"hash": "20e9bf63ae83"
},
{
"rule": "selector-purity",
"line": 149,
"line": 150,
"message": "Direct page locator call: n8n.page.getByTestId('copy-input')",
"hash": "dd5c167f4482"
},
{
"rule": "api-purity",
"line": 153,
"line": 154,
"message": "Raw API call detected: request.get(",
"hash": "5128d500e46f"
}
@ -2472,13 +2464,13 @@
"tests/e2e/workflows/editor/execution/logs.spec.ts": [
{
"rule": "selector-purity",
"line": 253,
"line": 268,
"message": "Chained locator call in test: n8n.ndv.outputPanel .getDataContainer() .locator('a')",
"hash": "6a673016ebff"
},
{
"rule": "api-purity",
"line": 263,
"line": 278,
"message": "Raw API call detected: request.get(",
"hash": "5128d500e46f"
}
@ -2486,7 +2478,7 @@
"tests/e2e/workflows/editor/expressions/inline.spec.ts": [
{
"rule": "selector-purity",
"line": 55,
"line": 54,
"message": "Chained locator call in test: n8n.ndv.getParameterInput('value').getByRole('textbox')",
"hash": "e6c73a21e494"
}
@ -2494,31 +2486,31 @@
"tests/e2e/workflows/editor/expressions/mapping.spec.ts": [
{
"rule": "selector-purity",
"line": 60,
"line": 61,
"message": "Chained locator call in test: n8n.ndv.inputPanel.get().getByText(expectedJsonText)",
"hash": "9865414150f3"
},
{
"rule": "selector-purity",
"line": 106,
"line": 107,
"message": "Chained locator call in test: n8n.ndv.inputPanel.get().getByText('Schedule Trigger')",
"hash": "2043cf03c7a7"
},
{
"rule": "selector-purity",
"line": 117,
"line": 118,
"message": "Chained locator call in test: schemaItem.locator('span')",
"hash": "dae1f9ee44ac"
},
{
"rule": "selector-purity",
"line": 158,
"line": 159,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'String' }).click()",
"hash": "c3130f54285b"
},
{
"rule": "selector-purity",
"line": 158,
"line": 159,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'String' })",
"hash": "6351fa2e946a"
}
@ -2838,19 +2830,19 @@
"tests/e2e/workflows/editor/ndv/pinning.spec.ts": [
{
"rule": "selector-purity",
"line": 81,
"line": 82,
"message": "Chained locator call in test: runDataHeader.getByRole('button', { name: 'Edit Output' })",
"hash": "aa1904651d9c"
},
{
"rule": "selector-purity",
"line": 175,
"line": 178,
"message": "Chained locator call in test: n8n.ndv.getAssignmentValue('assignments').getByText('Expr...",
"hash": "25491e82dd43"
},
{
"rule": "selector-purity",
"line": 184,
"line": 187,
"message": "Chained locator call in test: n8n.ndv.getParameterInputHint().getByText(expectedOutput)",
"hash": "393fc24d0bf4"
}
@ -2994,7 +2986,7 @@
"tests/e2e/workflows/editor/workflow-actions/archive.spec.ts": [
{
"rule": "selector-purity",
"line": 40,
"line": 41,
"message": "Chained locator call in test: n8n.workflowSettingsModal.getArchiveMenuItem().locator('..')",
"hash": "ec8d53703e0b"
}
@ -3002,106 +2994,88 @@
"tests/e2e/workflows/editor/workflow-actions/settings.spec.ts": [
{
"rule": "selector-purity",
"line": 32,
"line": 33,
"message": "Direct page locator call: n8n.page.getByRole('option').count()",
"hash": "43ba2cdb87a8"
},
{
"rule": "selector-purity",
"line": 32,
"line": 33,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 34,
"line": 35,
"message": "Direct page locator call: n8n.page.getByRole('option').last().click()",
"hash": "4dcd8e905b3c"
},
{
"rule": "selector-purity",
"line": 34,
"line": 35,
"message": "Direct page locator call: n8n.page.getByRole('option').last()",
"hash": "a8b0a552c436"
},
{
"rule": "selector-purity",
"line": 34,
"line": 35,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 37,
"line": 38,
"message": "Direct page locator call: n8n.page.getByRole('option').first()",
"hash": "f8c4aff6a0b3"
},
{
"rule": "selector-purity",
"line": 37,
"line": 38,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 38,
"line": 39,
"message": "Direct page locator call: n8n.page.getByRole('option').nth(1).click()",
"hash": "5a25d5a68877"
},
{
"rule": "selector-purity",
"line": 38,
"line": 39,
"message": "Direct page locator call: n8n.page.getByRole('option').nth(1)",
"hash": "0e8a6f4130fa"
},
{
"rule": "selector-purity",
"line": 38,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 41,
"line": 39,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 42,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 43,
"message": "Direct page locator call: n8n.page.getByRole('option').last().click()",
"hash": "4dcd8e905b3c"
},
{
"rule": "selector-purity",
"line": 42,
"line": 43,
"message": "Direct page locator call: n8n.page.getByRole('option').last()",
"hash": "a8b0a552c436"
},
{
"rule": "selector-purity",
"line": 42,
"line": 43,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 45,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 46,
"message": "Direct page locator call: n8n.page.getByRole('option').last().click()",
"hash": "4dcd8e905b3c"
},
{
"rule": "selector-purity",
"line": 46,
"message": "Direct page locator call: n8n.page.getByRole('option').last()",
"hash": "a8b0a552c436"
},
{
"rule": "selector-purity",
"line": 46,
@ -3110,22 +3084,22 @@
},
{
"rule": "selector-purity",
"line": 49,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 50,
"line": 47,
"message": "Direct page locator call: n8n.page.getByRole('option').last().click()",
"hash": "4dcd8e905b3c"
},
{
"rule": "selector-purity",
"line": 50,
"line": 47,
"message": "Direct page locator call: n8n.page.getByRole('option').last()",
"hash": "a8b0a552c436"
},
{
"rule": "selector-purity",
"line": 47,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 50,
@ -3134,111 +3108,129 @@
},
{
"rule": "selector-purity",
"line": 53,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 54,
"line": 51,
"message": "Direct page locator call: n8n.page.getByRole('option').last().click()",
"hash": "4dcd8e905b3c"
},
{
"rule": "selector-purity",
"line": 54,
"line": 51,
"message": "Direct page locator call: n8n.page.getByRole('option').last()",
"hash": "a8b0a552c436"
},
{
"rule": "selector-purity",
"line": 51,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 54,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
},
{
"rule": "selector-purity",
"line": 55,
"message": "Direct page locator call: n8n.page.getByRole('option').last().click()",
"hash": "4dcd8e905b3c"
},
{
"rule": "selector-purity",
"line": 55,
"message": "Direct page locator call: n8n.page.getByRole('option').last()",
"hash": "a8b0a552c436"
},
{
"rule": "selector-purity",
"line": 55,
"message": "Direct page locator call: n8n.page.getByRole('option')",
"hash": "c58b0877f9a1"
}
],
"tests/e2e/workflows/executions/list.spec.ts": [
{
"rule": "selector-purity",
"line": 48,
"line": 49,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'Success' }).click()",
"hash": "fbad8571f166"
},
{
"rule": "selector-purity",
"line": 48,
"line": 49,
"message": "Direct page locator call: n8n.page.getByRole('option', { name: 'Success' })",
"hash": "75835fe42ab8"
},
{
"rule": "selector-purity",
"line": 192,
"line": 194,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 233,
"line": 235,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 236,
"line": 238,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 239,
"line": 241,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 242,
"line": 244,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 245,
"line": 247,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 248,
"line": 250,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 251,
"line": 253,
"message": "Chained locator call in test: iframe.locator('body')",
"hash": "44c6c75041a8"
},
{
"rule": "selector-purity",
"line": 270,
"line": 272,
"message": "Direct page locator call: n8n.page.getByTestId('workflow-execution-no-trigger-conte...",
"hash": "407bd53b3360"
},
{
"rule": "selector-purity",
"line": 272,
"line": 274,
"message": "Direct page locator call: n8n.page.getByRole('button', { name: 'Add first step' })....",
"hash": "90ae792f4909"
},
{
"rule": "selector-purity",
"line": 272,
"line": 274,
"message": "Direct page locator call: n8n.page.getByRole('button', { name: 'Add first step' })",
"hash": "ae0e7537f378"
},
{
"rule": "selector-purity",
"line": 278,
"line": 280,
"message": "Direct page locator call: n8n.page.getByTestId('workflow-execution-no-content')",
"hash": "36b71f3e9617"
}
@ -3328,7 +3320,7 @@
"tests/e2e/app-config/nps-survey.spec.ts": [
{
"rule": "api-purity",
"line": 50,
"line": 51,
"message": "Raw API call detected: fetch(",
"hash": "b52df352569e"
}
@ -3363,6 +3355,22 @@
"hash": "b52df352569e"
}
],
"tests/e2e/dynamic-credentials/execution-status.spec.ts": [
{
"rule": "api-purity",
"line": 203,
"message": "Raw API call detected: request.get(",
"hash": "5128d500e46f"
}
],
"tests/e2e/dynamic-credentials/external-user-trigger.spec.ts": [
{
"rule": "api-purity",
"line": 173,
"message": "Raw API call detected: request.get(",
"hash": "5128d500e46f"
}
],
"tests/e2e/sharing/workflow-sharing.spec.ts": [
{
"rule": "api-purity",

View file

@ -6,10 +6,14 @@ import { BasePage } from './BasePage';
* Page object for the MFA login page that appears after entering email/password when MFA is enabled.
*/
export class MfaLoginPage extends BasePage {
getForm(): Locator {
get container(): Locator {
return this.page.getByTestId('mfa-login-form');
}
getForm(): Locator {
return this.container;
}
getMfaCodeField(): Locator {
return this.getForm().locator('input[name="mfaCode"]');
}