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
241 lines
6.7 KiB
TypeScript
241 lines
6.7 KiB
TypeScript
import { Project } from 'ts-morph';
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
|
import { setConfig, resetConfig, defaultConfig } from '../config.js';
|
|
import { DuplicateLogicRule } from './duplicate-logic.rule.js';
|
|
|
|
describe('DuplicateLogicRule', () => {
|
|
const rule = new DuplicateLogicRule();
|
|
|
|
function createTestProject(files: Record<string, string>): Project {
|
|
const project = new Project({ useInMemoryFileSystem: true });
|
|
for (const [path, content] of Object.entries(files)) {
|
|
project.createSourceFile(path, content);
|
|
}
|
|
return project;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setConfig({
|
|
...defaultConfig,
|
|
rootDir: '/test-root',
|
|
patterns: {
|
|
...defaultConfig.patterns,
|
|
pages: ['pages/**/*.ts'],
|
|
tests: ['tests/**/*.spec.ts'],
|
|
flows: ['composables/**/*.ts'],
|
|
helpers: ['helpers/**/*.ts'],
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetConfig();
|
|
});
|
|
|
|
describe('duplicate methods detection', () => {
|
|
it('detects duplicate methods across page objects', () => {
|
|
const project = createTestProject({
|
|
// Use paths that match pages/**/*.ts pattern
|
|
'/test-root/pages/workflow/PageA.ts': `
|
|
export class PageA {
|
|
async duplicateMethod() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
await this.page.waitForSelector('.done');
|
|
}
|
|
}
|
|
`,
|
|
'/test-root/pages/workflow/PageB.ts': `
|
|
export class PageB {
|
|
async anotherDuplicate() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
await this.page.waitForSelector('.done');
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
expect(violations.length).toBe(1);
|
|
expect(violations[0].message).toContain('Duplicate method logic');
|
|
expect(violations[0].message).toContain('PageB.anotherDuplicate()');
|
|
expect(violations[0].message).toContain('PageA.duplicateMethod()');
|
|
});
|
|
|
|
it('ignores methods with different structure', () => {
|
|
const project = createTestProject({
|
|
'/test-root/pages/workflow/PageA.ts': `
|
|
export class PageA {
|
|
async methodA() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
await this.page.waitForSelector('.done');
|
|
}
|
|
}
|
|
`,
|
|
'/test-root/pages/workflow/PageB.ts': `
|
|
export class PageB {
|
|
async methodB() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'different');
|
|
// Different - extra statement
|
|
console.log('extra');
|
|
await this.page.waitForSelector('.done');
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
expect(violations.length).toBe(0);
|
|
});
|
|
|
|
it('ignores methods in the same file', () => {
|
|
const project = createTestProject({
|
|
'/test-root/pages/workflow/PageA.ts': `
|
|
export class PageA {
|
|
async methodA() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
await this.page.waitForSelector('.done');
|
|
}
|
|
|
|
async methodB() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
await this.page.waitForSelector('.done');
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
// Same file duplicates are allowed (may be intentional)
|
|
expect(violations.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('duplicate test detection', () => {
|
|
it('detects duplicate test bodies', () => {
|
|
const project = createTestProject({
|
|
// Use subdirectory to match tests/**/*.spec.ts pattern
|
|
'/test-root/tests/e2e/test1.spec.ts': `
|
|
test('first test', async ({ page }) => {
|
|
await page.goto('/home');
|
|
await page.click('button');
|
|
await page.waitForSelector('.done');
|
|
});
|
|
`,
|
|
'/test-root/tests/e2e/test2.spec.ts': `
|
|
test('second test', async ({ page }) => {
|
|
await page.goto('/home');
|
|
await page.click('button');
|
|
await page.waitForSelector('.done');
|
|
});
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
expect(violations.length).toBe(1);
|
|
expect(violations[0].message).toContain('Duplicate test logic');
|
|
});
|
|
});
|
|
|
|
describe('test duplicating method', () => {
|
|
it('detects when test duplicates page object method', () => {
|
|
const project = createTestProject({
|
|
// Use subdirectory to match pages/**/*.ts pattern
|
|
'/test-root/pages/auth/HomePage.ts': `
|
|
export class HomePage {
|
|
async login() {
|
|
await this.page.fill('#user', 'test');
|
|
await this.page.fill('#pass', 'pass');
|
|
await this.page.click('#submit');
|
|
}
|
|
}
|
|
`,
|
|
// Use subdirectory to match tests/**/*.spec.ts pattern
|
|
'/test-root/tests/e2e/login.spec.ts': `
|
|
test('should login', async ({ page }) => {
|
|
await page.fill('#user', 'test');
|
|
await page.fill('#pass', 'pass');
|
|
await page.click('#submit');
|
|
});
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
// Should detect that test duplicates HomePage.login()
|
|
expect(violations.length).toBe(1);
|
|
expect(violations[0].message).toContain('Test duplicates existing method');
|
|
expect(violations[0].message).toContain('HomePage.login()');
|
|
});
|
|
});
|
|
|
|
describe('minStatements threshold', () => {
|
|
it('ignores methods with fewer than 2 statements', () => {
|
|
const project = createTestProject({
|
|
'/test-root/pages/workflow/PageA.ts': `
|
|
export class PageA {
|
|
async shortMethod() {
|
|
await this.page.click('button');
|
|
}
|
|
}
|
|
`,
|
|
'/test-root/pages/workflow/PageB.ts': `
|
|
export class PageB {
|
|
async anotherShort() {
|
|
await this.page.click('button');
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
// Only 1 statement, below threshold of 2
|
|
expect(violations.length).toBe(0);
|
|
});
|
|
|
|
it('detects duplicates with exactly 2 statements', () => {
|
|
const project = createTestProject({
|
|
'/test-root/pages/workflow/PageA.ts': `
|
|
export class PageA {
|
|
async twoStatements() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
}
|
|
}
|
|
`,
|
|
'/test-root/pages/workflow/PageB.ts': `
|
|
export class PageB {
|
|
async alsoTwoStatements() {
|
|
await this.page.click('button');
|
|
await this.page.fill('input', 'text');
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
|
|
const files = project.getSourceFiles();
|
|
const violations = rule.analyze(project, files);
|
|
|
|
// 2 statements meets the threshold
|
|
expect(violations.length).toBe(1);
|
|
});
|
|
});
|
|
});
|