Files
n8n/packages/testing/janitor/src/rules/deduplication.rule.ts
T
alighasami 3d5eaf9445
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
first commit
2026-03-17 16:22:57 +03:30

176 lines
5.4 KiB
TypeScript

import { SyntaxKind, type Project, type SourceFile, type CallExpression } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import { isComponentFile, getRelativePath } from '../utils/paths.js';
interface TestIdUsage {
file: string;
line: number;
column: number;
root: string; // The locator root (e.g., "this.page", "this.container", "this.getPanel()")
}
/**
* Deduplication Rule
*
* Ensures getByTestId() calls accessing the SAME DOM element are consolidated.
* Uses AST analysis to determine the locator "root" - two calls are only duplicates
* if they have the same root AND the same test ID.
*
* Examples:
* - this.page.getByTestId('modal') in FileA and FileB → DUPLICATE (same root, same ID)
* - this.container.getByTestId('view') vs this.page.getByTestId('view') → NOT duplicate
* - this.getOutputPanel().getByTestId('view') vs this.getInputPanel().getByTestId('view') → NOT duplicate
*
* Scopes:
* - pages/ (excluding components) = one scope
* - pages/components/ = separate scope
*
* Only flags when the same {root}:{testId} combination appears in multiple files.
*/
export class DeduplicationRule extends BaseRule {
readonly id = 'deduplication';
readonly name = 'Deduplication';
readonly description = 'Test IDs should be unique within their scope';
readonly severity = 'warning' as const;
getTargetGlobs(): string[] {
const config = getConfig();
return config.patterns.pages;
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const { pagesScope, componentsScope } = this.buildSelectorMaps(files);
return [
...this.findDuplicates(pagesScope, 'pages', files),
...this.findDuplicates(componentsScope, 'components', files),
];
}
private buildSelectorMaps(files: SourceFile[]): {
pagesScope: Map<string, TestIdUsage[]>;
componentsScope: Map<string, TestIdUsage[]>;
} {
const pagesScope = new Map<string, TestIdUsage[]>();
const componentsScope = new Map<string, TestIdUsage[]>();
for (const file of files) {
const filePath = file.getFilePath();
const scope = isComponentFile(filePath) ? componentsScope : pagesScope;
this.collectTestIdUsages(file, scope);
}
return { pagesScope, componentsScope };
}
private collectTestIdUsages(file: SourceFile, scope: Map<string, TestIdUsage[]>): void {
const filePath = file.getFilePath();
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const usage = this.extractTestIdUsage(call, filePath);
if (!usage) continue;
const key = `${usage.root}:${usage.testId}`;
const existing = scope.get(key) ?? [];
existing.push({
file: usage.file,
line: usage.line,
column: usage.column,
root: usage.root,
});
scope.set(key, existing);
}
}
private extractTestIdUsage(
call: CallExpression,
filePath: string,
): (TestIdUsage & { testId: string }) | null {
const expr = call.getExpression();
const text = expr.getText();
if (!text.endsWith('.getByTestId')) return null;
const args = call.getArguments();
if (args.length === 0) return null;
const firstArg = args[0];
if (firstArg.getKind() !== SyntaxKind.StringLiteral) return null;
const stringLit = firstArg.asKind(SyntaxKind.StringLiteral);
if (!stringLit) return null;
return {
file: filePath,
line: call.getStartLineNumber(),
column: call.getStart() - call.getStartLinePos(),
root: this.extractLocatorRoot(text),
testId: stringLit.getLiteralText(),
};
}
private findDuplicates(
scope: Map<string, TestIdUsage[]>,
scopeName: string,
files: SourceFile[],
): Violation[] {
const violations: Violation[] = [];
for (const [key, usages] of scope) {
// Only flag if same root:testId appears in multiple files
const uniqueFiles = new Set(usages.map((u) => u.file));
if (uniqueFiles.size <= 1) continue;
// Extract testId from key (format: "root:testId")
const testId = key.substring(key.lastIndexOf(':') + 1);
const root = usages[0].root;
// Create a violation for each usage except the first
const sortedUsages = [...usages].sort((a, b) => a.file.localeCompare(b.file));
const firstFile = getRelativePath(sortedUsages[0].file);
for (let i = 1; i < sortedUsages.length; i++) {
const usage = sortedUsages[i];
const file = files.find((f) => f.getFilePath() === usage.file);
if (!file) continue;
violations.push(
this.createViolation(
file,
usage.line,
usage.column,
`Duplicate locator: ${root}.getByTestId("${testId}") in ${scopeName} scope`,
`Also used in ${firstFile}. Consider consolidating to a shared component.`,
),
);
}
}
return violations;
}
/**
* Extract the locator root from a getByTestId call expression.
* E.g., "this.page.getByTestId" → "this.page"
* "this.container.getByTestId" → "this.container"
* "this.getPanel().getByTestId" → "this.getPanel()"
*/
private extractLocatorRoot(exprText: string): string {
// Remove the trailing .getByTestId
const root = exprText.replace(/\.getByTestId$/, '');
// Normalize common patterns for better grouping
// this.page.locator(...).* chains should be considered scoped
if (root.includes('.locator(')) {
return 'scoped'; // Mark as scoped - won't match other scoped calls
}
// Normalize method calls to include parentheses for clarity
return root;
}
}