test: Add Playwright janitor for test architecture enforcement (no-changelog) (#24869)

This commit is contained in:
Declan Carroll 2026-02-13 11:03:13 +00:00 committed by GitHub
parent e3eafc7e87
commit 593fc27863
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
146 changed files with 17894 additions and 1540 deletions

View File

@ -154,6 +154,7 @@ What we use for testing and writing tests:
- For frontend we use `vitest`
- For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`.
See `packages/testing/playwright/README.md` for details.
- **For Playwright test maintenance/cleanup**, see @packages/testing/playwright/AGENTS.md (includes janitor tool for static analysis, dead code removal, architecture enforcement, and TCR workflows).
### Common Development Tasks

View File

@ -32,3 +32,9 @@ pre-commit:
skip:
- merge
- rebase
playwright_janitor:
glob: 'packages/testing/playwright/**/*.ts'
run: |
files=$(echo {staged_files} | tr ' ' ',')
pnpm --filter=n8n-playwright janitor --files="$files"
skip: true # Disabled for now - enable when baseline is committed

View File

@ -58,7 +58,7 @@
"prettier": "3.6.2",
"prompts": "^2.4.2",
"rimraf": "catalog:",
"ts-morph": "^26.0.0",
"ts-morph": "catalog:",
"typescript-eslint": "^8.35.0"
},
"devDependencies": {

4
packages/testing/janitor/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
dist/
node_modules/
coverage/
*.tsbuildinfo

View File

@ -0,0 +1 @@
@README.md

View File

@ -0,0 +1,790 @@
# @n8n/playwright-janitor
Static analysis and architecture enforcement for Playwright test suites.
## Why?
Playwright tests are easy to write but hard to maintain at scale. Without guardrails, test code accumulates problems:
- **Selector duplication** - Same `getByTestId('button')` scattered across files
- **Leaky abstractions** - Tests directly manipulating the DOM instead of using page objects
- **Dead code** - Unused page object methods nobody deletes
- **Architecture drift** - Flows importing pages, pages importing tests, layers bleeding together
- **Orphaned test data** - Workflow files nobody references anymore
The janitor catches these problems through static analysis, enforcing your architecture before bad patterns spread.
## Architecture Model
The janitor enforces a layered architecture for Playwright test suites:
```
┌─────────────────────────────────────────────────────────┐
│ Tests │
│ test('user can login', async ({ app }) => { ... }) │
└──────────────────────────┬──────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────┐
│ Flows / Composables │
│ await app.workflows.createAndRun('my-workflow') │
└──────────────────────────┬──────────────────────────────┘
│ orchestrates
┌─────────────────────────────────────────────────────────┐
│ Page Objects │
│ await this.canvas.addNode('HTTP Request') │
└──────────────────────────┬──────────────────────────────┘
│ encapsulates
┌─────────────────────────────────────────────────────────┐
│ Components │
│ await this.nodePanel.selectNode('Webhook') │
└──────────────────────────┬──────────────────────────────┘
│ wraps
┌─────────────────────────────────────────────────────────┐
│ Playwright API │
│ page.getByTestId(), page.locator(), page.click() │
└─────────────────────────────────────────────────────────┘
```
**Key principles:**
1. **Dependencies flow downward** - Tests depend on flows, flows on pages, pages on components
2. **No skipping layers** - Tests should use flows, not reach directly into page internals
3. **Selectors belong in page objects** - Raw `getByTestId()` calls don't belong in tests or flows
4. **One home per selector** - Each test ID should be defined in exactly one page object
## Quick Start
### Installation
```bash
pnpm add -D @n8n/playwright-janitor
```
### Configuration
Create a `janitor.config.js` in your Playwright test root:
```typescript
import { defineConfig } from '@n8n/playwright-janitor';
export default defineConfig({
rootDir: __dirname,
// Where your different artifact types live
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'], // or 'actions/**/*.ts', 'scenarios/**/*.ts'
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts'],
factories: ['factories/**/*.ts'],
testData: ['workflows/**/*'], // Static JSON/fixtures
},
// The main page object facade that exposes sub-pages
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page', 'APIRequestContext'],
},
// What you call the fixture in your tests
fixtureObjectName: 'app', // test('...', async ({ app }) => ...)
});
```
### Run Analysis
```typescript
import { runAnalysis } from '@n8n/playwright-janitor';
import config from './janitor.config.js';
const report = runAnalysis(config);
console.log(`Found ${report.summary.totalViolations} violations`);
```
Or create a script:
```typescript
// scripts/run-janitor.ts
import { runAnalysis, toConsole } from '@n8n/playwright-janitor';
import config from '../janitor.config.js';
const report = runAnalysis(config);
toConsole(report);
process.exit(report.summary.totalViolations > 0 ? 1 : 0);
```
### Baseline (Incremental Cleanup)
For existing codebases with many violations, use a baseline to enable incremental cleanup:
```bash
# Create baseline of current violations
playwright-janitor baseline
# Commit the baseline
git add .janitor-baseline.json
git commit -m "chore: add janitor baseline"
```
Once a baseline exists, janitor and TCR **only fail on new violations**. Pre-existing violations are tracked but don't block commits.
**Safeguard:** TCR blocks commits that modify `.janitor-baseline.json`. This prevents accidentally "fixing" violations by updating the baseline instead of the actual code. Baseline updates must always be done manually.
```bash
# This now passes (only checks for NEW violations)
playwright-janitor tcr --execute -m="Add new feature"
# As you fix violations, update the baseline (manual commit required - TCR won't commit baseline changes)
playwright-janitor baseline
git add .janitor-baseline.json
git commit -m "chore: update baseline after cleanup"
```
**Baseline file format:** `.janitor-baseline.json` - tracks violations by file and content hash, so line number shifts don't cause false positives.
### List Rules
View all available rules with their descriptions:
```bash
# Human-readable list
playwright-janitor rules
# JSON output (for AI agents/automation)
playwright-janitor rules --json
# Verbose (includes target globs)
playwright-janitor rules --verbose
```
The JSON output is useful for AI agents that need to understand the rules before writing code.
## Rules
### Architecture Rules
#### `boundary-protection`
**Severity:** error
Prevents pages from importing other pages directly. Each page should be independent; if you need to compose pages, that's what the facade/flows layer is for.
```typescript
// Bad - WorkflowPage importing SettingsPage
import { SettingsPage } from './SettingsPage';
export class WorkflowPage {
async openSettings() {
await this.settingsPage.open(); // Coupling between pages
}
}
// Good - Pages are independent, composition happens in flows
export class WorkflowPage {
async getWorkflowName() {
return this.header.getByTestId('workflow-name').textContent();
}
}
```
#### `scope-lockdown`
**Severity:** error
Enforces explicit architectural intent for page objects. Each page must either:
1. Have a `container` getter (scoped component - must use container for all locators)
2. Have a navigation method (standalone top-level page - can use `this.page` directly)
This prevents ambiguous page objects and ensures consistent patterns.
```typescript
// Bad - Ambiguous page (neither container nor navigation method)
export class SettingsPage {
async toggleOption() {
await this.page.getByTestId('toggle').click(); // Is this a page or component?
}
}
// Good - Standalone page with navigation method
export class SettingsPage {
async goto() {
await this.page.goto('/settings');
}
async toggleOption() {
await this.page.getByTestId('toggle').click(); // OK - explicit standalone page
}
}
// Good - Scoped component with container
export class NodePanel {
get container() { return this.page.locator('.node-panel'); }
async selectNode(name: string) {
await this.container.getByTestId('node-item').click(); // Scoped to container
}
}
// Bad - Component with container using unscoped locators
export class NodePanel {
get container() { return this.page.locator('.node-panel'); }
async selectNode(name: string) {
await this.page.getByTestId('node-item').click(); // Escapes container!
}
}
```
**Configuration:**
```typescript
rules: {
'scope-lockdown': {
enabled: true,
severity: 'error',
// Customize which method names indicate a standalone page
navigationMethods: ['goto', 'navigate', 'visit', 'open'],
},
}
```
#### `selector-purity`
**Severity:** error
Raw Playwright locators (`getByTestId`, `locator`, etc.) should only appear in page objects, not in tests or flows.
**Catches:**
- Direct page locator calls: `page.getByTestId()`, `app.page.locator()`
- Chained locator calls on variables: `someLocator.locator()`, `category.getByText()`
**Note:** Selectors inside `expect()` calls are allowed by default (`allowInExpect: true`). This recognizes that assertions often need to check specific elements.
```typescript
// Bad - Direct page locator in test
test('creates workflow', async ({ app }) => {
await app.page.getByTestId('new-workflow-btn').click(); // Leaked selector
});
// Bad - Chained locator on returned Locator
test('finds links', async ({ app }) => {
const category = app.settings.getCategory('nodes');
const links = category.locator('a[href*="/workflow/"]'); // Leaked selector
});
// Good - Selector encapsulated in page object
test('creates workflow', async ({ app }) => {
await app.workflows.create(); // Implementation hidden
});
// Good - Page object returns the specific element
test('finds links', async ({ app }) => {
const links = app.settings.getWorkflowLinks('nodes'); // Selector in page object
});
```
#### `no-page-in-flow`
**Severity:** warning
Flows/composables shouldn't access `page` directly. They should work through page objects.
```typescript
// Bad - Flow reaching into page internals
export class WorkflowComposer {
async createAndRun() {
await this.app.page.getByTestId('run-btn').click(); // Direct page access
}
}
// Good - Flow uses page objects
export class WorkflowComposer {
async createAndRun() {
await this.app.canvas.runWorkflow(); // Through page object
}
}
```
Certain page-level operations are allowed (configurable via `allowPatterns`):
- `page.keyboard.*` - Keyboard shortcuts
- `page.evaluate()` - JavaScript execution
- `page.waitForLoadState()` - Navigation waits
- `page.waitForURL()` - URL assertions
- `page.reload()` - Page refresh
#### `api-purity`
**Severity:** warning
Raw HTTP calls (`request.get()`, `fetch()`) should go through API service classes, not appear directly in tests.
```typescript
// Bad - Raw HTTP in test
test('gets workflows', async ({ request }) => {
const response = await request.get('/api/workflows');
});
// Good - Through API service
test('gets workflows', async ({ api }) => {
const workflows = await api.workflows.list();
});
```
### Code Quality Rules
#### `dead-code`
**Severity:** warning | **Fixable:** yes
Detects unused public methods and properties in page objects. If nothing references a method, it's probably dead code.
```typescript
export class WorkflowPage {
async usedMethod() { /* called from tests */ }
async unusedMethod() { /* nobody calls this */ } // Violation
}
```
#### `deduplication`
**Severity:** warning
Detects the same `getByTestId()` value used in multiple page object files. Each test ID should have one authoritative home.
```typescript
// pages/WorkflowPage.ts
this.page.getByTestId('save-button'); // Duplicate
// pages/SettingsPage.ts
this.page.getByTestId('save-button'); // Duplicate
```
**Note:** Same ID within a single file is allowed (e.g., helper methods).
#### `test-data-hygiene`
**Severity:** warning
Detects:
- **Orphaned test data** - Workflow/expectation files not referenced by any test
- **Generic names** - Files named `test.json`, `data.json`, `workflow_1.json`
- **Ticket-only names** - Files named just `CAT-123.json` without description
```
workflows/
webhook-with-retry.json Good - Descriptive
test.json Bad - Generic
CAT-123.json Bad - Ticket-only
unused-workflow.json Bad - Orphaned (if not referenced)
```
#### `duplicate-logic`
**Severity:** warning
Detects duplicate code using AST structural fingerprinting. Finds copy-paste patterns across tests, pages, flows, and helpers by normalizing code structure (ignoring variable names and literal values).
Catches:
- **Duplicate methods** - Same logic in multiple page objects
- **Duplicate tests** - Copy-pasted test bodies across files
- **Tests duplicating methods** - Test code that reimplements existing page object methods
```typescript
// pages/WorkflowPage.ts
async saveWorkflow() {
await this.page.click('#save');
await this.page.fill('#name', 'workflow');
await this.page.waitForSelector('.saved');
}
// pages/CredentialPage.ts - Violation: duplicates WorkflowPage.saveWorkflow()
async saveCredential() {
await this.page.click('#save');
await this.page.fill('#name', 'credential');
await this.page.waitForSelector('.saved');
}
```
**Threshold:** Methods/tests with fewer than 2 statements are ignored (configurable via `minStatements`).
## Configuration Reference
```typescript
interface JanitorConfig {
/** Root directory for the Playwright test suite (absolute path) */
rootDir: string;
/** Directory patterns for different artifact types */
patterns: {
pages: string[];
components: string[];
flows: string[];
tests: string[];
services: string[];
fixtures: string[];
helpers: string[];
factories: string[];
testData: string[];
};
/** Files to exclude from page analysis (facades, base classes) */
excludeFromPages: string[];
/** Facade configuration - the main aggregator that exposes page objects */
facade: {
file: string; // Path relative to rootDir
className: string; // e.g., 'AppPage'
excludeTypes: string[]; // Types to exclude from mapping
};
/** The fixture object name used in tests */
fixtureObjectName: string; // e.g., 'app', 'po', 'n8n'
/** The API fixture/helper object name */
apiFixtureName: string; // e.g., 'api'
/** Patterns indicating raw API calls */
rawApiPatterns: RegExp[];
/** What you call the middle layer */
flowLayerName: string; // e.g., 'Composable', 'Action', 'Flow'
/** Rule-specific configuration */
rules: {
[ruleId: string]: {
enabled?: boolean;
severity?: 'error' | 'warning' | 'off';
allowPatterns?: RegExp[];
};
};
/** TCR configuration */
tcr: {
testCommand: string; // Default: 'npx playwright test'
};
}
```
## Disabling Rules
### Globally
```typescript
// janitor.config.js
export default defineConfig({
// ...
rules: {
'dead-code': { enabled: false },
'deduplication': { severity: 'off' },
},
});
```
### Per-Rule Allow Patterns
```typescript
rules: {
'no-page-in-flow': {
allowPatterns: [
/\.page\.keyboard/, // Allow keyboard shortcuts
/\.page\.evaluate/, // Allow JS execution
],
},
}
```
## Programmatic API
```typescript
import {
defineConfig,
runAnalysis,
createDefaultRunner,
RuleRunner,
BaseRule,
toJSON,
toConsole,
} from '@n8n/playwright-janitor';
// Simple usage
const report = runAnalysis(config);
// Custom runner with specific rules
const runner = new RuleRunner();
runner.registerRule(new BoundaryProtectionRule());
runner.registerRule(new SelectorPurityRule());
const { project, root } = createProject(config.rootDir);
const report = runner.run(project, root);
// Output
toConsole(report); // Human-readable
const json = toJSON(report); // Machine-readable
```
## Writing Custom Rules
Extend `BaseRule` to create custom rules:
```typescript
import { SyntaxKind } from 'ts-morph';
import { BaseRule } from '@n8n/playwright-janitor';
import type { Project, SourceFile, Violation } from '@n8n/playwright-janitor';
export class NoHardcodedUrlsRule extends BaseRule {
readonly id = 'no-hardcoded-urls';
readonly name = 'No Hardcoded URLs';
readonly description = 'URLs should come from configuration';
readonly severity = 'warning' as const;
getTargetGlobs(): string[] {
return ['**/*.ts']; // Analyze all TypeScript files
}
analyze(project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
for (const file of files) {
// Use ts-morph to analyze the AST
const stringLiterals = file.getDescendantsOfKind(SyntaxKind.StringLiteral);
for (const literal of stringLiterals) {
const value = literal.getLiteralText();
if (value.startsWith('http://') || value.startsWith('https://')) {
violations.push(
this.createViolation(
file,
literal.getStartLineNumber(),
literal.getStart() - literal.getStartLinePos(),
`Hardcoded URL found: ${value}`,
'Move URL to configuration or environment variable',
),
);
}
}
}
return violations;
}
}
// Register with runner
const runner = createDefaultRunner();
runner.registerRule(new NoHardcodedUrlsRule());
```
## Integration with CI
Add to your CI pipeline:
```yaml
# .github/workflows/test.yml
- name: Run Janitor
run: pnpm janitor
```
The janitor exits with code 1 if violations are found, failing the build.
## Philosophy
The janitor embodies these principles:
1. **Catch problems early** - Static analysis finds issues without running tests
2. **Enforce by default** - Good architecture should be the path of least resistance
3. **Configurable, not prescriptive** - Your naming conventions, your layers, your rules
4. **Fixable where possible** - Dead code removal shouldn't require manual work
5. **AI-friendly guardrails** - When AI generates test code, the janitor keeps it clean
## TCR (Test && Commit || Revert)
The janitor includes tools for TCR-style development workflows, where changes are automatically committed if tests pass, or reverted if they fail.
**Recommended Workflow:**
1. Run `pnpm janitor` to identify violations
2. Fix violations in your code
3. **Debug and verify** - Run affected tests manually, check behavior
4. **TCR as the last step** - Once confident, use TCR to safely commit
> **Why TCR last?** Running TCR immediately after fixing violations doesn't give you time to debug if something breaks. The revert happens automatically, and you lose your work. Fix → verify → TCR ensures you only commit working code.
### Impact Analysis
Determine which tests are affected by file changes:
```typescript
import { createProject, ImpactAnalyzer, formatImpactConsole } from '@n8n/playwright-janitor';
const { project } = createProject('./');
const analyzer = new ImpactAnalyzer(project);
// Analyze impact of changed files
const result = analyzer.analyze(['pages/CanvasPage.ts', 'pages/WorkflowPage.ts']);
console.log(`Affected tests: ${result.affectedTests.length}`);
result.affectedTests.forEach(t => console.log(` - ${t}`));
// Or use the formatter
formatImpactConsole(result, true); // verbose mode
```
### Method-Level Impact
Track which tests use specific page object methods:
```typescript
import { createProject, MethodUsageAnalyzer } from '@n8n/playwright-janitor';
const { project } = createProject('./');
const analyzer = new MethodUsageAnalyzer(project);
// Build a complete index of method usages
const index = analyzer.buildIndex();
console.log(`Tracked ${Object.keys(index.methods).length} methods`);
// Find tests affected by a specific method change
const impact = analyzer.getMethodImpact('CanvasPage.addNode');
console.log(`Tests using CanvasPage.addNode():`);
impact.affectedTestFiles.forEach(t => console.log(` - ${t}`));
```
### AST Diff Analysis
Detect which methods changed in a file compared to git HEAD:
```typescript
import { diffFileMethods, formatDiffConsole } from '@n8n/playwright-janitor';
const result = diffFileMethods('pages/CanvasPage.ts', 'HEAD');
console.log(`Changed methods:`);
for (const change of result.changedMethods) {
const symbol = change.changeType === 'added' ? '+'
: change.changeType === 'removed' ? '-' : '~';
console.log(` ${symbol} ${change.className}.${change.methodName}`);
}
```
### TCR Executor
Run the full TCR workflow:
```typescript
import { TcrExecutor } from '@n8n/playwright-janitor';
const tcr = new TcrExecutor();
// Dry run - analyze but don't commit/revert
const result = await tcr.run({ verbose: true });
console.log(`Changed files: ${result.changedFiles.length}`);
console.log(`Changed methods: ${result.changedMethods.length}`);
console.log(`Affected tests: ${result.affectedTests.length}`);
console.log(`Tests passed: ${result.testsPassed}`);
// Execute TCR - commit on success, revert on failure
const executed = await tcr.run({
execute: true,
commitMessage: 'feat: Add new workflow feature'
});
console.log(`Action taken: ${executed.action}`); // 'commit' | 'revert' | 'dry-run'
```
### Watch Mode
Continuously run TCR on file changes:
```typescript
const tcr = new TcrExecutor();
tcr.watch({ execute: true }); // Ctrl+C to stop
```
### Codebase Inventory
Generate a complete inventory of your test codebase:
```typescript
import { createProject, InventoryAnalyzer, formatInventoryConsole } from '@n8n/playwright-janitor';
const { project } = createProject('./');
const analyzer = new InventoryAnalyzer(project);
const inventory = analyzer.generate();
console.log(`Pages: ${inventory.summary.totalPages}`);
console.log(`Components: ${inventory.summary.totalComponents}`);
console.log(`Flows: ${inventory.summary.totalFlows}`);
console.log(`Test files: ${inventory.summary.totalTestFiles}`);
console.log(`Total tests: ${inventory.summary.totalTests}`);
console.log(`Total methods: ${inventory.summary.totalMethods}`);
// Detailed output
formatInventoryConsole(inventory, true); // verbose mode
// Export as markdown
import { formatInventoryMarkdown } from '@n8n/playwright-janitor';
const markdown = formatInventoryMarkdown(inventory);
```
### TCR Types
```typescript
interface TcrOptions {
/** Git ref to compare against (default: HEAD) */
baseRef?: string;
/** Whether to actually commit/revert (false = dry run) */
execute?: boolean;
/** Custom commit message */
commitMessage?: string;
/** Watch mode - re-run on file changes */
watch?: boolean;
/** Verbose output */
verbose?: boolean;
/** Override test command (default: from config or 'npx playwright test') */
testCommand?: string;
}
interface TcrResult {
changedFiles: string[];
changedMethods: MethodChange[];
affectedTests: string[];
testsRun: string[];
testsPassed: boolean;
action: 'commit' | 'revert' | 'dry-run';
durationMs: number;
}
interface MethodChange {
className: string;
methodName: string;
changeType: 'added' | 'removed' | 'modified';
}
interface ImpactResult {
changedFiles: string[];
affectedFiles: string[];
affectedTests: string[];
graph: Record<string, string[]>;
}
```
## Development
```bash
pnpm install
pnpm build
pnpm test
```
## License
MIT

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig, {
ignores: ['coverage/**'],
}, {
rules: {
'@typescript-eslint/naming-convention': [
'error',
// Allow kebab-case for rule IDs in config objects
{
selector: 'objectLiteralProperty',
format: null,
filter: {
regex: '^[a-z]+-[a-z-]+$', // kebab-case pattern
match: true,
},
},
],
},
});

View File

@ -0,0 +1,45 @@
{
"name": "@n8n/playwright-janitor",
"version": "0.1.0",
"description": "Static analysis and architecture enforcement for Playwright test suites",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"playwright-janitor": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit"
},
"keywords": [
"playwright",
"testing",
"static-analysis",
"architecture",
"linting"
],
"license": "MIT",
"dependencies": {
"glob": "^10.0.0"
},
"peerDependencies": {
"ts-morph": ">=20.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "catalog:",
"ts-morph": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@ -0,0 +1,206 @@
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
const CLI_PATH = path.join(__dirname, '..', 'dist', 'cli.js');
describe('CLI', () => {
describe('--help', () => {
it('shows help text', () => {
const output = execSync(`node ${CLI_PATH} --help`, { encoding: 'utf-8' });
expect(output).toContain('Playwright Janitor');
expect(output).toContain('Commands:');
expect(output).toContain('baseline');
expect(output).toContain('inventory');
expect(output).toContain('impact');
expect(output).toContain('tcr');
expect(output).toContain('--ignore-baseline');
});
});
describe('--list', () => {
it('lists available rules', () => {
const output = execSync(`node ${CLI_PATH} --list`, { encoding: 'utf-8' });
expect(output).toContain('Available rules:');
expect(output).toContain('boundary-protection');
expect(output).toContain('selector-purity');
expect(output).toContain('dead-code');
expect(output).toContain('scope-lockdown');
});
});
describe('baseline --help', () => {
it('shows baseline help', () => {
const output = execSync(`node ${CLI_PATH} baseline --help`, { encoding: 'utf-8' });
expect(output).toContain('Baseline');
expect(output).toContain('.janitor-baseline.json');
expect(output).toContain('incremental cleanup');
});
});
describe('inventory --help', () => {
it('shows inventory help', () => {
const output = execSync(`node ${CLI_PATH} inventory --help`, { encoding: 'utf-8' });
expect(output).toContain('Inventory');
expect(output).toContain('JSON');
});
});
describe('impact --help', () => {
it('shows impact help', () => {
const output = execSync(`node ${CLI_PATH} impact --help`, { encoding: 'utf-8' });
expect(output).toContain('Impact');
expect(output).toContain('--file');
});
});
describe('method-impact --help', () => {
it('shows method-impact help', () => {
const output = execSync(`node ${CLI_PATH} method-impact --help`, { encoding: 'utf-8' });
expect(output).toContain('Method Impact');
expect(output).toContain('--method');
});
});
describe('tcr --help', () => {
it('shows tcr help', () => {
const output = execSync(`node ${CLI_PATH} tcr --help`, { encoding: 'utf-8' });
expect(output).toContain('TCR');
expect(output).toContain('--execute');
expect(output).toContain('--message');
expect(output).toContain('--max-diff-lines');
});
});
describe('argument parsing', () => {
let tempDir: string;
beforeAll(() => {
// Use realpathSync to avoid macOS /var -> /private/var symlink issues
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-')));
// Create tsconfig.json (required by ts-morph)
fs.writeFileSync(
path.join(tempDir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'node',
strict: true,
},
include: ['**/*.ts'],
}),
);
// Create minimal config as .js (Node.js can't dynamically import .ts files)
fs.writeFileSync(
path.join(tempDir, 'janitor.config.js'),
`module.exports = {
rootDir: '${tempDir}',
patterns: {
pages: ['pages/**/*.ts'],
components: [],
flows: [],
tests: ['tests/**/*.spec.ts'],
services: [],
fixtures: [],
helpers: [],
factories: [],
testData: [],
},
excludeFromPages: [],
facade: { file: 'pages/AppPage.ts', className: 'AppPage', excludeTypes: [] },
fixtureObjectName: 'app',
apiFixtureName: 'api',
rawApiPatterns: [],
flowLayerName: 'Composable',
architectureLayers: [],
rules: {},
};`,
);
fs.mkdirSync(path.join(tempDir, 'pages'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true });
});
afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('accepts --json flag', () => {
// Create a clean page file
fs.writeFileSync(path.join(tempDir, 'pages', 'CleanPage.ts'), 'export class CleanPage {}');
try {
const output = execSync(
`node ${CLI_PATH} --json --config=${path.join(tempDir, 'janitor.config.js')}`,
{
encoding: 'utf-8',
cwd: tempDir,
},
);
// Should be valid JSON
expect(() => JSON.parse(output) as unknown).not.toThrow();
} catch (error) {
// Even if there are violations, output should be JSON
const stderr = (error as { stdout?: string }).stdout ?? '';
if (stderr) {
expect(() => JSON.parse(stderr) as unknown).not.toThrow();
}
}
});
it('accepts --verbose flag', () => {
fs.writeFileSync(
path.join(tempDir, 'pages', 'VerbosePage.ts'),
'export class VerbosePage {}',
);
try {
const output = execSync(
`node ${CLI_PATH} --verbose --config=${path.join(tempDir, 'janitor.config.js')}`,
{
encoding: 'utf-8',
cwd: tempDir,
},
);
// Verbose output should include more detail
expect(output).toBeDefined();
} catch {
// May fail with violations, that's OK for this test
}
});
it('accepts --ignore-baseline flag', () => {
fs.writeFileSync(
path.join(tempDir, 'pages', 'BaselinePage.ts'),
'export class BaselinePage {}',
);
try {
const output = execSync(
`node ${CLI_PATH} --ignore-baseline --config=${path.join(tempDir, 'janitor.config.js')}`,
{
encoding: 'utf-8',
cwd: tempDir,
},
);
// Should not show "Using baseline" message when ignoring
expect(output).not.toContain('Using baseline');
} catch {
// May fail with violations, that's OK for this test
}
});
});
});

View File

@ -0,0 +1,477 @@
#!/usr/bin/env node
/**
* Playwright Janitor CLI
*
* Usage:
* playwright-janitor # Run all rules
* playwright-janitor inventory # Show codebase inventory
* playwright-janitor impact # Show impact of changes
* playwright-janitor tcr # TCR workflow
* playwright-janitor --help # Show help
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
parseArgs,
type CliOptions,
showHelp,
showBaselineHelp,
showImpactHelp,
showInventoryHelp,
showMethodImpactHelp,
showRulesHelp,
showTcrHelp,
} from './cli/index.js';
import { setConfig, getConfig, defineConfig, type JanitorConfig } from './config.js';
import {
generateBaseline,
saveBaseline,
loadBaseline,
filterReportByBaseline,
formatBaselineInfo,
getBaselinePath,
} from './core/baseline.js';
import {
ImpactAnalyzer,
formatImpactConsole,
formatImpactJSON,
formatTestList,
} from './core/impact-analyzer.js';
import {
InventoryAnalyzer,
formatInventoryJSON,
formatInventorySummaryJSON,
formatInventoryCategoryJSON,
toSummary,
toCategory,
filterByFile,
type InventoryCategory,
} from './core/inventory-analyzer.js';
import {
MethodUsageAnalyzer,
formatMethodImpactConsole,
formatMethodImpactJSON,
formatMethodImpactTestList,
formatMethodUsageIndexConsole,
formatMethodUsageIndexJSON,
} from './core/method-usage-analyzer.js';
import { createProject } from './core/project-loader.js';
import { toJSON, toConsole, printFixResults } from './core/reporter.js';
import { TcrExecutor, formatTcrResultConsole, formatTcrResultJSON } from './core/tcr-executor.js';
import { createDefaultRunner } from './index.js';
import type { RunOptions } from './types.js';
async function loadConfig(configPath?: string): Promise<JanitorConfig> {
const cwd = process.cwd();
// Try to find config file (JS files only - TS requires a runtime like tsx)
const configLocations = configPath
? [configPath]
: ['janitor.config.js', 'janitor.config.mjs', '.janitorrc.js'];
for (const location of configLocations) {
const fullPath = path.isAbsolute(location) ? location : path.join(cwd, location);
if (fs.existsSync(fullPath)) {
try {
const configModule = (await import(fullPath)) as {
default?: JanitorConfig;
} & JanitorConfig;
const config: JanitorConfig = configModule.default ?? configModule;
// Ensure rootDir is set
config.rootDir ??= path.dirname(fullPath);
return config;
} catch (error) {
console.error(`Error loading config from ${fullPath}:`, error);
process.exit(1);
}
}
}
// No config file found - create minimal config
console.warn('No janitor.config.js found, using minimal defaults');
return defineConfig({ rootDir: cwd });
}
const VALID_CATEGORIES: InventoryCategory[] = [
'pages',
'components',
'composables',
'services',
'fixtures',
'helpers',
'factories',
'testData',
];
function runInventory(options: CliOptions): void {
const config = getConfig();
const { project } = createProject(config.rootDir);
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
// Summary mode - minimal output for AI
if (options.summary) {
console.log(formatInventorySummaryJSON(toSummary(report)));
return;
}
// Category filter - single category with method names only
if (options.category) {
if (!VALID_CATEGORIES.includes(options.category as InventoryCategory)) {
console.error(`Invalid category: ${options.category}`);
console.error(`Valid categories: ${VALID_CATEGORIES.join(', ')}`);
process.exit(1);
}
console.log(
formatInventoryCategoryJSON(toCategory(report, options.category as InventoryCategory)),
);
return;
}
// File filter - detailed info for single file
if (options.files && options.files.length === 1) {
const result = filterByFile(report, options.files[0]);
if (result) {
console.log(JSON.stringify(result, null, 2));
} else {
console.error(`File not found in inventory: ${options.files[0]}`);
process.exit(1);
}
return;
}
// Default: full inventory
console.log(formatInventoryJSON(report));
}
async function runImpact(options: CliOptions): Promise<void> {
const config = getConfig();
// Create project
const { project } = createProject(config.rootDir);
// Determine changed files
let changedFiles = options.files ?? [];
if (changedFiles.length === 0) {
// Use git status to find changed files
const { getChangedFiles } = await import('./utils/git-operations.js');
changedFiles = getChangedFiles({
scopeDir: config.rootDir,
extensions: ['.ts'],
});
}
if (changedFiles.length === 0) {
console.log('No changed files detected.');
return;
}
// Analyze impact
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(changedFiles);
// Output
if (options.json) {
console.log(formatImpactJSON(result));
} else if (options.testList) {
console.log(formatTestList(result));
} else {
formatImpactConsole(result, options.verbose);
}
}
function runMethodImpact(options: CliOptions): void {
const config = getConfig();
// Create project
const { project } = createProject(config.rootDir);
// Create analyzer
const analyzer = new MethodUsageAnalyzer(project);
// Build full index if requested
if (options.methodIndex) {
const index = analyzer.buildIndex();
if (options.json) {
console.log(formatMethodUsageIndexJSON(index));
} else {
formatMethodUsageIndexConsole(index);
}
return;
}
// Require --method for impact analysis
if (!options.method) {
console.error('Error: --method=ClassName.methodName is required');
console.error('Example: --method=CanvasPage.addNode');
console.error('\nOr use --index to build the full method usage index');
process.exit(1);
}
try {
const result = analyzer.getMethodImpact(options.method);
if (options.testList) {
console.log(formatMethodImpactTestList(result));
} else if (options.json) {
console.log(formatMethodImpactJSON(result));
} else {
formatMethodImpactConsole(result, options.verbose);
}
} catch (error) {
console.error(`Error: ${(error as Error).message}`);
process.exit(1);
}
}
function runTcr(options: CliOptions): void {
const tcr = new TcrExecutor();
const result = tcr.run({
execute: options.execute,
commitMessage: options.message,
baseRef: options.baseRef,
verbose: options.verbose,
targetBranch: options.targetBranch,
maxDiffLines: options.maxDiffLines,
testCommand: options.testCommand,
});
// Output
if (options.json) {
console.log(formatTcrResultJSON(result));
} else {
formatTcrResultConsole(result, options.verbose);
}
// Exit code based on result
if (!result.success && options.execute) {
process.exit(1);
}
}
function runAnalyze(options: CliOptions): void {
const config = getConfig();
const runner = createDefaultRunner();
// Create project
const { project, root } = createProject(config.rootDir);
// Build run options
const runOptions: RunOptions = {};
if (options.files && options.files.length > 0) {
runOptions.files = options.files;
}
if (options.fix) {
runOptions.fix = true;
runOptions.write = options.write;
}
// Pass rule-specific config
if (options.allowInExpect) {
runOptions.ruleConfig = {
'selector-purity': { allowInExpect: true },
};
}
// Run analysis
let report = options.rule
? runner.runRule(project, root, options.rule, runOptions)
: runner.run(project, root, runOptions);
if (!report) {
console.error('Failed to generate report');
process.exit(1);
}
// Auto-filter by baseline if present (unless --ignore-baseline or --fix)
const baseline = loadBaseline(config.rootDir);
let baselineFiltered = false;
const originalViolations = report.summary.totalViolations;
if (baseline && !options.fix && !options.ignoreBaseline) {
// Don't filter during fix mode or when baseline is ignored
report = filterReportByBaseline(report, baseline, config.rootDir);
baselineFiltered = true;
}
// Output results
if (options.json) {
console.log(toJSON(report));
} else {
if (baselineFiltered && options.verbose) {
console.log(formatBaselineInfo(baseline!));
console.log(
`New violations: ${report.summary.totalViolations} (${originalViolations} total, ${originalViolations - report.summary.totalViolations} in baseline)\n`,
);
} else if (baselineFiltered && report.summary.totalViolations < originalViolations) {
console.log(
`Using baseline: ${originalViolations - report.summary.totalViolations} known violations filtered\n`,
);
}
toConsole(report, options.verbose);
if (options.fix) {
printFixResults(report, options.write);
}
}
// Exit with error code if violations found
if (report.summary.totalViolations > 0 && !(options.fix && options.write)) {
process.exit(1);
}
}
function runBaseline(options: CliOptions): void {
const config = getConfig();
// Create runner and project
const runner = createDefaultRunner();
const { project, root } = createProject(config.rootDir);
// Run full analysis (no file filter, no baseline filter)
const report = runner.run(project, root, {});
if (!report) {
console.error('Failed to generate report');
process.exit(1);
}
// Generate and save baseline
const baseline = generateBaseline(report, config.rootDir);
saveBaseline(baseline, config.rootDir);
const baselinePath = getBaselinePath(config.rootDir);
if (options.verbose) {
console.log(`\nBaseline created: ${baselinePath}`);
console.log(`Total violations: ${baseline.totalViolations}`);
console.log(`Files with violations: ${Object.keys(baseline.violations).length}`);
console.log('\nViolations by rule:');
for (const result of report.results) {
if (result.violations.length > 0) {
console.log(` ${result.rule}: ${result.violations.length}`);
}
}
} else {
console.log(`Baseline created: ${baselinePath} (${baseline.totalViolations} violations)`);
}
console.log('\nNext steps:');
console.log(' git add .janitor-baseline.json');
console.log(' git commit -m "chore: add janitor baseline"');
}
function runRules(options: CliOptions): void {
const runner = createDefaultRunner();
const rules = runner.getRuleDetails();
if (options.json) {
console.log(JSON.stringify(rules, null, 2));
} else {
console.log('\nAvailable Rules\n' + '='.repeat(50) + '\n');
for (const rule of rules) {
const fixable = rule.fixable ? ' [fixable]' : '';
const enabled = rule.enabled ? '' : ' (disabled)';
const severity = rule.severity.toUpperCase();
console.log(`${rule.id}${fixable}${enabled}`);
console.log(` Name: ${rule.name}`);
console.log(` Severity: ${severity}`);
console.log(` ${rule.description}`);
if (options.verbose) {
console.log(` Targets: ${rule.targetGlobs.join(', ')}`);
}
console.log('');
}
console.log(`Total: ${rules.length} rules (${rules.filter((r) => r.enabled).length} enabled)`);
}
}
async function main(): Promise<void> {
const options = parseArgs();
// Handle help/list commands that don't need config
if (options.help) {
switch (options.command) {
case 'tcr':
showTcrHelp();
break;
case 'baseline':
showBaselineHelp();
break;
case 'inventory':
showInventoryHelp();
break;
case 'impact':
showImpactHelp();
break;
case 'method-impact':
showMethodImpactHelp();
break;
case 'rules':
showRulesHelp();
break;
default:
showHelp();
}
return;
}
if (options.list) {
const runner = createDefaultRunner();
console.log('\nAvailable rules:\n');
for (const ruleId of runner.getRegisteredRules()) {
const fixable = runner.isRuleFixable(ruleId) ? ' [fixable]' : '';
console.log(` - ${ruleId}${fixable}`);
}
console.log('');
return;
}
// Load config once for all commands that need it
const config = await loadConfig(options.config);
setConfig(config);
switch (options.command) {
case 'tcr':
runTcr(options);
break;
case 'baseline':
runBaseline(options);
break;
case 'inventory':
runInventory(options);
break;
case 'impact':
await runImpact(options);
break;
case 'method-impact':
runMethodImpact(options);
break;
case 'rules':
runRules(options);
break;
default:
runAnalyze(options);
}
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

View File

@ -0,0 +1,262 @@
import { describe, it, expect, afterEach } from 'vitest';
import { parseArgs } from './arg-parser.js';
describe('arg-parser', () => {
const originalArgv = process.argv;
afterEach(() => {
process.argv = originalArgv;
});
function setArgs(args: string[]): void {
process.argv = ['node', 'cli.js', ...args];
}
describe('subcommands', () => {
it('defaults to analyze command', () => {
setArgs([]);
const result = parseArgs();
expect(result.command).toBe('analyze');
});
it('parses tcr command', () => {
setArgs(['tcr']);
const result = parseArgs();
expect(result.command).toBe('tcr');
});
it('parses inventory command', () => {
setArgs(['inventory']);
const result = parseArgs();
expect(result.command).toBe('inventory');
});
it('parses impact command', () => {
setArgs(['impact']);
const result = parseArgs();
expect(result.command).toBe('impact');
});
it('parses method-impact command', () => {
setArgs(['method-impact']);
const result = parseArgs();
expect(result.command).toBe('method-impact');
});
it('parses baseline command', () => {
setArgs(['baseline']);
const result = parseArgs();
expect(result.command).toBe('baseline');
});
});
describe('boolean flags', () => {
it('parses --help', () => {
setArgs(['--help']);
const result = parseArgs();
expect(result.help).toBe(true);
});
it('parses -h', () => {
setArgs(['-h']);
const result = parseArgs();
expect(result.help).toBe(true);
});
it('parses --json', () => {
setArgs(['--json']);
const result = parseArgs();
expect(result.json).toBe(true);
});
it('parses --verbose', () => {
setArgs(['--verbose']);
const result = parseArgs();
expect(result.verbose).toBe(true);
});
it('parses -v', () => {
setArgs(['-v']);
const result = parseArgs();
expect(result.verbose).toBe(true);
});
it('parses --fix', () => {
setArgs(['--fix']);
const result = parseArgs();
expect(result.fix).toBe(true);
});
it('parses --write', () => {
setArgs(['--write']);
const result = parseArgs();
expect(result.write).toBe(true);
});
it('parses --list', () => {
setArgs(['--list']);
const result = parseArgs();
expect(result.list).toBe(true);
});
it('parses -l', () => {
setArgs(['-l']);
const result = parseArgs();
expect(result.list).toBe(true);
});
it('parses --execute', () => {
setArgs(['--execute']);
const result = parseArgs();
expect(result.execute).toBe(true);
});
it('parses -x', () => {
setArgs(['-x']);
const result = parseArgs();
expect(result.execute).toBe(true);
});
it('parses --test-list', () => {
setArgs(['--test-list']);
const result = parseArgs();
expect(result.testList).toBe(true);
});
it('parses --index', () => {
setArgs(['--index']);
const result = parseArgs();
expect(result.methodIndex).toBe(true);
});
it('parses --allow-in-expect', () => {
setArgs(['--allow-in-expect']);
const result = parseArgs();
expect(result.allowInExpect).toBe(true);
});
it('parses --ignore-baseline', () => {
setArgs(['--ignore-baseline']);
const result = parseArgs();
expect(result.ignoreBaseline).toBe(true);
});
});
describe('value flags', () => {
it('parses --config=path', () => {
setArgs(['--config=/path/to/config.js']);
const result = parseArgs();
expect(result.config).toBe('/path/to/config.js');
});
it('parses --rule=id', () => {
setArgs(['--rule=dead-code']);
const result = parseArgs();
expect(result.rule).toBe('dead-code');
});
it('parses --file=path', () => {
setArgs(['--file=src/file.ts']);
const result = parseArgs();
expect(result.files).toContain('src/file.ts');
});
it('parses --files=comma,separated', () => {
setArgs(['--files=a.ts,b.ts,c.ts']);
const result = parseArgs();
expect(result.files).toEqual(['a.ts', 'b.ts', 'c.ts']);
});
it('parses --message=text', () => {
setArgs(['--message=Fix bug']);
const result = parseArgs();
expect(result.message).toBe('Fix bug');
});
it('parses -m=text', () => {
setArgs(['-m=Fix bug']);
const result = parseArgs();
expect(result.message).toBe('Fix bug');
});
it('parses --base=ref', () => {
setArgs(['--base=HEAD~1']);
const result = parseArgs();
expect(result.baseRef).toBe('HEAD~1');
});
it('parses --method=Class.method', () => {
setArgs(['--method=CanvasPage.addNode']);
const result = parseArgs();
expect(result.method).toBe('CanvasPage.addNode');
});
it('parses --target-branch=name', () => {
setArgs(['--target-branch=main']);
const result = parseArgs();
expect(result.targetBranch).toBe('main');
});
it('parses --max-diff-lines=number', () => {
setArgs(['--max-diff-lines=500']);
const result = parseArgs();
expect(result.maxDiffLines).toBe(500);
});
it('parses --test-command=cmd', () => {
setArgs(['--test-command=pnpm test']);
const result = parseArgs();
expect(result.testCommand).toBe('pnpm test');
});
});
describe('combined arguments', () => {
it('parses subcommand with flags', () => {
setArgs(['tcr', '--execute', '--verbose', '-m=Fix bug']);
const result = parseArgs();
expect(result.command).toBe('tcr');
expect(result.execute).toBe(true);
expect(result.verbose).toBe(true);
expect(result.message).toBe('Fix bug');
});
it('parses multiple file flags', () => {
setArgs(['--file=a.ts', '--file=b.ts']);
const result = parseArgs();
expect(result.files).toContain('a.ts');
expect(result.files).toContain('b.ts');
});
});
describe('default values', () => {
it('has correct defaults', () => {
setArgs([]);
const result = parseArgs();
expect(result.command).toBe('analyze');
expect(result.config).toBeUndefined();
expect(result.rule).toBeUndefined();
expect(result.files).toEqual([]);
expect(result.json).toBe(false);
expect(result.verbose).toBe(false);
expect(result.fix).toBe(false);
expect(result.write).toBe(false);
expect(result.help).toBe(false);
expect(result.list).toBe(false);
expect(result.execute).toBe(false);
expect(result.message).toBeUndefined();
expect(result.baseRef).toBeUndefined();
expect(result.targetBranch).toBeUndefined();
expect(result.maxDiffLines).toBeUndefined();
expect(result.testCommand).toBeUndefined();
expect(result.testList).toBe(false);
expect(result.method).toBeUndefined();
expect(result.methodIndex).toBe(false);
expect(result.allowInExpect).toBe(false);
expect(result.ignoreBaseline).toBe(false);
});
});
});

View File

@ -0,0 +1,225 @@
/**
* CLI Argument Parser
*
* Extracts argument parsing logic from main CLI.
* Handles subcommands, flags, and options.
*/
export type Command =
| 'analyze'
| 'tcr'
| 'inventory'
| 'impact'
| 'method-impact'
| 'baseline'
| 'rules';
export interface CliOptions {
command: Command;
config?: string;
rule?: string;
files?: string[];
json: boolean;
verbose: boolean;
fix: boolean;
write: boolean;
help: boolean;
list: boolean;
// TCR-specific options
execute: boolean;
message?: string;
baseRef?: string;
targetBranch?: string;
maxDiffLines?: number;
testCommand?: string;
// Impact-specific options
testList: boolean;
// Method-impact specific options
method?: string;
methodIndex: boolean;
// Rule-specific options
allowInExpect: boolean;
// Baseline options
ignoreBaseline: boolean;
// Inventory-specific options
summary: boolean;
category?: string;
}
const SUBCOMMANDS: Record<string, Command> = {
tcr: 'tcr',
inventory: 'inventory',
impact: 'impact',
'method-impact': 'method-impact',
baseline: 'baseline',
rules: 'rules',
};
interface FlagHandler {
(options: CliOptions, value?: string): void;
}
const FLAG_HANDLERS: Record<string, FlagHandler> = {
'--help': (opts) => {
opts.help = true;
},
'-h': (opts) => {
opts.help = true;
},
'--json': (opts) => {
opts.json = true;
},
'--verbose': (opts) => {
opts.verbose = true;
},
'-v': (opts) => {
opts.verbose = true;
},
'--fix': (opts) => {
opts.fix = true;
},
'--write': (opts) => {
opts.write = true;
},
'--list': (opts) => {
opts.list = true;
},
'-l': (opts) => {
opts.list = true;
},
'--execute': (opts) => {
opts.execute = true;
},
'-x': (opts) => {
opts.execute = true;
},
'--test-list': (opts) => {
opts.testList = true;
},
'--index': (opts) => {
opts.methodIndex = true;
},
'--allow-in-expect': (opts) => {
opts.allowInExpect = true;
},
'--ignore-baseline': (opts) => {
opts.ignoreBaseline = true;
},
'--summary': (opts) => {
opts.summary = true;
},
'-s': (opts) => {
opts.summary = true;
},
};
const VALUE_FLAG_HANDLERS: Record<string, (options: CliOptions, value: string) => void> = {
'--config=': (opts, value) => {
opts.config = value;
},
'--rule=': (opts, value) => {
opts.rule = value;
},
'--file=': (opts, value) => {
opts.files?.push(value);
},
'--files=': (opts, value) => {
opts.files?.push(...value.split(','));
},
'--message=': (opts, value) => {
opts.message = value;
},
'-m=': (opts, value) => {
opts.message = value;
},
'--base=': (opts, value) => {
opts.baseRef = value;
},
'--method=': (opts, value) => {
opts.method = value;
},
'--target-branch=': (opts, value) => {
opts.targetBranch = value;
},
'--max-diff-lines=': (opts, value) => {
opts.maxDiffLines = Number.parseInt(value, 10);
},
'--test-command=': (opts, value) => {
opts.testCommand = value;
},
'--category=': (opts, value) => {
opts.category = value;
},
};
function createDefaultOptions(): CliOptions {
return {
command: 'analyze',
config: undefined,
rule: undefined,
files: [],
json: false,
verbose: false,
fix: false,
write: false,
help: false,
list: false,
execute: false,
message: undefined,
baseRef: undefined,
targetBranch: undefined,
maxDiffLines: undefined,
testCommand: undefined,
testList: false,
method: undefined,
methodIndex: false,
allowInExpect: false,
ignoreBaseline: false,
summary: false,
category: undefined,
};
}
function parseSubcommand(args: string[]): { command: Command; startIdx: number } {
if (args[0] && !args[0].startsWith('-')) {
const subcommand = SUBCOMMANDS[args[0]];
if (subcommand) {
return { command: subcommand, startIdx: 1 };
}
}
return { command: 'analyze', startIdx: 0 };
}
function parseFlags(args: string[], startIdx: number, options: CliOptions): void {
for (let i = startIdx; i < args.length; i++) {
const arg = args[i];
// Handle simple flags
const handler = FLAG_HANDLERS[arg];
if (handler) {
handler(options);
continue;
}
// Handle value flags
for (const [prefix, valueHandler] of Object.entries(VALUE_FLAG_HANDLERS)) {
if (arg.startsWith(prefix)) {
const value = arg.slice(prefix.length);
valueHandler(options, value);
break;
}
}
}
}
export function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const options = createDefaultOptions();
const { command, startIdx } = parseSubcommand(args);
options.command = command;
parseFlags(args, startIdx, options);
return options;
}

View File

@ -0,0 +1,156 @@
/**
* CLI Help Messages
*
* Centralized help text for all commands.
*/
export function showHelp(): void {
console.log(`
Playwright Janitor - Static analysis for Playwright test architecture
Usage:
playwright-janitor [command] [options]
Commands:
(default) Run static analysis rules
rules Show detailed information about all rules (for AI agents)
baseline Create/update baseline of known violations
inventory Show codebase inventory (pages, components, flows, tests)
impact Analyze impact of file changes (which tests to run)
method-impact Find tests that use a specific method (e.g., CanvasPage.addNode)
tcr Run TCR (Test && Commit || Revert) workflow
Analysis Options:
--config=<path> Path to janitor.config.js (default: ./janitor.config.js)
--rule=<id> Run a specific rule
--file=<path> Analyze a specific file
--files=<p1,p2> Analyze multiple files (comma-separated)
--json Output as JSON
--verbose, -v Detailed output with suggestions
--fix Preview fixes (dry run)
--fix --write Apply fixes to disk
--list, -l List available rules
--allow-in-expect Skip selector-purity violations inside expect()
--ignore-baseline Show all violations, ignoring .janitor-baseline.json
--help, -h Show this help
Examples:
playwright-janitor # Run all rules
playwright-janitor --rule=dead-code # Run specific rule
playwright-janitor inventory # Show codebase structure
playwright-janitor impact --file=pages/X # Show what tests are affected
playwright-janitor --fix --write # Apply auto-fixes
For command-specific help:
playwright-janitor rules --help
playwright-janitor baseline --help
playwright-janitor inventory --help
playwright-janitor impact --help
playwright-janitor method-impact --help
playwright-janitor tcr --help
`);
}
export function showInventoryHelp(): void {
console.log(`
Inventory - Generate JSON inventory of codebase structure
Usage: playwright-janitor inventory [options]
Options:
--summary, -s Compact output (~500 tokens) - counts, facade props, categories
--category=<name> Filter to single category with method names only
--file=<path> Detailed info for a single file (full method signatures)
Categories: pages, components, composables, services, fixtures, helpers, factories, testData
Examples:
playwright-janitor inventory --summary # AI-friendly overview
playwright-janitor inventory --category=pages # All pages, method names only
playwright-janitor inventory --file=CanvasPage.ts # Full details for one file
playwright-janitor inventory # Full inventory (verbose)
Progressive disclosure for AI:
1. --summary to understand the landscape
2. --category=X to explore a specific area
3. --file=X.ts for detailed method signatures
`);
}
export function showImpactHelp(): void {
console.log(`
Impact - Find affected tests for changed files
Options: --file=<path>, --files=<p1,p2>, --json, --test-list, --verbose
Example: playwright-janitor impact --test-list | xargs npx playwright test
`);
}
export function showMethodImpactHelp(): void {
console.log(`
Method Impact - Find tests using a specific method
Options: --method=<Class.method>, --index, --json, --test-list, --verbose
Example: playwright-janitor method-impact --method=CanvasPage.addNode
`);
}
export function showTcrHelp(): void {
console.log(`
TCR - Test && Commit || Revert workflow
Options:
--execute, -x Actually commit/revert (default: dry run)
--message=<msg> Commit message
--target-branch=<name> Branch to diff against
--max-diff-lines=<n> Skip if diff exceeds N lines
--test-command=<cmd> Test command (files appended)
--json, --verbose
Example: playwright-janitor tcr --execute -m="Fix bug"
`);
}
export function showBaselineHelp(): void {
console.log(`
Baseline - Snapshot current violations for incremental cleanup
Creates .janitor-baseline.json with all current violations. When this file
exists, janitor and TCR only fail on NEW violations not in the baseline.
Usage:
playwright-janitor baseline # Create/update baseline
playwright-janitor baseline --verbose # Show what's being baselined
Workflow:
1. Run 'janitor baseline' to snapshot current state
2. Commit .janitor-baseline.json
3. Now TCR only fails on new violations
4. As you fix violations, re-run 'janitor baseline' to update
Example:
playwright-janitor baseline && git add .janitor-baseline.json
`);
}
export function showRulesHelp(): void {
console.log(`
Rules - Show detailed information about available rules
Usage:
playwright-janitor rules # List all rules with descriptions
playwright-janitor rules --json # Output as JSON (for AI agents)
playwright-janitor rules --verbose # Include what each rule catches
Output includes:
- Rule ID and name
- Description
- Severity (error/warning)
- Whether it's fixable
- What patterns it catches
- Exceptions/allowed patterns
Example:
playwright-janitor rules --json | jq '.[] | select(.id == "selector-purity")'
`);
}

View File

@ -0,0 +1,10 @@
export { parseArgs, type CliOptions, type Command } from './arg-parser.js';
export {
showHelp,
showBaselineHelp,
showImpactHelp,
showInventoryHelp,
showMethodImpactHelp,
showRulesHelp,
showTcrHelp,
} from './help.js';

View File

@ -0,0 +1,228 @@
/**
* Janitor Configuration
*
* This file defines the patterns and conventions for your Playwright test architecture.
* Customize these values to match your project's structure and naming conventions.
*/
import type { FilePatterns, FacadeConfig, RuleSettings, RuleId } from './types.js';
/**
* Complete janitor configuration.
* Use `defineConfig()` to create a config with sensible defaults.
*/
export interface JanitorConfig {
/**
* Root directory for the Playwright test suite (absolute path).
* All patterns are resolved relative to this directory.
*/
rootDir: string;
/**
* Directory patterns for different artifact types.
* Use glob patterns relative to rootDir.
*/
patterns: FilePatterns;
/**
* Files to exclude from page analysis (facades, base classes).
* @example ['BasePage.ts', 'AppPage.ts']
*/
excludeFromPages: string[];
/**
* Facade configuration - the main aggregator that exposes page objects.
* The facade is the central page object that tests interact with.
*/
facade: FacadeConfig;
/**
* The fixture object name used in tests.
* This is the object that provides page objects in test functions.
* @example 'app' for `test('...', async ({ app }) => ...)`
*/
fixtureObjectName: string;
/**
* The API fixture/helper object name.
* This is the object that provides API services.
* @example 'api' for `test('...', async ({ api }) => ...)`
*/
apiFixtureName: string;
/**
* Patterns that indicate raw API calls that should go through services.
* @example [/\brequest\.(get|post)\s*\(/i, /\bfetch\s*\(/]
*/
rawApiPatterns: RegExp[];
/**
* What you call the middle layer between tests and pages.
* Used in error messages and suggestions.
* @example 'Composable', 'Action', 'Flow', 'Scenario'
*/
flowLayerName: string;
/**
* Architecture layers for documentation (top to bottom).
* @example ['Tests', 'Composables', 'Pages', 'BasePage']
*/
architectureLayers: string[];
/**
* Rule-specific configuration.
* Override severity, disable rules, or add allow patterns.
*/
rules: {
[K in RuleId]?: RuleSettings;
};
/** TCR configuration */
tcr: {
/** Test command. File paths will be appended. @default 'npx playwright test' */
testCommand: string;
/** Number of Playwright workers. Always appended to test command. @default 1 */
workerCount?: number;
};
}
/**
* Minimal default configuration.
* Projects should provide their own config via defineConfig().
*/
export const defaultConfig: Omit<JanitorConfig, 'rootDir'> = {
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts', 'utils/**/*.ts'],
factories: ['test-data/**/*.ts', 'factories/**/*.ts'],
testData: ['workflows/**/*', 'expectations/**/*'],
},
excludeFromPages: ['BasePage.ts', 'BaseModal.ts', 'BaseComponent.ts'],
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page', 'APIRequestContext'],
},
fixtureObjectName: 'app',
apiFixtureName: 'api',
rawApiPatterns: [
/\brequest\.(get|post|put|patch|delete|head)\s*\(/i,
/\bfetch\s*\(/,
/\.request\(\s*['"`]/,
],
flowLayerName: 'Composable',
architectureLayers: ['Tests', 'Composables', 'Pages', 'BasePage'],
rules: {
'boundary-protection': { enabled: true, severity: 'error' },
'scope-lockdown': {
enabled: true,
severity: 'error',
navigationMethods: ['goto', 'navigate', 'visit', 'open'],
},
'selector-purity': { enabled: true, severity: 'error', allowInExpect: true },
deduplication: { enabled: true, severity: 'warning' },
'dead-code': { enabled: true, severity: 'warning' },
'no-page-in-flow': {
enabled: true,
severity: 'warning',
allowPatterns: [
/\.page\.keyboard/,
/\.page\.evaluate/,
/\.page\.waitForLoadState/,
/\.page\.waitForURL/,
/\.page\.reload/,
],
},
'api-purity': { enabled: true, severity: 'warning' },
'test-data-hygiene': { enabled: true, severity: 'warning' },
// Opt-in rule: enforces facade pattern for page object access
// Enable in projects that use a facade pattern (e.g., n8n.canvas instead of new CanvasPage())
'no-direct-page-instantiation': { enabled: false, severity: 'error' },
},
tcr: {
testCommand: 'npx playwright test',
},
};
/** Runtime configuration holder */
let currentConfig: JanitorConfig | null = null;
/**
* Input type for defineConfig - rootDir is required, everything else optional.
*/
export type DefineConfigInput = Partial<JanitorConfig> & { rootDir: string };
/**
* Define a janitor configuration (for use in janitor.config.ts files).
* Merges provided config with sensible defaults.
*
* @example
* ```typescript
* export default defineConfig({
* rootDir: __dirname,
* fixtureObjectName: 'app',
* facade: {
* file: 'pages/AppPage.ts',
* className: 'AppPage',
* },
* });
* ```
*/
export function defineConfig(config: DefineConfigInput): JanitorConfig {
return {
...defaultConfig,
...config,
patterns: { ...defaultConfig.patterns, ...config.patterns },
facade: { ...defaultConfig.facade, ...config.facade },
rules: { ...defaultConfig.rules, ...config.rules },
tcr: { ...defaultConfig.tcr, ...config.tcr },
};
}
/**
* Get the current configuration.
* @throws Error if configuration hasn't been set
*/
export function getConfig(): JanitorConfig {
if (!currentConfig) {
throw new Error(
'Janitor configuration not initialized. Call setConfig() or load a janitor.config.ts file first.',
);
}
return currentConfig;
}
/**
* Set the runtime configuration.
*/
export function setConfig(config: JanitorConfig): void {
currentConfig = config;
}
/**
* Check if configuration has been initialized.
*/
export function hasConfig(): boolean {
return currentConfig !== null;
}
/**
* Reset configuration (mainly for testing).
*/
export function resetConfig(): void {
currentConfig = null;
}

View File

@ -0,0 +1,353 @@
import { Project } from 'ts-morph';
import { describe, it, expect } from 'vitest';
/**
* Tests for AST-based method extraction and diffing logic.
*
* Note: We can't easily test the full diffFileMethods() function in unit tests
* because it relies on git. Instead, we test the core extraction logic by
* recreating the method extraction in isolation.
*/
// Helper to extract methods from a source file (mirrors the internal function)
function extractMethods(sourceFile: ReturnType<Project['createSourceFile']>): Map<string, string> {
const methods = new Map<string, string>();
for (const classDecl of sourceFile.getClasses()) {
const className = classDecl.getName() ?? 'AnonymousClass';
for (const method of classDecl.getMethods()) {
const methodName = method.getName();
const key = `${className}.${methodName}`;
const bodyText = method.getText();
methods.set(key, bodyText);
}
}
return methods;
}
// Helper to diff two method maps
function diffMethods(
baseMethods: Map<string, string>,
currentMethods: Map<string, string>,
): Array<{ className: string; methodName: string; changeType: 'added' | 'removed' | 'modified' }> {
const changes: Array<{
className: string;
methodName: string;
changeType: 'added' | 'removed' | 'modified';
}> = [];
// Check for modified or removed methods
for (const [key, baseText] of baseMethods) {
const currentText = currentMethods.get(key);
const [className, methodName] = key.split('.');
if (currentText === undefined) {
changes.push({ className, methodName, changeType: 'removed' });
} else if (currentText !== baseText) {
changes.push({ className, methodName, changeType: 'modified' });
}
}
// Check for added methods
for (const [key] of currentMethods) {
if (!baseMethods.has(key)) {
const [className, methodName] = key.split('.');
changes.push({ className, methodName, changeType: 'added' });
}
}
return changes;
}
describe('AST Diff Analyzer', () => {
describe('Method Extraction', () => {
it('extracts methods from a class', () => {
const project = new Project({ useInMemoryFileSystem: true });
const file = project.createSourceFile(
'test.ts',
`
export class CanvasPage {
async addNode(name: string) {
await this.click(name);
}
async connectNodes(from: string, to: string) {
await this.drag(from, to);
}
get container() {
return this.page.getByTestId('canvas');
}
}
`,
);
const methods = extractMethods(file);
expect(methods.size).toBe(2); // Only methods, not getters
expect(methods.has('CanvasPage.addNode')).toBe(true);
expect(methods.has('CanvasPage.connectNodes')).toBe(true);
});
it('extracts methods from multiple classes', () => {
const project = new Project({ useInMemoryFileSystem: true });
const file = project.createSourceFile(
'test.ts',
`
export class PageA {
methodA() { return 'a'; }
}
export class PageB {
methodB() { return 'b'; }
}
`,
);
const methods = extractMethods(file);
expect(methods.size).toBe(2);
expect(methods.has('PageA.methodA')).toBe(true);
expect(methods.has('PageB.methodB')).toBe(true);
});
it('handles empty class', () => {
const project = new Project({ useInMemoryFileSystem: true });
const file = project.createSourceFile(
'test.ts',
`
export class EmptyPage {
}
`,
);
const methods = extractMethods(file);
expect(methods.size).toBe(0);
});
});
describe('Method Diffing', () => {
it('detects added methods', () => {
const project = new Project({ useInMemoryFileSystem: true });
const baseFile = project.createSourceFile(
'base.ts',
`
export class CanvasPage {
async addNode() {}
}
`,
);
const currentFile = project.createSourceFile(
'current.ts',
`
export class CanvasPage {
async addNode() {}
async deleteNode() {}
}
`,
);
const baseMethods = extractMethods(baseFile);
const currentMethods = extractMethods(currentFile);
const changes = diffMethods(baseMethods, currentMethods);
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
className: 'CanvasPage',
methodName: 'deleteNode',
changeType: 'added',
});
});
it('detects removed methods', () => {
const project = new Project({ useInMemoryFileSystem: true });
const baseFile = project.createSourceFile(
'base.ts',
`
export class CanvasPage {
async addNode() {}
async deleteNode() {}
}
`,
);
const currentFile = project.createSourceFile(
'current.ts',
`
export class CanvasPage {
async addNode() {}
}
`,
);
const baseMethods = extractMethods(baseFile);
const currentMethods = extractMethods(currentFile);
const changes = diffMethods(baseMethods, currentMethods);
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
className: 'CanvasPage',
methodName: 'deleteNode',
changeType: 'removed',
});
});
it('detects modified methods', () => {
const project = new Project({ useInMemoryFileSystem: true });
const baseFile = project.createSourceFile(
'base.ts',
`
export class CanvasPage {
async addNode() {
await this.click('add');
}
}
`,
);
const currentFile = project.createSourceFile(
'current.ts',
`
export class CanvasPage {
async addNode() {
await this.click('add');
await this.waitForAnimation();
}
}
`,
);
const baseMethods = extractMethods(baseFile);
const currentMethods = extractMethods(currentFile);
const changes = diffMethods(baseMethods, currentMethods);
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({
className: 'CanvasPage',
methodName: 'addNode',
changeType: 'modified',
});
});
it('detects no changes when methods are identical', () => {
const project = new Project({ useInMemoryFileSystem: true });
const baseFile = project.createSourceFile(
'base.ts',
`
export class CanvasPage {
async addNode() {
await this.click('add');
}
}
`,
);
const currentFile = project.createSourceFile(
'current.ts',
`
export class CanvasPage {
async addNode() {
await this.click('add');
}
}
`,
);
const baseMethods = extractMethods(baseFile);
const currentMethods = extractMethods(currentFile);
const changes = diffMethods(baseMethods, currentMethods);
expect(changes).toHaveLength(0);
});
it('detects multiple changes', () => {
const project = new Project({ useInMemoryFileSystem: true });
const baseFile = project.createSourceFile(
'base.ts',
`
export class CanvasPage {
async addNode() { return 'old'; }
async deleteNode() {}
async oldMethod() {}
}
`,
);
const currentFile = project.createSourceFile(
'current.ts',
`
export class CanvasPage {
async addNode() { return 'new'; }
async deleteNode() {}
async newMethod() {}
}
`,
);
const baseMethods = extractMethods(baseFile);
const currentMethods = extractMethods(currentFile);
const changes = diffMethods(baseMethods, currentMethods);
expect(changes).toHaveLength(3);
const added = changes.find((c) => c.changeType === 'added');
const removed = changes.find((c) => c.changeType === 'removed');
const modified = changes.find((c) => c.changeType === 'modified');
expect(added?.methodName).toBe('newMethod');
expect(removed?.methodName).toBe('oldMethod');
expect(modified?.methodName).toBe('addNode');
});
it('handles class rename as remove + add', () => {
const project = new Project({ useInMemoryFileSystem: true });
const baseFile = project.createSourceFile(
'base.ts',
`
export class OldPage {
async doSomething() {}
}
`,
);
const currentFile = project.createSourceFile(
'current.ts',
`
export class NewPage {
async doSomething() {}
}
`,
);
const baseMethods = extractMethods(baseFile);
const currentMethods = extractMethods(currentFile);
const changes = diffMethods(baseMethods, currentMethods);
expect(changes).toHaveLength(2);
const added = changes.find((c) => c.changeType === 'added');
const removed = changes.find((c) => c.changeType === 'removed');
expect(added).toEqual({
className: 'NewPage',
methodName: 'doSomething',
changeType: 'added',
});
expect(removed).toEqual({
className: 'OldPage',
methodName: 'doSomething',
changeType: 'removed',
});
});
});
});

View File

@ -0,0 +1,215 @@
/**
* AST Diff Analyzer - Detects which methods changed in a file
*
* Uses ts-morph to compare the current file against git HEAD,
* identifying exactly which methods were added, removed, or modified.
*/
import { execFileSync } from 'node:child_process';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Project, type SourceFile, SyntaxKind } from 'ts-morph';
export interface MethodChange {
className: string;
methodName: string;
changeType: 'added' | 'removed' | 'modified';
}
export interface FileDiffResult {
filePath: string;
changedMethods: MethodChange[];
isNewFile: boolean;
isDeletedFile: boolean;
parseTimeMs: number;
}
/**
* Get the git root directory
*/
function getGitRoot(): string {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf-8',
}).trim();
}
/**
* Get file content from git at a specific ref
*/
function getGitFileContent(filePath: string, ref: string = 'HEAD'): string | null {
try {
// Resolve to absolute path
const absolutePath = path.resolve(filePath);
// Get git root and make path relative to it
const gitRoot = getGitRoot();
const relativePath = path.relative(gitRoot, absolutePath);
return execFileSync('git', ['show', `${ref}:${relativePath}`], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
return null; // File doesn't exist at that ref
}
}
/**
* Extract methods from a source file with their content hashes
*/
function extractMethods(sourceFile: SourceFile): Map<string, string> {
const methods = new Map<string, string>();
// Get all classes
const classes = sourceFile.getClasses();
for (const classDecl of classes) {
const className = classDecl.getName() ?? 'AnonymousClass';
// Get all methods (including getters/setters)
const classMethods = classDecl.getMethods();
for (const method of classMethods) {
const methodName = method.getName();
const key = `${className}.${methodName}`;
// Hash the method body for comparison
const bodyText = method.getText();
const hash = crypto.createHash('md5').update(bodyText).digest('hex');
methods.set(key, hash);
}
// Also check property declarations that are arrow functions
const properties = classDecl.getProperties();
for (const prop of properties) {
const initializer = prop.getInitializer();
if (initializer && initializer.getKind() === SyntaxKind.ArrowFunction) {
const propName = prop.getName();
const key = `${className}.${propName}`;
const bodyText = initializer.getText();
const hash = crypto.createHash('md5').update(bodyText).digest('hex');
methods.set(key, hash);
}
}
}
return methods;
}
/**
* Compare two versions of a file and return changed methods
*/
export function diffFileMethods(filePath: string, baseRef: string = 'HEAD'): FileDiffResult {
const startTime = performance.now();
// Get current file content
const currentProject = new Project({ useInMemoryFileSystem: true });
let currentSource: SourceFile;
try {
const currentContent = fs.readFileSync(filePath, 'utf-8');
currentSource = currentProject.createSourceFile('current.ts', currentContent);
} catch {
// Current file doesn't exist (deleted)
return {
filePath,
changedMethods: [],
isNewFile: false,
isDeletedFile: true,
parseTimeMs: performance.now() - startTime,
};
}
// Get base file content from git
const baseContent = getGitFileContent(filePath, baseRef);
if (baseContent === null) {
// New file - all methods are "added"
const currentMethods = extractMethods(currentSource);
const changedMethods: MethodChange[] = [];
for (const [key] of currentMethods) {
const [className, methodName] = key.split('.');
changedMethods.push({ className, methodName, changeType: 'added' });
}
return {
filePath,
changedMethods,
isNewFile: true,
isDeletedFile: false,
parseTimeMs: performance.now() - startTime,
};
}
// Parse base file
const baseProject = new Project({ useInMemoryFileSystem: true });
const baseSource = baseProject.createSourceFile('base.ts', baseContent);
// Extract methods from both versions
const baseMethods = extractMethods(baseSource);
const currentMethods = extractMethods(currentSource);
// Compare
const changedMethods: MethodChange[] = [];
// Check for modified or removed methods
for (const [key, baseHash] of baseMethods) {
const currentHash = currentMethods.get(key);
const [className, methodName] = key.split('.');
if (currentHash === undefined) {
changedMethods.push({ className, methodName, changeType: 'removed' });
} else if (currentHash !== baseHash) {
changedMethods.push({ className, methodName, changeType: 'modified' });
}
}
// Check for added methods
for (const [key] of currentMethods) {
if (!baseMethods.has(key)) {
const [className, methodName] = key.split('.');
changedMethods.push({ className, methodName, changeType: 'added' });
}
}
return {
filePath,
changedMethods,
isNewFile: false,
isDeletedFile: false,
parseTimeMs: performance.now() - startTime,
};
}
/**
* Format diff result for console output
*/
export function formatDiffConsole(result: FileDiffResult): void {
console.log(`\nFile: ${result.filePath}`);
console.log(`Parse time: ${result.parseTimeMs.toFixed(1)}ms`);
if (result.isNewFile) {
console.log('Status: New file');
} else if (result.isDeletedFile) {
console.log('Status: Deleted file');
}
if (result.changedMethods.length === 0) {
console.log('No method changes detected');
return;
}
console.log(`\nChanged methods (${result.changedMethods.length}):`);
for (const change of result.changedMethods) {
const symbol =
change.changeType === 'added' ? '+' : change.changeType === 'removed' ? '-' : '~';
console.log(` ${symbol} ${change.className}.${change.methodName}`);
}
}
/**
* Format diff result as JSON
*/
export function formatDiffJSON(result: FileDiffResult): string {
return JSON.stringify(result, null, 2);
}

View File

@ -0,0 +1,289 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
generateBaseline,
saveBaseline,
loadBaseline,
hasBaseline,
filterNewViolations,
filterReportByBaseline,
getBaselinePath,
type BaselineFile,
} from './baseline.js';
import type { Violation, JanitorReport } from '../types.js';
describe('baseline', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'janitor-baseline-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getBaselinePath', () => {
it('returns correct path', () => {
const result = getBaselinePath('/some/project');
expect(result).toBe('/some/project/.janitor-baseline.json');
});
});
describe('hasBaseline', () => {
it('returns false when no baseline exists', () => {
expect(hasBaseline(tempDir)).toBe(false);
});
it('returns true when baseline exists', () => {
fs.writeFileSync(path.join(tempDir, '.janitor-baseline.json'), '{}');
expect(hasBaseline(tempDir)).toBe(true);
});
});
describe('generateBaseline', () => {
it('creates baseline from report', () => {
const report: JanitorReport = {
timestamp: '2024-01-01T00:00:00Z',
projectRoot: tempDir,
rules: { enabled: ['dead-code'], disabled: [] },
results: [
{
rule: 'dead-code',
violations: [
{
file: path.join(tempDir, 'pages/TestPage.ts'),
line: 10,
column: 1,
rule: 'dead-code',
message: 'Unused method: oldMethod',
severity: 'warning',
},
],
filesAnalyzed: 1,
executionTimeMs: 10,
},
],
summary: {
totalViolations: 1,
byRule: { 'dead-code': 1 },
bySeverity: { error: 0, warning: 1, info: 0 },
filesAnalyzed: 1,
},
};
const baseline = generateBaseline(report, tempDir);
expect(baseline.version).toBe(1);
expect(baseline.totalViolations).toBe(1);
expect(baseline.violations['pages/TestPage.ts']).toHaveLength(1);
expect(baseline.violations['pages/TestPage.ts'][0].rule).toBe('dead-code');
expect(baseline.violations['pages/TestPage.ts'][0].line).toBe(10);
expect(baseline.violations['pages/TestPage.ts'][0].hash).toBeDefined();
});
});
describe('saveBaseline / loadBaseline', () => {
it('round-trips baseline to disk', () => {
const baseline: BaselineFile = {
version: 1,
generated: '2024-01-01T00:00:00Z',
totalViolations: 2,
violations: {
'pages/TestPage.ts': [
{ rule: 'dead-code', line: 10, message: 'Unused method', hash: 'abc123' },
],
},
};
saveBaseline(baseline, tempDir);
const loaded = loadBaseline(tempDir);
expect(loaded).not.toBeNull();
expect(loaded!.version).toBe(1);
expect(loaded!.totalViolations).toBe(2);
expect(loaded!.violations['pages/TestPage.ts']).toHaveLength(1);
});
it('returns null when no baseline exists', () => {
const loaded = loadBaseline(tempDir);
expect(loaded).toBeNull();
});
});
describe('filterNewViolations', () => {
const baseline: BaselineFile = {
version: 1,
generated: '2024-01-01T00:00:00Z',
totalViolations: 1,
violations: {
'pages/TestPage.ts': [
{
rule: 'dead-code',
line: 10,
message: 'Unused method: oldMethod',
hash: 'abc123', // This hash matches the violation below
},
],
},
};
it('filters out violations in baseline', () => {
// Create a violation that matches the baseline (same rule + message = same hash)
const violations: Violation[] = [
{
file: path.join(tempDir, 'pages/TestPage.ts'),
line: 10,
column: 1,
rule: 'dead-code',
message: 'Unused method: oldMethod',
severity: 'warning',
},
];
// Generate a real baseline from these violations to get matching hashes
const report: JanitorReport = {
timestamp: '',
projectRoot: tempDir,
rules: { enabled: [], disabled: [] },
results: [{ rule: 'dead-code', violations, filesAnalyzed: 1, executionTimeMs: 0 }],
summary: {
totalViolations: 1,
byRule: {},
bySeverity: { error: 0, warning: 0, info: 0 },
filesAnalyzed: 1,
},
};
const realBaseline = generateBaseline(report, tempDir);
// Now filter - should find no new violations
const newViolations = filterNewViolations(violations, realBaseline, tempDir);
expect(newViolations).toHaveLength(0);
});
it('keeps violations not in baseline', () => {
const violations: Violation[] = [
{
file: path.join(tempDir, 'pages/NewPage.ts'),
line: 20,
column: 1,
rule: 'dead-code',
message: 'Unused method: newMethod',
severity: 'warning',
},
];
const newViolations = filterNewViolations(violations, baseline, tempDir);
expect(newViolations).toHaveLength(1);
});
it('matches by hash even if line number changed', () => {
// Same rule + message but different line number
const violations: Violation[] = [
{
file: path.join(tempDir, 'pages/TestPage.ts'),
line: 15, // Line shifted from 10 to 15
column: 1,
rule: 'dead-code',
message: 'Unused method: oldMethod', // Same message
severity: 'warning',
},
];
// Generate baseline with the original violation
const originalViolation: Violation = {
file: path.join(tempDir, 'pages/TestPage.ts'),
line: 10,
column: 1,
rule: 'dead-code',
message: 'Unused method: oldMethod',
severity: 'warning',
};
const report: JanitorReport = {
timestamp: '',
projectRoot: tempDir,
rules: { enabled: [], disabled: [] },
results: [
{
rule: 'dead-code',
violations: [originalViolation],
filesAnalyzed: 1,
executionTimeMs: 0,
},
],
summary: {
totalViolations: 1,
byRule: {},
bySeverity: { error: 0, warning: 0, info: 0 },
filesAnalyzed: 1,
},
};
const realBaseline = generateBaseline(report, tempDir);
// Filter - should match even with different line number
const newViolations = filterNewViolations(violations, realBaseline, tempDir);
expect(newViolations).toHaveLength(0);
});
});
describe('filterReportByBaseline', () => {
it('recalculates summary after filtering', () => {
const violations: Violation[] = [
{
file: path.join(tempDir, 'pages/TestPage.ts'),
line: 10,
column: 1,
rule: 'dead-code',
message: 'Unused method: oldMethod',
severity: 'warning',
},
{
file: path.join(tempDir, 'pages/TestPage.ts'),
line: 20,
column: 1,
rule: 'dead-code',
message: 'Unused method: newMethod',
severity: 'warning',
},
];
const report: JanitorReport = {
timestamp: '2024-01-01T00:00:00Z',
projectRoot: tempDir,
rules: { enabled: ['dead-code'], disabled: [] },
results: [
{
rule: 'dead-code',
violations,
filesAnalyzed: 1,
executionTimeMs: 10,
},
],
summary: {
totalViolations: 2,
byRule: { 'dead-code': 2 },
bySeverity: { error: 0, warning: 2, info: 0 },
filesAnalyzed: 1,
},
};
// Create baseline with only the first violation
const baselineReport: JanitorReport = {
...report,
results: [{ ...report.results[0], violations: [violations[0]] }],
summary: { ...report.summary, totalViolations: 1 },
};
const baseline = generateBaseline(baselineReport, tempDir);
// Filter - should have 1 new violation
const filtered = filterReportByBaseline(report, baseline, tempDir);
expect(filtered.summary.totalViolations).toBe(1);
expect(filtered.results[0].violations).toHaveLength(1);
expect(filtered.results[0].violations[0].message).toBe('Unused method: newMethod');
});
});
});

View File

@ -0,0 +1,204 @@
/**
* Baseline Management
*
* Stores known violations so incremental cleanup doesn't fail on pre-existing issues.
* Only NEW violations (not in baseline) will cause failures.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Violation, JanitorReport } from '../types.js';
const BASELINE_FILENAME = '.janitor-baseline.json';
const BASELINE_VERSION = 1;
export interface BaselineEntry {
rule: string;
line: number;
message: string;
/** Hash of message + context for fuzzy matching when lines shift */
hash: string;
}
export interface BaselineFile {
version: number;
generated: string;
totalViolations: number;
violations: Record<string, BaselineEntry[]>;
}
/**
* Generate a hash for a violation to enable fuzzy matching
* when line numbers shift due to edits above the violation
*/
function hashViolation(violation: Violation): string {
// Hash based on rule + message (not line number)
const content = `${violation.rule}:${violation.message}`;
return crypto.createHash('md5').update(content).digest('hex').slice(0, 12);
}
/**
* Get the baseline file path for a given root directory
*/
export function getBaselinePath(rootDir: string): string {
return path.join(rootDir, BASELINE_FILENAME);
}
/**
* Check if a baseline file exists
*/
export function hasBaseline(rootDir: string): boolean {
return fs.existsSync(getBaselinePath(rootDir));
}
/**
* Load baseline from disk if it exists
*/
export function loadBaseline(rootDir: string): BaselineFile | null {
const baselinePath = getBaselinePath(rootDir);
if (!fs.existsSync(baselinePath)) {
return null;
}
try {
const content = fs.readFileSync(baselinePath, 'utf-8');
const baseline = JSON.parse(content) as BaselineFile;
// Version check for future compatibility
if (baseline.version !== BASELINE_VERSION) {
console.warn(
`Baseline version mismatch (got ${baseline.version}, expected ${BASELINE_VERSION}). Regenerate with: janitor baseline`,
);
}
return baseline;
} catch (error) {
console.warn(`Failed to load baseline: ${(error as Error).message}`);
return null;
}
}
/**
* Generate a baseline from a janitor report
*/
export function generateBaseline(report: JanitorReport, rootDir: string): BaselineFile {
const violations: Record<string, BaselineEntry[]> = {};
for (const result of report.results) {
for (const violation of result.violations) {
// Use relative path for portability
const relativePath = path.relative(rootDir, violation.file);
if (!violations[relativePath]) {
violations[relativePath] = [];
}
violations[relativePath].push({
rule: violation.rule,
line: violation.line,
message: violation.message,
hash: hashViolation(violation),
});
}
}
return {
version: BASELINE_VERSION,
generated: new Date().toISOString(),
totalViolations: report.summary.totalViolations,
violations,
};
}
/**
* Save baseline to disk
*/
export function saveBaseline(baseline: BaselineFile, rootDir: string): void {
const baselinePath = getBaselinePath(rootDir);
fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, '\t') + '\n');
}
/**
* Check if a violation exists in the baseline
* Uses fuzzy matching: same file + same hash (rule + message)
*/
function isInBaseline(violation: Violation, baseline: BaselineFile, rootDir: string): boolean {
const relativePath = path.relative(rootDir, violation.file);
const fileBaseline = baseline.violations[relativePath];
if (!fileBaseline) {
return false;
}
const violationHash = hashViolation(violation);
// Check for matching hash (same rule + message, regardless of line shift)
return fileBaseline.some((entry) => entry.hash === violationHash);
}
/**
* Filter violations to only return NEW ones (not in baseline)
*/
export function filterNewViolations(
violations: Violation[],
baseline: BaselineFile,
rootDir: string,
): Violation[] {
return violations.filter((v) => !isInBaseline(v, baseline, rootDir));
}
/**
* Filter a full report to only include new violations
*/
export function filterReportByBaseline(
report: JanitorReport,
baseline: BaselineFile,
rootDir: string,
): JanitorReport {
const filteredResults = report.results.map((result) => ({
...result,
violations: filterNewViolations(result.violations, baseline, rootDir),
}));
// Recalculate summary
let totalViolations = 0;
const byRule: Record<string, number> = {};
const bySeverity: Record<string, number> = { error: 0, warning: 0, info: 0 };
for (const result of filteredResults) {
totalViolations += result.violations.length;
byRule[result.rule] = result.violations.length;
for (const violation of result.violations) {
bySeverity[violation.severity] = (bySeverity[violation.severity] || 0) + 1;
}
}
return {
...report,
results: filteredResults,
summary: {
...report.summary,
totalViolations,
byRule,
bySeverity: bySeverity as Record<'error' | 'warning' | 'info', number>,
},
};
}
/**
* Format baseline info for console output
*/
export function formatBaselineInfo(baseline: BaselineFile): string {
const lines: string[] = [
`Baseline loaded (${baseline.totalViolations} known violations from ${baseline.generated})`,
];
const fileCount = Object.keys(baseline.violations).length;
lines.push(` Files with violations: ${fileCount}`);
return lines.join('\n');
}

View File

@ -0,0 +1,113 @@
/**
* Facade Resolver
*
* Parses the facade file (e.g., AppPage) to build bidirectional mappings
* between class names and their property names in the facade.
*
* Used by ImpactAnalyzer and MethodUsageAnalyzer to understand the
* relationship between page object classes and how they're accessed.
*/
import * as path from 'node:path';
import { type Project } from 'ts-morph';
import { getConfig } from '../config.js';
import { getRootDir } from '../utils/paths.js';
export interface FacadeMapping {
/** Map from class name to property name(s): "CanvasPage" → ["canvas"] */
classToProperty: Map<string, string[]>;
/** Map from property name to class name: "canvas" → "CanvasPage" */
propertyToClass: Map<string, string>;
/** Absolute path to the facade file */
facadePath: string;
}
/**
* Resolves facade mappings between class types and property names.
* Parses the facade file once and provides both lookup directions.
*/
export class FacadeResolver {
private mapping: FacadeMapping;
constructor(private project: Project) {
this.mapping = this.buildMapping();
}
/** Get property name(s) for a class: "CanvasPage" → ["canvas"] */
getPropertiesForClass(className: string): string[] {
return this.mapping.classToProperty.get(className) ?? [];
}
/** Get class name for a property: "canvas" → "CanvasPage" */
getClassForProperty(propertyName: string): string | undefined {
return this.mapping.propertyToClass.get(propertyName);
}
/** Get the full property→class mapping as a record */
getPropertyToClassMap(): Record<string, string> {
return Object.fromEntries(this.mapping.propertyToClass);
}
/** Get the full class→property mapping */
getClassToPropertyMap(): Map<string, string[]> {
return this.mapping.classToProperty;
}
/** Get the absolute path to the facade file */
getFacadePath(): string {
return this.mapping.facadePath;
}
/** Check if a path is the facade file */
isFacade(absolutePath: string): boolean {
return absolutePath === this.mapping.facadePath;
}
private buildMapping(): FacadeMapping {
const config = getConfig();
const root = getRootDir();
const facadePath = path.join(root, config.facade.file);
const classToProperty = new Map<string, string[]>();
const propertyToClass = new Map<string, string>();
const facadeFile = this.project.getSourceFile(facadePath);
if (!facadeFile) {
console.warn(`Warning: Could not find facade file at ${facadePath}`);
return { classToProperty, propertyToClass, facadePath };
}
const facadeClass = facadeFile.getClass(config.facade.className);
if (!facadeClass) {
console.warn(`Warning: Could not find ${config.facade.className} class`);
return { classToProperty, propertyToClass, facadePath };
}
for (const prop of facadeClass.getProperties()) {
const propName = prop.getName();
const propType = prop.getType();
const typeName = extractTypeName(propType.getText());
if (typeName && !config.facade.excludeTypes.includes(typeName)) {
// class → property (one class can have multiple properties)
const existing = classToProperty.get(typeName) ?? [];
existing.push(propName);
classToProperty.set(typeName, existing);
// property → class (one-to-one)
propertyToClass.set(propName, typeName);
}
}
return { classToProperty, propertyToClass, facadePath };
}
}
/**
* Extract simple type name from potentially complex type string.
* Removes import() paths: import("./CanvasPage").CanvasPage CanvasPage
*/
export function extractTypeName(typeText: string): string {
return typeText.replace(/import\([^)]+\)\./g, '');
}

View File

@ -0,0 +1,657 @@
import { Project } from 'ts-morph';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ImpactAnalyzer } from './impact-analyzer.js';
import { setConfig, resetConfig, defineConfig } from '../config.js';
/**
* Tests for ImpactAnalyzer - import graph tracing and test discovery.
*
* These tests verify that:
* 1. Core/base files affect all tests (via transitive imports)
* 2. Leaf files only affect tests that import them
* 3. Facade pattern is handled correctly
*/
// Create mock functions with proper types
const mockFindFilesRecursive = vi.fn<() => string[]>(() => []);
const mockReadFileSync = vi.fn<(path: string) => string>(() => '');
// Mock fs and path helpers to avoid filesystem access
vi.mock('../utils/paths.js', async () => {
const actual = await vi.importActual('../utils/paths.js');
return {
...actual,
getRootDir: () => '/test-root',
findFilesRecursive: (_dir: string, _suffix: string) => mockFindFilesRecursive(),
};
});
// Mock fs for file reading in findTestsUsingProperties
vi.mock('fs', async () => {
const actual = await vi.importActual('fs');
return {
...actual,
readFileSync: (filePath: string) => mockReadFileSync(filePath),
};
});
describe('ImpactAnalyzer', () => {
let project: Project;
beforeEach(() => {
project = new Project({ useInMemoryFileSystem: true });
setConfig(
defineConfig({
rootDir: '/test-root',
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page', 'APIRequestContext'],
},
}),
);
// Reset mocks
mockFindFilesRecursive.mockReturnValue([]);
mockReadFileSync.mockReturnValue('');
});
afterEach(() => {
resetConfig();
vi.restoreAllMocks();
});
describe('Import Graph Tracing', () => {
it('traces direct imports', () => {
// Setup: TestFile imports CanvasPage
project.createSourceFile(
'/test-root/pages/CanvasPage.ts',
`
export class CanvasPage {
async addNode() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/canvas.spec.ts',
`
import { CanvasPage } from '../pages/CanvasPage';
test('adds node', async () => {
const page = new CanvasPage();
await page.addNode();
});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/CanvasPage.ts']);
expect(result.affectedFiles).toContain('tests/canvas.spec.ts');
});
it('traces transitive imports (A → B → C)', () => {
// Setup: Test imports PageB, PageB imports PageA
// Changing PageA should affect Test
project.createSourceFile(
'/test-root/pages/PageA.ts',
`
export class PageA {
doSomething() {}
}
`,
);
project.createSourceFile(
'/test-root/pages/PageB.ts',
`
import { PageA } from './PageA';
export class PageB extends PageA {
doMore() {}
}
`,
);
project.createSourceFile(
'/test-root/tests/pageB.spec.ts',
`
import { PageB } from '../pages/PageB';
test('does more', async () => {
const page = new PageB();
page.doMore();
});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/PageA.ts']);
// Changing PageA affects PageB, which affects pageB.spec.ts
expect(result.affectedFiles).toContain('pages/PageB.ts');
expect(result.affectedTests).toContain('tests/pageB.spec.ts');
});
it('base file affects all dependent tests', () => {
// Setup: BasePage is imported by multiple pages
// Changing BasePage should affect all tests using those pages
project.createSourceFile(
'/test-root/pages/BasePage.ts',
`
export class BasePage {
protected click(selector: string) {}
}
`,
);
project.createSourceFile(
'/test-root/pages/CanvasPage.ts',
`
import { BasePage } from './BasePage';
export class CanvasPage extends BasePage {
async addNode() { this.click('add'); }
}
`,
);
project.createSourceFile(
'/test-root/pages/WorkflowsPage.ts',
`
import { BasePage } from './BasePage';
export class WorkflowsPage extends BasePage {
async createWorkflow() { this.click('create'); }
}
`,
);
project.createSourceFile(
'/test-root/tests/canvas.spec.ts',
`
import { CanvasPage } from '../pages/CanvasPage';
test('canvas test', () => {});
`,
);
project.createSourceFile(
'/test-root/tests/workflows.spec.ts',
`
import { WorkflowsPage } from '../pages/WorkflowsPage';
test('workflows test', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/BasePage.ts']);
// Both tests should be affected via transitive dependency
expect(result.affectedTests).toContain('tests/canvas.spec.ts');
expect(result.affectedTests).toContain('tests/workflows.spec.ts');
});
it('leaf file only affects its direct consumers', () => {
// Setup: UtilHelper is only used by one test
project.createSourceFile(
'/test-root/utils/helper.ts',
`
export function formatData(data: any) { return data; }
`,
);
project.createSourceFile(
'/test-root/tests/format.spec.ts',
`
import { formatData } from '../utils/helper';
test('formats data', () => {});
`,
);
project.createSourceFile(
'/test-root/tests/other.spec.ts',
`
// Does not import helper
test('other test', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['utils/helper.ts']);
expect(result.affectedTests).toContain('tests/format.spec.ts');
expect(result.affectedTests).not.toContain('tests/other.spec.ts');
});
});
describe('Test File Detection', () => {
it('identifies test files by path pattern', () => {
project.createSourceFile('/test-root/tests/example.spec.ts', "test('example', () => {});");
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['tests/example.spec.ts']);
// When a test file itself changes, it's in the affected tests
expect(result.affectedTests).toContain('tests/example.spec.ts');
});
it('does not mark non-spec files as tests', () => {
// Setup: CanvasPage has no dependents
project.createSourceFile(
'/test-root/pages/CanvasPage.ts',
`
export class CanvasPage {}
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/CanvasPage.ts']);
// CanvasPage.ts is in changedFiles but has no dependents
// affectedFiles contains dependents, not the changed file itself
expect(result.changedFiles).toContain('pages/CanvasPage.ts');
expect(result.affectedTests).not.toContain('pages/CanvasPage.ts');
});
});
describe('Dependency Graph', () => {
it('builds correct dependency graph', () => {
project.createSourceFile(
'/test-root/pages/PageA.ts',
`
export class PageA {}
`,
);
project.createSourceFile(
'/test-root/pages/PageB.ts',
`
import { PageA } from './PageA';
export class PageB {}
`,
);
project.createSourceFile(
'/test-root/tests/test.spec.ts',
`
import { PageB } from '../pages/PageB';
test('test', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/PageA.ts']);
// Graph shows PageA is depended on by PageB, which is depended on by test
expect(result.graph['pages/PageA.ts']).toBeDefined();
expect(result.graph['pages/PageA.ts']).toContain('pages/PageB.ts');
});
});
describe('Facade-Aware Impact Analysis', () => {
it('stops at facade and uses property-based search to find affected tests', () => {
// This is the key integration test for facade-aware impact analysis
// Setup:
// - SecurityPage is imported by AppPage (facade)
// - Multiple tests import the facade
// - Only tests that USE app.security.* should be affected
// Create the page being changed
project.createSourceFile(
'/test-root/pages/SecurityPage.ts',
`
export class SecurityPage {
async runAudit() {}
}
`,
);
// Create the facade that imports the page
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { SecurityPage } from './SecurityPage';
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly page: Page;
readonly security: SecurityPage;
readonly canvas: CanvasPage;
}
`,
);
// Create CanvasPage (not being changed)
project.createSourceFile(
'/test-root/pages/CanvasPage.ts',
`
export class CanvasPage {
async addNode() {}
}
`,
);
// Mock findFilesRecursive to return test files
mockFindFilesRecursive.mockReturnValue([
'/test-root/tests/security.spec.ts',
'/test-root/tests/canvas.spec.ts',
'/test-root/tests/other.spec.ts',
]);
// Mock reading test files - each with different property usages
mockReadFileSync.mockImplementation((filePath) => {
const path = String(filePath);
if (path.includes('security.spec.ts')) {
// Uses app.security.*
return `
import { test } from '@playwright/test';
test('security audit', async ({ app }) => {
await app.security.runAudit();
});
`;
}
if (path.includes('canvas.spec.ts')) {
// Uses app.canvas.* (different page)
return `
import { test } from '@playwright/test';
test('canvas test', async ({ app }) => {
await app.canvas.addNode();
});
`;
}
// other.spec.ts doesn't use any page objects
return `
import { test } from '@playwright/test';
test('other test', async ({ app }) => {
// no page object calls
});
`;
});
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/SecurityPage.ts']);
// CRITICAL: Only security.spec.ts should be affected
// canvas.spec.ts and other.spec.ts should NOT be affected
// even though they would all import the same facade
expect(result.affectedTests).toContain('tests/security.spec.ts');
expect(result.affectedTests).not.toContain('tests/canvas.spec.ts');
expect(result.affectedTests).not.toContain('tests/other.spec.ts');
});
it('base class changes affect all tests via transitive dependency through facade', () => {
// Real n8n pattern: tests → fixtures → facade → pages → BasePage
// Changing BasePage should affect ALL tests, not just ones using specific properties
// BasePage - the core base class
project.createSourceFile(
'/test-root/pages/BasePage.ts',
`
export class BasePage {
protected click(selector: string) {}
}
`,
);
// Pages that extend BasePage
project.createSourceFile(
'/test-root/pages/CanvasPage.ts',
`
import { BasePage } from './BasePage';
export class CanvasPage extends BasePage {
async addNode() { this.click('add'); }
}
`,
);
project.createSourceFile(
'/test-root/pages/SecurityPage.ts',
`
import { BasePage } from './BasePage';
export class SecurityPage extends BasePage {
async runAudit() { this.click('audit'); }
}
`,
);
// Facade imports all pages
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { CanvasPage } from './CanvasPage';
import { SecurityPage } from './SecurityPage';
export class AppPage {
readonly page: Page;
readonly canvas: CanvasPage;
readonly security: SecurityPage;
}
`,
);
// Fixtures import facade (like real n8n)
project.createSourceFile(
'/test-root/fixtures/base.ts',
`
import { AppPage } from '../pages/AppPage';
export const test = { n8n: new AppPage() };
`,
);
// Tests import fixtures
project.createSourceFile(
'/test-root/tests/canvas.spec.ts',
`
import { test } from '../fixtures/base';
test('canvas test', async ({ n8n }) => {
await n8n.canvas.addNode();
});
`,
);
project.createSourceFile(
'/test-root/tests/security.spec.ts',
`
import { test } from '../fixtures/base';
test('security test', async ({ n8n }) => {
await n8n.security.runAudit();
});
`,
);
// Mock findFilesRecursive to return test files for property search
mockFindFilesRecursive.mockReturnValue([
'/test-root/tests/canvas.spec.ts',
'/test-root/tests/security.spec.ts',
]);
// Mock reading test files for property pattern matching
mockReadFileSync.mockImplementation((filePath) => {
const path = String(filePath);
if (path.includes('canvas.spec.ts')) {
return 'await app.canvas.addNode();';
}
if (path.includes('security.spec.ts')) {
return 'await app.security.runAudit();';
}
return '';
});
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/BasePage.ts']);
// BOTH tests should be affected - BasePage is core infrastructure
// When BasePage changes:
// 1. CanvasPage & SecurityPage are dependents (they import BasePage)
// 2. AppPage (facade) imports CanvasPage & SecurityPage
// 3. Facade-aware search finds tests using .canvas. and .security.
// 4. Result: ALL tests are affected (correct!)
expect(result.affectedTests).toContain('tests/canvas.spec.ts');
expect(result.affectedTests).toContain('tests/security.spec.ts');
});
it('fixture file changes affect all tests that import it', () => {
// When fixtures/base.ts changes, all tests should be affected
project.createSourceFile(
'/test-root/fixtures/base.ts',
`
export const test = { n8n: {} };
`,
);
project.createSourceFile(
'/test-root/tests/test1.spec.ts',
`
import { test } from '../fixtures/base';
test('test 1', () => {});
`,
);
project.createSourceFile(
'/test-root/tests/test2.spec.ts',
`
import { test } from '../fixtures/base';
test('test 2', () => {});
`,
);
project.createSourceFile(
'/test-root/tests/standalone.spec.ts',
`
// Does NOT import fixtures/base
import { test } from '@playwright/test';
test('standalone', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['fixtures/base.ts']);
// Tests that import fixtures are affected
expect(result.affectedTests).toContain('tests/test1.spec.ts');
expect(result.affectedTests).toContain('tests/test2.spec.ts');
// Standalone test that doesn't import fixtures is NOT affected
expect(result.affectedTests).not.toContain('tests/standalone.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(
'/test-root/pages/NewFeaturePage.ts',
`
export class NewFeaturePage {
async doThing() {}
}
`,
);
// Facade doesn't include NewFeaturePage
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/NewFeaturePage.ts']);
// Should still work - falls back to camelCase: newFeaturePage
expect(result.changedFiles).toContain('pages/NewFeaturePage.ts');
});
});
describe('Edge Cases', () => {
it('handles file not found gracefully', () => {
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['nonexistent/file.ts']);
// Should not throw, just warn
expect(result.changedFiles).toEqual(['nonexistent/file.ts']);
expect(result.affectedTests).toEqual([]);
});
it('handles circular imports', () => {
// Setup circular dependency: A imports B, B imports A
project.createSourceFile(
'/test-root/pages/CircularA.ts',
`
import { CircularB } from './CircularB';
export class CircularA {}
`,
);
project.createSourceFile(
'/test-root/pages/CircularB.ts',
`
import { CircularA } from './CircularA';
export class CircularB {}
`,
);
project.createSourceFile(
'/test-root/tests/circular.spec.ts',
`
import { CircularA } from '../pages/CircularA';
test('circular', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
// Should not infinite loop
const result = analyzer.analyze(['pages/CircularA.ts']);
expect(result.affectedTests).toContain('tests/circular.spec.ts');
});
it('handles multiple changed files', () => {
project.createSourceFile(
'/test-root/pages/PageA.ts',
`
export class PageA {}
`,
);
project.createSourceFile(
'/test-root/pages/PageB.ts',
`
export class PageB {}
`,
);
project.createSourceFile(
'/test-root/tests/testA.spec.ts',
`
import { PageA } from '../pages/PageA';
test('a', () => {});
`,
);
project.createSourceFile(
'/test-root/tests/testB.spec.ts',
`
import { PageB } from '../pages/PageB';
test('b', () => {});
`,
);
const analyzer = new ImpactAnalyzer(project);
const result = analyzer.analyze(['pages/PageA.ts', 'pages/PageB.ts']);
expect(result.changedFiles).toHaveLength(2);
expect(result.affectedTests).toContain('tests/testA.spec.ts');
expect(result.affectedTests).toContain('tests/testB.spec.ts');
});
});
});

View File

@ -0,0 +1,241 @@
/**
* Impact Analyzer
*
* Analyzes the impact of file changes - which tests need to run?
* Uses import graph tracing with facade-aware property-based search.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { type Project, type SourceFile } from 'ts-morph';
import { FacadeResolver } from './facade-resolver.js';
import { getRootDir, findFilesRecursive, getRelativePath, isTestFile } from '../utils/paths.js';
export interface ImpactResult {
changedFiles: string[];
affectedFiles: string[];
affectedTests: string[];
graph: Record<string, string[]>; // file -> files that depend on it
}
/**
* Analyze the impact of file changes - what tests need to run?
*/
export class ImpactAnalyzer {
private root: string;
private facade: FacadeResolver;
constructor(private project: Project) {
this.root = getRootDir();
this.facade = new FacadeResolver(project);
}
/**
* Given a list of changed files, determine which test files are affected
*/
analyze(changedFiles: string[]): ImpactResult {
const absolutePaths = changedFiles.map((f) =>
path.isAbsolute(f) ? f : path.join(this.root, f),
);
const affectedSet = new Set<string>();
const graph: Record<string, string[]> = {};
// For each changed file, find all files that depend on it
for (const filePath of absolutePaths) {
const sourceFile = this.project.getSourceFile(filePath);
if (!sourceFile) {
console.warn(`Warning: File not found in project: ${filePath}`);
continue;
}
const relativePath = getRelativePath(filePath);
// If the changed file is itself a test, it's affected
if (isTestFile(relativePath)) {
affectedSet.add(filePath);
}
// Find property names this file exposes (for property-based search)
const propertyNames = this.extractPropertyNames(sourceFile);
const dependents = this.findAllDependents(sourceFile, new Set(), propertyNames);
graph[relativePath] = dependents.map((f) => getRelativePath(f));
for (const dep of dependents) {
affectedSet.add(dep);
}
}
// Convert to relative paths and filter
const allAffected = Array.from(affectedSet).map((f) => getRelativePath(f));
const affectedTests = allAffected
.filter((f) => isTestFile(f))
.sort((a, b) => a.localeCompare(b));
return {
changedFiles: absolutePaths.map((f) => getRelativePath(f)),
affectedFiles: allAffected.sort((a, b) => a.localeCompare(b)),
affectedTests,
graph,
};
}
/**
* Extract property names that a file's exports are exposed as in the facade
* Uses the pre-built facade property map for accurate lookup
*/
private extractPropertyNames(file: SourceFile): string[] {
const names: string[] = [];
// Get exported class names and look up their property names in facade
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
const className = classDecl.getName();
if (className) {
// Look up actual property name(s) from facade
const facadeProps = this.facade.getPropertiesForClass(className);
if (facadeProps.length > 0) {
names.push(...facadeProps);
} else {
// Fallback to camelCase conversion if not in facade
const propertyName = className.charAt(0).toLowerCase() + className.slice(1);
names.push(propertyName);
}
}
}
}
return names;
}
/**
* Find all files that depend on a source file
* Stops at facades and switches to property-based search
*/
private findAllDependents(
file: SourceFile,
visited: Set<string>,
propertyNames: string[],
): string[] {
const filePath = file.getFilePath();
if (visited.has(filePath)) {
return [];
}
visited.add(filePath);
const dependents: string[] = [];
// Get direct dependents (files that import this file)
const directDependents = file.getReferencingSourceFiles();
for (const dep of directDependents) {
const depPath = dep.getFilePath();
// 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);
dependents.push(...testsUsingProperty);
continue;
}
// Not a facade - continue normal import tracing
dependents.push(depPath);
// For the next level, track what property this file is exposed as
const nextPropertyNames = this.extractPropertyNames(dep);
const combinedProperties = [...propertyNames, ...nextPropertyNames];
const transitiveDeps = this.findAllDependents(dep, visited, combinedProperties);
dependents.push(...transitiveDeps);
}
return dependents;
}
/**
* Find test files that actually use the given property names
* Uses grep-style search for .propertyName. patterns
*/
private findTestsUsingProperties(propertyNames: string[]): string[] {
if (propertyNames.length === 0) {
return [];
}
const testsDir = path.join(this.root, 'tests');
const matchingTests = new Set<string>();
// Build regex pattern to match property access: .logsPanel. or .logsPanel)
const patterns = propertyNames.map((name) => new RegExp(`\\.${name}[.)]`));
// Recursively find all test files
const testFiles = findFilesRecursive(testsDir, '.spec.ts');
for (const testFile of testFiles) {
try {
const content = fs.readFileSync(testFile, 'utf-8');
for (const pattern of patterns) {
if (pattern.test(content)) {
matchingTests.add(testFile);
break;
}
}
} catch {
// File read error, skip
}
}
return Array.from(matchingTests);
}
}
/**
* Format impact result for console output
*/
export function formatImpactConsole(result: ImpactResult, verbose = false): void {
console.log('\n====================================');
console.log(' IMPACT ANALYSIS REPORT');
console.log('====================================\n');
console.log(`Changed files: ${result.changedFiles.length}`);
result.changedFiles.forEach((f) => console.log(` - ${f}`));
console.log(`\nAffected test files: ${result.affectedTests.length}`);
if (result.affectedTests.length === 0) {
console.log(' (none - changes do not affect any tests)');
} else {
result.affectedTests.forEach((f) => console.log(` - ${f}`));
}
if (verbose) {
console.log(`\nAll affected files: ${result.affectedFiles.length}`);
result.affectedFiles.forEach((f) => console.log(` - ${f}`));
console.log('\nDependency graph:');
for (const [file, deps] of Object.entries(result.graph)) {
if (deps.length > 0) {
console.log(` ${file}:`);
deps.forEach((d) => console.log(`${d}`));
}
}
}
console.log('');
}
/**
* Format impact result as JSON
*/
export function formatImpactJSON(result: ImpactResult): string {
return JSON.stringify(result, null, 2);
}
/**
* Format as simple list of test files (for piping to playwright)
*/
export function formatTestList(result: ImpactResult): string {
return result.affectedTests.join('\n');
}

View File

@ -0,0 +1,421 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { Project } from 'ts-morph';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
InventoryAnalyzer,
formatInventoryJSON,
toSummary,
toCategory,
filterByFile,
} from './inventory-analyzer.js';
import { setConfig, resetConfig, defineConfig } from '../config.js';
/**
* Create a project with the given files written to disk
*/
function createTestProject(tempDir: string, files: Record<string, string>): Project {
// Write files to disk
for (const [filePath, content] of Object.entries(files)) {
const fullPath = path.join(tempDir, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
// Create project
return new Project({
tsConfigFilePath: path.join(tempDir, 'tsconfig.json'),
skipAddingFilesFromTsConfig: false,
});
}
describe('InventoryAnalyzer', () => {
let tempDir: string;
beforeEach(() => {
// Use realpathSync to avoid macOS /var -> /private/var symlink issues
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-test-')));
// Create directory structure
fs.mkdirSync(path.join(tempDir, 'pages', 'components'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'composables'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'tests', 'e2e'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'services'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'workflows'), { recursive: true });
// Create tsconfig.json
fs.writeFileSync(
path.join(tempDir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'node',
strict: true,
},
include: ['**/*.ts'],
}),
);
// Set up config
const config = defineConfig({
rootDir: tempDir,
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts'],
factories: ['factories/**/*.ts'],
testData: ['workflows/**/*'],
},
excludeFromPages: ['BasePage.ts', 'AppPage.ts'],
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page'],
},
fixtureObjectName: 'app',
});
setConfig(config);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
resetConfig();
});
describe('Page Detection', () => {
it('detects page classes', () => {
const project = createTestProject(tempDir, {
'pages/WorkflowPage.ts': `
export class WorkflowPage {
async openWorkflow(name: string) {}
async saveWorkflow() {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.pages.length).toBe(1);
expect(report.pages[0].name).toBe('WorkflowPage');
expect(report.pages[0].methods.length).toBe(2);
});
it('extracts method parameters', () => {
const project = createTestProject(tempDir, {
'pages/CanvasPage.ts': `
export class CanvasPage {
async addNode(type: string, position?: { x: number; y: number }) {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const method = report.pages[0].methods[0];
expect(method.name).toBe('addNode');
expect(method.params.length).toBe(2);
expect(method.params[0].name).toBe('type');
expect(method.params[0].type).toBe('string');
});
it('excludes configured files from pages', () => {
const project = createTestProject(tempDir, {
'pages/BasePage.ts': 'export class BasePage {}',
'pages/AppPage.ts': 'export class AppPage {}',
'pages/RealPage.ts': 'export class RealPage {}',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.pages.length).toBe(1);
expect(report.pages[0].name).toBe('RealPage');
});
});
describe('Component Detection', () => {
it('detects component classes', () => {
const project = createTestProject(tempDir, {
'pages/components/NodePanel.ts': `
export class NodePanel {
async selectNode(name: string) {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.components.length).toBe(1);
expect(report.components[0].name).toBe('NodePanel');
});
});
describe('Composable Detection', () => {
it('detects composable classes', () => {
const project = createTestProject(tempDir, {
'composables/WorkflowComposer.ts': `
export class WorkflowComposer {
async createAndRun(name: string) {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.composables.length).toBe(1);
expect(report.composables[0].name).toBe('WorkflowComposer');
});
});
describe('Service Detection', () => {
it('detects service classes', () => {
const project = createTestProject(tempDir, {
'services/ApiHelper.ts': `
export class ApiHelper {
async createWorkflow(data: object) {}
async deleteWorkflow(id: string) {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.services.length).toBe(1);
expect(report.services[0].name).toBe('ApiHelper');
expect(report.services[0].methods.length).toBe(2);
});
});
describe('Test Data Detection', () => {
it('detects workflow files', () => {
fs.writeFileSync(
path.join(tempDir, 'workflows', 'simple-webhook.json'),
JSON.stringify({ name: 'Simple Webhook' }),
);
fs.writeFileSync(
path.join(tempDir, 'workflows', 'complex-flow.json'),
JSON.stringify({ name: 'Complex Flow' }),
);
const project = new Project({ useInMemoryFileSystem: true });
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.testData.length).toBe(2);
});
});
describe('Summary', () => {
it('calculates correct counts', () => {
const project = createTestProject(tempDir, {
'pages/PageA.ts': 'export class PageA { method1() {} method2() {} }',
'pages/PageB.ts': 'export class PageB { method1() {} }',
'pages/components/CompA.ts': 'export class CompA { method1() {} }',
'composables/FlowA.ts': 'export class FlowA { method1() {} }',
'services/ServiceA.ts': 'export class ServiceA { method1() {} }',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
expect(report.summary.pages).toBe(2);
expect(report.summary.components).toBe(1);
expect(report.summary.composables).toBe(1);
expect(report.summary.services).toBe(1);
});
});
describe('JSON Output', () => {
it('produces valid JSON', () => {
const project = createTestProject(tempDir, {
'pages/TestPage.ts': 'export class TestPage { doSomething() {} }',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const json = formatInventoryJSON(report);
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
expect(() => JSON.parse(json) as unknown).not.toThrow();
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
const parsed = JSON.parse(json) as { pages?: unknown; summary?: unknown };
expect(parsed.pages).toBeDefined();
expect(parsed.summary).toBeDefined();
});
});
describe('toSummary', () => {
it('produces compact output with counts and categories', () => {
const project = createTestProject(tempDir, {
'pages/PageA.ts': 'export class PageA { method1() {} }',
'pages/PageB.ts': 'export class PageB { method1() {} }',
'composables/FlowA.ts': 'export class FlowA { method1() {} }',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const summary = toSummary(report);
expect(summary.counts.pages).toBe(2);
expect(summary.counts.composables).toBe(1);
expect(summary.categories).toContain('pages');
expect(summary.categories).toContain('composables');
});
it('includes facade property names when facade exists', () => {
const project = createTestProject(tempDir, {
'pages/AppPage.ts': `
import { CanvasPage } from './CanvasPage';
export class AppPage {
canvas = new CanvasPage();
settings = new SettingsPage();
}
`,
'pages/CanvasPage.ts': 'export class CanvasPage {}',
'pages/SettingsPage.ts': 'export class SettingsPage {}',
});
// Update config to use this facade
const config = defineConfig({
rootDir: tempDir,
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts'],
factories: ['factories/**/*.ts'],
testData: ['workflows/**/*'],
},
excludeFromPages: ['AppPage.ts'],
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page'],
},
fixtureObjectName: 'app',
});
setConfig(config);
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const summary = toSummary(report);
expect(summary.facade).toContain('canvas');
expect(summary.facade).toContain('settings');
});
});
describe('toCategory', () => {
it('returns category items with method names only', () => {
const project = createTestProject(tempDir, {
'pages/CanvasPage.ts': `
export class CanvasPage {
async addNode(type: string) {}
async deleteNode(id: string) {}
private helperMethod() {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const view = toCategory(report, 'pages');
expect(view.category).toBe('pages');
expect(view.items.length).toBe(1);
expect(view.items[0].name).toBe('CanvasPage');
expect(view.items[0].methods).toEqual(['addNode', 'deleteNode']);
});
it('handles testData category without methods', () => {
fs.writeFileSync(
path.join(tempDir, 'workflows', 'test-workflow.json'),
JSON.stringify({ name: 'Test' }),
);
const project = new Project({ useInMemoryFileSystem: true });
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const view = toCategory(report, 'testData');
expect(view.category).toBe('testData');
expect(view.items.length).toBe(1);
expect(view.items[0].name).toBe('test-workflow.json');
expect(view.items[0].methods).toBeUndefined();
});
});
describe('filterByFile', () => {
it('finds page by filename', () => {
const project = createTestProject(tempDir, {
'pages/CanvasPage.ts': `
export class CanvasPage {
async addNode(type: string) {}
}
`,
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const result = filterByFile(report, 'CanvasPage.ts');
expect(result).not.toBeNull();
expect(result?.name).toBe('CanvasPage');
// Page objects have methods array
expect((result as { methods?: unknown[] })?.methods?.length).toBe(1);
});
it('finds page by class name', () => {
const project = createTestProject(tempDir, {
'pages/CanvasPage.ts': 'export class CanvasPage { method1() {} }',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const result = filterByFile(report, 'CanvasPage');
expect(result).not.toBeNull();
expect(result?.name).toBe('CanvasPage');
});
it('returns null for non-existent file', () => {
const project = createTestProject(tempDir, {
'pages/CanvasPage.ts': 'export class CanvasPage {}',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const result = filterByFile(report, 'NonExistent.ts');
expect(result).toBeNull();
});
it('finds composables', () => {
const project = createTestProject(tempDir, {
'composables/WorkflowComposer.ts': 'export class WorkflowComposer { create() {} }',
});
const analyzer = new InventoryAnalyzer(project);
const report = analyzer.generate();
const result = filterByFile(report, 'WorkflowComposer');
expect(result).not.toBeNull();
expect(result?.name).toBe('WorkflowComposer');
});
});
});

View File

@ -0,0 +1,828 @@
/**
* Inventory Analyzer - Generates inventory of Playwright test codebase
*/
import { glob } from 'glob';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
SyntaxKind,
type Project,
type SourceFile,
type ClassDeclaration,
type MethodDeclaration,
type PropertyDeclaration,
type FunctionDeclaration,
type CallExpression,
} from 'ts-morph';
import { getConfig } from '../config.js';
import { FacadeResolver } from './facade-resolver.js';
import { getSourceFiles } from './project-loader.js';
import { getRootDir, getRelativePath } from '../utils/paths.js';
export interface ParameterInfo {
name: string;
type: string;
optional: boolean;
}
export interface MethodInfo {
name: string;
params: ParameterInfo[];
returnType: string;
async: boolean;
description?: string;
}
export interface PropertyInfo {
name: string;
type: string;
readonly: boolean;
}
export interface PageInfo {
name: string;
file: string;
container: string | null;
methods: MethodInfo[];
properties: PropertyInfo[];
extendsClass?: string;
}
export interface ComponentInfo extends PageInfo {
mountedIn: string[];
}
export interface ServiceMethodInfo extends MethodInfo {
httpMethod?: string;
endpoint?: string;
}
export interface ServiceInfo {
name: string;
file: string;
methods: ServiceMethodInfo[];
}
export interface FixtureInfo {
name: string;
file: string;
provides: Record<string, string>;
extendsFixture?: string;
}
export interface HelperInfo {
name: string;
file: string;
functions: MethodInfo[];
}
export interface FactoryInfo {
name: string;
file: string;
methods: MethodInfo[];
builds: string;
}
export interface ComposableInfo {
name: string;
file: string;
methods: MethodInfo[];
usesPages: string[];
}
export interface TestDataInfo {
name: string;
file: string;
category: string;
sizeKb: number;
}
export interface FacadePropertyInfo {
property: string;
type: string;
}
export interface FacadeInfo {
file: string;
className: string;
exposedPages: FacadePropertyInfo[];
unexposedPages: string[];
}
export interface InventoryReport {
timestamp: string;
rootDir: string;
summary: {
pages: number;
components: number;
composables: number;
services: number;
fixtures: number;
helpers: number;
factories: number;
testData: number;
};
facade: FacadeInfo | null;
pages: PageInfo[];
components: ComponentInfo[];
composables: ComposableInfo[];
services: ServiceInfo[];
fixtures: FixtureInfo[];
helpers: HelperInfo[];
factories: FactoryInfo[];
testData: TestDataInfo[];
}
/** Compact summary for AI consumption - minimal tokens */
export interface InventorySummary {
counts: {
pages: number;
components: number;
composables: number;
services: number;
helpers: number;
testData: number;
};
facade: string[] | null;
categories: string[];
}
export type InventoryCategory =
| 'pages'
| 'components'
| 'composables'
| 'services'
| 'fixtures'
| 'helpers'
| 'factories'
| 'testData';
/** Filtered view of a single category - method names only */
export interface InventoryCategoryView {
category: InventoryCategory;
items: Array<{
name: string;
file: string;
methods?: string[];
}>;
}
export class InventoryAnalyzer {
private root: string;
private facade: FacadeResolver;
constructor(private project: Project) {
this.root = getRootDir();
this.facade = new FacadeResolver(project);
}
generate(): InventoryReport {
const pages = this.analyzePages();
const components = this.analyzeComponents(pages);
const composables = this.analyzeComposables();
const services = this.analyzeServices();
const fixtures = this.analyzeFixtures();
const helpers = this.analyzeHelpers();
const factories = this.analyzeFactories();
const testData = this.analyzeTestData();
const facade = this.analyzeFacade(pages);
return {
timestamp: new Date().toISOString(),
rootDir: this.root,
summary: {
pages: pages.length,
components: components.length,
composables: composables.length,
services: services.length,
fixtures: fixtures.length,
helpers: helpers.length,
factories: factories.length,
testData: testData.length,
},
facade,
pages,
components,
composables,
services,
fixtures,
helpers,
factories,
testData,
};
}
private analyzePages(): PageInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.pages);
const pages: PageInfo[] = [];
for (const file of files) {
const filePath = getRelativePath(file.getFilePath());
if (filePath.includes('/components/')) continue;
const shouldExclude = config.excludeFromPages.some((exclude) => filePath.endsWith(exclude));
if (shouldExclude) continue;
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
pages.push(this.extractPageInfo(classDecl, file));
}
}
}
return pages.sort((a, b) => a.name.localeCompare(b.name));
}
private extractPageInfo(classDecl: ClassDeclaration, file: SourceFile): PageInfo {
return {
name: classDecl.getName() ?? 'Anonymous',
file: getRelativePath(file.getFilePath()),
container: this.extractContainer(classDecl),
methods: this.extractMethods(classDecl),
properties: this.extractProperties(classDecl),
extendsClass: classDecl.getExtends()?.getText(),
};
}
private extractContainer(classDecl: ClassDeclaration): string | null {
const getter = classDecl.getGetAccessor('container');
if (getter) {
const body = getter.getBody()?.getText() ?? '';
const containerPattern = /getByTestId\(['"]([^'"]+)['"]\)/;
const match = containerPattern.exec(body);
return match ? `getByTestId('${match[1]}')` : 'custom';
}
const prop = classDecl.getProperty('container');
if (prop) {
const initializer = prop.getInitializer()?.getText();
return initializer ?? 'defined';
}
const method = classDecl.getMethod('getContainer');
if (method) return 'getContainer()';
return null;
}
private analyzeComponents(_pages: PageInfo[]): ComponentInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.components);
const components: ComponentInfo[] = [];
for (const file of files) {
const filePath = getRelativePath(file.getFilePath());
if (filePath.endsWith('BaseModal.ts') || filePath.endsWith('BaseComponent.ts')) continue;
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
const componentName = classDecl.getName() ?? '';
const mountedIn = this.findComponentUsage(componentName);
components.push({
...this.extractPageInfo(classDecl, file),
mountedIn,
});
}
}
}
return components.sort((a, b) => a.name.localeCompare(b.name));
}
private findComponentUsage(componentName: string): string[] {
const config = getConfig();
const usedIn: string[] = [];
const pageFiles = getSourceFiles(this.project, config.patterns.pages);
for (const file of pageFiles) {
const filePath = getRelativePath(file.getFilePath());
if (filePath.includes('/components/')) continue;
const content = file.getText();
if (content.includes(`new ${componentName}(`)) {
for (const classDecl of file.getClasses()) {
const pageName = classDecl.getName();
if (pageName && !usedIn.includes(pageName)) {
usedIn.push(pageName);
}
}
}
}
return usedIn.sort();
}
private analyzeComposables(): ComposableInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.flows);
const composables: ComposableInfo[] = [];
for (const file of files) {
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
const composableInfo = this.extractComposableInfo(classDecl, file);
composables.push(composableInfo);
}
}
}
return composables.sort((a, b) => a.name.localeCompare(b.name));
}
private extractComposableInfo(classDecl: ClassDeclaration, file: SourceFile): ComposableInfo {
return {
name: classDecl.getName() ?? 'Anonymous',
file: getRelativePath(file.getFilePath()),
methods: this.extractMethods(classDecl),
usesPages: this.findPageObjectUsage(classDecl),
};
}
private findPageObjectUsage(classDecl: ClassDeclaration): string[] {
const config = getConfig();
const usedPages = new Set<string>();
const content = classDecl.getText();
const pattern = new RegExp(`this\\.${config.fixtureObjectName}\\.(\\w+)`, 'g');
const matches = content.matchAll(pattern);
for (const match of matches) {
const pageName = match[1];
// 'page' is raw Playwright, not a page object
if (pageName !== 'page') {
usedPages.add(pageName);
}
}
return Array.from(usedPages).sort();
}
private analyzeServices(): ServiceInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.services);
const services: ServiceInfo[] = [];
for (const file of files) {
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
services.push(this.extractServiceInfo(classDecl, file));
}
}
}
return services.sort((a, b) => a.name.localeCompare(b.name));
}
private extractServiceInfo(classDecl: ClassDeclaration, file: SourceFile): ServiceInfo {
const methods = classDecl
.getMethods()
.filter((m) => !m.hasModifier(SyntaxKind.PrivateKeyword))
.map((m) => this.extractServiceMethod(m));
return {
name: classDecl.getName() ?? 'Anonymous',
file: getRelativePath(file.getFilePath()),
methods,
};
}
private extractServiceMethod(method: MethodDeclaration): ServiceMethodInfo {
const name = method.getName();
const baseInfo = this.extractMethodInfo(method);
const httpMethod = this.inferHttpMethod(name);
const endpoint = this.extractEndpoint(method);
return { ...baseInfo, httpMethod, endpoint };
}
private inferHttpMethod(methodName: string): string | undefined {
const lower = methodName.toLowerCase();
if (lower.startsWith('get') || lower.startsWith('fetch') || lower.startsWith('list'))
return 'GET';
if (lower.startsWith('create') || lower.startsWith('add') || lower.startsWith('post'))
return 'POST';
if (lower.startsWith('update') || lower.startsWith('edit') || lower.startsWith('put'))
return 'PUT';
if (lower.startsWith('delete') || lower.startsWith('remove')) return 'DELETE';
return undefined;
}
private extractEndpoint(method: MethodDeclaration): string | undefined {
const jsDocs = method.getJsDocs();
const endpointPattern = /@endpoint\s+(\S+)/;
for (const doc of jsDocs) {
const text = doc.getText();
const match = endpointPattern.exec(text);
if (match) return match[1];
}
const body = method.getBody()?.getText() ?? '';
const urlPattern = /['"`](\/api\/[^'"`]+)['"`]/;
const urlMatch = urlPattern.exec(body);
if (urlMatch) return urlMatch[1];
return undefined;
}
private analyzeFixtures(): FixtureInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.fixtures);
const fixtures: FixtureInfo[] = [];
for (const file of files) {
const filePath = getRelativePath(file.getFilePath());
const extendCalls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of extendCalls) {
const expr = call.getExpression().getText();
if (expr.endsWith('.extend')) {
const fixtureInfo = this.extractFixtureInfo(call, file);
if (fixtureInfo) fixtures.push(fixtureInfo);
}
}
for (const varDecl of file.getVariableDeclarations()) {
if (varDecl.isExported() && varDecl.getName().toLowerCase().includes('fixture')) {
fixtures.push({ name: varDecl.getName(), file: filePath, provides: {} });
}
}
}
return fixtures;
}
private extractFixtureInfo(call: CallExpression, file: SourceFile): FixtureInfo | null {
const args = call.getArguments();
if (args.length === 0) return null;
const firstArg = args[0];
const provides: Record<string, string> = {};
if (firstArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
const objLit = firstArg.asKind(SyntaxKind.ObjectLiteralExpression);
for (const prop of objLit?.getProperties() ?? []) {
if (prop.getKind() === SyntaxKind.PropertyAssignment) {
const propAssign = prop.asKind(SyntaxKind.PropertyAssignment);
const name = propAssign?.getName() ?? '';
const init = propAssign?.getInitializer()?.getText() ?? '';
provides[name] = this.inferFixtureType(init);
}
}
}
return { name: 'test', file: getRelativePath(file.getFilePath()), provides };
}
private inferFixtureType(initializerText: string): string {
const newPattern = /new\s+(\w+)/;
const newMatch = newPattern.exec(initializerText);
if (newMatch) return newMatch[1];
return 'unknown';
}
private analyzeHelpers(): HelperInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.helpers);
const helpers: HelperInfo[] = [];
for (const file of files) {
const functions: MethodInfo[] = [];
for (const func of file.getFunctions()) {
if (func.isExported()) {
functions.push(this.extractFunctionInfo(func));
}
}
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
functions.push(...this.extractMethods(classDecl));
}
}
if (functions.length > 0) {
helpers.push({
name: this.getClassName(file) ?? path.basename(file.getFilePath(), '.ts'),
file: getRelativePath(file.getFilePath()),
functions,
});
}
}
return helpers.sort((a, b) => a.name.localeCompare(b.name));
}
private getClassName(file: SourceFile): string | undefined {
return file.getClasses()[0]?.getName();
}
private extractFunctionInfo(func: FunctionDeclaration): MethodInfo {
return {
name: func.getName() ?? 'anonymous',
params: func.getParameters().map((p) => ({
name: p.getName(),
type: this.simplifyType(p.getType().getText()),
optional: p.isOptional(),
})),
returnType: this.simplifyType(func.getReturnType().getText()),
async: func.isAsync(),
description: this.extractJsDocDescription(func),
};
}
private analyzeFactories(): FactoryInfo[] {
const config = getConfig();
const files = getSourceFiles(this.project, config.patterns.factories);
const factories: FactoryInfo[] = [];
for (const file of files) {
for (const classDecl of file.getClasses()) {
if (classDecl.isExported()) {
const name = classDecl.getName() ?? '';
factories.push({
name,
file: getRelativePath(file.getFilePath()),
methods: this.extractMethods(classDecl),
builds: this.inferFactoryProduct(classDecl),
});
}
}
for (const func of file.getFunctions()) {
if (func.isExported() && func.getName()?.toLowerCase().includes('factory')) {
factories.push({
name: func.getName() ?? 'anonymous',
file: getRelativePath(file.getFilePath()),
methods: [this.extractFunctionInfo(func)],
builds: this.simplifyType(func.getReturnType().getText()),
});
}
}
}
return factories.sort((a, b) => a.name.localeCompare(b.name));
}
private inferFactoryProduct(classDecl: ClassDeclaration): string {
const buildMethod = classDecl.getMethod('build') ?? classDecl.getMethod('create');
if (buildMethod) return this.simplifyType(buildMethod.getReturnType().getText());
const name = classDecl.getName() ?? '';
if (name.endsWith('Factory')) return name.replace('Factory', '');
if (name.endsWith('Builder')) return name.replace('Builder', '');
return 'unknown';
}
private analyzeTestData(): TestDataInfo[] {
const config = getConfig();
const testData: TestDataInfo[] = [];
for (const pattern of config.patterns.testData) {
try {
const files = glob.sync(pattern, { cwd: this.root, nodir: true });
for (const file of files) {
const absolutePath = path.join(this.root, file);
try {
const stats = fs.statSync(absolutePath);
testData.push({
name: path.basename(file),
file,
category: path.dirname(file),
sizeKb: Math.round((stats.size / 1024) * 10) / 10,
});
} catch {
// Skip files that can't be read
}
}
} catch {
// Skip patterns that fail
}
}
return testData.sort((a, b) => a.name.localeCompare(b.name));
}
private analyzeFacade(pages: PageInfo[]): FacadeInfo | null {
const config = getConfig();
const classToPropertyMap = this.facade.getClassToPropertyMap();
// No facade configured or found
if (classToPropertyMap.size === 0) {
return null;
}
// Build exposed pages from facade resolver
const exposedPages: FacadePropertyInfo[] = [];
const exposedTypes = new Set<string>();
for (const [className, properties] of classToPropertyMap) {
exposedTypes.add(className);
for (const property of properties) {
exposedPages.push({ property, type: className });
}
}
// Find pages not exposed on the facade
const unexposedPages = pages
.filter((page) => !exposedTypes.has(page.name))
.map((page) => page.name)
.sort();
return {
file: config.facade.file,
className: config.facade.className,
exposedPages: exposedPages.sort((a, b) => a.property.localeCompare(b.property)),
unexposedPages,
};
}
private extractMethods(classDecl: ClassDeclaration): MethodInfo[] {
return classDecl
.getMethods()
.filter((m) => !m.hasModifier(SyntaxKind.PrivateKeyword))
.map((m) => this.extractMethodInfo(m));
}
private extractMethodInfo(method: MethodDeclaration): MethodInfo {
return {
name: method.getName(),
params: method.getParameters().map((p) => ({
name: p.getName(),
type: this.simplifyType(p.getType().getText()),
optional: p.isOptional(),
})),
returnType: this.simplifyType(method.getReturnType().getText()),
async: method.isAsync(),
description: this.extractJsDocDescription(method),
};
}
private extractProperties(classDecl: ClassDeclaration): PropertyInfo[] {
const properties: PropertyInfo[] = [];
for (const prop of classDecl.getProperties()) {
if (!prop.hasModifier(SyntaxKind.PrivateKeyword)) {
properties.push(this.extractPropertyInfo(prop));
}
}
for (const getter of classDecl.getGetAccessors()) {
if (!getter.hasModifier(SyntaxKind.PrivateKeyword)) {
properties.push({
name: getter.getName(),
type: this.simplifyType(getter.getReturnType().getText()),
readonly: true,
});
}
}
return properties;
}
private extractPropertyInfo(prop: PropertyDeclaration): PropertyInfo {
return {
name: prop.getName(),
type: this.simplifyType(prop.getType().getText()),
readonly: prop.hasModifier(SyntaxKind.ReadonlyKeyword),
};
}
private extractJsDocDescription(
node: MethodDeclaration | FunctionDeclaration,
): string | undefined {
const jsDocs = node.getJsDocs();
if (jsDocs.length === 0) return undefined;
return jsDocs[0].getDescription()?.trim() || undefined;
}
private simplifyType(type: string): string {
let simplified = type.replace(/import\([^)]+\)\./g, '');
simplified = simplified.replace(/Promise<(.+)>/, '$1');
if (simplified.length > 50) simplified = simplified.slice(0, 47) + '...';
return simplified;
}
}
export function formatInventoryJSON(inventory: InventoryReport): string {
return JSON.stringify(inventory, null, 2);
}
export function formatInventorySummaryJSON(summary: InventorySummary): string {
return JSON.stringify(summary, null, 2);
}
export function formatInventoryCategoryJSON(view: InventoryCategoryView): string {
return JSON.stringify(view, null, 2);
}
/** Extract compact summary from full report */
export function toSummary(report: InventoryReport): InventorySummary {
return {
counts: {
pages: report.summary.pages,
components: report.summary.components,
composables: report.summary.composables,
services: report.summary.services,
helpers: report.summary.helpers,
testData: report.summary.testData,
},
facade: report.facade?.exposedPages.map((p) => p.property) ?? null,
categories: ['pages', 'components', 'composables', 'services', 'helpers', 'testData'],
};
}
/** Extract single category with method names only */
export function toCategory(
report: InventoryReport,
category: InventoryCategory,
): InventoryCategoryView {
const extractMethods = (
items: Array<{ name: string; file: string; methods?: MethodInfo[] }>,
): InventoryCategoryView['items'] =>
items.map((item) => ({
name: item.name,
file: item.file,
methods: item.methods?.map((m) => m.name),
}));
switch (category) {
case 'pages':
return { category, items: extractMethods(report.pages) };
case 'components':
return { category, items: extractMethods(report.components) };
case 'composables':
return { category, items: extractMethods(report.composables) };
case 'services':
return { category, items: extractMethods(report.services) };
case 'helpers':
return {
category,
items: report.helpers.map((h) => ({
name: h.name,
file: h.file,
methods: h.functions.map((f) => f.name),
})),
};
case 'fixtures':
return {
category,
items: report.fixtures.map((f) => ({ name: f.name, file: f.file })),
};
case 'factories':
return { category, items: extractMethods(report.factories) };
case 'testData':
return {
category,
items: report.testData.map((t) => ({ name: t.name, file: t.file })),
};
}
}
/** All possible return types from filterByFile */
export type InventoryItem =
| PageInfo
| ComponentInfo
| ComposableInfo
| ServiceInfo
| HelperInfo
| FixtureInfo
| FactoryInfo
| TestDataInfo;
/** Filter report to single file - returns detailed info for that file only */
export function filterByFile(report: InventoryReport, fileName: string): InventoryItem | null {
const normalizedName = fileName
.replace(/^.*\//, '')
.replace(/\.ts$/, '')
.replace(/\.json$/, '');
const collections: Array<{ items: Array<{ name: string; file: string }> }> = [
{ items: report.pages },
{ items: report.components },
{ items: report.composables },
{ items: report.services },
{ items: report.helpers },
{ items: report.fixtures },
{ items: report.factories },
{ items: report.testData },
];
for (const { items } of collections) {
for (const item of items) {
if (item.file.includes(fileName) || item.name === normalizedName) {
return item as InventoryItem;
}
}
}
return null;
}

View File

@ -0,0 +1,517 @@
import { Project } from 'ts-morph';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MethodUsageAnalyzer } from './method-usage-analyzer.js';
import { setConfig, resetConfig, defineConfig } from '../config.js';
/**
* Tests for MethodUsageAnalyzer - fixture mapping and method usage tracking.
*
* These tests verify that:
* 1. Fixture properties are correctly mapped to class names from facade
* 2. Method calls in tests are correctly identified and tracked
* 3. Method impact analysis returns correct affected test files
*/
// Create mock functions with proper types
const mockFindFilesRecursive = vi.fn<() => string[]>(() => []);
const mockReadFileSync = vi.fn<(path: string) => string>(() => '');
// Mock fs and path helpers to avoid filesystem access
vi.mock('../utils/paths.js', async () => {
const actual = await vi.importActual('../utils/paths.js');
return {
...actual,
getRootDir: () => '/test-root',
findFilesRecursive: (_dir: string, _suffix: string) => mockFindFilesRecursive(),
};
});
// Mock fs for file reading in extractMethodUsages
vi.mock('fs', async () => {
const actual = await vi.importActual('fs');
return {
...actual,
readFileSync: (filePath: string) => mockReadFileSync(filePath),
};
});
describe('MethodUsageAnalyzer', () => {
let project: Project;
beforeEach(() => {
project = new Project({ useInMemoryFileSystem: true });
setConfig(
defineConfig({
rootDir: '/test-root',
fixtureObjectName: 'app',
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page', 'APIRequestContext'],
},
}),
);
// Reset mocks
mockFindFilesRecursive.mockReturnValue([]);
mockReadFileSync.mockReturnValue('');
});
afterEach(() => {
resetConfig();
vi.restoreAllMocks();
});
describe('Fixture Mapping Extraction', () => {
it('extracts fixture property to class mapping from facade', () => {
// Create the facade file
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page } from '@playwright/test';
import { CanvasPage } from './CanvasPage';
import { WorkflowsPage } from './WorkflowsPage';
import { NodeEditorPage } from './NodeEditorPage';
export class AppPage {
readonly page: Page;
readonly canvas: CanvasPage;
readonly workflows: WorkflowsPage;
readonly nodeEditor: NodeEditorPage;
}
`,
);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
// Verify fixture mappings (excluding 'page' which is in excludeTypes)
expect(index.fixtureMapping.canvas).toBe('CanvasPage');
expect(index.fixtureMapping.workflows).toBe('WorkflowsPage');
expect(index.fixtureMapping.nodeEditor).toBe('NodeEditorPage');
expect(index.fixtureMapping.page).toBeUndefined();
});
it('handles empty facade gracefully', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
export class AppPage {
// No properties
}
`,
);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
expect(Object.keys(index.fixtureMapping)).toHaveLength(0);
});
it('filters excluded types from mapping', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { Page, APIRequestContext } from '@playwright/test';
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly page: Page;
readonly request: APIRequestContext;
readonly canvas: CanvasPage;
}
`,
);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
// Only canvas should be mapped (page and request are excluded)
expect(Object.keys(index.fixtureMapping)).toContain('canvas');
expect(Object.keys(index.fixtureMapping)).not.toContain('page');
expect(Object.keys(index.fixtureMapping)).not.toContain('request');
});
});
describe('Method Usage Extraction', () => {
it('extracts method calls from test files', () => {
// Setup facade
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
// Mock finding test files
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
// Mock reading the test file
mockReadFileSync.mockReturnValue(`
import { test, expect } from '@playwright/test';
test('adds a node', async ({ app }) => {
await app.canvas.addNode('Manual Trigger');
await app.canvas.connectNodes('A', 'B');
});
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
expect(index.methods['CanvasPage.addNode']).toBeDefined();
expect(index.methods['CanvasPage.addNode']).toHaveLength(1);
expect(index.methods['CanvasPage.connectNodes']).toBeDefined();
expect(index.methods['CanvasPage.connectNodes']).toHaveLength(1);
});
it('tracks multiple usages of same method', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
mockReadFileSync.mockReturnValue(`
test('workflow test', async ({ app }) => {
await app.canvas.addNode('Trigger');
await app.canvas.addNode('HTTP');
await app.canvas.addNode('Set');
});
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
expect(index.methods['CanvasPage.addNode']).toHaveLength(3);
});
it('tracks usages across multiple test files', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue([
'/test-root/tests/canvas.spec.ts',
'/test-root/tests/nodes.spec.ts',
]);
mockReadFileSync.mockImplementation((filePath: unknown) => {
if (String(filePath).includes('canvas.spec.ts')) {
return `
test('canvas test', async ({ app }) => {
await app.canvas.addNode('Trigger');
});
`;
} else {
return `
test('nodes test', async ({ app }) => {
await app.canvas.addNode('HTTP');
});
`;
}
});
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
const usages = index.methods['CanvasPage.addNode'];
expect(usages).toHaveLength(2);
const testFiles = new Set(usages.map((u) => u.testFile));
expect(testFiles.size).toBe(2);
});
it('captures line and column information', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
mockReadFileSync.mockReturnValue(`line1
line2
await app.canvas.addNode('Trigger');
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
const usage = index.methods['CanvasPage.addNode'][0];
expect(usage.line).toBe(3);
expect(usage.column).toBeGreaterThan(0);
});
it('captures full call text', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
mockReadFileSync.mockReturnValue(`
await app.canvas.addNode('Manual Trigger');
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
const usage = index.methods['CanvasPage.addNode'][0];
expect(usage.fullCall).toContain('app.canvas.addNode');
expect(usage.fullCall).toContain('Manual Trigger');
});
it('ignores unknown fixture properties', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
// 'unknown' is not in the fixture mapping
mockReadFileSync.mockReturnValue(`
await app.unknown.someMethod();
await app.canvas.addNode();
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
// Only CanvasPage.addNode should be tracked
expect(Object.keys(index.methods)).toEqual(['CanvasPage.addNode']);
});
});
describe('Method Impact Analysis', () => {
it('returns affected test files for a method', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue([
'/test-root/tests/canvas.spec.ts',
'/test-root/tests/other.spec.ts',
]);
mockReadFileSync.mockImplementation((filePath: unknown) => {
if (String(filePath).includes('canvas.spec.ts')) {
return "await app.canvas.addNode('X');";
} else {
return '// no canvas calls';
}
});
const analyzer = new MethodUsageAnalyzer(project);
const result = analyzer.getMethodImpact('CanvasPage.addNode');
expect(result.className).toBe('CanvasPage');
expect(result.methodName).toBe('addNode');
expect(result.affectedTestFiles).toContain('tests/canvas.spec.ts');
expect(result.affectedTestFiles).not.toContain('tests/other.spec.ts');
});
it('returns empty when method has no usages', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue([]);
const analyzer = new MethodUsageAnalyzer(project);
const result = analyzer.getMethodImpact('CanvasPage.unusedMethod');
expect(result.usages).toHaveLength(0);
expect(result.affectedTestFiles).toHaveLength(0);
});
it('throws on invalid method format', () => {
project.createSourceFile('/test-root/pages/AppPage.ts', 'export class AppPage {}');
mockFindFilesRecursive.mockReturnValue([]);
const analyzer = new MethodUsageAnalyzer(project);
expect(() => analyzer.getMethodImpact('invalidFormat')).toThrow('Invalid format');
});
});
describe('List Used Methods', () => {
it('returns methods sorted by usage count', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
import { WorkflowsPage } from './WorkflowsPage';
export class AppPage {
readonly canvas: CanvasPage;
readonly workflows: WorkflowsPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
mockReadFileSync.mockReturnValue(`
app.canvas.addNode('A');
app.canvas.addNode('B');
app.canvas.addNode('C');
app.workflows.create();
`);
const analyzer = new MethodUsageAnalyzer(project);
const methods = analyzer.listUsedMethods();
expect(methods[0].method).toBe('CanvasPage.addNode');
expect(methods[0].usageCount).toBe(3);
expect(methods[1].method).toBe('WorkflowsPage.create');
expect(methods[1].usageCount).toBe(1);
});
});
describe('Edge Cases', () => {
it('handles missing facade file gracefully', () => {
// Don't create the facade file
mockFindFilesRecursive.mockReturnValue([]);
const analyzer = new MethodUsageAnalyzer(project);
// Should not throw, just return empty index
const index = analyzer.buildIndex();
expect(Object.keys(index.fixtureMapping)).toHaveLength(0);
expect(Object.keys(index.methods)).toHaveLength(0);
});
it('handles file read errors gracefully', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
mockReadFileSync.mockImplementation(() => {
throw new Error('File not found');
});
const analyzer = new MethodUsageAnalyzer(project);
// Should not throw
const index = analyzer.buildIndex();
expect(Object.keys(index.methods)).toHaveLength(0);
});
it('handles nested parentheses in method calls', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
mockReadFileSync.mockReturnValue(`
await app.canvas.addNode(getNodeName('trigger', { type: 'manual' }));
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
const usage = index.methods['CanvasPage.addNode'][0];
expect(usage.fullCall).toContain('app.canvas.addNode');
// The full call should include the nested parens
expect(usage.fullCall).toContain('getNodeName');
});
it('handles multiple fixture calls on same line', () => {
project.createSourceFile(
'/test-root/pages/AppPage.ts',
`
import { CanvasPage } from './CanvasPage';
export class AppPage {
readonly canvas: CanvasPage;
}
`,
);
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
mockReadFileSync.mockReturnValue(`
await app.canvas.addNode('A'); await app.canvas.deleteNode('B');
`);
const analyzer = new MethodUsageAnalyzer(project);
const index = analyzer.buildIndex();
expect(index.methods['CanvasPage.addNode']).toHaveLength(1);
expect(index.methods['CanvasPage.deleteNode']).toHaveLength(1);
});
});
});

View File

@ -0,0 +1,241 @@
/**
* Method Usage Analyzer - Builds index of page object method usages in tests
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { type Project } from 'ts-morph';
import { getConfig } from '../config.js';
import { FacadeResolver } from './facade-resolver.js';
import { getRootDir, findFilesRecursive, getRelativePath } from '../utils/paths.js';
export interface MethodUsage {
testFile: string;
line: number;
column: number;
fullCall: string;
}
export interface MethodUsageIndex {
methods: Record<string, MethodUsage[]>;
fixtureMapping: Record<string, string>;
timestamp: string;
testFilesAnalyzed: number;
}
export interface MethodImpactResult {
className: string;
methodName: string;
usages: MethodUsage[];
affectedTestFiles: string[];
}
export class MethodUsageAnalyzer {
private facade: FacadeResolver;
private fixtureMapping: Record<string, string> = {};
constructor(project: Project) {
this.facade = new FacadeResolver(project);
}
buildIndex(): MethodUsageIndex {
this.fixtureMapping = this.facade.getPropertyToClassMap();
const testFiles = this.findTestFiles();
const methods: Record<string, MethodUsage[]> = {};
for (const testFile of testFiles) {
const usages = this.extractMethodUsages(testFile);
for (const usage of usages) {
const key = usage.key;
if (!methods[key]) methods[key] = [];
methods[key].push({
testFile: usage.testFile,
line: usage.line,
column: usage.column,
fullCall: usage.fullCall,
});
}
}
return {
methods,
fixtureMapping: this.fixtureMapping,
timestamp: new Date().toISOString(),
testFilesAnalyzed: testFiles.length,
};
}
getMethodImpact(classAndMethod: string): MethodImpactResult {
const [className, methodName] = this.parseClassMethod(classAndMethod);
const index = this.buildIndex();
const key = `${className}.${methodName}`;
const usages = index.methods[key] || [];
const affectedTestFiles = [...new Set(usages.map((u) => u.testFile))].sort((a, b) =>
a.localeCompare(b),
);
return { className, methodName, usages, affectedTestFiles };
}
listUsedMethods(): Array<{ method: string; usageCount: number; testFileCount: number }> {
const index = this.buildIndex();
return Object.entries(index.methods)
.map(([method, usages]) => ({
method,
usageCount: usages.length,
testFileCount: new Set(usages.map((u) => u.testFile)).size,
}))
.sort((a, b) => b.usageCount - a.usageCount);
}
private findTestFiles(): string[] {
const root = getRootDir();
const testsDir = path.join(root, 'tests');
return findFilesRecursive(testsDir, '.spec.ts');
}
private extractMethodUsages(
testFilePath: string,
): Array<{ key: string; testFile: string; line: number; column: number; fullCall: string }> {
const usages: Array<{
key: string;
testFile: string;
line: number;
column: number;
fullCall: string;
}> = [];
const config = getConfig();
const relativePath = getRelativePath(testFilePath);
try {
const content = fs.readFileSync(testFilePath, 'utf-8');
const lines = content.split('\n');
const fixtureObjectName = config.fixtureObjectName;
const pattern = new RegExp(`${fixtureObjectName}\\.(\\w+)\\.(\\w+)\\s*\\(`, 'g');
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
let match: RegExpExecArray | null;
pattern.lastIndex = 0;
while ((match = pattern.exec(line)) !== null) {
const propertyName = match[1];
const methodName = match[2];
const className = this.fixtureMapping[propertyName];
if (!className) continue;
const callStart = match.index;
const fullCall = this.extractFullCall(line, callStart);
usages.push({
key: `${className}.${methodName}`,
testFile: relativePath,
line: lineIndex + 1,
column: match.index + 1,
fullCall,
});
}
}
} catch {
// Skip files that can't be read
}
return usages;
}
private extractFullCall(line: string, startIndex: number): string {
let depth = 0;
let endIndex = startIndex;
for (let i = startIndex; i < line.length; i++) {
const char = line[i];
if (char === '(') depth++;
else if (char === ')') {
depth--;
if (depth === 0) {
endIndex = i + 1;
break;
}
}
}
if (endIndex === startIndex) endIndex = Math.min(startIndex + 60, line.length);
const call = line.slice(startIndex, endIndex).trim();
return call.length > 80 ? call.slice(0, 77) + '...' : call;
}
private parseClassMethod(input: string): [string, string] {
const parts = input.split('.');
if (parts.length === 2) return [parts[0], parts[1]];
throw new Error(
`Invalid format: "${input}". Expected "ClassName.methodName" (e.g., "CanvasPage.addNode")`,
);
}
}
export function formatMethodImpactConsole(result: MethodImpactResult, verbose = false): void {
console.log('\n====================================');
console.log(' METHOD IMPACT ANALYSIS');
console.log('====================================\n');
console.log(`Method: ${result.className}.${result.methodName}()`);
console.log(`Total usages: ${result.usages.length}`);
console.log(`Affected test files: ${result.affectedTestFiles.length}`);
if (result.affectedTestFiles.length === 0) {
console.log('\n (no test files use this method)');
} else {
console.log('\nAffected tests:');
for (const testFile of result.affectedTestFiles) {
const usagesInFile = result.usages.filter((u) => u.testFile === testFile);
console.log(` - ${testFile} (${usagesInFile.length} usages)`);
if (verbose) {
for (const usage of usagesInFile) {
console.log(` L${usage.line}: ${usage.fullCall}`);
}
}
}
}
console.log('');
}
export function formatMethodImpactJSON(result: MethodImpactResult): string {
return JSON.stringify(result, null, 2);
}
export function formatMethodImpactTestList(result: MethodImpactResult): string {
return result.affectedTestFiles.join('\n');
}
export function formatMethodUsageIndexConsole(index: MethodUsageIndex, limit = 20): void {
console.log('\n====================================');
console.log(' METHOD USAGE INDEX');
console.log('====================================\n');
console.log(`Test files analyzed: ${index.testFilesAnalyzed}`);
console.log(`Unique methods tracked: ${Object.keys(index.methods).length}`);
console.log(`Fixture properties mapped: ${Object.keys(index.fixtureMapping).length}`);
console.log('\nTop used methods:');
const sorted = Object.entries(index.methods)
.map(([method, usages]) => ({ method, count: usages.length }))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
for (const { method, count } of sorted) {
console.log(` ${method.padEnd(50)} ${count} usages`);
}
console.log('');
}
export function formatMethodUsageIndexJSON(index: MethodUsageIndex): string {
return JSON.stringify(index, null, 2);
}

View File

@ -0,0 +1,142 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createProject, createInMemoryProject } from './project-loader.js';
describe('project-loader', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'janitor-project-loader-test-'));
// Create a minimal tsconfig.json
const tsconfig = {
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'node',
strict: true,
},
include: ['**/*.ts'],
};
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('createProject', () => {
it('creates a project with the given root directory', () => {
const { project, root } = createProject(tempDir);
expect(root).toBe(tempDir);
expect(project).toBeDefined();
});
it('returns a ts-morph Project instance', () => {
const { project } = createProject(tempDir);
// Verify it has ts-morph Project methods
expect(typeof project.getSourceFiles).toBe('function');
expect(typeof project.addSourceFilesAtPaths).toBe('function');
expect(typeof project.createSourceFile).toBe('function');
});
it('can load source files from the project directory', () => {
// Create a test file
const pagesDir = path.join(tempDir, 'pages');
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(path.join(pagesDir, 'TestPage.ts'), 'export class TestPage { foo() {} }');
const { project } = createProject(tempDir);
// Add source files matching the glob
const files = project.addSourceFilesAtPaths(path.join(tempDir, 'pages/**/*.ts'));
expect(files.length).toBe(1);
expect(files[0].getFilePath()).toContain('TestPage.ts');
});
it('project can parse TypeScript classes and methods', () => {
// Create a test file with a class
fs.writeFileSync(
path.join(tempDir, 'TestClass.ts'),
`export class TestClass {
publicMethod() { return 1; }
private privateMethod() { return 2; }
}`,
);
const { project } = createProject(tempDir);
const files = project.addSourceFilesAtPaths(path.join(tempDir, '*.ts'));
expect(files.length).toBe(1);
const classes = files[0].getClasses();
expect(classes.length).toBe(1);
expect(classes[0].getName()).toBe('TestClass');
const methods = classes[0].getMethods();
expect(methods.length).toBe(2);
});
});
describe('createInMemoryProject', () => {
it('creates an in-memory project for testing', () => {
const project = createInMemoryProject();
expect(project).toBeDefined();
expect(typeof project.getSourceFiles).toBe('function');
});
it('can add and retrieve source files', () => {
const project = createInMemoryProject();
const sourceFile = project.createSourceFile('test.ts', 'export const foo = 42;');
expect(sourceFile).toBeDefined();
expect(sourceFile.getFilePath()).toContain('test.ts');
const files = project.getSourceFiles();
expect(files.length).toBe(1);
});
it('can parse TypeScript AST in memory', () => {
const project = createInMemoryProject();
const sourceFile = project.createSourceFile(
'pages/TestPage.ts',
`export class TestPage {
container = this.page.locator('.container');
async click() { await this.container.click(); }
}`,
);
const classes = sourceFile.getClasses();
expect(classes.length).toBe(1);
expect(classes[0].getName()).toBe('TestPage');
const properties = classes[0].getProperties();
expect(properties.length).toBe(1);
expect(properties[0].getName()).toBe('container');
});
it('supports multiple files with imports', () => {
const project = createInMemoryProject();
project.createSourceFile('base.ts', 'export class BasePage { protected page: any; }');
project.createSourceFile(
'derived.ts',
`import { BasePage } from './base';
export class DerivedPage extends BasePage {}`,
);
const files = project.getSourceFiles();
expect(files.length).toBe(2);
});
});
});

View File

@ -0,0 +1,71 @@
import * as path from 'node:path';
import { Project, type SourceFile } from 'ts-morph';
import { getConfig } from '../config.js';
import { getRelativePath as getRelPath } from '../utils/paths.js';
export interface ProjectContext {
project: Project;
root: string;
}
/**
* Initialize ts-morph Project with the configured root directory
*/
export function createProject(rootDir?: string): ProjectContext {
const root = rootDir ?? getConfig().rootDir;
const tsconfigPath = path.join(root, 'tsconfig.json');
const project = new Project({
tsConfigFilePath: tsconfigPath,
});
return {
project,
root,
};
}
/**
* Create an in-memory project for testing
*/
export function createInMemoryProject(): Project {
return new Project({ useInMemoryFileSystem: true });
}
/**
* Get source files matching the given glob patterns
* @param project - ts-morph Project instance
* @param globs - Array of glob patterns relative to root
* @returns Matching source files
*/
export function getSourceFiles(project: Project, globs: string[]): SourceFile[] {
const root = getConfig().rootDir;
const absoluteGlobs = globs.map((glob) => path.join(root, glob));
// Add files to project if not already present, then return them
// This ensures files matching globs are loaded even if not in tsconfig
const addedFiles = project.addSourceFilesAtPaths(absoluteGlobs);
// Also get any existing files that match (in case already loaded via tsconfig)
const existingFiles: SourceFile[] = [];
for (const glob of absoluteGlobs) {
existingFiles.push(...project.getSourceFiles(glob));
}
// Deduplicate
const uniqueFiles = new Map<string, SourceFile>();
for (const file of [...addedFiles, ...existingFiles]) {
uniqueFiles.set(file.getFilePath(), file);
}
return Array.from(uniqueFiles.values());
}
/**
* Get the relative path from root directory
* Re-exported for convenience
*/
export function getRelativePath(filePath: string): string {
return getRelPath(filePath);
}

View File

@ -0,0 +1,199 @@
import type { JanitorReport, Violation, FixResult } from '../types.js';
import { getRelativePath } from './project-loader.js';
/**
* Output report as JSON (for LLM consumption)
*/
export function toJSON(report: JanitorReport): string {
// Convert absolute paths to relative for cleaner output
const cleanedReport = {
...report,
results: report.results.map((result) => ({
...result,
violations: result.violations.map((v) => ({
...v,
file: getRelativePath(v.file),
})),
})),
};
return JSON.stringify(cleanedReport, null, 2);
}
/**
* Output report to console (human-readable)
*/
export function toConsole(report: JanitorReport, verbose = false): void {
const { summary, results } = report;
// Header
console.log('\n====================================');
console.log(' JANITOR ANALYSIS REPORT');
console.log('====================================\n');
console.log(`Timestamp: ${report.timestamp}`);
console.log(`Files analyzed: ${summary.filesAnalyzed}`);
console.log(`Rules enabled: ${report.rules.enabled.join(', ') || 'none'}`);
if (report.rules.disabled.length > 0) {
console.log(`Rules disabled: ${report.rules.disabled.join(', ')}`);
}
console.log('');
// Summary
if (summary.totalViolations === 0) {
console.log('All clean! No violations found.\n');
return;
}
console.log(`Found ${summary.totalViolations} violation(s)\n`);
// By severity
console.log('By severity:');
if (summary.bySeverity.error > 0) {
console.log(` Errors: ${summary.bySeverity.error}`);
}
if (summary.bySeverity.warning > 0) {
console.log(` Warnings: ${summary.bySeverity.warning}`);
}
if (summary.bySeverity.info > 0) {
console.log(` Info: ${summary.bySeverity.info}`);
}
console.log('');
// By rule
console.log('By rule:');
for (const [rule, count] of Object.entries(summary.byRule)) {
if (count > 0) {
console.log(` ${rule}: ${count}`);
}
}
console.log('');
// Detailed violations
if (verbose) {
console.log('------------------------------------');
console.log('DETAILED VIOLATIONS');
console.log('------------------------------------\n');
for (const result of results) {
if (result.violations.length === 0) continue;
console.log(
`${result.rule} (${result.violations.length} violations, ${result.executionTimeMs}ms)`,
);
console.log('');
for (const violation of result.violations) {
printViolation(violation);
}
console.log('');
}
} else {
// Non-verbose: group by file
const byFile = new Map<string, Violation[]>();
for (const result of results) {
for (const violation of result.violations) {
const relativePath = getRelativePath(violation.file);
if (!byFile.has(relativePath)) {
byFile.set(relativePath, []);
}
byFile.get(relativePath)!.push(violation);
}
}
console.log('------------------------------------');
console.log('VIOLATIONS BY FILE');
console.log('------------------------------------\n');
for (const [file, violations] of byFile) {
console.log(`${file} (${violations.length})`);
for (const v of violations) {
const icon = getSeverityIcon(v.severity);
console.log(` ${icon} L${v.line}: [${v.rule}] ${v.message}`);
}
console.log('');
}
console.log('Use --verbose for detailed output with suggestions.\n');
}
}
function printViolation(v: Violation): void {
const relativePath = getRelativePath(v.file);
const icon = getSeverityIcon(v.severity);
console.log(` ${icon} ${relativePath}:${v.line}:${v.column}`);
console.log(` ${v.message}`);
if (v.suggestion) {
console.log(` Suggestion: ${v.suggestion}`);
}
console.log('');
}
function getSeverityIcon(severity: string): string {
switch (severity) {
case 'error':
return '[ERR]';
case 'warning':
return '[WARN]';
case 'info':
return '[INFO]';
default:
return '[?]';
}
}
/**
* Output fix results to console
*/
export function printFixResults(report: JanitorReport, write: boolean): void {
const allFixes: FixResult[] = [];
for (const result of report.results) {
if (result.fixes) {
allFixes.push(...result.fixes);
}
}
if (allFixes.length === 0) {
console.log('\nNo fixable violations found.\n');
return;
}
console.log('\n------------------------------------');
console.log(write ? ' FIXES APPLIED' : ' FIX PREVIEW (dry run)');
console.log('------------------------------------\n');
for (const fix of allFixes) {
const icon = fix.applied ? '[OK]' : '[PREVIEW]';
const actionLabel = getActionLabel(fix.action);
const target = fix.target ? ` ${fix.target}` : '';
console.log(` ${icon} ${actionLabel}${target}`);
console.log(` ${fix.file}`);
}
console.log('');
if (!write) {
console.log(`${allFixes.length} fix(es) would be applied.`);
console.log('Use --fix --write to apply changes.\n');
} else {
console.log(`${allFixes.length} fix(es) applied.`);
console.log('Run: pnpm typecheck && pnpm lint\n');
}
}
function getActionLabel(action: FixResult['action']): string {
switch (action) {
case 'remove-file':
return 'DELETE FILE';
case 'remove-method':
return 'REMOVE METHOD';
case 'remove-property':
return 'REMOVE PROPERTY';
case 'edit':
return 'EDIT';
default:
return action;
}
}

View File

@ -0,0 +1,278 @@
import * as path from 'node:path';
import type { Project, SourceFile } from 'ts-morph';
import type { BaseRule } from '../rules/base-rule.js';
import type {
JanitorReport,
RuleResult,
Severity,
RunOptions,
RuleConfig,
RuleInfo,
} from '../types.js';
import { getSourceFiles } from './project-loader.js';
import { getConfig } from '../config.js';
export class RuleRunner {
private rules: Map<string, BaseRule> = new Map();
private enabledRules: Set<string> = new Set();
private ruleConfigs: Map<string, RuleConfig> = new Map();
/**
* Register a rule with the runner
*/
registerRule(rule: BaseRule): void {
this.rules.set(rule.id, rule);
this.enabledRules.add(rule.id);
}
/**
* Enable a specific rule
*/
enableRule(ruleId: string): void {
if (this.rules.has(ruleId)) {
this.enabledRules.add(ruleId);
}
}
/**
* Disable a specific rule
*/
disableRule(ruleId: string): void {
this.enabledRules.delete(ruleId);
}
/**
* Enable only specific rules (disable all others)
*/
enableOnly(ruleIds: string[]): void {
this.enabledRules.clear();
for (const id of ruleIds) {
if (this.rules.has(id)) {
this.enabledRules.add(id);
}
}
}
/**
* Get all registered rule IDs
*/
getRegisteredRules(): string[] {
return Array.from(this.rules.keys());
}
/**
* Check if a rule is fixable
*/
isRuleFixable(ruleId: string): boolean {
return this.rules.get(ruleId)?.fixable ?? false;
}
/**
* Get enabled rule IDs
*/
getEnabledRules(): string[] {
return Array.from(this.enabledRules);
}
/**
* Get disabled rule IDs
*/
getDisabledRules(): string[] {
return Array.from(this.rules.keys()).filter((id) => !this.enabledRules.has(id));
}
/**
* Get detailed information about all registered rules
*/
getRuleDetails(): RuleInfo[] {
return Array.from(this.rules.values()).map((rule) => ({
id: rule.id,
name: rule.name,
description: rule.description,
severity: rule.severity,
fixable: rule.fixable,
enabled: this.enabledRules.has(rule.id),
targetGlobs: rule.getTargetGlobs(),
}));
}
/**
* Get detailed information about a specific rule
*/
getRuleInfo(ruleId: string): RuleInfo | undefined {
const rule = this.rules.get(ruleId);
if (!rule) return undefined;
return {
id: rule.id,
name: rule.name,
description: rule.description,
severity: rule.severity,
fixable: rule.fixable,
enabled: this.enabledRules.has(rule.id),
targetGlobs: rule.getTargetGlobs(),
};
}
/**
* Configure a specific rule
*/
configureRule(ruleId: string, config: RuleConfig): void {
this.ruleConfigs.set(ruleId, config);
const rule = this.rules.get(ruleId);
if (rule) {
rule.configure(config);
}
}
/**
* Get files for a rule, optionally filtered by specific file paths
*/
private getFilesForRule(project: Project, rule: BaseRule, targetFiles?: string[]): SourceFile[] {
const root = getConfig().rootDir;
if (targetFiles && targetFiles.length > 0) {
// Filter to only the specified files that match rule's globs
const ruleGlobs = rule.getTargetGlobs();
const allRuleFiles = getSourceFiles(project, ruleGlobs);
const allRuleFilePaths = new Set(allRuleFiles.map((f) => f.getFilePath()));
// Resolve target files to absolute paths and filter
return targetFiles
.map((f) => {
const absolutePath = path.isAbsolute(f) ? f : path.join(root, f);
return project.getSourceFile(absolutePath);
})
.filter((f): f is SourceFile => f !== undefined && allRuleFilePaths.has(f.getFilePath()));
}
return getSourceFiles(project, rule.getTargetGlobs());
}
/**
* Run all enabled rules and return results
*/
run(project: Project, projectRoot: string, options?: RunOptions): JanitorReport {
const results: RuleResult[] = [];
const allFilesAnalyzed = new Set<string>();
// Apply rule configurations from options
if (options?.ruleConfig) {
for (const [ruleId, config] of Object.entries(options.ruleConfig)) {
this.configureRule(ruleId, config);
}
}
for (const ruleId of this.enabledRules) {
const rule = this.rules.get(ruleId);
if (!rule) continue;
// Get files for this rule (optionally filtered)
const files = this.getFilesForRule(project, rule, options?.files);
// Track all files analyzed
for (const file of files) {
allFilesAnalyzed.add(file.getFilePath());
}
// Execute the rule
const result = rule.execute(project, files);
// Apply fixes if in fix mode and rule supports it
if (options?.fix && rule.fixable) {
const fixableViolations = result.violations.filter((v) => v.fixable);
if (fixableViolations.length > 0) {
result.fixes = rule.fix(project, fixableViolations, options.write ?? false);
}
}
results.push(result);
}
// Build summary
const summary = this.buildSummary(results, allFilesAnalyzed.size);
return {
timestamp: new Date().toISOString(),
projectRoot,
rules: {
enabled: this.getEnabledRules(),
disabled: this.getDisabledRules(),
},
results,
summary,
};
}
/**
* Run a specific rule by ID
*/
runRule(
project: Project,
projectRoot: string,
ruleId: string,
options?: RunOptions,
): JanitorReport | null {
const rule = this.rules.get(ruleId);
if (!rule) {
return null;
}
// Apply rule configuration from options
if (options?.ruleConfig?.[ruleId]) {
rule.configure(options.ruleConfig[ruleId]);
}
const files = this.getFilesForRule(project, rule, options?.files);
const result = rule.execute(project, files);
// Apply fixes if in fix mode and rule supports it
if (options?.fix && rule.fixable) {
const fixableViolations = result.violations.filter((v) => v.fixable);
if (fixableViolations.length > 0) {
result.fixes = rule.fix(project, fixableViolations, options.write ?? false);
}
}
const summary = this.buildSummary([result], files.length);
return {
timestamp: new Date().toISOString(),
projectRoot,
rules: {
enabled: [ruleId],
disabled: this.getRegisteredRules().filter((id) => id !== ruleId),
},
results: [result],
summary,
};
}
private buildSummary(results: RuleResult[], filesAnalyzed: number): JanitorReport['summary'] {
const byRule: Record<string, number> = {};
const bySeverity: Record<Severity, number> = {
error: 0,
warning: 0,
info: 0,
};
let totalViolations = 0;
for (const result of results) {
byRule[result.rule] = result.violations.length;
totalViolations += result.violations.length;
for (const violation of result.violations) {
bySeverity[violation.severity]++;
}
}
return {
totalViolations,
byRule,
bySeverity,
filesAnalyzed,
};
}
}

View File

@ -0,0 +1,504 @@
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { saveBaseline, type BaselineFile } from './baseline.js';
import { TcrExecutor } from './tcr-executor.js';
import { setConfig, resetConfig, defineConfig } from '../config.js';
describe('TcrExecutor', () => {
let tempDir: string;
let originalCwd: string;
beforeEach(() => {
originalCwd = process.cwd();
// Use realpathSync to avoid macOS /var -> /private/var symlink issues
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tcr-test-')));
// Initialize git repo
execSync('git init', { cwd: tempDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: tempDir, stdio: 'pipe' });
// Create minimal structure with placeholder files (git doesn't track empty dirs)
fs.mkdirSync(path.join(tempDir, 'pages'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'pages', '.gitkeep'), '');
fs.writeFileSync(path.join(tempDir, 'tests', '.gitkeep'), '');
// Create tsconfig.json
fs.writeFileSync(
path.join(tempDir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'node',
strict: true,
skipLibCheck: true,
noEmit: true,
},
include: ['**/*.ts'],
}),
);
// Create package.json with typecheck script
fs.writeFileSync(
path.join(tempDir, 'package.json'),
JSON.stringify({
name: 'tcr-test',
scripts: {
typecheck: 'tsc --noEmit',
},
}),
);
// Set up config
const config = defineConfig({
rootDir: tempDir,
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts'],
factories: ['factories/**/*.ts'],
testData: ['workflows/**/*'],
},
excludeFromPages: ['BasePage.ts'],
facade: {
file: 'pages/AppPage.ts',
className: 'AppPage',
excludeTypes: ['Page'],
},
fixtureObjectName: 'app',
});
setConfig(config);
// Initial commit
execSync('git add -A', { cwd: tempDir, stdio: 'pipe' });
execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'pipe' });
process.chdir(tempDir);
});
afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
resetConfig();
});
describe('getChangedFiles', () => {
it('detects new files', () => {
fs.writeFileSync(path.join(tempDir, 'pages', 'NewPage.ts'), 'export class NewPage {}');
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
expect(result.changedFiles.length).toBeGreaterThan(0);
expect(result.changedFiles.some((f) => f.includes('NewPage.ts'))).toBe(true);
});
it('detects modified files', () => {
// Create and commit a file first
fs.writeFileSync(path.join(tempDir, 'pages', 'TestPage.ts'), 'export class TestPage {}');
execSync('git add -A && git commit -m "Add TestPage"', { cwd: tempDir, stdio: 'pipe' });
// Now modify it
fs.writeFileSync(
path.join(tempDir, 'pages', 'TestPage.ts'),
'export class TestPage { modified() {} }',
);
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
expect(result.changedFiles.some((f) => f.includes('TestPage.ts'))).toBe(true);
});
it('returns empty array when no changes', () => {
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
expect(result.changedFiles).toHaveLength(0);
expect(result.success).toBe(true);
});
it('detects new test files in new directories (unstaged)', () => {
// Create a new directory with a test file (not staged)
// This reproduces the bug where git status shows "?? new-dir/" instead of the file
const newDir = path.join(tempDir, 'tests', 'e2e', 'new-feature');
fs.mkdirSync(newDir, { recursive: true });
fs.writeFileSync(
path.join(newDir, 'new-feature.spec.ts'),
'import { test } from "@playwright/test"; test("new feature", () => {});',
);
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// Should detect the actual test file, not just the directory
expect(result.changedFiles.some((f) => f.includes('new-feature.spec.ts'))).toBe(true);
// Should include the test in affected tests (as a directly changed test file)
// Note: affectedTests uses relative paths from root
const hasTestInChanged = result.changedFiles.some((f) => f.endsWith('.spec.ts'));
const hasTestInAffected = result.affectedTests.length > 0;
expect(hasTestInChanged || hasTestInAffected).toBe(true);
});
it('detects new test files in new directories (staged)', () => {
// Create a new directory with a test file and stage it
const newDir = path.join(tempDir, 'tests', 'e2e', 'staged-feature');
fs.mkdirSync(newDir, { recursive: true });
// Use valid TypeScript without external imports to pass typecheck
fs.writeFileSync(
path.join(newDir, 'staged-feature.spec.ts'),
'export const test = { name: "staged feature test" };\n',
);
// Stage the new file
execSync('git add -A', { cwd: tempDir, stdio: 'pipe' });
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// Should detect the actual test file, not just the directory
expect(result.changedFiles.some((f) => f.includes('staged-feature.spec.ts'))).toBe(true);
// Should NOT include directory paths
expect(result.changedFiles.some((f) => f.endsWith('staged-feature/'))).toBe(false);
// Should include in affected tests
expect(result.affectedTests.some((t) => t.includes('staged-feature.spec.ts'))).toBe(true);
});
it('detects deleted files', () => {
// Create and commit a file first
fs.writeFileSync(
path.join(tempDir, 'pages', 'ToDelete.ts'),
'export class ToDelete { method() {} }',
);
execSync('git add -A && git commit -m "Add ToDelete"', { cwd: tempDir, stdio: 'pipe' });
// Now delete it
fs.unlinkSync(path.join(tempDir, 'pages', 'ToDelete.ts'));
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
expect(result.changedFiles.some((f) => f.includes('ToDelete.ts'))).toBe(true);
});
it('detects renamed files', () => {
// Create and commit a file first
fs.writeFileSync(
path.join(tempDir, 'pages', 'OldName.ts'),
'export class OldName { method() {} }',
);
execSync('git add -A && git commit -m "Add OldName"', { cwd: tempDir, stdio: 'pipe' });
// Rename it using git mv
execSync('git mv pages/OldName.ts pages/NewName.ts', { cwd: tempDir, stdio: 'pipe' });
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// Should detect the new name
expect(result.changedFiles.some((f) => f.includes('NewName.ts'))).toBe(true);
});
});
describe('changedMethods', () => {
it('detects added methods', () => {
// Put file at root level (not in any rule pattern, but still analyzed for methods)
fs.writeFileSync(
path.join(tempDir, 'MethodClass.ts'),
`export class MethodClass {
existing() {}
}`,
);
execSync('git add -A && git commit -m "Add MethodClass"', { cwd: tempDir, stdio: 'pipe' });
// Add a new method
fs.writeFileSync(
path.join(tempDir, 'MethodClass.ts'),
`export class MethodClass {
existing() {}
newMethod() {}
}`,
);
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// changedMethods is now populated early in the flow, even if later checks fail
expect(result.changedMethods.some((m) => m.methodName === 'newMethod')).toBe(true);
expect(result.changedMethods.find((m) => m.methodName === 'newMethod')?.changeType).toBe(
'added',
);
});
it('detects removed methods', () => {
// Put file at root level (not in any rule pattern, but still analyzed for methods)
fs.writeFileSync(
path.join(tempDir, 'RemoveClass.ts'),
`export class RemoveClass {
keepMe() {}
removeMe() {}
}`,
);
execSync('git add -A && git commit -m "Add RemoveClass"', { cwd: tempDir, stdio: 'pipe' });
// Remove one method
fs.writeFileSync(
path.join(tempDir, 'RemoveClass.ts'),
`export class RemoveClass {
keepMe() {}
}`,
);
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// changedMethods is now populated early in the flow, even if later checks fail
expect(result.changedMethods.some((m) => m.methodName === 'removeMe')).toBe(true);
expect(result.changedMethods.find((m) => m.methodName === 'removeMe')?.changeType).toBe(
'removed',
);
});
it('detects modified methods', () => {
// Put file at root level (not in any rule pattern, but still analyzed for methods)
fs.writeFileSync(
path.join(tempDir, 'ModifyClass.ts'),
`export class ModifyClass {
myMethod() { return 1; }
}`,
);
execSync('git add -A && git commit -m "Add ModifyClass"', { cwd: tempDir, stdio: 'pipe' });
// Modify the method body
fs.writeFileSync(
path.join(tempDir, 'ModifyClass.ts'),
`export class ModifyClass {
myMethod() { return 2; }
}`,
);
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// changedMethods is now populated early in the flow, even if later checks fail
expect(result.changedMethods.some((m) => m.methodName === 'myMethod')).toBe(true);
expect(result.changedMethods.find((m) => m.methodName === 'myMethod')?.changeType).toBe(
'modified',
);
});
});
describe('maxDiffLines', () => {
it('skips when diff exceeds max lines', () => {
// Create a file and commit it first
fs.writeFileSync(path.join(tempDir, 'pages', 'LargePage.ts'), 'export class LargePage {}');
execSync('git add -A && git commit -m "Add LargePage"', { cwd: tempDir, stdio: 'pipe' });
// Now modify it with many lines - git diff counts changes to tracked files
const largeContent = Array(100).fill('export const x = 1;').join('\n');
fs.writeFileSync(path.join(tempDir, 'pages', 'LargePage.ts'), largeContent);
const tcr = new TcrExecutor();
const result = tcr.run({ maxDiffLines: 10, verbose: false });
expect(result.success).toBe(false);
expect(result.failedStep).toBe('diff-too-large');
expect(result.totalDiffLines).toBeGreaterThan(10);
});
it('proceeds when diff is under max lines', () => {
// Create a file and commit it first
fs.writeFileSync(path.join(tempDir, 'pages', 'SmallPage.ts'), 'export class SmallPage {}');
execSync('git add -A && git commit -m "Add SmallPage"', { cwd: tempDir, stdio: 'pipe' });
// Now make a small modification
fs.writeFileSync(
path.join(tempDir, 'pages', 'SmallPage.ts'),
'export class SmallPage { x = 1; }',
);
const tcr = new TcrExecutor();
const result = tcr.run({ maxDiffLines: 100, verbose: false });
// May fail on rules but not on diff size
expect(result.failedStep).not.toBe('diff-too-large');
});
});
describe('baseline protection', () => {
it('blocks when baseline file is modified', () => {
// Create and commit a baseline file first
const baseline: BaselineFile = {
version: 1,
generated: new Date().toISOString(),
totalViolations: 0,
violations: {},
};
saveBaseline(baseline, tempDir);
execSync('git add -A && git commit -m "Add baseline"', { cwd: tempDir, stdio: 'pipe' });
// Now modify the baseline
const modifiedBaseline: BaselineFile = {
...baseline,
totalViolations: 5,
violations: { 'some/file.ts': [] },
};
saveBaseline(modifiedBaseline, tempDir);
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
expect(result.success).toBe(false);
expect(result.failedStep).toBe('baseline-modified');
expect(result.action).toBe('dry-run');
});
it('allows changes when baseline is not modified', () => {
// Create and commit a baseline file
const baseline: BaselineFile = {
version: 1,
generated: new Date().toISOString(),
totalViolations: 0,
violations: {},
};
saveBaseline(baseline, tempDir);
execSync('git add -A && git commit -m "Add baseline"', { cwd: tempDir, stdio: 'pipe' });
// Modify a different file (not baseline)
fs.writeFileSync(path.join(tempDir, 'pages', 'OtherPage.ts'), 'export class OtherPage {}');
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
// Should not fail on baseline-modified
expect(result.failedStep).not.toBe('baseline-modified');
});
});
describe('baseline filtering', () => {
it('filters known violations from baseline', () => {
// Create a file with a violation (importing another page)
fs.writeFileSync(
path.join(tempDir, 'pages', 'PageA.ts'),
`import { PageB } from './PageB';
export class PageA {}`,
);
fs.writeFileSync(path.join(tempDir, 'pages', 'PageB.ts'), 'export class PageB {}');
// Create baseline with this violation
const baseline: BaselineFile = {
version: 1,
generated: new Date().toISOString(),
totalViolations: 1,
violations: {
'pages/PageA.ts': [
{
rule: 'boundary-protection',
line: 1,
message: "Page 'PageA' imports another page 'PageB'",
hash: 'abc123',
},
],
},
};
saveBaseline(baseline, tempDir);
const tcr = new TcrExecutor();
// The violation should be filtered by baseline
// Note: This test verifies the baseline is loaded, actual filtering depends on hash matching
const result = tcr.run({ verbose: false });
// Result depends on whether hash matches - mainly testing that baseline is loaded
expect(result).toBeDefined();
});
});
describe('dry run vs execute', () => {
it('does not commit in dry run mode', () => {
fs.writeFileSync(path.join(tempDir, 'pages', 'DryRunPage.ts'), 'export class DryRunPage {}');
const tcr = new TcrExecutor();
const result = tcr.run({ execute: false, verbose: false });
expect(result.action).toBe('dry-run');
// File should still be uncommitted
const status = execSync('git status --porcelain', { cwd: tempDir, encoding: 'utf-8' });
expect(status).toContain('DryRunPage.ts');
});
});
describe('result structure', () => {
it('returns complete result object', () => {
fs.writeFileSync(path.join(tempDir, 'pages', 'ResultPage.ts'), 'export class ResultPage {}');
const tcr = new TcrExecutor();
const result = tcr.run({ verbose: false });
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('changedFiles');
expect(result).toHaveProperty('changedMethods');
expect(result).toHaveProperty('affectedTests');
expect(result).toHaveProperty('testsRun');
expect(result).toHaveProperty('testsPassed');
expect(result).toHaveProperty('ruleViolations');
expect(result).toHaveProperty('typecheckPassed');
expect(result).toHaveProperty('action');
expect(result).toHaveProperty('durationMs');
expect(typeof result.durationMs).toBe('number');
});
});
describe('testCommand config', () => {
it('uses testCommand from config when set', () => {
// Update config with custom test command
const config = defineConfig({
rootDir: tempDir,
tcr: {
testCommand: 'echo "custom test command"',
},
});
setConfig(config);
// Create a test file to trigger test execution
fs.writeFileSync(
path.join(tempDir, 'tests', 'example.spec.ts'),
'import { test } from "@playwright/test"; test("example", () => {});',
);
const tcr = new TcrExecutor();
// With no affected tests, the test command won't run
// but we can verify config is read by checking the executor initializes
const result = tcr.run({ verbose: false });
expect(result).toBeDefined();
});
it('CLI testCommand overrides config', () => {
// Set config with one command
const config = defineConfig({
rootDir: tempDir,
tcr: {
testCommand: 'echo "config command"',
},
});
setConfig(config);
const tcr = new TcrExecutor();
// CLI option should take precedence (tested indirectly through options)
const result = tcr.run({ testCommand: 'echo "cli command"', verbose: false });
expect(result).toBeDefined();
});
});
});

View File

@ -0,0 +1,574 @@
/**
* TCR Executor - Test && Commit || Revert workflow
*/
import { execSync } from 'node:child_process';
import * as path from 'node:path';
import { Project } from 'ts-morph';
import { diffFileMethods, type MethodChange } from './ast-diff-analyzer.js';
import { loadBaseline, filterNewViolations } from './baseline.js';
import { MethodUsageAnalyzer, type MethodUsageIndex } from './method-usage-analyzer.js';
import { createProject } from './project-loader.js';
import { RuleRunner } from './rule-runner.js';
import { ApiPurityRule } from '../rules/api-purity.rule.js';
import { BoundaryProtectionRule } from '../rules/boundary-protection.rule.js';
import { DeadCodeRule } from '../rules/dead-code.rule.js';
import { DeduplicationRule } from '../rules/deduplication.rule.js';
import { NoPageInFlowRule } from '../rules/no-page-in-flow.rule.js';
import { ScopeLockdownRule } from '../rules/scope-lockdown.rule.js';
import { SelectorPurityRule } from '../rules/selector-purity.rule.js';
import { TestDataHygieneRule } from '../rules/test-data-hygiene.rule.js';
import {
getChangedFiles as gitGetChangedFiles,
getTotalDiffLines as gitGetTotalDiffLines,
commit as gitCommit,
revert as gitRevert,
} from '../utils/git-operations.js';
import { createLogger, type Logger } from '../utils/logger.js';
import { getRootDir } from '../utils/paths.js';
import { buildTestCommand } from '../utils/test-command.js';
export interface TcrOptions {
baseRef?: string;
execute?: boolean;
commitMessage?: string;
verbose?: boolean;
targetBranch?: string;
maxDiffLines?: number;
testCommand?: string;
}
export interface TcrResult {
success: boolean;
failedStep?: 'rules' | 'typecheck' | 'tests' | 'diff-too-large' | 'baseline-modified';
changedFiles: string[];
changedMethods: MethodChange[];
affectedTests: string[];
testsRun: string[];
testsPassed: boolean;
ruleViolations: number;
typecheckPassed: boolean;
action: 'commit' | 'revert' | 'dry-run';
durationMs: number;
totalDiffLines?: number;
testCommand?: string;
}
export class TcrExecutor {
private project: Project;
private methodUsageIndex: MethodUsageIndex | null = null;
private root: string;
private logger: Logger = createLogger();
constructor() {
this.root = getRootDir();
this.project = new Project({
tsConfigFilePath: path.join(this.root, 'tsconfig.json'),
skipAddingFilesFromTsConfig: false,
});
}
run(options: TcrOptions = {}): TcrResult {
const startTime = performance.now();
const {
baseRef = 'HEAD',
execute = false,
verbose = false,
targetBranch,
maxDiffLines,
testCommand,
} = options;
this.logger = createLogger({ verbose });
const changedFiles = this.getChangedFiles(targetBranch);
// Validation: diff size
const diffValidation = this.validateDiffSize(changedFiles, maxDiffLines, targetBranch);
if (diffValidation) {
return this.buildResult({ ...diffValidation, durationMs: performance.now() - startTime });
}
// Validation: baseline not modified (prevents AI from "fixing" violations by updating baseline)
const baselineValidation = this.validateBaselineNotModified(changedFiles);
if (baselineValidation) {
return this.buildResult({ ...baselineValidation, durationMs: performance.now() - startTime });
}
// No changes
if (changedFiles.length === 0) {
this.logger.debug('No changes detected');
return this.buildResult({
success: true,
action: 'dry-run',
durationMs: performance.now() - startTime,
});
}
this.logger.debug(`Changed files: ${changedFiles.length}`);
this.logger.debugList(changedFiles);
// Analyze changed methods early (useful for understanding the change even if later checks fail)
const changedMethods = this.extractChangedMethods(changedFiles, baseRef);
this.logChangedMethods(changedMethods);
// Validation: rules
this.logger.debug('\nRunning janitor rules...');
const ruleViolations = this.runRules(changedFiles);
if (ruleViolations > 0) {
this.logger.debug(`\n\u2717 Found ${ruleViolations} rule violation(s)`);
if (execute) this.doRevert();
return this.buildResult({
success: false,
failedStep: 'rules',
changedFiles,
changedMethods,
ruleViolations,
action: execute ? 'revert' : 'dry-run',
durationMs: performance.now() - startTime,
});
}
this.logger.debug('\u2713 Rules passed');
// Validation: typecheck
this.logger.debug('\nRunning typecheck...');
const typecheckPassed = this.runTypecheck();
if (!typecheckPassed) {
this.logger.debug('\u2717 Typecheck failed');
if (execute) this.doRevert();
return this.buildResult({
success: false,
failedStep: 'typecheck',
changedFiles,
changedMethods,
ruleViolations,
typecheckPassed: false,
action: execute ? 'revert' : 'dry-run',
durationMs: performance.now() - startTime,
});
}
this.logger.debug('\u2713 Typecheck passed');
// Find affected tests
const affectedTests = this.findAffectedTests(changedFiles, changedMethods);
this.logger.debug(`\nAffected tests: ${affectedTests.length}`);
this.logger.debugList(affectedTests);
// No tests affected
if (affectedTests.length === 0) {
this.logger.debug('\nNo tests affected by changes');
if (execute) {
this.doCommit(options.commitMessage ?? 'TCR: No tests affected');
return this.buildResult({
success: true,
changedFiles,
changedMethods,
ruleViolations,
typecheckPassed,
action: 'commit',
durationMs: performance.now() - startTime,
});
}
return this.buildResult({
success: true,
changedFiles,
changedMethods,
ruleViolations,
typecheckPassed,
action: 'dry-run',
durationMs: performance.now() - startTime,
});
}
// Run tests
const testsPassed = this.runTests(affectedTests, testCommand);
// Commit or revert based on test results
const action = this.determineAction(execute, testsPassed, options.commitMessage);
return this.buildResult({
success: testsPassed,
failedStep: testsPassed ? undefined : 'tests',
changedFiles,
changedMethods,
affectedTests,
testsRun: affectedTests,
testsPassed,
ruleViolations,
typecheckPassed,
action,
durationMs: performance.now() - startTime,
});
}
// --- Result Builder ---
private buildResult(params: Partial<TcrResult>): TcrResult {
return {
success: params.success ?? false,
failedStep: params.failedStep,
changedFiles: params.changedFiles ?? [],
changedMethods: params.changedMethods ?? [],
affectedTests: params.affectedTests ?? [],
testsRun: params.testsRun ?? [],
testsPassed: params.testsPassed ?? false,
ruleViolations: params.ruleViolations ?? 0,
typecheckPassed: params.typecheckPassed ?? true,
action: params.action ?? 'dry-run',
durationMs: params.durationMs ?? 0,
totalDiffLines: params.totalDiffLines,
testCommand: params.testCommand,
};
}
// --- Validation Methods ---
private validateDiffSize(
changedFiles: string[],
maxDiffLines: number | undefined,
targetBranch: string | undefined,
): Partial<TcrResult> | null {
if (!maxDiffLines || changedFiles.length === 0) return null;
const totalDiffLines = gitGetTotalDiffLines(this.root, targetBranch);
if (totalDiffLines > maxDiffLines) {
this.logger.debug(
`\n\u2717 Diff too large: ${totalDiffLines} lines exceeds max ${maxDiffLines}`,
);
return {
success: false,
failedStep: 'diff-too-large',
changedFiles,
totalDiffLines,
action: 'dry-run',
};
}
this.logger.debug(`Diff size: ${totalDiffLines} lines (max: ${maxDiffLines})`);
return null;
}
private validateBaselineNotModified(changedFiles: string[]): Partial<TcrResult> | null {
// Check git status directly for baseline file (not relying on changedFiles which filters by .ts)
try {
const status = execSync('git status --porcelain', {
cwd: this.root,
encoding: 'utf-8',
});
const hasBaselineChange = status
.split('\n')
.some((line) => line.includes('.janitor-baseline.json'));
if (!hasBaselineChange) return null;
} catch {
return null;
}
this.logger.debug('\n✗ Cannot commit baseline changes via TCR');
this.logger.debug(' Baseline updates must be done manually:');
this.logger.debug(' git checkout .janitor-baseline.json');
this.logger.debug(' # Fix the actual violations, then:');
this.logger.debug(' pnpm janitor baseline && git add .janitor-baseline.json');
return {
success: false,
failedStep: 'baseline-modified',
changedFiles,
action: 'dry-run',
};
}
// --- Action Helpers ---
private determineAction(
execute: boolean,
testsPassed: boolean,
commitMessage?: string,
): 'commit' | 'revert' | 'dry-run' {
if (!execute) return 'dry-run';
if (testsPassed) {
this.doCommit(commitMessage ?? 'TCR: Tests passed');
return 'commit';
}
this.doRevert();
return 'revert';
}
private doCommit(message: string): void {
if (gitCommit(message, this.root)) {
this.logger.info(`\n\u2713 Committed: ${message}`);
} else {
this.logger.error('Failed to commit');
}
}
private doRevert(): void {
if (gitRevert(this.root)) {
this.logger.info('\n\u2717 Reverted changes');
} else {
this.logger.error('Failed to revert');
}
}
// --- Method Analysis ---
private extractChangedMethods(changedFiles: string[], baseRef: string): MethodChange[] {
const changedMethods: MethodChange[] = [];
for (const file of changedFiles) {
if (file.endsWith('.ts') && !file.endsWith('.spec.ts')) {
const diff = diffFileMethods(file, baseRef);
changedMethods.push(...diff.changedMethods);
}
}
return changedMethods;
}
private logChangedMethods(changedMethods: MethodChange[]): void {
if (changedMethods.length === 0) return;
this.logger.debug(`\nChanged methods: ${changedMethods.length}`);
for (const change of changedMethods) {
const symbol =
change.changeType === 'added' ? '+' : change.changeType === 'removed' ? '-' : '~';
this.logger.debug(` ${symbol} ${change.className}.${change.methodName}`);
}
}
private runRules(changedFiles: string[]): number {
const runner = this.createRuleRunner();
const { project } = createProject(this.root);
const tsFiles = changedFiles
.filter((f) => f.endsWith('.ts'))
.map((f) => path.relative(this.root, f));
if (tsFiles.length === 0) return 0;
const report = runner.run(project, this.root, { files: tsFiles });
return this.countNewViolations(report);
}
private createRuleRunner(): RuleRunner {
const runner = new RuleRunner();
runner.registerRule(new BoundaryProtectionRule());
runner.registerRule(new ScopeLockdownRule());
runner.registerRule(new SelectorPurityRule());
runner.registerRule(new NoPageInFlowRule());
runner.registerRule(new ApiPurityRule());
runner.registerRule(new DeadCodeRule());
runner.registerRule(new DeduplicationRule());
runner.registerRule(new TestDataHygieneRule());
return runner;
}
private countNewViolations(report: ReturnType<RuleRunner['run']>): number {
const baseline = loadBaseline(this.root);
if (!baseline) {
this.logViolationsWithoutBaseline(report);
return report.summary.totalViolations;
}
return this.countFilteredViolations(report, baseline);
}
private logViolationsWithoutBaseline(report: ReturnType<RuleRunner['run']>): void {
if (report.summary.totalViolations === 0) return;
this.logger.debug('\nViolations in changed files:');
for (const result of report.results) {
if (result.violations.length > 0) {
this.logger.debug(` ${result.rule}: ${result.violations.length}`);
}
}
}
private countFilteredViolations(
report: ReturnType<RuleRunner['run']>,
baseline: NonNullable<ReturnType<typeof loadBaseline>>,
): number {
let filteredCount = 0;
for (const result of report.results) {
const newViolations = filterNewViolations(result.violations, baseline, this.root);
filteredCount += newViolations.length;
if (newViolations.length > 0) {
this.logger.debug(
`\n${result.rule}: ${newViolations.length} new (${result.violations.length} total)`,
);
for (const v of newViolations) {
this.logger.debug(` ${path.relative(this.root, v.file)}:${v.line} - ${v.message}`);
}
}
}
const baselinedCount = report.summary.totalViolations - filteredCount;
this.logger.debug(`\nBaseline: ${baselinedCount} known violations filtered`);
return filteredCount;
}
private runTypecheck(): boolean {
try {
const stdio = this.logger.isVerbose() ? 'inherit' : 'pipe';
execSync('pnpm typecheck', { cwd: this.root, stdio });
return true;
} catch {
return false;
}
}
private getChangedFiles(targetBranch?: string): string[] {
return gitGetChangedFiles({ targetBranch, scopeDir: this.root, extensions: ['.ts'] });
}
private findAffectedTests(changedFiles: string[], changedMethods: MethodChange[]): string[] {
const affectedTests = new Set<string>();
const changedTestFiles = this.getChangedTestFiles(changedFiles);
const methodIndex = this.getMethodUsageIndex();
// Find tests using modified/removed methods
this.addTestsUsingMethods(
changedMethods.filter((m) => m.changeType !== 'added'),
methodIndex,
affectedTests,
);
// Find tests using newly added methods
this.addTestsUsingNewMethods(
changedMethods.filter((m) => m.changeType === 'added'),
changedTestFiles,
affectedTests,
);
// Include directly changed test files
for (const testFile of changedTestFiles) {
affectedTests.add(testFile);
}
return Array.from(affectedTests).sort((a, b) => a.localeCompare(b));
}
private getChangedTestFiles(changedFiles: string[]): string[] {
return changedFiles
.filter((f) => f.endsWith('.spec.ts'))
.map((f) => path.relative(this.root, f));
}
private getMethodUsageIndex(): MethodUsageIndex {
if (!this.methodUsageIndex) {
const analyzer = new MethodUsageAnalyzer(this.project);
this.methodUsageIndex = analyzer.buildIndex();
}
return this.methodUsageIndex;
}
private addTestsUsingMethods(
methods: MethodChange[],
methodIndex: MethodUsageIndex,
affectedTests: Set<string>,
): void {
for (const change of methods) {
const key = `${change.className}.${change.methodName}`;
const usages = methodIndex.methods[key] ?? [];
for (const usage of usages) {
affectedTests.add(usage.testFile);
}
}
}
private addTestsUsingNewMethods(
addedMethods: MethodChange[],
changedTestFiles: string[],
affectedTests: Set<string>,
): void {
if (addedMethods.length === 0 || changedTestFiles.length === 0) return;
for (const testFile of changedTestFiles) {
const fullPath = path.join(this.root, testFile);
const sourceFile = this.project.getSourceFile(fullPath);
if (!sourceFile) continue;
const content = sourceFile.getFullText();
for (const method of addedMethods) {
const methodPattern = new RegExp(`\\.${method.methodName}\\s*\\(`);
if (methodPattern.test(content)) {
affectedTests.add(testFile);
break;
}
}
}
}
private runTests(testFiles: string[], testCommand?: string): boolean {
this.logger.debug(`\nRunning ${testFiles.length} test file(s)...`);
try {
const cmd = buildTestCommand(testFiles, testCommand);
this.logger.debug(`Command: ${cmd}`);
const stdio = this.logger.isVerbose() ? 'inherit' : 'pipe';
execSync(cmd, { cwd: this.root, stdio });
return true;
} catch {
return false;
}
}
}
export function formatTcrResultConsole(result: TcrResult, verbose = false): void {
console.log('\n====================================');
console.log(' TCR RESULT');
console.log('====================================\n');
console.log(`Duration: ${(result.durationMs / 1000).toFixed(2)}s`);
console.log(`Action taken: ${result.action}`);
console.log(`Overall: ${result.success ? '✓ Success' : '✗ Failed'}`);
if (result.failedStep) console.log(`Failed at: ${result.failedStep}`);
if (result.changedFiles.length > 0) {
console.log(`\nChanged files (${result.changedFiles.length}):`);
result.changedFiles.forEach((f) => console.log(` - ${f}`));
}
console.log('\nChecks:');
if (result.totalDiffLines !== undefined) {
const diffStatus = result.failedStep === 'diff-too-large' ? '✗ Too large' : '✓ OK';
console.log(` Diff size: ${diffStatus} (${result.totalDiffLines} lines)`);
}
if (result.failedStep === 'baseline-modified') {
console.log(' Baseline: ✗ Modified (baseline updates must be done manually)');
}
console.log(
` Rules: ${result.ruleViolations === 0 ? '✓ Passed' : `${result.ruleViolations} violation(s)`}`,
);
console.log(` Typecheck: ${result.typecheckPassed ? '✓ Passed' : '✗ Failed'}`);
console.log(` Tests: ${result.testsPassed ? '✓ Passed' : '✗ Failed'}`);
if (verbose && result.changedMethods.length > 0) {
console.log(`\nChanged methods (${result.changedMethods.length}):`);
for (const change of result.changedMethods) {
const symbol =
change.changeType === 'added' ? '+' : change.changeType === 'removed' ? '-' : '~';
console.log(` ${symbol} ${change.className}.${change.methodName}`);
}
}
if (result.affectedTests.length > 0) {
console.log(`\nAffected tests (${result.affectedTests.length}):`);
result.affectedTests.forEach((t) => console.log(` - ${t}`));
}
console.log('');
}
export function formatTcrResultJSON(result: TcrResult): string {
return JSON.stringify(result, null, 2);
}

View File

@ -0,0 +1,230 @@
/**
* @n8n/playwright-janitor
*
* Static analysis and architecture enforcement for Playwright test suites.
*
* @example
* ```typescript
* import { defineConfig, runAnalysis, createDefaultRunner } from '@n8n/playwright-janitor';
*
* const config = defineConfig({
* rootDir: __dirname,
* fixtureObjectName: 'app',
* facade: {
* file: 'pages/AppPage.ts',
* className: 'AppPage',
* },
* });
*
* const report = runAnalysis(config);
* ```
*/
export {
defineConfig,
getConfig,
setConfig,
hasConfig,
resetConfig,
defaultConfig,
type JanitorConfig,
type DefineConfigInput,
} from './config.js';
export type {
Severity,
BuiltInRuleId,
RuleId,
RuleSettings,
RuleSettingsMap,
RuleConfig,
RunOptions,
Violation,
FixData,
MethodFixData,
PropertyFixData,
ClassFixData,
EditFixData,
FixAction,
FixResult,
RuleResult,
ReportSummary,
JanitorReport,
Rule,
RuleInfo,
FilePatterns,
FacadeConfig,
} from './types.js';
export { isMethodFix, isPropertyFix, isClassFix, isEditFix } from './types.js';
export type {
Project,
SourceFile,
CallExpression,
ClassDeclaration,
MethodDeclaration,
PropertyDeclaration,
Node,
} from './types.js';
export { SyntaxKind } from './types.js';
export { RuleRunner } from './core/rule-runner.js';
export { FacadeResolver, extractTypeName, type FacadeMapping } from './core/facade-resolver.js';
export {
createProject,
createInMemoryProject,
getSourceFiles,
getRelativePath,
type ProjectContext,
} from './core/project-loader.js';
export { toJSON, toConsole, printFixResults } from './core/reporter.js';
export {
BaseRule,
BoundaryProtectionRule,
ScopeLockdownRule,
SelectorPurityRule,
DeadCodeRule,
ApiPurityRule,
NoPageInFlowRule,
DeduplicationRule,
TestDataHygieneRule,
DuplicateLogicRule,
NoDirectPageInstantiationRule,
} from './rules/index.js';
export {
LOCATOR_METHODS,
PAGE_LEVEL_METHODS,
hasContainerMember,
getCallExpressions,
isLocatorCall,
isUnscopedPageCall,
isPageLevelMethod,
getMethodName,
getImportPaths,
isPageImport,
getTestIdArgument,
truncateText,
} from './utils/ast-helpers.js';
export {
getRootDir,
resolvePath,
getRelativePath as getRelativePathFromRoot,
findFilesRecursive,
matchesPatterns,
isExcludedPage,
isPageFile,
isComponentFile,
isFlowFile,
isTestFile,
} from './utils/paths.js';
export {
TcrExecutor,
formatTcrResultConsole,
formatTcrResultJSON,
type TcrOptions,
type TcrResult,
} from './core/tcr-executor.js';
export {
diffFileMethods,
formatDiffConsole,
formatDiffJSON,
type MethodChange,
type FileDiffResult,
} from './core/ast-diff-analyzer.js';
export {
MethodUsageAnalyzer,
formatMethodImpactConsole,
formatMethodImpactJSON,
formatMethodImpactTestList,
formatMethodUsageIndexConsole,
formatMethodUsageIndexJSON,
type MethodUsage,
type MethodUsageIndex,
type MethodImpactResult,
} from './core/method-usage-analyzer.js';
export {
ImpactAnalyzer,
formatImpactConsole,
formatImpactJSON,
formatTestList,
type ImpactResult,
} from './core/impact-analyzer.js';
export {
InventoryAnalyzer,
formatInventoryJSON,
type MethodInfo,
type ParameterInfo,
type PropertyInfo,
type PageInfo,
type ComponentInfo,
type ComposableInfo,
type ServiceInfo,
type ServiceMethodInfo,
type FixtureInfo,
type HelperInfo,
type FactoryInfo,
type TestDataInfo,
type FacadeInfo,
type FacadePropertyInfo,
type InventoryReport,
} from './core/inventory-analyzer.js';
export {
generateBaseline,
saveBaseline,
loadBaseline,
hasBaseline,
filterNewViolations,
filterReportByBaseline,
formatBaselineInfo,
getBaselinePath,
type BaselineEntry,
type BaselineFile,
} from './core/baseline.js';
import { setConfig, type JanitorConfig } from './config.js';
import { createProject } from './core/project-loader.js';
import { RuleRunner } from './core/rule-runner.js';
import { ApiPurityRule } from './rules/api-purity.rule.js';
import { BoundaryProtectionRule } from './rules/boundary-protection.rule.js';
import { DeadCodeRule } from './rules/dead-code.rule.js';
import { DeduplicationRule } from './rules/deduplication.rule.js';
import { DuplicateLogicRule } from './rules/duplicate-logic.rule.js';
import { NoDirectPageInstantiationRule } from './rules/no-direct-page-instantiation.rule.js';
import { NoPageInFlowRule } from './rules/no-page-in-flow.rule.js';
import { ScopeLockdownRule } from './rules/scope-lockdown.rule.js';
import { SelectorPurityRule } from './rules/selector-purity.rule.js';
import { TestDataHygieneRule } from './rules/test-data-hygiene.rule.js';
import type { JanitorReport, RunOptions } from './types.js';
export function createDefaultRunner(): RuleRunner {
const runner = new RuleRunner();
runner.registerRule(new BoundaryProtectionRule());
runner.registerRule(new ScopeLockdownRule());
runner.registerRule(new SelectorPurityRule());
runner.registerRule(new NoPageInFlowRule());
runner.registerRule(new ApiPurityRule());
runner.registerRule(new DeadCodeRule());
runner.registerRule(new DeduplicationRule());
runner.registerRule(new TestDataHygieneRule());
runner.registerRule(new DuplicateLogicRule());
runner.registerRule(new NoDirectPageInstantiationRule());
return runner;
}
export function runAnalysis(config: JanitorConfig, options?: RunOptions): JanitorReport {
setConfig(config);
const { project, root } = createProject(config.rootDir);
const runner = createDefaultRunner();
return runner.run(project, root, options);
}

View File

@ -0,0 +1,135 @@
import { describe } from 'vitest';
import { ApiPurityRule } from './api-purity.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('ApiPurityRule', () => {
const rule = new ApiPurityRule();
test('allows API service usage', ({ project, createFile }) => {
const file = createFile(
'/tests/workflow.spec.ts',
`
import { test } from '../fixtures/base';
test('creates workflow', async ({ n8n, api }) => {
await api.workflows.create({ name: 'Test' });
await api.credentials.list();
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('detects request.get() calls', ({ project, createFile }) => {
const file = createFile(
'/tests/workflow.spec.ts',
`
import { test } from '../fixtures/base';
test('gets workflow', async ({ request }) => {
const response = await request.get('/api/workflows');
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('request.get');
});
test('detects request.post() calls', ({ project, createFile }) => {
const file = createFile(
'/tests/workflow.spec.ts',
`
import { test } from '../fixtures/base';
test('creates workflow', async ({ request }) => {
await request.post('/api/workflows', { data: { name: 'Test' } });
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].suggestion).toContain('api');
});
test('detects fetch() calls', ({ project, createFile }) => {
const file = createFile(
'/tests/workflow.spec.ts',
`
import { test } from '../fixtures/base';
test('fetches data', async () => {
const response = await fetch('https://api.example.com/data');
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('fetch');
});
test('detects multiple raw API calls', ({ project, createFile }) => {
const file = createFile(
'/tests/workflow.spec.ts',
`
import { test } from '../fixtures/base';
test('multiple API calls', async ({ request }) => {
await request.get('/api/workflows');
await request.post('/api/workflows', { data: {} });
await request.delete('/api/workflows/1');
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(3);
});
test('detects raw API calls in composables', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
async createAndRun(request: any) {
await request.post('/api/workflows', { data: {} });
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
});
test('reports correct line numbers', ({ project, createFile }) => {
const file = createFile(
'/tests/workflow.spec.ts',
`import { test } from '../fixtures/base';
test('test', async ({ request }) => {
// line 4
// line 5
await request.get('/api/test'); // line 6
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].line).toBe(6);
});
});

View File

@ -0,0 +1,119 @@
import type { Project, SourceFile } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import { truncateText } from '../utils/ast-helpers.js';
/**
* API Purity Rule
*
* Tests and composables should use API service helpers, not raw HTTP calls.
* This enforces the two-layer API architecture:
*
* Tests/Composables API Services
*
* Why this matters:
* - Builds a reusable, typed API interface over time
* - New devs can discover available endpoints via --list-services
* - Consistent error handling and response parsing
* - Easy to mock in tests
*
* Violations:
* - request.get(), request.post(), etc. in tests/composables
* - fetch() calls in tests/composables
* - Direct HTTP method calls that bypass services
*
* Exceptions:
* - API service files themselves (services/**) can make raw calls
* - Fixture setup files can make raw calls
*/
export class ApiPurityRule extends BaseRule {
readonly id = 'api-purity';
readonly name = 'API Purity';
readonly description = 'Tests and composables should use API services, not raw HTTP calls';
readonly severity = 'error' as const;
getTargetGlobs(): string[] {
const config = getConfig();
return [...config.patterns.tests, ...config.patterns.flows];
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
const config = getConfig();
for (const file of files) {
const content = file.getText();
// Check each raw API pattern
for (const pattern of config.rawApiPatterns) {
// Reset regex state for global patterns
const regex = new RegExp(
pattern.source,
pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g',
);
let match;
while ((match = regex.exec(content)) !== null) {
// Get some context around the match for allowPattern checking
const contextStart = Math.max(0, match.index - 50);
const contextEnd = Math.min(content.length, match.index + match[0].length + 50);
const context = content.substring(contextStart, contextEnd);
// Check if this matches any allow patterns
if (this.isAllowed(context)) {
continue;
}
// Find the line number for this match
const beforeMatch = content.substring(0, match.index);
const line = beforeMatch.split('\n').length;
const lastNewline = beforeMatch.lastIndexOf('\n');
const column = match.index - lastNewline;
// Get context around the match for the message
const matchText = match[0];
const suggestion = this.getSuggestion(matchText);
violations.push(
this.createViolation(
file,
line,
column,
`Raw API call detected: ${truncateText(matchText, 40)}`,
suggestion,
),
);
}
}
}
return violations;
}
/**
* Get appropriate suggestion based on the API call pattern
*/
private getSuggestion(matchText: string): string {
const config = getConfig();
const apiName = config.apiFixtureName;
if (matchText.includes('request.get')) {
return `Use an API service method: this.${apiName}.workflows.get(), this.${apiName}.credentials.list(), etc.`;
}
if (matchText.includes('request.post')) {
return `Use an API service method: this.${apiName}.workflows.create(), this.${apiName}.credentials.create(), etc.`;
}
if (matchText.includes('request.put') || matchText.includes('request.patch')) {
return `Use an API service method: this.${apiName}.workflows.update(), etc.`;
}
if (matchText.includes('request.delete')) {
return `Use an API service method: this.${apiName}.workflows.delete(), etc.`;
}
if (matchText.includes('fetch')) {
return 'Use an API service instead of fetch(). Add the endpoint to the appropriate service class.';
}
return `Add this endpoint to an API service class and use it via this.${apiName}.*`;
}
}

View File

@ -0,0 +1,127 @@
import type { Project, SourceFile } from 'ts-morph';
import { getConfig } from '../config.js';
import type { Severity, Violation, RuleResult, RuleConfig, FixResult, FixData } from '../types.js';
export abstract class BaseRule {
abstract readonly id: string;
abstract readonly name: string;
abstract readonly description: string;
abstract readonly severity: Severity;
/** Whether this rule supports auto-fixing */
readonly fixable: boolean = false;
/** Rule-specific configuration */
protected config: RuleConfig = {};
/**
* Get the effective severity for this rule (may be overridden in config)
*/
getEffectiveSeverity(): Severity {
const ruleConfig = getConfig().rules?.[this.id];
if (ruleConfig?.severity === 'off') {
return 'info'; // Will be filtered out
}
if (ruleConfig?.severity) {
return ruleConfig.severity;
}
return this.severity;
}
/**
* Check if this rule is enabled
*/
isEnabled(): boolean {
const ruleConfig = getConfig().rules?.[this.id];
if (ruleConfig?.enabled === false) return false;
if (ruleConfig?.severity === 'off') return false;
return true;
}
/**
* Check if a text matches any of the allow patterns for this rule
*/
protected isAllowed(text: string): boolean {
const ruleConfig = getConfig().rules?.[this.id];
const allowPatterns = ruleConfig?.allowPatterns ?? [];
return allowPatterns.some((pattern) => pattern.test(text));
}
/**
* Define which file patterns this rule should analyze
* @returns Array of glob patterns relative to root
*/
abstract getTargetGlobs(): string[];
/**
* Analyze files and return violations
* @param project - ts-morph Project instance
* @param files - Source files matching the target globs
* @returns Array of violations found
*/
abstract analyze(project: Project, files: SourceFile[]): Violation[];
/**
* Apply fixes for violations. Override in fixable rules.
* @param project - ts-morph Project instance
* @param violations - Violations to fix
* @param write - Whether to write changes to disk
* @returns Array of fix results
*/
fix(_project: Project, _violations: Violation[], _write: boolean): FixResult[] {
return [];
}
/**
* Configure the rule with options
*/
configure(config: RuleConfig): void {
this.config = { ...this.config, ...config };
}
/**
* Execute the rule with timing instrumentation
* @param project - ts-morph Project instance
* @param files - Source files matching the target globs
* @returns Rule result with violations and metadata
*/
execute(project: Project, files: SourceFile[]): RuleResult {
const startTime = performance.now();
const violations = this.analyze(project, files);
const endTime = performance.now();
return {
rule: this.id,
violations,
filesAnalyzed: files.length,
executionTimeMs: Math.round((endTime - startTime) * 100) / 100,
fixable: this.fixable,
};
}
/**
* Helper to create a violation with consistent structure
*/
protected createViolation(
file: SourceFile,
line: number,
column: number,
message: string,
suggestion?: string,
fixable?: boolean,
fixData?: FixData,
): Violation {
return {
file: file.getFilePath(),
line,
column,
rule: this.id,
message,
severity: this.getEffectiveSeverity(),
suggestion,
fixable,
fixData,
};
}
}

View File

@ -0,0 +1,97 @@
import { describe } from 'vitest';
import { BoundaryProtectionRule } from './boundary-protection.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('BoundaryProtectionRule', () => {
const rule = new BoundaryProtectionRule();
test('should allow imports from components', ({ project, createFile }) => {
const file = createFile(
'/pages/CanvasPage.ts',
`
import { NodePanel } from './components/NodePanel';
export class CanvasPage {
readonly nodePanel = new NodePanel();
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('should flag direct page imports', ({ project, createFile }) => {
const file = createFile(
'/pages/CanvasPage.ts',
`
import { WorkflowPage } from './WorkflowPage';
export class CanvasPage {
readonly workflows = new WorkflowPage();
}
`,
);
// Also create the imported file so ts-morph can resolve it
createFile(
'/pages/WorkflowPage.ts',
`
export class WorkflowPage {}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('imports another page');
expect(violations[0].rule).toBe('boundary-protection');
});
test('should allow imports from base classes', ({ project, createFile }) => {
const file = createFile(
'/pages/CanvasPage.ts',
`
import { BasePage } from './BasePage';
export class CanvasPage extends BasePage {
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('should skip excluded files', ({ project, createFile }) => {
const file = createFile(
'/pages/BasePage.ts',
`
import { SomePage } from './SomePage';
export class BasePage {
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('should allow external package imports', ({ project, createFile }) => {
const file = createFile(
'/pages/CanvasPage.ts',
`
import { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export class CanvasPage {
constructor(private page: Page) {}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
});

View File

@ -0,0 +1,77 @@
import type { Project, SourceFile } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import { getImportPaths, isPageImport } from '../utils/ast-helpers.js';
import { isExcludedPage } from '../utils/paths.js';
/**
* Boundary Protection Rule
*
* Pages should not import other pages directly.
* This prevents tight coupling between page objects.
*
* Allowed:
* - Imports from components directory
* - Imports from base classes (BasePage, BaseModal, etc.)
* - Imports from external packages
*
* Violations:
* - import { CanvasPage } from './CanvasPage' (in a page file)
* - import { WorkflowPage } from '../WorkflowPage'
*/
export class BoundaryProtectionRule extends BaseRule {
readonly id = 'boundary-protection';
readonly name = 'Boundary Protection';
readonly description = 'Pages should not import other pages directly';
readonly severity = 'error' as const;
getTargetGlobs(): string[] {
const config = getConfig();
// Only analyze page files (excluding components)
return config.patterns.pages.filter((p) => !p.includes('components'));
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
for (const file of files) {
const filePath = file.getFilePath();
// Skip excluded files (facades, base classes)
if (isExcludedPage(filePath)) {
continue;
}
// Skip if file is in components directory
if (filePath.includes('/components/')) {
continue;
}
const importPaths = getImportPaths(file);
for (const importPath of importPaths) {
if (isPageImport(importPath, filePath)) {
const importDecl = file
.getImportDeclarations()
.find((d) => d.getModuleSpecifierValue() === importPath);
if (importDecl) {
violations.push(
this.createViolation(
file,
importDecl.getStartLineNumber(),
0,
`Page imports another page: ${importPath}`,
'Use composition through composables or inject via constructor instead',
),
);
}
}
}
}
return violations;
}
}

View File

@ -0,0 +1,189 @@
import { describe } from 'vitest';
import { DeadCodeRule } from './dead-code.rule.js';
import { test, expect } from '../test/fixtures.js';
import { isMethodFix } from '../types.js';
describe('DeadCodeRule', () => {
const rule = new DeadCodeRule();
test('allows methods with external references', ({ project, createFile }) => {
createFile(
'/pages/TestPage.ts',
`
export class TestPage {
async clickButton() {
// implementation
}
}
`,
);
createFile(
'/tests/test.spec.ts',
`
import { TestPage } from '../pages/TestPage';
const page = new TestPage();
page.clickButton();
`,
);
const pageFile = project.getSourceFile('/pages/TestPage.ts')!;
const violations = rule.analyze(project, [pageFile]);
expect(violations).toHaveLength(0);
});
test('detects unused method', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
async usedMethod() {}
async unusedMethod() {}
}
`,
);
createFile(
'/tests/test.spec.ts',
`
import { TestPage } from '../pages/TestPage';
const page = new TestPage();
page.usedMethod();
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('unusedMethod');
expect(violations[0].fixable).toBe(true);
});
test('detects unused property', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
usedProp = 'used';
unusedProp = 'unused';
}
`,
);
createFile(
'/tests/test.spec.ts',
`
import { TestPage } from '../pages/TestPage';
const page = new TestPage();
console.log(page.usedProp);
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('unusedProp');
});
test('detects dead class with no references', ({ project, createFile }) => {
const file = createFile(
'/pages/DeadPage.ts',
`
export class DeadPage {
async doSomething() {}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('Dead class');
expect(violations[0].message).toContain('DeadPage');
});
test('skips private methods', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
async publicMethod() {
this.privateHelper();
}
private privateHelper() {}
}
`,
);
createFile(
'/tests/test.spec.ts',
`
import { TestPage } from '../pages/TestPage';
const page = new TestPage();
page.publicMethod();
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('skips protected properties', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
protected container = 'div';
async publicMethod() {}
}
`,
);
createFile(
'/tests/test.spec.ts',
`
import { TestPage } from '../pages/TestPage';
const page = new TestPage();
page.publicMethod();
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('provides correct fix data', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
async unusedMethod() {}
}
`,
);
createFile(
'/tests/test.spec.ts',
`
import { TestPage } from '../pages/TestPage';
const page = new TestPage();
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
const fixData = violations[0].fixData;
expect(fixData).toBeDefined();
expect(fixData?.type).toBe('method');
if (fixData && isMethodFix(fixData)) {
expect(fixData.className).toBe('TestPage');
expect(fixData.memberName).toBe('unusedMethod');
}
});
});

View File

@ -0,0 +1,294 @@
import * as fs from 'node:fs';
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation, FixResult } from '../types.js';
import { getRelativePath } from '../utils/paths.js';
/**
* Dead Code Rule
*
* Finds and optionally removes unused methods, properties, and classes.
* Uses reference tracing to detect code that isn't used anywhere in the codebase.
*
* Detects:
* - Unused public methods (no external references)
* - Unused public properties (no external references)
* - Dead classes (entire class not referenced externally)
* - Empty classes (no public members)
*
* Supports auto-fixing with --fix --write
*/
export class DeadCodeRule extends BaseRule {
readonly id = 'dead-code';
readonly name = 'Dead Code';
readonly description = 'Find and remove unused methods, properties, and classes';
readonly severity = 'warning' as const;
readonly fixable = true;
getTargetGlobs(): string[] {
const config = getConfig();
return [
...config.patterns.pages,
...config.patterns.flows,
...config.patterns.helpers,
...config.patterns.services,
];
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
for (const file of files) {
const fileViolations = this.analyzeFile(file);
violations.push(...fileViolations);
}
return violations;
}
private analyzeFile(file: SourceFile): Violation[] {
const violations: Violation[] = [];
for (const classDecl of file.getClasses()) {
const className = classDecl.getName();
if (!className) continue;
// Check if entire class is unused
if (!this.hasExternalReferences(file, classDecl)) {
violations.push(this.createDeadClassViolation(file, classDecl, className));
continue; // Don't report individual members if whole class is dead
}
// Check individual members
violations.push(...this.checkUnusedMethods(file, classDecl, className));
violations.push(...this.checkUnusedProperties(file, classDecl, className));
}
return violations;
}
private createDeadClassViolation(
file: SourceFile,
classDecl: ReturnType<SourceFile['getClasses']>[0],
className: string,
): Violation {
return this.createViolation(
file,
classDecl.getStartLineNumber(),
0,
`Dead class: ${className} has no external references`,
'Remove the entire class or file',
true,
{ type: 'class', className },
);
}
private checkUnusedMethods(
file: SourceFile,
classDecl: ReturnType<SourceFile['getClasses']>[0],
className: string,
): Violation[] {
const violations: Violation[] = [];
for (const method of classDecl.getMethods()) {
if (method.hasModifier(SyntaxKind.PrivateKeyword)) continue;
const methodName = method.getName();
if (!this.hasExternalReferences(file, method)) {
violations.push(
this.createViolation(
file,
method.getStartLineNumber(),
0,
`Unused method: ${className}.${methodName}()`,
'Remove the method or make it private',
true,
{ type: 'method', className, memberName: methodName },
),
);
}
}
return violations;
}
private checkUnusedProperties(
file: SourceFile,
classDecl: ReturnType<SourceFile['getClasses']>[0],
className: string,
): Violation[] {
const violations: Violation[] = [];
for (const prop of classDecl.getProperties()) {
if (this.isPrivateOrProtected(prop)) continue;
const propName = prop.getName();
if (!this.hasExternalReferences(file, prop)) {
violations.push(
this.createViolation(
file,
prop.getStartLineNumber(),
0,
`Unused property: ${className}.${propName}`,
'Remove the property or make it private',
true,
{ type: 'property', className, memberName: propName },
),
);
}
}
return violations;
}
private isPrivateOrProtected(
prop: ReturnType<ReturnType<SourceFile['getClasses']>[0]['getProperties']>[0],
): boolean {
return (
prop.hasModifier(SyntaxKind.PrivateKeyword) || prop.hasModifier(SyntaxKind.ProtectedKeyword)
);
}
private hasExternalReferences(
sourceFile: SourceFile,
node: { findReferencesAsNodes: () => unknown[]; getStartLineNumber: () => number },
): boolean {
const refs = node.findReferencesAsNodes() as Array<{
getSourceFile: () => SourceFile;
getStartLineNumber: () => number;
}>;
return refs.some(
(ref) =>
ref.getSourceFile() !== sourceFile ||
ref.getStartLineNumber() !== node.getStartLineNumber(),
);
}
fix(project: Project, violations: Violation[], write: boolean): FixResult[] {
const results: FixResult[] = [];
const byFile = this.groupViolationsByFile(violations);
const filesToDelete = new Set<string>();
for (const [filePath, fileViolations] of byFile) {
const sourceFile = project.getSourceFile(filePath);
if (!sourceFile) continue;
// Check if entire file should be deleted
if (this.shouldDeleteFile(sourceFile, fileViolations)) {
filesToDelete.add(filePath);
results.push(this.createFixResult(filePath, 'remove-file', write));
continue;
}
// Process individual member removals
results.push(...this.processFileViolations(sourceFile, filePath, fileViolations, write));
}
// Apply changes
if (write) {
project.saveSync();
this.deleteFiles(filesToDelete);
}
return results;
}
private groupViolationsByFile(violations: Violation[]): Map<string, Violation[]> {
const byFile = new Map<string, Violation[]>();
for (const v of violations) {
if (!v.fixable || !v.fixData) continue;
const existing = byFile.get(v.file) ?? [];
existing.push(v);
byFile.set(v.file, existing);
}
return byFile;
}
private shouldDeleteFile(sourceFile: SourceFile, fileViolations: Violation[]): boolean {
const deadClassViolations = fileViolations.filter((v) => v.fixData?.type === 'class');
const allClassesInFile = sourceFile.getClasses();
return deadClassViolations.length === allClassesInFile.length && allClassesInFile.length > 0;
}
private createFixResult(
filePath: string,
action: 'remove-file' | 'remove-method' | 'remove-property',
write: boolean,
target?: string,
): FixResult {
return {
file: getRelativePath(filePath),
action,
target,
applied: write,
};
}
private processFileViolations(
sourceFile: SourceFile,
filePath: string,
fileViolations: Violation[],
write: boolean,
): FixResult[] {
const results: FixResult[] = [];
for (const violation of fileViolations) {
const fixData = violation.fixData;
if (!fixData || fixData.type === 'class' || fixData.type === 'edit') continue;
const classDecl = sourceFile.getClass(fixData.className);
if (!classDecl) continue;
const result = this.removeMember(classDecl, filePath, fixData, write);
if (result) results.push(result);
}
return results;
}
private removeMember(
classDecl: ReturnType<SourceFile['getClass']>,
filePath: string,
fixData: { type: 'method' | 'property'; className: string; memberName: string },
write: boolean,
): FixResult | null {
if (!classDecl) return null;
if (fixData.type === 'method') {
const method = classDecl.getMethod(fixData.memberName);
if (method) {
if (write) method.remove();
return this.createFixResult(
filePath,
'remove-method',
write,
`${fixData.className}.${fixData.memberName}()`,
);
}
} else if (fixData.type === 'property') {
const prop = classDecl.getProperty(fixData.memberName);
if (prop) {
if (write) prop.remove();
return this.createFixResult(
filePath,
'remove-property',
write,
`${fixData.className}.${fixData.memberName}`,
);
}
}
return null;
}
private deleteFiles(filesToDelete: Set<string>): void {
for (const filePath of filesToDelete) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
}

View File

@ -0,0 +1,138 @@
import { describe } from 'vitest';
import { DeduplicationRule } from './deduplication.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('DeduplicationRule', () => {
const rule = new DeduplicationRule();
test('detects duplicate test IDs across files', ({ project, createFile }) => {
const file1 = createFile(
'/pages/PageA.ts',
`
export class PageA {
getSomething() {
return this.page.getByTestId('my-button');
}
}
`,
);
const file2 = createFile(
'/pages/PageB.ts',
`
export class PageB {
getButton() {
return this.page.getByTestId('my-button');
}
}
`,
);
const violations = rule.analyze(project, [file1, file2]);
expect(violations.length).toBeGreaterThanOrEqual(1);
expect(violations[0].message).toContain('my-button');
});
test('allows unique test IDs', ({ project, createFile }) => {
const file1 = createFile(
'/pages/PageA.ts',
`
export class PageA {
getSomething() {
return this.page.getByTestId('page-a-button');
}
}
`,
);
const file2 = createFile(
'/pages/PageB.ts',
`
export class PageB {
getButton() {
return this.page.getByTestId('page-b-button');
}
}
`,
);
const violations = rule.analyze(project, [file1, file2]);
expect(violations).toHaveLength(0);
});
test('allows same test ID within same file', ({ project, createFile }) => {
const file = createFile(
'/pages/PageA.ts',
`
export class PageA {
getButton1() {
return this.container.getByTestId('button');
}
getButton2() {
return this.container.getByTestId('button');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('separates components and pages scopes', ({ project, createFile }) => {
const pageFile = createFile(
'/pages/PageA.ts',
`
export class PageA {
getSomething() {
return this.page.getByTestId('shared-id');
}
}
`,
);
const componentFile = createFile(
'/pages/components/ComponentA.ts',
`
export class ComponentA {
getSomething() {
return this.root.getByTestId('shared-id');
}
}
`,
);
const violations = rule.analyze(project, [pageFile, componentFile]);
expect(violations).toHaveLength(0);
});
test('skips dynamic test IDs (template literals)', ({ project, createFile }) => {
const file1 = createFile(
'/pages/PageA.ts',
`
export class PageA {
getSomething(id: string) {
return this.page.getByTestId(\`item-\${id}\`);
}
}
`,
);
const file2 = createFile(
'/pages/PageB.ts',
`
export class PageB {
getItem(id: string) {
return this.page.getByTestId(\`item-\${id}\`);
}
}
`,
);
const violations = rule.analyze(project, [file1, file2]);
expect(violations).toHaveLength(0);
});
});

View File

@ -0,0 +1,175 @@
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;
}
}

View File

@ -0,0 +1,240 @@
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);
});
});
});

View File

@ -0,0 +1,519 @@
import {
SyntaxKind,
type Project,
type SourceFile,
type Node,
type MethodDeclaration,
} from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import { getRelativePath, matchesPatterns } from '../utils/paths.js';
/** Playwright fixture names normalized to FIXTURE token */
const PLAYWRIGHT_FIXTURES = ['page', 'request', 'context', 'browser'];
interface MethodFingerprint {
file: string;
className: string;
methodName: string;
fingerprint: string;
line: number;
statementCount: number;
}
interface TestFingerprint {
file: string;
testName: string;
fingerprint: string;
line: number;
statementCount: number;
}
/**
* Detects duplicate code using AST structural fingerprinting.
* Normalizes code to structural patterns, ignoring variable names and literals.
*/
export class DuplicateLogicRule extends BaseRule {
readonly id = 'duplicate-logic';
readonly name = 'Duplicate Logic';
readonly description = 'Detects duplicate code patterns across tests and page objects';
readonly severity = 'warning' as const;
private minStatements = 2;
private methodIndex = new Map<string, MethodFingerprint[]>();
getTargetGlobs(): string[] {
const config = getConfig();
return [
...config.patterns.tests,
...config.patterns.pages,
...config.patterns.flows,
...config.patterns.helpers,
];
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const config = getConfig();
// Separate files by type
const pageFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.pages));
const flowFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.flows));
const helperFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.helpers));
const testFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.tests));
// Build method index from pages, flows, helpers
this.buildMethodIndex([...pageFiles, ...flowFiles, ...helperFiles]);
const violations: Violation[] = [];
// 1. Find duplicate methods within pages/flows/helpers
violations.push(...this.findDuplicateMethods(files));
// 2. Find tests that duplicate existing page object methods
violations.push(...this.findTestsDuplicatingMethods(testFiles, files));
// 3. Find duplicate test bodies
violations.push(...this.findDuplicateTests(testFiles, files));
return violations;
}
private fileMatchesPatterns(file: SourceFile, patterns: string[]): boolean {
return matchesPatterns(file.getFilePath(), patterns);
}
private buildMethodIndex(files: SourceFile[]): void {
this.methodIndex.clear();
for (const file of files) {
const filePath = file.getFilePath();
const classes = file.getClasses();
for (const cls of classes) {
const className = cls.getName() ?? 'Anonymous';
const methods = cls.getMethods();
for (const method of methods) {
// Skip private methods, getters, setters
if (method.hasModifier(SyntaxKind.PrivateKeyword)) continue;
const result = this.fingerprintMethod(method);
if (!result) continue;
const entry: MethodFingerprint = {
file: filePath,
className,
methodName: method.getName(),
fingerprint: result.hash,
line: method.getStartLineNumber(),
statementCount: result.statementCount,
};
const existing = this.methodIndex.get(result.hash) ?? [];
existing.push(entry);
this.methodIndex.set(result.hash, existing);
}
}
}
}
private findDuplicateMethods(files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
for (const [_hash, methods] of this.methodIndex) {
// Only flag if same fingerprint in multiple files
const uniqueFiles = new Set(methods.map((m) => m.file));
if (uniqueFiles.size <= 1) continue;
// Sort by file path for consistent reporting
const sorted = [...methods].sort((a, b) => a.file.localeCompare(b.file));
const first = sorted[0];
const firstLocation = `${getRelativePath(first.file)}:${first.className}.${first.methodName}()`;
for (let i = 1; i < sorted.length; i++) {
const method = sorted[i];
const file = files.find((f) => f.getFilePath() === method.file);
if (!file) continue;
violations.push(
this.createViolation(
file,
method.line,
0,
`Duplicate method logic: ${method.className}.${method.methodName}() has identical structure to ${firstLocation}`,
'Consider extracting to a shared base class or utility function',
),
);
}
}
return violations;
}
private findTestsDuplicatingMethods(
testFiles: SourceFile[],
allFiles: SourceFile[],
): Violation[] {
const violations: Violation[] = [];
for (const testFile of testFiles) {
const tests = this.extractTestBodies(testFile);
for (const test of tests) {
// Check if test body matches any indexed method
const match = this.findMatchingMethod(test.fingerprint);
if (!match) continue;
// Don't report if same file
if (match.file === testFile.getFilePath()) continue;
const file = allFiles.find((f) => f.getFilePath() === testFile.getFilePath());
if (!file) continue;
violations.push(
this.createViolation(
file,
test.line,
0,
`Test duplicates existing method: "${test.testName}" contains logic similar to ${match.className}.${match.methodName}()`,
`Use the existing method from ${getRelativePath(match.file)} instead of duplicating`,
),
);
}
}
return violations;
}
private findDuplicateTests(testFiles: SourceFile[], allFiles: SourceFile[]): Violation[] {
const violations: Violation[] = [];
const testIndex = new Map<string, TestFingerprint[]>();
// Build test fingerprint index
for (const file of testFiles) {
const tests = this.extractTestBodies(file);
for (const test of tests) {
const existing = testIndex.get(test.fingerprint) ?? [];
existing.push(test);
testIndex.set(test.fingerprint, existing);
}
}
// Find duplicates
for (const [_hash, tests] of testIndex) {
// Only flag if same fingerprint in multiple files
const uniqueFiles = new Set(tests.map((t) => t.file));
if (uniqueFiles.size <= 1) continue;
const sorted = [...tests].sort((a, b) => a.file.localeCompare(b.file));
const first = sorted[0];
const firstLocation = `${getRelativePath(first.file)}:"${first.testName}"`;
for (let i = 1; i < sorted.length; i++) {
const test = sorted[i];
const file = allFiles.find((f) => f.getFilePath() === test.file);
if (!file) continue;
violations.push(
this.createViolation(
file,
test.line,
0,
`Duplicate test logic: "${test.testName}" has identical structure to ${firstLocation}`,
'Consider extracting shared setup to a helper function or fixture',
),
);
}
}
return violations;
}
private extractTestBodies(file: SourceFile): TestFingerprint[] {
const results: TestFingerprint[] = [];
const filePath = file.getFilePath();
// Find test() and test.describe() calls
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const expr = call.getExpression();
const text = expr.getText();
// Match test('name', ...) or it('name', ...)
if (text !== 'test' && text !== 'it') continue;
const args = call.getArguments();
if (args.length < 2) continue;
// Get test name
const nameArg = args[0];
let testName = 'anonymous';
if (nameArg.getKind() === SyntaxKind.StringLiteral) {
const stringLit = nameArg.asKind(SyntaxKind.StringLiteral);
if (stringLit) testName = stringLit.getLiteralText();
}
// Get test body (second argument should be arrow function or function)
const bodyArg = args[1];
const result = this.fingerprintNode(bodyArg);
if (!result || result.statementCount < this.minStatements) continue;
results.push({
file: filePath,
testName,
fingerprint: result.hash,
line: call.getStartLineNumber(),
statementCount: result.statementCount,
});
}
return results;
}
private findMatchingMethod(testFingerprint: string): MethodFingerprint | null {
const matches = this.methodIndex.get(testFingerprint);
return matches?.[0] ?? null;
}
private fingerprintMethod(
method: MethodDeclaration,
): { hash: string; statementCount: number } | null {
const body = method.getBody();
if (!body) return null;
// Body should be a Block
const block = body.asKind(SyntaxKind.Block);
if (!block) return null;
const statements = block.getStatements();
if (statements.length < this.minStatements) return null;
const normalized = statements.map((s) => this.normalizeNode(s)).join('|');
return {
hash: this.hashString(normalized),
statementCount: statements.length,
};
}
private fingerprintNode(node: Node): { hash: string; statementCount: number } | null {
let body: Node | undefined;
if (node.getKind() === SyntaxKind.ArrowFunction) {
body = node.asKind(SyntaxKind.ArrowFunction)?.getBody();
} else if (node.getKind() === SyntaxKind.FunctionExpression) {
body = node.asKind(SyntaxKind.FunctionExpression)?.getBody();
}
if (!body) return null;
const block = body.asKind(SyntaxKind.Block);
if (!block) return null;
const statements = block.getStatements();
if (statements.length < this.minStatements) return null;
const normalized = statements.map((s) => this.normalizeNode(s)).join('|');
return {
hash: this.hashString(normalized),
statementCount: statements.length,
};
}
private normalizeNode(node: Node): string {
const kind = node.getKind();
switch (kind) {
// Await expression
case SyntaxKind.AwaitExpression: {
const children = node.getChildren();
const expr = children.find((c) => c.getKind() !== SyntaxKind.AwaitKeyword);
return `await(${expr ? this.normalizeNode(expr) : ''})`;
}
// Call expression - core pattern matching
case SyntaxKind.CallExpression: {
const call = node.asKind(SyntaxKind.CallExpression);
if (!call) return 'call';
const expr = call.getExpression();
const normalizedExpr = this.normalizeExpression(expr);
const argCount = call.getArguments().length;
return `call(${normalizedExpr},args=${argCount})`;
}
// Property access - normalize but keep structure
case SyntaxKind.PropertyAccessExpression: {
const propAccess = node.asKind(SyntaxKind.PropertyAccessExpression);
if (!propAccess) return 'prop';
const obj = propAccess.getExpression();
const name = propAccess.getName();
// Keep method/property names but normalize the object
return `${this.normalizeNode(obj)}.${name}`;
}
// Variable declaration
case SyntaxKind.VariableStatement: {
const varStmt = node.asKind(SyntaxKind.VariableStatement);
if (!varStmt) return 'var';
const decls = varStmt.getDeclarationList().getDeclarations();
const patterns = decls.map((d) => {
const init = d.getInitializer();
return `VAR=${init ? this.normalizeNode(init) : 'undefined'}`;
});
return `var(${patterns.join(',')})`;
}
// Expression statement
case SyntaxKind.ExpressionStatement: {
const exprStmt = node.asKind(SyntaxKind.ExpressionStatement);
if (!exprStmt) return 'expr';
return this.normalizeNode(exprStmt.getExpression());
}
// Return statement
case SyntaxKind.ReturnStatement: {
const retStmt = node.asKind(SyntaxKind.ReturnStatement);
if (!retStmt) return 'return';
const expr = retStmt.getExpression();
return `return(${expr ? this.normalizeNode(expr) : ''})`;
}
// If statement
case SyntaxKind.IfStatement: {
const ifStmt = node.asKind(SyntaxKind.IfStatement);
if (!ifStmt) return 'if';
const condition = this.normalizeNode(ifStmt.getExpression());
const thenBranch = ifStmt.getThenStatement();
const elseBranch = ifStmt.getElseStatement();
let pattern = `if(${condition})`;
if (thenBranch) pattern += `then(${this.countStatements(thenBranch)})`;
if (elseBranch) pattern += `else(${this.countStatements(elseBranch)})`;
return pattern;
}
// For loop
case SyntaxKind.ForStatement:
case SyntaxKind.ForOfStatement:
case SyntaxKind.ForInStatement: {
const forStmt =
node.asKind(SyntaxKind.ForOfStatement) ??
node.asKind(SyntaxKind.ForInStatement) ??
node.asKind(SyntaxKind.ForStatement);
if (!forStmt) return 'for';
const body = forStmt.getStatement();
return `for(body=${this.countStatements(body)})`;
}
// Identifiers - normalize to generic token
case SyntaxKind.Identifier:
return 'ID';
// String literals - normalize to generic token
case SyntaxKind.StringLiteral:
return 'STR';
// Number literals
case SyntaxKind.NumericLiteral:
return 'NUM';
// Template literals
case SyntaxKind.TemplateExpression:
case SyntaxKind.NoSubstitutionTemplateLiteral:
return 'TMPL';
// Object/array literals
case SyntaxKind.ObjectLiteralExpression:
return 'OBJ';
case SyntaxKind.ArrayLiteralExpression:
return 'ARR';
// Binary expressions
case SyntaxKind.BinaryExpression: {
const binExpr = node.asKind(SyntaxKind.BinaryExpression);
if (!binExpr) return 'bin';
const op = binExpr.getOperatorToken().getText();
const left = this.normalizeNode(binExpr.getLeft());
const right = this.normalizeNode(binExpr.getRight());
return `bin(${left},${op},${right})`;
}
// Default - use kind name
default:
return SyntaxKind[kind] ?? 'unknown';
}
}
private normalizeExpression(node: Node): string {
const kind = node.getKind();
const config = getConfig();
if (kind === SyntaxKind.PropertyAccessExpression) {
const propAccess = node.asKind(SyntaxKind.PropertyAccessExpression);
if (!propAccess) return 'prop';
const obj = propAccess.getExpression();
const name = propAccess.getName();
// Normalize this.page, this.request etc to FIXTURE for cross-layer matching
if (obj.getKind() === SyntaxKind.ThisKeyword && PLAYWRIGHT_FIXTURES.includes(name)) {
return 'FIXTURE';
}
const objNorm = this.normalizeExpression(obj);
return `${objNorm}.${name}`;
}
if (kind === SyntaxKind.CallExpression) {
const call = node.asKind(SyntaxKind.CallExpression);
if (!call) return 'call';
const expr = call.getExpression();
return `${this.normalizeExpression(expr)}()`;
}
if (kind === SyntaxKind.ThisKeyword) {
return 'this';
}
if (kind === SyntaxKind.Identifier) {
const name = node.asKind(SyntaxKind.Identifier)?.getText() ?? 'id';
if (PLAYWRIGHT_FIXTURES.includes(name)) return 'FIXTURE';
if (name === config.fixtureObjectName || name === config.apiFixtureName) return 'APP';
return 'VAR';
}
return 'expr';
}
private countStatements(node: Node): number {
if (node.getKind() === SyntaxKind.Block) {
const block = node.asKind(SyntaxKind.Block);
return block?.getStatements().length ?? 0;
}
return 1;
}
/** djb2 hash for fingerprint strings */
private hashString(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
// Convert to hex string for readability
return (hash >>> 0).toString(16);
}
}

View File

@ -0,0 +1,14 @@
export { BaseRule } from './base-rule.js';
export { BoundaryProtectionRule } from './boundary-protection.rule.js';
export { ScopeLockdownRule } from './scope-lockdown.rule.js';
export { SelectorPurityRule } from './selector-purity.rule.js';
export { DeadCodeRule } from './dead-code.rule.js';
export { ApiPurityRule } from './api-purity.rule.js';
export { NoPageInFlowRule } from './no-page-in-flow.rule.js';
export { DeduplicationRule } from './deduplication.rule.js';
export { TestDataHygieneRule } from './test-data-hygiene.rule.js';
export { DuplicateLogicRule } from './duplicate-logic.rule.js';
export { NoDirectPageInstantiationRule } from './no-direct-page-instantiation.rule.js';
// Re-export types for convenience
export type { Violation, FixResult, RuleResult, RuleConfig } from '../types.js';

View File

@ -0,0 +1,147 @@
import { describe } from 'vitest';
import { NoDirectPageInstantiationRule } from './no-direct-page-instantiation.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('NoDirectPageInstantiationRule', () => {
const rule = new NoDirectPageInstantiationRule();
test('detects new CanvasPage() in test file', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/canvas.spec.ts',
`
import { CanvasPage } from '../../pages/CanvasPage';
test('my test', async ({ page }) => {
const canvas = new CanvasPage(page);
await canvas.addNode('Code');
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('Direct page instantiation');
expect(violations[0].message).toContain('CanvasPage');
});
test('detects multiple page instantiations', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/workflow.spec.ts',
`
import { CanvasPage } from '../../pages/CanvasPage';
import { SettingsPage } from '../../pages/SettingsPage';
test('my test', async ({ page }) => {
const canvas = new CanvasPage(page);
const settings = new SettingsPage(page);
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(2);
});
test('provides helpful suggestion with fixture name', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/canvas.spec.ts',
`
test('my test', async ({ page }) => {
const canvas = new CanvasPage(page);
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].suggestion).toContain('n8n.canvas');
expect(violations[0].suggestion).toContain('instead of new CanvasPage()');
});
test('allows non-page class instantiation', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/utils.spec.ts',
`
test('my test', async () => {
const helper = new TestHelper();
const date = new Date();
const map = new Map();
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('targets only test files via getTargetGlobs', ({ project: _project }) => {
// project fixture initializes config
const globs = rule.getTargetGlobs();
// Should only target test files
expect(globs).toEqual(['tests/**/*.spec.ts']);
// Should NOT include pages, composables, etc.
expect(globs.some((g) => g.includes('pages'))).toBe(false);
expect(globs.some((g) => g.includes('composables'))).toBe(false);
});
test('handles complex class names', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/settings.spec.ts',
`
test('my test', async ({ page }) => {
const personal = new SettingsPersonalPage(page);
const security = new SettingsSecurityAuditPage(page);
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(2);
expect(violations[0].suggestion).toContain('n8n.settingsPersonal');
expect(violations[1].suggestion).toContain('n8n.settingsSecurityAudit');
});
test('does not match classes that start with Page', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/utils.spec.ts',
`
test('my test', async () => {
const paginator = new Paginator();
const pageSize = new PageSize();
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('matches only exact Page suffix pattern', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/various.spec.ts',
`
test('my test', async ({ page }) => {
// Should match - ends with Page
const canvas = new CanvasPage(page);
// Should NOT match - doesn't match pattern
const pages = new MyPages(page);
const pager = new PageHandler(page);
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('CanvasPage');
});
});

View File

@ -0,0 +1,110 @@
/**
* No Direct Page Instantiation Rule
*
* Enforces the facade pattern by preventing direct instantiation of page objects in test files.
* Tests should access pages through the fixture object (e.g., n8n.canvas) instead of
* creating new instances directly (e.g., new CanvasPage()).
*
* This rule is OPT-IN (disabled by default) since not all projects use the facade pattern.
* Enable it in your janitor.config.ts:
*
* ```typescript
* rules: {
* 'no-direct-page-instantiation': { enabled: true }
* }
* ```
*
* Violations:
* - `new CanvasPage(page)` in test files
* - `new SettingsPage(...)` in test files
*
* Allowed:
* - Page instantiation in fixture files (where the facade is set up)
* - Page instantiation in page object files (for composition)
* - Page instantiation in composables (if they need to create pages)
*/
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import { truncateText } from '../utils/ast-helpers.js';
export class NoDirectPageInstantiationRule extends BaseRule {
readonly id = 'no-direct-page-instantiation';
readonly name = 'No Direct Page Instantiation';
readonly description =
'Tests should access pages through the fixture facade, not instantiate directly';
readonly severity = 'error' as const;
getTargetGlobs(): string[] {
const config = getConfig();
return config.patterns.tests;
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
for (const file of files) {
const newExpressions = file.getDescendantsOfKind(SyntaxKind.NewExpression);
for (const newExpr of newExpressions) {
const expr = newExpr.getExpression();
const className = expr.getText();
if (this.isPageInstantiation(className)) {
if (this.isAllowed(className)) {
continue;
}
const startLine = newExpr.getStartLineNumber();
const startColumn = newExpr.getStart() - newExpr.getStartLinePos();
const truncated = truncateText(newExpr.getText(), 60);
violations.push(
this.createViolation(
file,
startLine,
startColumn,
`Direct page instantiation: ${truncated}`,
this.getSuggestion(className),
),
);
}
}
}
return violations;
}
/**
* Check if a class name looks like a page object instantiation
* Matches patterns like: CanvasPage, SettingsPage, WorkflowListPage, etc.
*/
private isPageInstantiation(className: string): boolean {
// Match PascalCase names ending with "Page"
return /^[A-Z][a-zA-Z]*Page$/.test(className);
}
private getSuggestion(className: string): string {
const config = getConfig();
const fixtureName = config.fixtureObjectName;
// Convert CanvasPage -> canvas, SettingsPersonalPage -> settingsPersonal
const propertyName = this.classNameToPropertyName(className);
return `Access through ${fixtureName} facade: ${fixtureName}.${propertyName} instead of new ${className}()`;
}
/**
* Convert page class name to likely property name
* CanvasPage -> canvas
* SettingsPersonalPage -> settingsPersonal
*/
private classNameToPropertyName(className: string): string {
// Remove 'Page' suffix
const withoutPage = className.replace(/Page$/, '');
// Convert PascalCase to camelCase
return withoutPage.charAt(0).toLowerCase() + withoutPage.slice(1);
}
}

View File

@ -0,0 +1,149 @@
import { describe } from 'vitest';
import { NoPageInFlowRule } from './no-page-in-flow.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('NoPageInFlowRule', () => {
const rule = new NoPageInFlowRule();
test('allows page object usage', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
constructor(private n8n: any) {}
async createWorkflow() {
await this.n8n.canvas.openNewWorkflow();
await this.n8n.ndv.setName('Test');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('detects direct page.getByTestId access', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
constructor(private n8n: any) {}
async clickButton() {
await this.n8n.page.getByTestId('button').click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('page.getByTestId');
});
test('detects page.locator access', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
constructor(private n8n: any) {}
async findElement() {
return this.n8n.page.locator('.my-class');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
});
test('detects page.goto access', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
constructor(private n8n: any) {}
async navigate() {
await this.n8n.page.goto('/workflows');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].suggestion).toContain('navigation');
});
test('detects multiple direct page accesses', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
constructor(private n8n: any) {}
async doStuff() {
await this.n8n.page.getByTestId('a').click();
await this.n8n.page.locator('.b').fill('text');
await this.n8n.page.getByRole('button').click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(3);
});
test('reports correct line numbers', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`export class WorkflowComposer {
constructor(private n8n: any) {}
async method() {
// line 5
await this.n8n.page.getByTestId('x').click(); // line 6
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].line).toBe(6);
});
test('provides context-specific suggestions', ({ project, createFile }) => {
const file = createFile(
'/composables/WorkflowComposer.ts',
`
export class WorkflowComposer {
constructor(private n8n: any) {}
async method() {
await this.n8n.page.getByTestId('x').click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(1);
expect(violations[0].suggestion).toBeDefined();
expect(violations[0].suggestion).toContain('page object');
});
});

View File

@ -0,0 +1,122 @@
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import { LOCATOR_METHODS } from '../utils/ast-helpers.js';
/**
* No Page In Flow Rule
*
* Flows/Composables should interact ONLY through page objects, never directly
* with fixture.page. This enforces the four-layer architecture:
*
* Tests Flows/Composables Pages BasePage
*
* Even "legitimate" page methods like keyboard, evaluate, reload should
* be wrapped in page object helpers for flows.
*
* Why this matters:
* - Keeps flows focused on high-level test workflows
* - All DOM/page interaction is abstracted through page objects
* - Makes tests more readable and maintainable
*
* Violations:
* - this.fixture.page.anything()
* - this.fixture.page (property access)
*
* This is stricter than selector-purity, which allows page-level methods
* like keyboard, evaluate, etc. in both composables and tests.
*/
export class NoPageInFlowRule extends BaseRule {
readonly id = 'no-page-in-flow';
readonly name = 'No Page In Flow';
readonly severity = 'warning' as const;
get description(): string {
const config = getConfig();
return `${config.flowLayerName}s should use page objects, not direct page access`;
}
getTargetGlobs(): string[] {
return getConfig().patterns.flows;
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
const config = getConfig();
const fixtureName = config.fixtureObjectName;
const flowLayerName = config.flowLayerName.toLowerCase();
// Build regex to match this.<fixture>.page.<method>
const pageAccessRegex = new RegExp(`^this\\.${fixtureName}\\.page\\.(\\w+)`);
for (const file of files) {
const propAccesses = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);
const flaggedPositions = new Set<string>();
for (const propAccess of propAccesses) {
const text = propAccess.getText();
const match = text.match(pageAccessRegex);
if (match) {
// Check if this matches any allow patterns
if (this.isAllowed(text)) {
continue;
}
const startLine = propAccess.getStartLineNumber();
const startColumn = propAccess.getStart() - propAccess.getStartLinePos();
const posKey = `${startLine}:${startColumn}`;
if (flaggedPositions.has(posKey)) {
continue;
}
flaggedPositions.add(posKey);
const methodName = match[1];
violations.push(
this.createViolation(
file,
startLine,
startColumn,
`Direct page access in ${flowLayerName}: this.${fixtureName}.page.${methodName}`,
this.getSuggestion(methodName),
),
);
}
}
}
return violations;
}
/**
* Get appropriate suggestion based on the method being called
*/
private getSuggestion(methodName: string): string {
if (methodName === 'keyboard') {
return 'Create a helper method in the relevant page object for keyboard interactions';
}
if (methodName === 'evaluate') {
return 'Create a helper method in the relevant page object for evaluate calls';
}
if (methodName === 'reload') {
return 'Use a page object method to handle page reload with proper waiting';
}
if (methodName === 'goto') {
return 'Use the page object navigation method instead';
}
if (methodName.startsWith('waitFor')) {
return 'Use a page object method for waiting operations';
}
if (LOCATOR_METHODS.includes(methodName)) {
return 'Use page object methods instead of direct locators';
}
if (methodName === 'url') {
return 'Use a page object method to check URL state';
}
return 'Wrap this page interaction in a page object method';
}
}

View File

@ -0,0 +1,177 @@
import { describe } from 'vitest';
import { ScopeLockdownRule } from './scope-lockdown.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('ScopeLockdownRule', () => {
const rule = new ScopeLockdownRule();
test('detects unscoped locator calls when container exists', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
get container() {
return this.page.getByTestId('root');
}
getSomething() {
return this.page.getByTestId('x');
}
}
`,
);
const violations = rule.analyze(project, [file]);
const unscopedCall = violations.find((v) => v.message.includes('Unscoped locator'));
expect(unscopedCall).toBeDefined();
});
test('allows properly scoped locators', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
get container() {
return this.page.getByTestId('root');
}
getSomething() {
return this.container.getByTestId('x');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('allows page-level methods like goto and waitForResponse', ({ project, createFile }) => {
const file = createFile(
'/pages/TestPage.ts',
`
export class TestPage {
get container() {
return this.page.getByTestId('root');
}
async navigate() {
await this.page.goto('/test');
await this.page.waitForResponse('/api/test');
await this.page.reload();
await this.page.keyboard.press('Enter');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('skips component files', ({ project, createFile }) => {
const file = createFile(
'/pages/components/TestComponent.ts',
`
export class TestComponent {
constructor(private root: Locator) {}
getSomething() {
return this.root.getByTestId('x');
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('skips BasePage.ts', ({ project, createFile }) => {
const file = createFile(
'/pages/BasePage.ts',
`
export class BasePage {
protected clickByTestId(testId: string) {
return this.page.getByTestId(testId).click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('allows standalone pages with navigation method', ({ project, createFile }) => {
const file = createFile(
'/pages/LoginPage.ts',
`
export class LoginPage {
async goto() {
await this.page.goto('/login');
}
async login() {
await this.page.getByTestId('email').fill('test@test.com');
}
}
`,
);
const violations = rule.analyze(project, [file]);
// Has navigation method = explicit standalone page, no violations
expect(violations).toHaveLength(0);
});
test('detects ambiguous pages without container or navigation method', ({
project,
createFile,
}) => {
const file = createFile(
'/pages/AmbiguousPage.ts',
`
export class AmbiguousPage {
async doSomething() {
await this.page.getByTestId('email').fill('test@test.com');
}
}
`,
);
const violations = rule.analyze(project, [file]);
// No container AND no navigation method = ambiguous
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('Ambiguous page');
});
test('allows custom navigation method names from config', ({ project, createFile }) => {
const file = createFile(
'/pages/SettingsPage.ts',
`
export class SettingsPage {
async navigate() {
await this.page.goto('/settings');
}
async toggleOption() {
await this.page.getByTestId('toggle').click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
// 'navigate' is in default config, so this should pass
expect(violations).toHaveLength(0);
});
});

View File

@ -0,0 +1,167 @@
import {
SyntaxKind,
type Project,
type SourceFile,
type CallExpression,
type ClassDeclaration,
} from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation } from '../types.js';
import {
hasContainerMember,
getCallExpressions,
isUnscopedPageCall,
isPageLevelMethod,
isLocatorCall,
} from '../utils/ast-helpers.js';
import { isExcludedPage, isComponentFile } from '../utils/paths.js';
/**
* Check if a call expression is inside a container getter or property definition
*/
function isInsideContainerDefinition(call: CallExpression): boolean {
let ancestor = call.getParent();
while (ancestor) {
// Check if we're inside a getter accessor named 'container'
if (ancestor.getKind() === SyntaxKind.GetAccessor) {
const getter = ancestor.asKind(SyntaxKind.GetAccessor);
if (getter?.getName() === 'container') {
return true;
}
}
// Check if we're inside a property declaration named 'container'
if (ancestor.getKind() === SyntaxKind.PropertyDeclaration) {
const prop = ancestor.asKind(SyntaxKind.PropertyDeclaration);
if (prop?.getName() === 'container') {
return true;
}
}
ancestor = ancestor.getParent();
}
return false;
}
/**
* Check if a class has a navigation method (indicating it's a standalone top-level page)
*/
function hasNavigationMethod(classDecl: ClassDeclaration, methodNames: string[]): boolean {
const methods = classDecl.getMethods();
return methods.some((method) => methodNames.includes(method.getName()));
}
/**
* Scope Lockdown Rule
*
* Page classes with a container must scope all locators to that container.
* This ensures isolation and prevents selector conflicts.
*
* A page must either:
* 1. Have a container property/getter and use it for all locators (scoped component/section)
* 2. Have a navigation method (standalone top-level page, can use this.page directly)
*
* Violations:
* - Page with container using this.page.getByTestId() instead of this.container.getByTestId()
* - Page with neither container nor navigation method (ambiguous architecture)
*
* Allowed:
* - this.page.goto(), this.page.waitForURL() etc. (page-level operations)
* - Pages with navigation methods (explicit standalone pages)
*/
export class ScopeLockdownRule extends BaseRule {
readonly id = 'scope-lockdown';
readonly name = 'Scope Lockdown';
readonly description = 'Page locators must be scoped to container';
readonly severity = 'error' as const;
private getNavigationMethods(): string[] {
const config = getConfig();
return config.rules?.['scope-lockdown']?.navigationMethods ?? ['goto'];
}
getTargetGlobs(): string[] {
const config = getConfig();
return config.patterns.pages;
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
const navigationMethods = this.getNavigationMethods();
for (const file of files) {
const filePath = file.getFilePath();
// Skip excluded files
if (isExcludedPage(filePath)) {
continue;
}
// Skip component files (they use a different pattern with root locator)
if (isComponentFile(filePath)) {
continue;
}
for (const classDecl of file.getClasses()) {
const className = classDecl.getName() ?? 'Anonymous';
const hasContainer = hasContainerMember(classDecl);
const hasNavMethod = hasNavigationMethod(classDecl, navigationMethods);
// Check for ambiguous pages (neither container nor navigation method)
if (!hasContainer && !hasNavMethod) {
const startLine = classDecl.getStartLineNumber();
const startColumn = classDecl.getStart() - classDecl.getStartLinePos();
violations.push(
this.createViolation(
file,
startLine,
startColumn,
`${className}: Ambiguous page - add a container (for scoped components) or a navigation method (for top-level pages)`,
`Add a 'container' getter or a navigation method (${navigationMethods.join(', ')})`,
),
);
continue;
}
// Skip classes without container but with navigation method (explicit standalone pages)
if (!hasContainer && hasNavMethod) {
continue;
}
// For classes with container, check for unscoped locator calls
const calls = getCallExpressions(classDecl);
for (const call of calls) {
// Skip page-level methods (goto, waitForURL, etc.)
if (isPageLevelMethod(call)) {
continue;
}
// Skip locator calls inside container definitions (e.g., get container() { return this.page.locator(...) })
if (isInsideContainerDefinition(call)) {
continue;
}
// Check for unscoped page locator calls
if (isUnscopedPageCall(call) && isLocatorCall(call)) {
const startLine = call.getStartLineNumber();
const startColumn = call.getStart() - call.getStartLinePos();
violations.push(
this.createViolation(
file,
startLine,
startColumn,
`${className}: Unscoped locator - use this.container instead of this.page`,
'Replace this.page.getByTestId(...) with this.container.getByTestId(...)',
),
);
}
}
}
}
return violations;
}
}

View File

@ -0,0 +1,166 @@
import { describe } from 'vitest';
import { SelectorPurityRule } from './selector-purity.rule.js';
import { test, expect } from '../test/fixtures.js';
describe('SelectorPurityRule', () => {
const rule = new SelectorPurityRule();
test('detects direct n8n.page.getByTestId in composable', ({ project, createFile }) => {
const file = createFile(
'/composables/TestComposer.ts',
`
export class TestComposer {
constructor(private n8n: n8nPage) {}
async doSomething() {
await this.n8n.page.getByTestId('something').click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations.length).toBeGreaterThan(0);
});
test('allows page object method calls', ({ project, createFile }) => {
const file = createFile(
'/composables/TestComposer.ts',
`
export class TestComposer {
constructor(private n8n: n8nPage) {}
async doSomething() {
await this.n8n.canvas.openNode('Code');
await this.n8n.ndv.close();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('allows page.keyboard and page.evaluate', ({ project, createFile }) => {
const file = createFile(
'/composables/TestComposer.ts',
`
export class TestComposer {
constructor(private n8n: n8nPage) {}
async doSomething() {
await this.n8n.page.keyboard.press('Enter');
await this.n8n.page.evaluate(() => console.log('test'));
await this.n8n.page.reload();
await this.n8n.page.waitForLoadState();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
test('detects direct page.getByTestId in test files', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/my-test.spec.ts',
`
test('my test', async ({ n8n }) => {
await n8n.page.getByTestId('something').click();
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations.length).toBeGreaterThan(0);
});
test('detects various locator methods', ({ project, createFile }) => {
const file = createFile(
'/composables/TestComposer.ts',
`
export class TestComposer {
constructor(private n8n: n8nPage) {}
async doSomething() {
await this.n8n.page.locator('.class').click();
await this.n8n.page.getByRole('button').click();
await this.n8n.page.getByText('text').click();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations.length).toBeGreaterThanOrEqual(3);
});
test('detects chained locator calls on variables in test files', ({ project, createFile }) => {
const file = createFile(
'/tests/e2e/my-test.spec.ts',
`
test('my test', async ({ n8n }) => {
const category = n8n.settingsPage.getCategory('credentials');
const links = category.locator('a[href*="/workflow/"]');
await expect(links.first()).toBeVisible();
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations.length).toBeGreaterThan(0);
expect(violations[0].message).toContain('Chained locator call');
});
test('detects chained getByRole on variables in test files', ({ project, createFile }) => {
// Use a simpler test case that mirrors the working test more closely
const file = createFile(
'/tests/e2e/another-test.spec.ts',
`
test('my test', async ({ n8n }) => {
const category = n8n.settingsPage.getCategory('credentials');
const button = category.getByRole('button');
await expect(button).toBeVisible();
});
`,
);
const violations = rule.analyze(project, [file]);
expect(violations.length).toBeGreaterThan(0);
expect(violations[0].message).toContain('Chained locator call');
});
test('allows this.container.locator in page objects (not test files)', ({
project,
createFile,
}) => {
// This is a composable file, not a test file, so chained locator detection doesn't apply
const file = createFile(
'/composables/TestComposer.ts',
`
export class TestComposer {
constructor(private n8n: n8nPage) {}
async doSomething() {
// This uses page object methods, not direct locators
await this.n8n.modal.getSubmitButton();
}
}
`,
);
const violations = rule.analyze(project, [file]);
expect(violations).toHaveLength(0);
});
});

View File

@ -0,0 +1,314 @@
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 { LOCATOR_METHODS, PAGE_LEVEL_METHODS, truncateText } from '../utils/ast-helpers.js';
import { matchesPatterns } from '../utils/paths.js';
/**
* Selector Purity Rule
*
* Ensures composables and tests don't use direct page.locator() calls.
* All selector interactions should go through page objects.
*
* Violations:
* - fixture.page.getByTestId(), this.fixture.page.locator(), etc.
* - page.getByTestId() in test files
* - Chained locator calls: someLocator.locator(), someLocator.getByRole(), etc.
* (where someLocator is a variable holding a Locator returned from a page object)
*
* Exceptions (legitimate page-level operations):
* - page.keyboard.*
* - page.evaluate()
* - page.reload()
* - page.waitForURL()
* - page.waitForLoadState()
* - page.goto()
*
* Configuration:
* - allowInExpect: Skip violations inside expect() calls (default: false)
*/
export class SelectorPurityRule extends BaseRule {
readonly id = 'selector-purity';
readonly name = 'Selector Purity';
readonly description = 'Composables and tests should use page objects, not direct locators';
readonly severity = 'error' as const;
getTargetGlobs(): string[] {
const config = getConfig();
return [...config.patterns.flows, ...config.patterns.tests];
}
analyze(_project: Project, files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
const config = getConfig();
for (const file of files) {
const filePath = file.getFilePath();
const isTestFile = matchesPatterns(filePath, config.patterns.tests);
// Find all call expressions
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const expr = call.getExpression();
const text = expr.getText();
// Check for direct page locator patterns
if (this.isDirectPageLocator(text)) {
// Skip Playwright assertion methods (toBeVisible, etc.)
if (this.isPlaywrightAssertionCall(call)) {
continue;
}
// Skip if it's inside an expect() call with page object
if (this.isExpectWithPageObject(call)) {
continue;
}
// Skip violations inside expect() if allowInExpect is enabled
// Check both runtime config and config file settings
const ruleSettings = getConfig().rules?.[this.id];
const allowInExpect = this.config.allowInExpect ?? ruleSettings?.allowInExpect;
if (allowInExpect && this.isInsideExpect(call)) {
continue;
}
const startLine = call.getStartLineNumber();
const startColumn = call.getStart() - call.getStartLinePos();
const truncated = truncateText(call.getText(), 60);
// Determine the source pattern for better suggestion
const suggestion = this.getSuggestion(text);
violations.push(
this.createViolation(
file,
startLine,
startColumn,
`Direct page locator call: ${truncated}`,
suggestion,
),
);
}
// Check for chained locator calls in test files (e.g., someLocator.locator())
if (isTestFile && this.isChainedLocatorCallAST(call)) {
// Skip Playwright assertion methods
if (this.isPlaywrightAssertionCall(call)) {
continue;
}
// Skip violations inside expect() if allowInExpect is enabled
const ruleSettings = getConfig().rules?.[this.id];
const allowInExpect = this.config.allowInExpect ?? ruleSettings?.allowInExpect;
if (allowInExpect && this.isInsideExpect(call)) {
continue;
}
const startLine = call.getStartLineNumber();
const startColumn = call.getStart() - call.getStartLinePos();
const truncated = truncateText(call.getText(), 60);
violations.push(
this.createViolation(
file,
startLine,
startColumn,
`Chained locator call in test: ${truncated}`,
'Move selector to page object method instead of chaining .locator() in tests',
),
);
}
}
}
return violations;
}
/**
* Check if expression is a direct page locator call
*/
private isDirectPageLocator(text: string): boolean {
const config = getConfig();
const fixtureName = config.fixtureObjectName;
for (const method of LOCATOR_METHODS) {
// Fixture.page.* patterns (e.g., app.page.getByTestId)
if (text.includes(`.${fixtureName}.page.${method}`)) {
return true;
}
// Direct page.* patterns (tests)
const directPagePattern = new RegExp(`(?<!\\w)page\\.${method}`);
if (directPagePattern.test(text)) {
return true;
}
const thisPagePattern = new RegExp(`this\\.page\\.${method}`);
if (thisPagePattern.test(text)) {
return true;
}
}
return false;
}
/**
* Check if the call is inside an expect() with a page object locator
*/
private isExpectWithPageObject(call: ReturnType<SourceFile['getDescendantsOfKind']>[0]): boolean {
const text = call.getText();
// If the call itself uses page object and not direct page locator, it's fine
if (!text.includes('.page.')) {
return false;
}
// Check for legitimate page-level methods
for (const method of PAGE_LEVEL_METHODS) {
if (text.includes(`.page.${method}`)) {
return true;
}
}
return false;
}
/** Playwright assertion methods that chain after expect() */
private static ASSERTION_METHODS = [
'toBeVisible',
'toBeHidden',
'toBeEnabled',
'toBeDisabled',
'toBeChecked',
'toBeEditable',
'toBeEmpty',
'toBeFocused',
'toBeAttached',
'toContainText',
'toHaveText',
'toHaveValue',
'toHaveValues',
'toHaveAttribute',
'toHaveClass',
'toHaveCount',
'toHaveCSS',
'toHaveId',
'toHaveJSProperty',
'toHaveScreenshot',
'toHaveTitle',
'toHaveURL',
'not',
];
/**
* Check if the call is inside an expect() call
*/
private isInsideExpect(call: CallExpression): boolean {
let current = call.getParent();
while (current) {
if (current.getKind() === SyntaxKind.CallExpression) {
const callExpr = current.asKind(SyntaxKind.CallExpression);
const exprText = callExpr?.getExpression().getText();
if (exprText === 'expect') {
return true;
}
}
current = current.getParent();
}
return false;
}
/**
* Check if this call is a Playwright assertion method
*/
private isPlaywrightAssertionCall(call: CallExpression): boolean {
const expr = call.getExpression();
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
const methodName = propAccess?.getName();
if (methodName && SelectorPurityRule.ASSERTION_METHODS.includes(methodName)) {
// Verify this is chained from expect()
const exprText = propAccess?.getExpression().getText() ?? '';
if (exprText.startsWith('expect(') || exprText.startsWith('expect.')) {
return true;
}
}
}
return false;
}
/**
* Check if a call expression is a chained locator call on a variable (not page/container)
* e.g., someLocator.locator('...'), category.getByRole('...')
*
* Uses AST analysis to properly identify the call structure.
*/
private isChainedLocatorCallAST(call: CallExpression): boolean {
const expr = call.getExpression();
// Must be a property access (e.g., something.locator)
if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) {
return false;
}
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
if (!propAccess) {
return false;
}
const methodName = propAccess.getName();
// Must be a locator method
if (!LOCATOR_METHODS.includes(methodName)) {
return false;
}
// Get the object the method is called on
const objectExpr = propAccess.getExpression();
const objectText = objectExpr.getText();
// Skip direct page access (handled by isDirectPageLocator)
if (objectText === 'page' || objectText === 'this.page') {
return false;
}
// Skip fixture.page access
const config = getConfig();
if (objectText === `${config.fixtureObjectName}.page`) {
return false;
}
// Skip container-based calls (legitimate in page objects)
if (objectText === 'container' || objectText === 'this.container') {
return false;
}
// Skip if object starts with 'this.' (page object internal method)
// e.g., this.getContainer().locator() is fine
if (objectText.startsWith('this.') && !objectText.includes('.page')) {
return false;
}
// At this point, we have a locator method called on something else
// This is a chained call on a variable holding a Locator
// e.g., const category = n8n.settingsPage.getCategory(); category.locator('...')
return true;
}
/**
* Get appropriate suggestion based on the pattern
*/
private getSuggestion(text: string): string {
const config = getConfig();
const fixtureName = config.fixtureObjectName;
if (text.includes(`.${fixtureName}.page.`)) {
return `Use page object methods instead: ${fixtureName}.<pageObject>.<method>()`;
}
if (text.includes('page.getByTestId')) {
return `Use page object methods from ${fixtureName} fixture instead of direct locators`;
}
return 'Extract selector to appropriate page object class';
}
}

View File

@ -0,0 +1,173 @@
import { Project, type SourceFile } from 'ts-morph';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestDataHygieneRule } from './test-data-hygiene.rule.js';
import { setConfig, resetConfig, defineConfig } from '../config.js';
/** Test interface to access private members of TestDataHygieneRule */
interface TestableTestDataHygieneRule {
badNamePatterns: RegExp[];
buildReferenceIndex(files: SourceFile[]): {
fileNames: Set<string>;
folderNames: Set<string>;
};
isOrphaned(
relativePath: string,
fileName: string,
dirName: string,
references: { fileNames: Set<string>; folderNames: Set<string> },
): boolean;
}
describe('TestDataHygieneRule', () => {
let project: Project;
let rule: TestDataHygieneRule;
beforeEach(() => {
project = new Project({ useInMemoryFileSystem: true });
rule = new TestDataHygieneRule();
setConfig(
defineConfig({
rootDir: '/',
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts'],
factories: ['factories/**/*.ts'],
testData: ['workflows/**/*', 'expectations/**/*'],
},
}),
);
});
afterEach(() => {
resetConfig();
});
it('detects generic workflow names', () => {
// Access private property for testing the patterns
const badNames = [
'Test_workflow_1.json',
'test.json',
'data.json',
'workflow_2.json',
'cat-1801.json',
];
const patterns = (rule as unknown as TestableTestDataHygieneRule).badNamePatterns;
for (const name of badNames) {
const matches = patterns.some((p) => p.test(name));
expect(matches).toBe(true);
}
});
it('allows descriptive workflow names', () => {
const goodNames = [
'webhook-with-wait.json',
'ai-agent-tool-call.json',
'merge-node-multiple-inputs.json',
'simple-http-request.json',
'subworkflow-child-workflow.json',
];
const patterns = (rule as unknown as TestableTestDataHygieneRule).badNamePatterns;
for (const name of goodNames) {
const matches = patterns.some((p) => p.test(name));
expect(matches).toBe(false);
}
});
it('detects ticket-only names', () => {
const ticketNames = ['cat-1801.json', 'CAT-123.json', 'ado-456.json', 'SUG-38.json'];
const patterns = (rule as unknown as TestableTestDataHygieneRule).badNamePatterns;
for (const name of ticketNames) {
const matches = patterns.some((p) => p.test(name));
expect(matches).toBe(true);
}
});
it('finds workflow file references', () => {
const file = project.createSourceFile(
'/tests/workflow.spec.ts',
`
import { test } from '../fixtures/base';
test('imports workflow', async ({ n8n }) => {
await n8n.workflows.import('my-workflow.json');
});
`,
);
const refs = (rule as unknown as TestableTestDataHygieneRule).buildReferenceIndex([file]);
expect(refs.fileNames.has('my-workflow.json')).toBe(true);
});
it('finds loadExpectations folder references', () => {
const file = project.createSourceFile(
'/tests/proxy.spec.ts',
`
import { test } from '../fixtures/base';
test('loads expectations', async ({ proxyServer }) => {
await proxyServer.loadExpectations('langchain');
});
`,
);
const refs = (rule as unknown as TestableTestDataHygieneRule).buildReferenceIndex([file]);
expect(refs.folderNames.has('langchain')).toBe(true);
});
it('detects orphaned expectation folders', () => {
const refs = { fileNames: new Set<string>(), folderNames: new Set(['langchain']) };
// langchain folder is referenced
const langchainOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
'expectations/langchain/test.json',
'test.json',
'expectations/langchain',
refs,
);
expect(langchainOrphaned).toBe(false);
// unused-folder is not referenced
const unusedOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
'expectations/unused-folder/test.json',
'test.json',
'expectations/unused-folder',
refs,
);
expect(unusedOrphaned).toBe(true);
});
it('detects orphaned workflow files', () => {
const refs = { fileNames: new Set(['used-workflow.json']), folderNames: new Set<string>() };
const usedOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
'workflows/used-workflow.json',
'used-workflow.json',
'workflows',
refs,
);
expect(usedOrphaned).toBe(false);
const unusedOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
'workflows/unused-workflow.json',
'unused-workflow.json',
'workflows',
refs,
);
expect(unusedOrphaned).toBe(true);
});
});

View File

@ -0,0 +1,256 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
import { BaseRule } from './base-rule.js';
import { getConfig } from '../config.js';
import type { Violation, FixResult } from '../types.js';
import { getRootDir, getRelativePath, getTestDataFiles } from '../utils/paths.js';
/**
* Test Data Hygiene Rule
*
* Ensures test data files (workflows, expectations, etc.) are:
* 1. Well-named (descriptive, not generic or hash-based)
* 2. Actually used (not orphaned)
*
* Naming violations:
* - Generic names: Test_workflow_1.json, test.json, data.json
* - Ticket-only names: cat-1801.json (no description)
* - Hash/timestamp names: 1766027608058-...-2ef5ce20.json
*
* Orphan detection:
* - Workflows: filename must be referenced in test files
* - Expectations: folder must be referenced via loadExpectations()
*/
export class TestDataHygieneRule extends BaseRule {
readonly id = 'test-data-hygiene';
readonly name = 'Test Data Hygiene';
readonly description = 'Ensure test data files are well-named and actually used';
readonly severity = 'warning' as const;
readonly fixable = true;
// Patterns that indicate poor naming
private readonly badNamePatterns = [
// Generic names
/^test[_-]?\d*\.json$/i,
/^data[_-]?\d*\.json$/i,
/^workflow[_-]?\d*\.json$/i,
/^Test_workflow_\d+\.json$/,
// Ticket-only names (e.g., cat-1801.json, CAT-123.json)
/^[a-z]{2,5}[_-]\d+\.json$/i,
// Timestamp/hash soup (proxy-generated)
/^\d{10,}-.*-[a-f0-9]{6,}\.json$/i,
];
// Folders that use folder-based loading (expectations)
private readonly folderBasedPaths = ['expectations'];
getTargetGlobs(): string[] {
const config = getConfig();
// We analyze test files, flows, fixtures, and helpers to find references
return [
...config.patterns.tests,
...config.patterns.flows,
...config.patterns.fixtures,
...config.patterns.helpers,
];
}
analyze(project: Project, _files: SourceFile[]): Violation[] {
const violations: Violation[] = [];
const config = getConfig();
const root = getRootDir();
// Get all test data files
const testDataFiles = getTestDataFiles(config.patterns.testData);
// Always load ALL test/flow/fixture/helper files for reference checking
// This ensures orphan detection works correctly even in targeted file mode (TCR)
const allReferenceGlobs = [
...config.patterns.tests,
...config.patterns.flows,
...config.patterns.fixtures,
...config.patterns.helpers,
];
const allReferenceFiles = this.loadAllFiles(project, allReferenceGlobs, root);
// Build reference index from ALL test files
const references = this.buildReferenceIndex(allReferenceFiles);
for (const dataFile of testDataFiles) {
const relativePath = path.relative(root, dataFile);
const fileName = path.basename(dataFile);
const dirName = path.dirname(relativePath);
// Check naming
const namingViolation = this.checkNaming(fileName, relativePath, root);
if (namingViolation) {
violations.push(namingViolation);
}
// Check if orphaned
const isOrphaned = this.isOrphaned(relativePath, fileName, dirName, references);
if (isOrphaned) {
violations.push(
this.createViolationForFile(
relativePath,
root,
`Orphaned test data: ${fileName} is not referenced in any test`,
'Remove the file or add a test that uses it',
true, // fixable
{ filePath: dataFile },
),
);
}
}
return violations;
}
private buildReferenceIndex(files: SourceFile[]): {
fileNames: Set<string>;
folderNames: Set<string>;
} {
const fileNames = new Set<string>();
const folderNames = new Set<string>();
for (const file of files) {
// Use AST to find actual string literals (excludes comments)
const stringLiterals = file.getDescendantsOfKind(SyntaxKind.StringLiteral);
for (const literal of stringLiterals) {
const value = literal.getLiteralText();
// Check for .json file references
if (value.endsWith('.json')) {
const fileName = path.basename(value);
fileNames.add(fileName);
}
// Check for loadExpectations calls
const parent = literal.getParent();
if (parent?.isKind(SyntaxKind.CallExpression)) {
const callExpr = parent.asKindOrThrow(SyntaxKind.CallExpression);
const exprText = callExpr.getExpression().getText();
if (exprText.endsWith('loadExpectations')) {
folderNames.add(value);
}
}
}
}
return { fileNames, folderNames };
}
private checkNaming(fileName: string, relativePath: string, _root: string): Violation | null {
// Skip checking expectations - they're auto-generated
if (relativePath.startsWith('expectations/')) {
return null;
}
for (const pattern of this.badNamePatterns) {
if (pattern.test(fileName)) {
return this.createViolationForFile(
relativePath,
getRootDir(),
`Poorly named test data: ${fileName}`,
'Use a descriptive name like "webhook-with-wait.json" or "ai-agent-tool-call.json"',
);
}
}
return null;
}
private loadAllFiles(project: Project, globs: string[], root: string): SourceFile[] {
const files: SourceFile[] = [];
for (const glob of globs) {
const absoluteGlob = path.join(root, glob);
const added = project.addSourceFilesAtPaths(absoluteGlob);
files.push(...added);
}
// Deduplicate
const uniqueFiles = new Map<string, SourceFile>();
for (const file of files) {
uniqueFiles.set(file.getFilePath(), file);
}
return Array.from(uniqueFiles.values());
}
private isOrphaned(
relativePath: string,
fileName: string,
_dirName: string,
references: { fileNames: Set<string>; folderNames: Set<string> },
): boolean {
// Check if this is a folder-based path (expectations)
const isFolderBased = this.folderBasedPaths.some((p) => relativePath.startsWith(p + '/'));
if (isFolderBased) {
// For expectations, check if the immediate parent folder is referenced
// expectations/langchain/file.json -> check for 'langchain'
const parts = relativePath.split('/');
if (parts.length >= 2) {
const expectationFolder = parts[1]; // e.g., 'langchain'
return !references.folderNames.has(expectationFolder);
}
}
// For workflows and other files, check if filename is referenced
return !references.fileNames.has(fileName);
}
private createViolationForFile(
relativePath: string,
root: string,
message: string,
suggestion: string,
fixable?: boolean,
fixData?: { filePath: string },
): Violation {
return {
file: path.join(root, relativePath),
line: 1,
column: 0,
rule: this.id,
message,
severity: this.severity,
suggestion,
fixable,
fixData: fixData ? { type: 'edit', replacement: JSON.stringify(fixData) } : undefined,
};
}
fix(_project: Project, violations: Violation[], write: boolean): FixResult[] {
const results: FixResult[] = [];
for (const violation of violations) {
if (!violation.fixable || !violation.fixData) continue;
// Parse the fixData
if (violation.fixData.type !== 'edit') continue;
try {
const data = JSON.parse(violation.fixData.replacement) as { filePath: string };
const filePath = data.filePath;
const relativePath = getRelativePath(filePath);
results.push({
file: relativePath,
action: 'remove-file',
target: path.basename(filePath),
applied: write,
});
if (write && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
// Skip invalid fix data
}
}
return results;
}
}

View File

@ -0,0 +1,45 @@
import { Project, type SourceFile } from 'ts-morph';
import { test as base, expect } from 'vitest';
import { setConfig, resetConfig, defineConfig, type DefineConfigInput } from '../config.js';
export interface RuleTestContext {
project: Project;
createFile: (path: string, code: string) => SourceFile;
}
const defaultTestConfig: DefineConfigInput = {
rootDir: '/',
excludeFromPages: ['BasePage.ts'],
fixtureObjectName: 'n8n',
apiFixtureName: 'api',
flowLayerName: 'Composable',
rawApiPatterns: [/\brequest\.(get|post|put|patch|delete|head)\s*\(/i, /\bfetch\s*\(/],
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: ['helpers/**/*.ts'],
factories: ['factories/**/*.ts'],
testData: [],
},
};
export const test = base.extend<RuleTestContext>({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
project: async ({ task: _ }, use) => {
const project = new Project({ useInMemoryFileSystem: true });
setConfig(defineConfig(defaultTestConfig));
await use(project);
resetConfig();
},
createFile: async ({ project }, use) => {
await use((path: string, code: string) => project.createSourceFile(path, code));
},
});
export { expect };

View File

@ -0,0 +1,190 @@
/**
* Core Types for @n8n/playwright-janitor
*/
export type {
Project,
SourceFile,
CallExpression,
ClassDeclaration,
MethodDeclaration,
PropertyDeclaration,
Node,
} from 'ts-morph';
export { SyntaxKind } from 'ts-morph';
export type Severity = 'error' | 'warning' | 'info';
export type BuiltInRuleId =
| 'boundary-protection'
| 'scope-lockdown'
| 'selector-purity'
| 'no-page-in-flow'
| 'api-purity'
| 'dead-code'
| 'deduplication'
| 'test-data-hygiene'
| 'duplicate-logic'
| 'no-direct-page-instantiation';
// Allow any string for custom rules, while BuiltInRuleId provides type-safe hints for built-in rules
export type RuleId = string;
export interface RuleSettings {
enabled?: boolean;
severity?: Severity | 'off';
allowPatterns?: RegExp[];
allowInExpect?: boolean;
/** Method names that indicate a standalone/top-level page (for scope-lockdown rule) */
navigationMethods?: string[];
}
export interface RuleConfig {
allowInExpect?: boolean;
navigationMethod?: string;
}
export type RuleSettingsMap = {
[K in RuleId]?: RuleSettings;
};
export interface RunOptions {
files?: string[];
ruleConfig?: Record<string, RuleConfig>;
fix?: boolean;
write?: boolean;
}
export interface Violation {
file: string;
line: number;
column: number;
rule: string;
message: string;
severity: Severity;
suggestion?: string;
fixable?: boolean;
fixData?: FixData;
}
export interface MethodFixData {
type: 'method';
className: string;
memberName: string;
}
export interface PropertyFixData {
type: 'property';
className: string;
memberName: string;
}
export interface ClassFixData {
type: 'class';
className: string;
}
export interface EditFixData {
type: 'edit';
replacement: string;
}
export type FixData = MethodFixData | PropertyFixData | ClassFixData | EditFixData;
export function isMethodFix(data: FixData): data is MethodFixData {
return data.type === 'method';
}
export function isPropertyFix(data: FixData): data is PropertyFixData {
return data.type === 'property';
}
export function isClassFix(data: FixData): data is ClassFixData {
return data.type === 'class';
}
export function isEditFix(data: FixData): data is EditFixData {
return data.type === 'edit';
}
export type FixAction = 'remove-method' | 'remove-property' | 'remove-file' | 'edit';
export interface FixResult {
file: string;
action: FixAction;
target?: string;
applied: boolean;
}
export interface RuleResult {
rule: string;
violations: Violation[];
filesAnalyzed: number;
executionTimeMs: number;
fixable?: boolean;
fixes?: FixResult[];
}
export interface ReportSummary {
totalViolations: number;
byRule: Record<string, number>;
bySeverity: Record<Severity, number>;
filesAnalyzed: number;
}
export interface JanitorReport {
timestamp: string;
projectRoot: string;
rules: {
enabled: string[];
disabled: string[];
};
results: RuleResult[];
summary: ReportSummary;
}
import type { Project, SourceFile } from 'ts-morph';
export interface Rule {
readonly id: string;
readonly name: string;
readonly description: string;
readonly severity: Severity;
readonly fixable: boolean;
getTargetGlobs(): string[];
analyze(project: Project, files: SourceFile[]): Violation[];
fix?(project: Project, violations: Violation[], write: boolean): FixResult[];
isEnabled(): boolean;
getEffectiveSeverity(): Severity;
configure(config: RuleConfig): void;
execute(project: Project, files: SourceFile[]): RuleResult;
}
export interface FilePatterns {
pages: string[];
components: string[];
flows: string[];
tests: string[];
services: string[];
fixtures: string[];
helpers: string[];
factories: string[];
testData: string[];
}
export interface FacadeConfig {
file: string;
className: string;
excludeTypes: string[];
}
export interface RuleInfo {
id: string;
name: string;
description: string;
severity: Severity;
fixable: boolean;
enabled: boolean;
targetGlobs: string[];
}

View File

@ -0,0 +1,180 @@
import { SyntaxKind, type ClassDeclaration, type CallExpression, type SourceFile } from 'ts-morph';
/** Locator method names that we track for scoping */
export const LOCATOR_METHODS = [
'getByTestId',
'getByRole',
'getByText',
'getByLabel',
'getByPlaceholder',
'getByAltText',
'getByTitle',
'locator',
];
/** Page-level methods that are legitimate to call on page directly */
export const PAGE_LEVEL_METHODS = [
'goto',
'waitForResponse',
'waitForURL',
'waitForLoadState',
'waitForTimeout',
'waitForSelector',
'waitForNavigation',
'evaluate',
'keyboard',
'mouse',
'reload',
'goBack',
'goForward',
'route',
'unroute',
'setViewportSize',
'screenshot',
'close',
'isClosed',
'url',
'title',
'content',
'bringToFront',
'context',
'on',
'once',
'off',
'removeListener',
];
/**
* Check if a class has a container property, getter, or getContainer method
*/
export function hasContainerMember(classDecl: ClassDeclaration): boolean {
const hasProperty = classDecl.getProperty('container') !== undefined;
const hasGetter = classDecl.getGetAccessor('container') !== undefined;
const hasMethod = classDecl.getMethod('getContainer') !== undefined;
return hasProperty || hasGetter || hasMethod;
}
/**
* Get all call expressions within a class
*/
export function getCallExpressions(classDecl: ClassDeclaration): CallExpression[] {
return classDecl.getDescendantsOfKind(SyntaxKind.CallExpression);
}
/**
* Check if a call expression is a locator call (getByTestId, locator, etc.)
*/
export function isLocatorCall(call: CallExpression): boolean {
const expr = call.getExpression();
const text = expr.getText();
return LOCATOR_METHODS.some((method) => text.endsWith(`.${method}`));
}
/**
* Check if a call expression starts with this.page (unscoped locator)
*/
export function isUnscopedPageCall(call: CallExpression): boolean {
const expr = call.getExpression();
const text = expr.getText();
// Check for this.page.getByTestId, this.page.locator, etc.
return LOCATOR_METHODS.some((method) => text.startsWith(`this.page.${method}`));
}
/**
* Check if a call is a page-level method (legitimate to call on page)
*/
export function isPageLevelMethod(call: CallExpression): boolean {
const expr = call.getExpression();
const text = expr.getText();
// Check for this.page.goto, this.page.waitForResponse, etc.
return PAGE_LEVEL_METHODS.some(
(method) => text.startsWith(`this.page.${method}`) || text === `this.page.${method}`,
);
}
/**
* Get the method name from a call expression
*/
export function getMethodName(call: CallExpression): string | undefined {
const expr = call.getExpression();
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
return propAccess?.getName();
}
return undefined;
}
/**
* Get all import paths from a source file
*/
export function getImportPaths(file: SourceFile): string[] {
return file.getImportDeclarations().map((imp) => imp.getModuleSpecifierValue());
}
/**
* Check if an import path points to a pages file (excluding components)
*/
export function isPageImport(importPath: string, currentFilePath: string): boolean {
// Relative imports starting with ./pages/ or ../pages/
if (importPath.includes('/pages/') && !importPath.includes('/pages/components/')) {
return true;
}
// Handle relative imports from within pages directory
const normalized = importPath.replace(/\\/g, '/');
if (normalized.startsWith('./') || normalized.startsWith('../')) {
// If current file is in pages/, check if import is also a page (not component)
if (currentFilePath.includes('/pages/') && !currentFilePath.includes('/pages/components/')) {
// Import to a sibling page file (not going into components/)
if (!normalized.includes('/components/') && !normalized.includes('/components')) {
const parts = normalized.split('/');
const lastPart = parts[parts.length - 1];
// If it's a direct sibling import (like ./WorkflowPage or ../SomePage)
// and not going into a subdirectory like ./components/
if (
parts.length === 2 &&
(parts[0] === '.' || parts[0] === '..') &&
!lastPart.startsWith('Base')
) {
return true;
}
}
}
}
return false;
}
/**
* Get string literal argument from getByTestId calls
*/
export function getTestIdArgument(call: CallExpression): string | undefined {
const methodName = getMethodName(call);
if (methodName !== 'getByTestId') return undefined;
const args = call.getArguments();
if (args.length === 0) return undefined;
const firstArg = args[0];
if (firstArg.getKind() === SyntaxKind.StringLiteral) {
const stringLit = firstArg.asKind(SyntaxKind.StringLiteral);
return stringLit?.getLiteralText();
}
return undefined;
}
/**
* Truncate text for display, collapsing whitespace
*/
export function truncateText(text: string, maxLength: number): string {
const singleLine = text.replace(/\s+/g, ' ');
if (singleLine.length <= maxLength) {
return singleLine;
}
return singleLine.slice(0, maxLength - 3) + '...';
}

View File

@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { parseGitStatus, parseGitDiff } from './git-operations.js';
describe('git-operations', () => {
const gitRoot = '/repo';
const scopeDir = '/repo/packages/testing';
describe('parseGitStatus', () => {
it('parses modified files', () => {
const output = ' M packages/testing/src/file.ts\n';
const result = parseGitStatus(output, gitRoot, scopeDir);
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
});
it('parses added files', () => {
const output = 'A packages/testing/src/new-file.ts\n';
const result = parseGitStatus(output, gitRoot, scopeDir);
expect(result).toEqual(['/repo/packages/testing/src/new-file.ts']);
});
it('parses renamed files (takes new path)', () => {
const output = 'R packages/testing/old.ts -> packages/testing/new.ts\n';
const result = parseGitStatus(output, gitRoot, scopeDir);
expect(result).toEqual(['/repo/packages/testing/new.ts']);
});
it('filters by scope directory', () => {
const output = [' M packages/testing/src/file.ts', ' M packages/other/src/file.ts'].join(
'\n',
);
const result = parseGitStatus(output, gitRoot, scopeDir);
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
});
it('filters by extension', () => {
const output = [' M packages/testing/src/file.ts', ' M packages/testing/src/file.json'].join(
'\n',
);
const result = parseGitStatus(output, gitRoot, scopeDir, ['.ts']);
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
});
it('allows all extensions when empty array', () => {
const output = [' M packages/testing/src/file.ts', ' M packages/testing/src/file.json'].join(
'\n',
);
const result = parseGitStatus(output, gitRoot, scopeDir, []);
expect(result).toHaveLength(2);
});
it('handles empty output', () => {
const result = parseGitStatus('', gitRoot, scopeDir);
expect(result).toEqual([]);
});
it('handles whitespace-only lines', () => {
const output = ' \n\n \n';
const result = parseGitStatus(output, gitRoot, scopeDir);
expect(result).toEqual([]);
});
it('handles directory paths with trailing slash (filters them out when extension specified)', () => {
// If git status somehow returns a directory path, it should be filtered by extension
const output = '?? packages/testing/tests/new-feature/\n';
const result = parseGitStatus(output, gitRoot, scopeDir, ['.ts']);
// Directory paths don't end in .ts, so they're filtered out
expect(result).toEqual([]);
});
it('handles directory paths with trailing slash (included when no extension filter)', () => {
const output = '?? packages/testing/tests/new-feature/\n';
const result = parseGitStatus(output, gitRoot, scopeDir, []);
// With no extension filter, directory paths are included as-is
expect(result).toEqual(['/repo/packages/testing/tests/new-feature/']);
});
});
describe('parseGitDiff', () => {
it('parses file paths', () => {
const output = 'packages/testing/src/file.ts\n';
const result = parseGitDiff(output, gitRoot, scopeDir);
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
});
it('filters by scope directory', () => {
const output = ['packages/testing/src/file.ts', 'packages/other/src/file.ts'].join('\n');
const result = parseGitDiff(output, gitRoot, scopeDir);
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
});
it('handles empty output', () => {
const result = parseGitDiff('', gitRoot, scopeDir);
expect(result).toEqual([]);
});
it('handles multiple files', () => {
const output = [
'packages/testing/src/a.ts',
'packages/testing/src/b.ts',
'packages/testing/src/c.ts',
].join('\n');
const result = parseGitDiff(output, gitRoot, scopeDir);
expect(result).toHaveLength(3);
});
});
});

View File

@ -0,0 +1,145 @@
/**
* Git Operations Utility
*
* Centralized git operations for the janitor tool.
*/
import { execFileSync } from 'node:child_process';
import * as path from 'node:path';
export interface GitChangedFilesOptions {
targetBranch?: string;
scopeDir: string;
extensions?: string[];
}
export function getGitRoot(cwd: string): string {
try {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf-8',
}).trim();
} catch {
return cwd;
}
}
export function parseGitStatus(
output: string,
gitRoot: string,
scopeDir: string,
extensions: string[] = ['.ts'],
): string[] {
const files: string[] = [];
const gitStatusPattern = /^.{2}\s+(.+)$/;
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const match = gitStatusPattern.exec(line);
if (match) {
const filePath = match[1];
// Handle renamed files (old -> new)
const actualPath = filePath.includes(' -> ') ? filePath.split(' -> ')[1] : filePath;
const fullPath = path.join(gitRoot, actualPath);
// Filter by scope directory
if (!fullPath.startsWith(scopeDir)) continue;
// Filter by extension if specified
if (extensions.length > 0) {
const hasValidExtension = extensions.some((ext) => actualPath.endsWith(ext));
if (!hasValidExtension) continue;
}
files.push(fullPath);
}
}
return files;
}
export function parseGitDiff(output: string, gitRoot: string, scopeDir: string): string[] {
const files: string[] = [];
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const fullPath = path.join(gitRoot, line);
if (fullPath.startsWith(scopeDir)) {
files.push(fullPath);
}
}
return files;
}
export function getChangedFiles(options: GitChangedFilesOptions): string[] {
const { targetBranch, scopeDir, extensions = [] } = options;
const gitRoot = getGitRoot(scopeDir);
try {
if (targetBranch) {
const output = execFileSync('git', ['diff', '--name-only', `${targetBranch}...HEAD`], {
cwd: gitRoot,
encoding: 'utf-8',
});
return parseGitDiff(output, gitRoot, scopeDir);
}
// Use -uall to show individual files in new directories instead of just "dir/"
const status = execFileSync('git', ['status', '--porcelain', '-uall'], {
cwd: gitRoot,
encoding: 'utf-8',
});
return parseGitStatus(status, gitRoot, scopeDir, extensions);
} catch {
return [];
}
}
export function getTotalDiffLines(scopeDir: string, targetBranch?: string): number {
const gitRoot = getGitRoot(scopeDir);
try {
const args = targetBranch
? ['diff', '--stat', `${targetBranch}...HEAD`]
: ['diff', '--stat', 'HEAD'];
const output = execFileSync('git', args, { cwd: gitRoot, encoding: 'utf-8' });
const lines = output.trim().split('\n');
const summaryLine = lines[lines.length - 1];
let total = 0;
const insertionsPattern = /(\d+)\s+insertions?\(\+\)/;
const deletionsPattern = /(\d+)\s+deletions?\(-\)/;
const insertionsMatch = insertionsPattern.exec(summaryLine);
const deletionsMatch = deletionsPattern.exec(summaryLine);
if (insertionsMatch) total += Number.parseInt(insertionsMatch[1], 10);
if (deletionsMatch) total += Number.parseInt(deletionsMatch[1], 10);
return total;
} catch {
return 0;
}
}
export function commit(message: string, cwd: string): boolean {
const gitRoot = getGitRoot(cwd);
try {
execFileSync('git', ['add', '-A'], { cwd: gitRoot });
execFileSync('git', ['commit', '-m', message], { cwd: gitRoot });
return true;
} catch {
return false;
}
}
export function revert(cwd: string): boolean {
const gitRoot = getGitRoot(cwd);
try {
execFileSync('git', ['checkout', '--', '.'], { cwd: gitRoot });
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,5 @@
export * from './ast-helpers.js';
export * from './git-operations.js';
export * from './logger.js';
export * from './paths.js';
export * from './test-command.js';

View File

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Logger, createLogger } from './logger.js';
describe('Logger', () => {
let consoleSpy: {
log: ReturnType<typeof vi.spyOn>;
warn: ReturnType<typeof vi.spyOn>;
error: ReturnType<typeof vi.spyOn>;
};
beforeEach(() => {
consoleSpy = {
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('info', () => {
it('always logs the message', () => {
const logger = new Logger();
logger.info('test message');
expect(consoleSpy.log).toHaveBeenCalledWith('test message');
});
it('includes prefix when set', () => {
const logger = new Logger({ prefix: '[PREFIX]' });
logger.info('test message');
expect(consoleSpy.log).toHaveBeenCalledWith('[PREFIX] test message');
});
});
describe('debug', () => {
it('does not log when verbose is false', () => {
const logger = new Logger({ verbose: false });
logger.debug('test message');
expect(consoleSpy.log).not.toHaveBeenCalled();
});
it('logs when verbose is true', () => {
const logger = new Logger({ verbose: true });
logger.debug('test message');
expect(consoleSpy.log).toHaveBeenCalledWith('test message');
});
it('includes prefix when set', () => {
const logger = new Logger({ verbose: true, prefix: '[DEBUG]' });
logger.debug('test message');
expect(consoleSpy.log).toHaveBeenCalledWith('[DEBUG] test message');
});
});
describe('warn', () => {
it('always logs to console.warn', () => {
const logger = new Logger();
logger.warn('warning message');
expect(consoleSpy.warn).toHaveBeenCalledWith('warning message');
});
});
describe('error', () => {
it('always logs to console.error', () => {
const logger = new Logger();
logger.error('error message');
expect(consoleSpy.error).toHaveBeenCalledWith('error message');
});
});
describe('success', () => {
it('logs with checkmark prefix', () => {
const logger = new Logger();
logger.success('done');
expect(consoleSpy.log).toHaveBeenCalledWith('\u2713 done');
});
});
describe('fail', () => {
it('logs with X prefix', () => {
const logger = new Logger();
logger.fail('failed');
expect(consoleSpy.log).toHaveBeenCalledWith('\u2717 failed');
});
});
describe('list', () => {
it('logs each item with default indent', () => {
const logger = new Logger();
logger.list(['item1', 'item2']);
expect(consoleSpy.log).toHaveBeenCalledWith(' - item1');
expect(consoleSpy.log).toHaveBeenCalledWith(' - item2');
});
it('uses custom indent', () => {
const logger = new Logger();
logger.list(['item'], 4);
expect(consoleSpy.log).toHaveBeenCalledWith(' - item');
});
});
describe('debugList', () => {
it('does not log when verbose is false', () => {
const logger = new Logger({ verbose: false });
logger.debugList(['item1', 'item2']);
expect(consoleSpy.log).not.toHaveBeenCalled();
});
it('logs when verbose is true', () => {
const logger = new Logger({ verbose: true });
logger.debugList(['item1', 'item2']);
expect(consoleSpy.log).toHaveBeenCalledWith(' - item1');
expect(consoleSpy.log).toHaveBeenCalledWith(' - item2');
});
});
describe('isVerbose', () => {
it('returns false by default', () => {
const logger = new Logger();
expect(logger.isVerbose()).toBe(false);
});
it('returns true when verbose is set', () => {
const logger = new Logger({ verbose: true });
expect(logger.isVerbose()).toBe(true);
});
});
describe('createLogger', () => {
it('creates a logger with default options', () => {
const logger = createLogger();
expect(logger).toBeInstanceOf(Logger);
expect(logger.isVerbose()).toBe(false);
});
it('creates a logger with custom options', () => {
const logger = createLogger({ verbose: true, prefix: 'test' });
expect(logger.isVerbose()).toBe(true);
});
});
});

View File

@ -0,0 +1,67 @@
/**
* Logger Utility - Centralized logging with verbose mode support.
*/
export interface LoggerOptions {
verbose?: boolean;
prefix?: string;
}
export class Logger {
private verbose: boolean;
private prefix: string;
constructor(options: LoggerOptions = {}) {
this.verbose = options.verbose ?? false;
this.prefix = options.prefix ?? '';
}
info(message: string): void {
const output = this.prefix ? `${this.prefix} ${message}` : message;
console.log(output);
}
debug(message: string): void {
if (!this.verbose) return;
const output = this.prefix ? `${this.prefix} ${message}` : message;
console.log(output);
}
warn(message: string): void {
const output = this.prefix ? `${this.prefix} ${message}` : message;
console.warn(output);
}
error(message: string): void {
const output = this.prefix ? `${this.prefix} ${message}` : message;
console.error(output);
}
success(message: string): void {
this.info(`\u2713 ${message}`);
}
fail(message: string): void {
this.info(`\u2717 ${message}`);
}
list(items: string[], indent = 2): void {
const padding = ' '.repeat(indent);
for (const item of items) {
this.info(`${padding}- ${item}`);
}
}
debugList(items: string[], indent = 2): void {
if (!this.verbose) return;
this.list(items, indent);
}
isVerbose(): boolean {
return this.verbose;
}
}
export function createLogger(options: LoggerOptions = {}): Logger {
return new Logger(options);
}

View File

@ -0,0 +1,141 @@
import { glob } from 'glob';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getConfig, hasConfig } from '../config.js';
/**
* Get the root directory for the Playwright test suite
* Uses the configured rootDir from janitor config
*/
export function getRootDir(): string {
return getConfig().rootDir;
}
/**
* Check if a file path is within the pages directory (excluding components)
*/
export function isPageFile(filePath: string): boolean {
const normalized = path.normalize(filePath).replaceAll('\\', '/');
return normalized.includes('/pages/') && !normalized.includes('/pages/components/');
}
/**
* Check if a file path is within the pages/components directory
*/
export function isComponentFile(filePath: string): boolean {
const normalized = path.normalize(filePath).replaceAll('\\', '/');
return normalized.includes('/pages/components/');
}
/**
* Check if a file path is within the flows directory (composables/actions/flows)
*/
export function isFlowFile(filePath: string): boolean {
const normalized = path.normalize(filePath).replaceAll('\\', '/');
const config = getConfig();
return config.patterns.flows.some((pattern) => {
const baseDir = pattern.replace('**/*.ts', '').replace('/**/*.ts', '');
return normalized.includes(`/${baseDir}`);
});
}
/**
* Check if a file path is a test file (in tests directory with .spec.ts extension)
*/
export function isTestFile(filePath: string): boolean {
const normalized = path.normalize(filePath).replaceAll('\\', '/');
return (
(normalized.startsWith('tests/') || normalized.includes('/tests/')) &&
normalized.endsWith('.spec.ts')
);
}
/**
* Check if a file should be excluded from page analysis
*/
export function isExcludedPage(filePath: string): boolean {
const normalized = path.normalize(filePath).replaceAll('\\', '/');
const config = getConfig();
return config.excludeFromPages.some((exclude) => normalized.endsWith(exclude));
}
/**
* Get files matching glob patterns from test data config
*/
export function getTestDataFiles(patterns: string[]): string[] {
const root = getRootDir();
const files: string[] = [];
for (const pattern of patterns) {
const matches = glob.sync(pattern, { cwd: root, nodir: true, absolute: true });
files.push(...matches);
}
return files;
}
/**
* Resolve a path relative to the root directory
*/
export function resolvePath(relativePath: string): string {
const root = getRootDir();
return path.isAbsolute(relativePath) ? relativePath : path.join(root, relativePath);
}
/**
* Get the relative path from root directory
*/
export function getRelativePath(absolutePath: string): string {
if (!hasConfig()) {
return absolutePath;
}
return path.relative(getRootDir(), absolutePath);
}
/**
* Recursively find files matching a suffix in a directory
*/
export function findFilesRecursive(dir: string, suffix: string): string[] {
const results: string[] = [];
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findFilesRecursive(fullPath, suffix));
} else if (entry.name.endsWith(suffix)) {
results.push(fullPath);
}
}
} catch {
// Directory read error, skip
}
return results;
}
/**
* Check if a file path matches any of the given glob patterns
*/
export function matchesPatterns(filePath: string, patterns: string[]): boolean {
const relativePath = getRelativePath(filePath);
for (const pattern of patterns) {
// Simple glob matching (supports ** and *)
const regexPattern = pattern
.replace(/\*\*/g, '<<<DOUBLESTAR>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<DOUBLESTAR>>>/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(relativePath)) {
return true;
}
}
return false;
}

View File

@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { resolveTestCommand, buildTestCommand } from './test-command.js';
import * as config from '../config.js';
vi.mock('../config.js', () => ({
hasConfig: vi.fn(),
getConfig: vi.fn(),
}));
describe('test-command', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('resolveTestCommand', () => {
it('appends default workers to override command', () => {
vi.mocked(config.hasConfig).mockReturnValue(false);
const result = resolveTestCommand('custom test command');
expect(result).toBe('custom test command --workers=1');
});
it('appends default workers to config command', () => {
vi.mocked(config.hasConfig).mockReturnValue(true);
vi.mocked(config.getConfig).mockReturnValue({
tcr: { testCommand: 'pnpm test' },
} as ReturnType<typeof config.getConfig>);
const result = resolveTestCommand();
expect(result).toBe('pnpm test --workers=1');
});
it('uses custom worker count from config', () => {
vi.mocked(config.hasConfig).mockReturnValue(true);
vi.mocked(config.getConfig).mockReturnValue({
tcr: { testCommand: 'pnpm test', workerCount: 4 },
} as ReturnType<typeof config.getConfig>);
const result = resolveTestCommand();
expect(result).toBe('pnpm test --workers=4');
});
it('uses config worker count with override command', () => {
vi.mocked(config.hasConfig).mockReturnValue(true);
vi.mocked(config.getConfig).mockReturnValue({
tcr: { testCommand: 'pnpm test', workerCount: 2 },
} as ReturnType<typeof config.getConfig>);
const result = resolveTestCommand('pnpm test:container');
expect(result).toBe('pnpm test:container --workers=2');
});
it('returns default when no override and no config', () => {
vi.mocked(config.hasConfig).mockReturnValue(false);
const result = resolveTestCommand();
expect(result).toBe('npx playwright test --workers=1');
});
it('returns default when config exists but no testCommand', () => {
vi.mocked(config.hasConfig).mockReturnValue(true);
vi.mocked(config.getConfig).mockReturnValue({
tcr: {},
} as ReturnType<typeof config.getConfig>);
const result = resolveTestCommand();
expect(result).toBe('npx playwright test --workers=1');
});
it('returns default when config exists but no tcr section', () => {
vi.mocked(config.hasConfig).mockReturnValue(true);
vi.mocked(config.getConfig).mockReturnValue({} as ReturnType<typeof config.getConfig>);
const result = resolveTestCommand();
expect(result).toBe('npx playwright test --workers=1');
});
});
describe('buildTestCommand', () => {
beforeEach(() => {
vi.mocked(config.hasConfig).mockReturnValue(false);
});
it('builds command with test files', () => {
const result = buildTestCommand(['test1.spec.ts', 'test2.spec.ts']);
expect(result).toBe('npx playwright test --workers=1 test1.spec.ts test2.spec.ts');
});
it('uses override command with workers appended', () => {
const result = buildTestCommand(['test.spec.ts'], 'pnpm test');
expect(result).toBe('pnpm test --workers=1 test.spec.ts');
});
it('handles empty file list', () => {
const result = buildTestCommand([]);
expect(result).toBe('npx playwright test --workers=1 ');
});
it('handles single file', () => {
const result = buildTestCommand(['single.spec.ts']);
expect(result).toBe('npx playwright test --workers=1 single.spec.ts');
});
});
});

View File

@ -0,0 +1,46 @@
/**
* Test Command Resolver
*
* Priority: CLI override > config file > fallback default
* Worker count is always appended from config (default: 1)
*/
import { getConfig, hasConfig } from '../config.js';
const DEFAULT_TEST_COMMAND = 'npx playwright test';
const DEFAULT_WORKERS = 1;
function getWorkerCount(): number {
if (hasConfig()) {
const config = getConfig();
return config.tcr?.workerCount ?? DEFAULT_WORKERS;
}
return DEFAULT_WORKERS;
}
function getBaseCommand(override?: string): string {
if (override) {
return override;
}
if (hasConfig()) {
const config = getConfig();
if (config.tcr?.testCommand) {
return config.tcr.testCommand;
}
}
return DEFAULT_TEST_COMMAND;
}
export function resolveTestCommand(override?: string): string {
const baseCommand = getBaseCommand(override);
const workers = getWorkerCount();
return `${baseCommand} --workers=${workers}`;
}
export function buildTestCommand(testFiles: string[], override?: string): string {
const baseCommand = resolveTestCommand(override);
const fileArgs = testFiles.join(' ');
return `${baseCommand} ${fileArgs}`;
}

View File

@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/__tests__/**"]
}

View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts', 'src/cli.ts'],
},
},
});

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,146 @@ pnpm --filter=n8n-playwright typecheck
Always trim output: `--reporter=list 2>&1 | tail -50`
## Test Maintenance (Janitor)
Static analysis for Playwright test architecture. Catches problems before they spread.
> **CRITICAL: Always use TCR for code changes.**
> When janitor identifies violations and you fix them, use `pnpm janitor tcr --execute` to safely commit. Never manually commit janitor-related fixes - TCR ensures tests pass before the commit lands.
### Golden Rules
1. **Analysis only?** Run `pnpm janitor` (no TCR needed)
2. **Making code changes?** Use TCR: `pnpm janitor tcr --execute -m="chore: ..."`
3. **Never** manually `git commit` janitor-related fixes - always go through TCR
4. **Never** modify `.janitor-baseline.json` via TCR - baseline updates must be done manually
### When to Use
| User Says | Intent | Approach |
|-----------|--------|----------|
| "Clean up the test codebase" | Incremental cleanup | Create baseline first, then use `--max-diff-lines=500` for small PRs. |
| "Start tracking violations" | Enable incremental cleanup | Run `janitor baseline` to snapshot current state, commit `.janitor-baseline.json`. |
| "Add a test for X" | New test following patterns | After writing, run janitor to verify architecture compliance. |
| "Fix architecture drift" | Enforce layered architecture | Run `selector-purity` and `no-page-in-flow` rules. |
| "Find dead code" | Remove unused methods | Run `dead-code` rule with `--fix --write` for auto-removal. |
| "Find copy-paste code" | Detect duplicates | Run `duplicate-logic` rule to find structural duplicates. |
| "This file is messy" | Targeted cleanup | Analyze specific file, fix issues, TCR to safely commit. |
| "Refactor this page object" | Safe refactoring | Use TCR - changes commit if tests pass, revert if they fail. |
| "What tests would break?" | Impact analysis | Run `impact` command before changing shared code. |
| "Prepare for PR" | Pre-commit check | Run janitor on changed files to catch violations early. |
### Architecture Rules
The janitor enforces a layered architecture:
```
Tests → Flows/Composables → Page Objects → Components → Playwright API
```
| Rule | What It Catches |
|------|-----------------|
| `selector-purity` | Raw locators in tests/flows: `page.getByTestId()`, `someLocator.locator()` |
| `no-page-in-flow` | Flows accessing `page` directly (should use page objects) |
| `boundary-protection` | Pages importing other pages (creates coupling) |
| `scope-lockdown` | Unscoped locators that escape their container |
| `dead-code` | Unused public methods in page objects |
| `deduplication` | Same selector defined in multiple files |
| `duplicate-logic` | Copy-pasted code across tests/pages (AST fingerprinting) |
### Commands
```bash
# Analyze entire codebase
pnpm janitor
# Analyze specific file
pnpm janitor --file=pages/CanvasPage.ts --verbose
# Run specific rule
pnpm janitor --rule=dead-code
# Auto-fix (dead-code only)
pnpm janitor:fix --rule=dead-code
# List all rules (short)
pnpm janitor --list
# Show detailed rule info (for AI agents)
pnpm janitor rules --json
# JSON output
pnpm janitor --json
```
### Baseline (Incremental Cleanup)
For codebases with existing violations, create a baseline to enable incremental cleanup:
```bash
# Create baseline - snapshots current violations
pnpm janitor baseline
# Commit the baseline
git add .janitor-baseline.json && git commit -m "chore: add janitor baseline"
```
Once baseline exists, janitor and TCR **only fail on NEW violations**. Pre-existing violations are tracked but don't block work.
> **Safeguard:** TCR blocks commits that modify `.janitor-baseline.json`. This prevents accidentally "fixing" violations by updating the baseline instead of the actual code. Baseline updates must be done manually after fixing violations.
```bash
# Update baseline after fixing violations (manual commit required)
pnpm janitor baseline
git add .janitor-baseline.json && git commit -m "chore: update baseline after cleanup"
```
### Incremental Cleanup Strategy
For large cleanups, keep diffs small and reviewable:
```bash
# Show ALL violations (ignoring baseline) for cleanup work
pnpm janitor --ignore-baseline --json
# Find easiest files to fix (lowest violation count)
pnpm janitor --ignore-baseline --json 2>/dev/null | jq '.fileReports | sort_by(.violationCount) | .[:5]'
# TCR with max diff size (skip if changes are too large)
pnpm janitor tcr --max-diff-lines=500 --execute -m="chore: cleanup"
```
**AI Cleanup Workflow:**
1. Use `--ignore-baseline` to see all violations (not just new ones)
2. Pick small fixes from the list
3. Fix violations, then TCR to safely commit
4. After fixing, run `pnpm janitor baseline` to update the baseline
### TCR Workflow (Test && Commit || Revert)
Safe refactoring loop: make changes, run affected tests, auto-commit or auto-revert.
```bash
# Dry run - see what would happen
pnpm janitor tcr --verbose
# Execute - actually commit/revert
pnpm janitor tcr --execute -m="chore: remove dead code"
# With guardrails - skip if diff too large
pnpm janitor tcr --execute --max-diff-lines=500 -m="chore: cleanup"
```
### After Writing New Tests
Always run janitor after adding or modifying tests to catch architecture violations early:
```bash
pnpm janitor --file=tests/my-new-test.spec.ts --verbose
```
See `packages/testing/janitor/README.md` for full documentation.
## Entry Points
All tests should start with `n8n.start.*` methods. See `composables/TestEntryComposer.ts`.
@ -233,6 +373,51 @@ test('API-only test', async ({ api }) => {
});
```
### Feature Flag Overrides
To test features behind feature flags (experiments), use `TestRequirements` with storage overrides:
```typescript
import type { TestRequirements } from '../config/TestRequirements';
const requirements: TestRequirements = {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ 'your_experiment': true }),
},
};
test.use({ requirements });
test('test with feature flag enabled', async ({ n8n }) => {
// Feature flag is now active for this test
});
```
**Common patterns:**
```typescript
// Single experiment
{ storage: { N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ '025_new_canvas': true }) } }
// Multiple experiments
{ storage: { N8N_EXPERIMENT_OVERRIDES: JSON.stringify({
'025_new_canvas': true,
'026_another_feature': 'variant_a'
}) } }
// Combined with other requirements
const requirements: TestRequirements = {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ 'your_experiment': true }),
},
capability: {
env: { TEST_ISOLATION: 'my-test-suite' },
},
};
```
**Reference:** `config/TestRequirements.ts` for full interface definition.
## Code Style
- Use specialized locators: `page.getByRole('button')` over `page.locator('[role=button]')`

View File

@ -0,0 +1 @@
@AGENTS.md

View File

@ -315,5 +315,89 @@ node scripts/import-victoria-data.mjs --start victoria-metrics-export.jsonl vict
- **Metrics UI:** http://localhost:8428/vmui/
- **Logs UI:** http://localhost:9428/select/vmui/
## Janitor (Static Analysis & Inventory)
Janitor enforces test architecture patterns and provides codebase discovery for devs and AI.
### Quick Commands
```bash
# Static analysis (run all rules)
npx tsx scripts/janitor/index.ts
# Dead code removal
npx tsx scripts/janitor/index.ts --rule=dead-code # Find unused
npx tsx scripts/janitor/index.ts --rule=dead-code --fix # Preview
npx tsx scripts/janitor/index.ts --rule=dead-code --fix --write # Remove
# Inventory (codebase discovery)
npx tsx scripts/janitor/index.ts --inventory # Summary
npx tsx scripts/janitor/index.ts --inventory --verbose # Full details
npx tsx scripts/janitor/index.ts --describe=CanvasPage # Single class
npx tsx scripts/janitor/index.ts --list-pages # All page objects
# File-level impact analysis (find affected tests)
npx tsx scripts/janitor/index.ts --impact=pages/CanvasPage.ts
npx tsx scripts/janitor/index.ts --impact=pages/CanvasPage.ts --tests | xargs playwright test
# Method-level impact analysis (precise test selection)
npx tsx scripts/janitor/index.ts --method-impact=CanvasPage.addNode # Find tests using method
npx tsx scripts/janitor/index.ts --method-impact=WorkflowsPage.addFolder --verbose # Show line-by-line usages
npx tsx scripts/janitor/index.ts --method-impact=CanvasPage.addNode --tests # For piping to playwright
npx tsx scripts/janitor/index.ts --method-index # Full method usage index
```
### Rules
| Rule | What it enforces |
|------|------------------|
| `selector-purity` | Tests use page objects, not raw locators |
| `scope-lockdown` | Page locators scoped to container |
| `boundary-protection` | Pages don't import other pages |
| `dead-code` | No unused methods/properties [fixable] |
Run `--list` for all rules and options.
### Method-Level Impact Analysis
When modifying a page object method, use `--method-impact` to find exactly which tests need to run:
```bash
# Example: modifying WorkflowsPage.addFolder()
npx tsx scripts/janitor/index.ts --method-impact=WorkflowsPage.addFolder
# Output:
# Method: WorkflowsPage.addFolder()
# Total usages: 7
# Affected test files: 2
# - tests/e2e/projects/folders-basic.spec.ts (3 usages)
# - tests/e2e/projects/folders-operations.spec.ts (4 usages)
# Run only affected tests
npx tsx scripts/janitor/index.ts --method-impact=WorkflowsPage.addFolder --tests | xargs playwright test
```
This works by understanding the fixture pattern (`n8n.workflows.addFolder()` → `WorkflowsPage.addFolder`) and scanning test files for actual method calls, not just imports.
Use `--method-index` to see all tracked methods and their usage counts across the test suite.
### For AI-Assisted Test Writing
```bash
# Generate context for AI
npx tsx scripts/janitor/index.ts --inventory --json > .playwright-inventory.json
npx tsx scripts/janitor/index.ts --describe=CanvasPage
# Validate AI output
npx tsx scripts/janitor/index.ts --file=tests/new-test.spec.ts
```
### Key Conventions
- **Entry points**: All tests start with `n8n.start.*` (see `TestEntryComposer`)
- **Page objects**: UI interactions go through `n8n.canvas`, `n8n.ndv`, etc.
- **API helpers**: Backend operations via `n8n.api.*`
- **Composables**: Multi-step flows via `n8n.flows.*`
## Writing Tests
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).

View File

@ -15,15 +15,6 @@ export class CanvasComposer {
await this.n8n.ndv.close();
}
/**
* Execute a node and wait for success toast notification
* @param nodeName - The node to execute
*/
async executeNodeAndWaitForToast(nodeName: string): Promise<void> {
await this.n8n.canvas.executeNode(nodeName);
await this.n8n.notifications.waitForNotificationAndClose('Node executed successfully');
}
/**
* Copy selected nodes and verify success toast
*/
@ -62,8 +53,8 @@ export class CanvasComposer {
* Switch between editor and workflow history and back
*/
async switchBetweenEditorAndHistory(): Promise<void> {
await this.n8n.page.getByTestId('workflow-history-button').click();
await this.n8n.page.getByTestId('workflow-history-close-button').click();
await this.n8n.canvas.openWorkflowHistory();
await this.n8n.canvas.closeWorkflowHistory();
await this.n8n.page.waitForLoadState();
await expect(this.n8n.canvas.getCanvasNodes().first()).toBeVisible();
await expect(this.n8n.canvas.getCanvasNodes().last()).toBeVisible();
@ -73,7 +64,7 @@ export class CanvasComposer {
* Switch between editor and workflow list and back
*/
async switchBetweenEditorAndWorkflowList(): Promise<void> {
await this.n8n.page.getByTestId('menu-item').first().click();
await this.n8n.sideBar.clickHomeButton();
await this.n8n.workflows.cards.getWorkflows().first().click();
await expect(this.n8n.canvas.getCanvasNodes().first()).toBeVisible();
await expect(this.n8n.canvas.getCanvasNodes().last()).toBeVisible();
@ -116,32 +107,6 @@ export class CanvasComposer {
}
}
/**
* Delay workflow GET request to simulate loading during page reload.
* Useful for testing save-blocking behavior during real loading states.
*
* @param workflowId - The workflow ID to delay loading for
* @param delayMs - Delay in milliseconds (default: 2000)
*/
async delayWorkflowLoad(workflowId: string, delayMs: number = 2000): Promise<void> {
await this.n8n.page.route(`**/rest/workflows/${workflowId}`, async (route) => {
if (route.request().method() === 'GET') {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.continue();
});
}
/**
* Remove the workflow load delay route handler.
* Should be called after delayWorkflowLoad() when testing is complete.
*
* @param workflowId - The workflow ID to stop delaying
*/
async undelayWorkflowLoad(workflowId: string): Promise<void> {
await this.n8n.page.unroute(`**/rest/workflows/${workflowId}`);
}
/**
* Rename a node using keyboard shortcut
* @param oldName - The current name of the node

View File

@ -48,18 +48,4 @@ export class CredentialsComposer {
async createFromApi(payload: CreateCredentialDto & { projectId?: string }) {
return await this.n8n.api.credentials.createCredential(payload);
}
/**
* Moves a credential to a different project.
* @param credentialName - The name of the credential to move
* @param projectNameOrEmail - The destination project name or user email
*/
async moveToProject(credentialName: string, projectNameOrEmail: string): Promise<void> {
const credentialCard = this.n8n.credentials.cards.getCredential(credentialName);
await this.n8n.credentials.cards.openCardActions(credentialCard);
await this.n8n.credentials.cards.getCardAction('move').click();
await this.n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await this.n8n.resourceMoveModal.selectProjectOption(projectNameOrEmail);
await this.n8n.resourceMoveModal.clickMoveCredentialButton();
}
}

View File

@ -1,5 +1,3 @@
import type { Request, Page } from '@playwright/test';
import type { n8nPage } from '../pages/n8nPage';
/**
@ -65,24 +63,4 @@ export class NodeDetailsViewComposer {
await addResourceItem.click();
}
/**
* Creates a new sub-workflow with redirect handling
* @param paramName - The parameter name for the resource locator
* @returns Promise with request data and new window page
*/
async createNewSubworkflowWithRedirect(
paramName: string,
): Promise<{ request: Request; page: Page }> {
const subWorkflowPagePromise = this.n8n.page.waitForEvent('popup');
const [request] = await Promise.all([
this.n8n.page.waitForRequest('**/rest/workflows'),
this.createNewSubworkflow(paramName),
]);
const page = await subWorkflowPagePromise;
return { request, page };
}
}

View File

@ -1,5 +1,4 @@
import { expect } from '@playwright/test';
import type { IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
@ -74,39 +73,6 @@ export class WorkflowComposer {
return { workflowName };
}
/**
* Creates a new workflow by importing from a URL
* @param url - The URL to import the workflow from
* @returns Promise that resolves when the import is complete
*/
async importWorkflowFromURL(url: string): Promise<void> {
await this.n8n.workflows.addResource.workflow();
await this.n8n.canvas.clickWorkflowMenu();
await this.n8n.canvas.clickImportFromURL();
await this.n8n.canvas.fillImportURLInput(url);
await this.n8n.canvas.clickConfirmImportURL();
}
/**
* Opens the import from URL dialog and then dismisses it by clicking outside
*/
async openAndDismissImportFromURLDialog(): Promise<void> {
await this.n8n.workflows.addResource.workflow();
await this.n8n.canvas.clickWorkflowMenu();
await this.n8n.canvas.clickImportFromURL();
await this.n8n.canvas.clickOutsideModal();
}
/**
* Opens the import from URL dialog and then cancels it
*/
async openAndCancelImportFromURLDialog(): Promise<void> {
await this.n8n.workflows.addResource.workflow();
await this.n8n.canvas.clickWorkflowMenu();
await this.n8n.canvas.clickImportFromURL();
await this.n8n.canvas.clickCancelImportURL();
}
/**
* Duplicates a workflow via the duplicate modal UI.
* Verifies the form interaction completes without errors.
@ -138,19 +104,6 @@ export class WorkflowComposer {
await saveButton.click();
}
/**
* Get workflow by name via API
* @param workflowName - Name of the workflow to find
* @returns Workflow object with id, name, and other properties
*/
async getWorkflowByName(workflowName: string): Promise<IWorkflowBase> {
const response = await this.n8n.api.request.get('/rest/workflows', {
params: new URLSearchParams({ filter: JSON.stringify({ name: workflowName }) }),
});
const workflows = await response.json();
return workflows.data[0];
}
/**
* Moves a workflow to a different project or user.
* @param workflowName - The name of the workflow to move

View File

@ -10,6 +10,7 @@ export default [
'ms-playwright-cache/**/*',
'coverage/**/*',
'scripts/**/*',
'janitor.config.mjs',
],
},
{

View File

@ -45,17 +45,6 @@ export class NavigationHelper {
await this.page.goto(url);
}
/**
* Navigate to executions page
* URLs:
* - Home executions: /home/executions
* - Project executions: /projects/{projectId}/executions
*/
async toExecutions(projectId?: string): Promise<void> {
const url = projectId ? `/projects/${projectId}/executions` : '/home/executions';
await this.page.goto(url);
}
async toDatatables(projectId?: string): Promise<void> {
const url = projectId ? `/projects/${projectId}/datatables` : '/home/datatables';
await this.page.goto(url);
@ -70,14 +59,6 @@ export class NavigationHelper {
await this.page.goto('/variables');
}
/**
* Navigate to settings page (global only)
* URL: /settings
*/
async toSettings(): Promise<void> {
await this.page.goto('/settings');
}
/**
* Navigate to personal settings
* URL: /settings/personal
@ -86,14 +67,6 @@ export class NavigationHelper {
await this.page.goto('/settings/personal');
}
/**
* Navigate to projects page
* URL: /projects
*/
async toProjects(): Promise<void> {
await this.page.goto('/projects');
}
/**
* Navigate to a specific project's dashboard
* URL: /projects/{projectId}
@ -204,14 +177,6 @@ export class NavigationHelper {
await this.page.goto('/settings/log-streaming');
}
/**
* Navigate to worker view
* URL: /settings/workers
*/
async toWorkerView(): Promise<void> {
await this.page.goto('/settings/workers');
}
/**
* Navigate to users management
* URL: /settings/users
@ -228,22 +193,6 @@ export class NavigationHelper {
await this.page.goto('/settings/api');
}
/**
* Navigate to LDAP settings
* URL: /settings/ldap
*/
async toLdapSettings(): Promise<void> {
await this.page.goto('/settings/ldap');
}
/**
* Navigate to SSO settings
* URL: /settings/sso
*/
async toSsoSettings(): Promise<void> {
await this.page.goto('/settings/sso');
}
/**
* Navigate to environments settings
* URL: /settings/environments
@ -252,14 +201,6 @@ export class NavigationHelper {
await this.page.goto('/settings/environments');
}
/**
* Navigate to external secrets settings
* URL: /settings/external-secrets
*/
async toExternalSecrets(): Promise<void> {
await this.page.goto('/settings/external-secrets');
}
/**
* Navigate to settings page
* URL: /settings/chat

View File

@ -0,0 +1,85 @@
/**
* n8n Playwright Janitor Configuration
*
* This configures the janitor for the n8n Playwright test suite.
*/
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { defineConfig } from '@n8n/playwright-janitor';
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
rootDir: __dirname,
patterns: {
pages: ['pages/**/*.ts'],
components: ['pages/components/**/*.ts'],
flows: ['composables/**/*.ts'],
tests: ['tests/**/*.spec.ts'],
services: ['services/**/*.ts'],
fixtures: ['fixtures/**/*.ts'],
helpers: [
'helpers/**/*.ts',
'utils/**/*.ts',
'config/**/*.ts', // TODO: Move TestRequirements to helpers/
'tests/**/fixtures.ts', // TODO: Consolidate colocated fixtures
],
factories: ['test-data/**/*.ts', 'factories/**/*.ts'],
testData: ['workflows/**/*'],
},
excludeFromPages: [
'n8nPage.ts', // Facade
'BasePage.ts', // Base class
'BaseModal.ts',
'BaseComponent.ts',
],
facade: {
file: 'pages/n8nPage.ts',
className: 'n8nPage',
excludeTypes: ['Page', 'ApiHelpers'],
},
fixtureObjectName: 'n8n',
apiFixtureName: 'api',
rawApiPatterns: [
/\brequest\.(get|post|put|patch|delete|head)\s*\(/i,
/\bfetch\s*\(/,
/\.request\(\s*['"`]/,
],
flowLayerName: 'Composable',
architectureLayers: ['Tests', 'Composables', 'Pages', 'BasePage'],
rules: {
'boundary-protection': { enabled: true, severity: 'error' },
'scope-lockdown': { enabled: true, severity: 'error' },
'selector-purity': { enabled: true, severity: 'error' },
deduplication: { enabled: true, severity: 'warning' },
'dead-code': { enabled: true, severity: 'warning' },
'no-page-in-flow': {
enabled: true,
severity: 'warning',
allowPatterns: [
/\.page\.keyboard/,
/\.page\.evaluate/,
/\.page\.waitForLoadState/,
/\.page\.waitForURL/,
/\.page\.reload/,
],
},
'api-purity': { enabled: true, severity: 'warning' },
// Enforce facade pattern - access pages through n8n.* instead of new *Page()
'no-direct-page-instantiation': { enabled: true, severity: 'error' },
},
tcr: {
testCommand: 'pnpm test:local',
workerCount: 1,
},
});

View File

@ -27,11 +27,14 @@
"prepare-test-image": "node scripts/build-test-image.mjs",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"janitor": "playwright-janitor",
"janitor:fix": "playwright-janitor --fix --write"
},
"devDependencies": {
"@currents/playwright": "catalog:e2e",
"@n8n/api-types": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/constants": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/db": "workspace:*",
@ -48,6 +51,7 @@
"nanoid": "catalog:",
"nyc": "^17.1.0",
"otplib": "^12.0.1",
"ts-morph": "catalog:",
"tsx": "catalog:",
"zod": "catalog:"
}

View File

@ -71,10 +71,6 @@ export class AIAssistantPage extends BasePage {
return this.page.getByTestId('quick-replies').locator('button');
}
getQuickReplies() {
return this.page.getByTestId('quick-replies');
}
getNewAssistantSessionModal() {
return this.page.getByTestId('new-assistant-session-modal');
}

View File

@ -1,15 +0,0 @@
import { BasePage } from './BasePage';
export class BecomeCreatorCTAPage extends BasePage {
getBecomeTemplateCreatorCta() {
return this.page.getByTestId('become-template-creator-cta');
}
getCloseBecomeTemplateCreatorCtaButton() {
return this.page.getByTestId('close-become-template-creator-cta');
}
async closeBecomeTemplateCreatorCta() {
await this.getCloseBecomeTemplateCreatorCtaButton().click();
}
}

View File

@ -1,5 +1,4 @@
import type { Locator } from '@playwright/test';
import { nanoid } from 'nanoid';
import { BasePage } from './BasePage';
import { ROUTES } from '../config/constants';
@ -35,19 +34,11 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('node-creator-item-name').getByText(subItemText, { exact: true });
}
getNthCreatorItem(index: number): Locator {
return this.page.getByTestId('node-creator-item').nth(index);
}
getNodeCreatorHeader(text?: string) {
const header = this.page.getByTestId('nodes-list-header');
return text ? header.filter({ hasText: text }) : header.first();
}
async selectNodeCreatorItemByText(nodeName: string) {
await this.page.getByText(nodeName).click();
}
nodeByName(nodeName: string): Locator {
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
}
@ -180,20 +171,6 @@ export class CanvasPage extends BasePage {
await this.getExecuteWorkflowButton(triggerNodeName).click();
}
async clickDebugInEditorButton(): Promise<void> {
await this.page.getByRole('button', { name: 'Debug in editor' }).click();
}
async pinNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Pin').click();
}
async unpinNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByText('Unpin').click();
}
async openNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).dblclick();
}
@ -272,10 +249,6 @@ export class CanvasPage extends BasePage {
await this.clickByTestId('workflow-menu-item-import-from-url');
}
async clickImportFromFile(): Promise<void> {
await this.clickByTestId('workflow-menu-item-import-from-file');
}
async fillImportURLInput(url: string): Promise<void> {
await this.getImportURLInput().fill(url);
}
@ -306,10 +279,6 @@ export class CanvasPage extends BasePage {
await responsePromise;
}
async cancelPublishWorkflowModal(): Promise<void> {
await this.page.getByTestId('workflow-publish-cancel-button').click();
}
async openShareModal(): Promise<void> {
await this.clickByTestId('workflow-menu');
await this.clickByTestId('workflow-menu-item-share');
@ -327,36 +296,6 @@ export class CanvasPage extends BasePage {
return this.nodeByName(nodeName).getByTestId('node-issues');
}
/**
* Add tags to the workflow
* @param count - The number of tags to add
* @returns An array of tag names
*/
async addTags(count: number = 1): Promise<string[]> {
const tags: string[] = [];
for (let i = 0; i < count; i++) {
const tag = `tag-${nanoid(8)}-${i}`;
tags.push(tag);
if (i === 0) {
await this.clickByText('Add tag');
} else {
await this.page
.getByTestId('tags-dropdown')
.getByText(tags[i - 1])
.click();
}
await this.page.getByRole('combobox').first().fill(tag);
await this.page.getByRole('combobox').first().press('Enter');
}
await this.page.click('body');
return tags;
}
async clickCreateTagButton(): Promise<void> {
await this.page.getByTestId('new-tag-link').click();
}
@ -383,18 +322,14 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('workflow-tags').locator('.n8n-tag:not(.count-container)');
}
getTagsDropdown(): Locator {
return this.page.getByTestId('tags-dropdown');
getWorkflowTagsElement(): Locator {
return this.page.getByTestId('workflow-tags');
}
getWorkflowTagsDropdown(): Locator {
return this.page.getByTestId('workflow-tags-dropdown');
}
getWorkflowTags(): Locator {
return this.page.getByTestId('workflow-tags');
}
getTagCloseButton(): Locator {
return this.getWorkflowTagsDropdown().locator('.el-tag__close');
}
@ -437,10 +372,6 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('workflow-open-publish-modal-button');
}
getPublishModalCallout(): Locator {
return this.page.getByTestId('workflowPublish-modal').locator('.n8n-callout');
}
getPublishButton(): Locator {
return this.page.getByTestId('workflow-publish-button');
}
@ -513,10 +444,6 @@ export class CanvasPage extends BasePage {
await this.getProductionChecklistIgnoreAllButton().click();
}
async clickProductionChecklistAction(actionText: string): Promise<void> {
await this.getProductionChecklistActionItem(actionText).click();
}
async duplicateNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Duplicate').click();
@ -526,10 +453,6 @@ export class CanvasPage extends BasePage {
return this.page.locator('[data-test-id="edge"]');
}
getConnectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
return this.connectionBetweenNodes(sourceNodeName, targetNodeName);
}
canvasNodePlusEndpointByName(nodeName: string): Locator {
return this.page
.locator(
@ -550,10 +473,6 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('node-creator-action-item');
}
nodeCreatorCategoryItem(categoryName: string): Locator {
return this.page.getByTestId('node-creator-category-item').getByText(categoryName);
}
nodeCreatorCategoryItems(): Locator {
return this.page.getByTestId('node-creator-category-item');
}
@ -636,10 +555,6 @@ export class CanvasPage extends BasePage {
await this.canvasPane().click({ button: 'right', position: { x: 10, y: 10 } });
}
getNodeLeftPosition(nodeLocator: Locator): Promise<number> {
return nodeLocator.evaluate((el) => el.getBoundingClientRect().left);
}
// Connection helpers
connectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
return this.page.locator(
@ -723,11 +638,6 @@ export class CanvasPage extends BasePage {
await this.openNewWorkflow();
}
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
await this.addNode(searchText);
await this.nodeCreatorSubItem(subItemText).click();
}
async openNewWorkflow() {
await this.page.goto(ROUTES.NEW_WORKFLOW_PAGE);
}
@ -791,12 +701,6 @@ export class CanvasPage extends BasePage {
.last();
}
getNodesWithSpinner(): Locator {
return this.page.locator(
'[data-test-id="canvas-node"].running, [data-test-id="canvas-node"].waiting',
);
}
getWaitingNodes(): Locator {
return this.page.locator('[data-test-id="canvas-node"].waiting');
}
@ -957,14 +861,6 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('canvas-node-status-success');
}
getCanvasHandlePlusWrapper(): Locator {
return this.page.getByTestId('canvas-handle-plus-wrapper');
}
getCanvasHandlePlus(): Locator {
return this.page.getByTestId('canvas-handle-plus');
}
getCanvasHandlePlusWrapperByName(nodeName: string): Locator {
return this.page
.locator(
@ -999,10 +895,6 @@ export class CanvasPage extends BasePage {
await this.page.waitForTimeout(100);
}
async hitSaveWorkflow(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+s');
}
async hitExecuteWorkflow(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+Enter');
}
@ -1083,7 +975,20 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('workflow-name-input');
}
getWorkflowNameInput(): Locator {
return this.page.getByTestId('inline-edit-input');
// Workflow History methods
getWorkflowHistoryButton(): Locator {
return this.page.getByTestId('workflow-history-button');
}
getWorkflowHistoryCloseButton(): Locator {
return this.page.getByTestId('workflow-history-close-button');
}
async openWorkflowHistory(): Promise<void> {
await this.getWorkflowHistoryButton().click();
}
async closeWorkflowHistory(): Promise<void> {
await this.getWorkflowHistoryCloseButton().click();
}
}

View File

@ -136,7 +136,6 @@ export class ChatHubChatPage extends BasePage {
getAttachButton(): Locator {
return this.page.getByRole('button', { name: /attach/i });
}
getFileInput(): Locator {
return this.page.locator('input[type="file"]');
}

View File

@ -2,10 +2,8 @@ import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { ChatHubPersonalAgentModal } from './components/ChatHubPersonalAgentModal';
import { ChatHubSidebar } from './components/ChatHubSidebar';
export class ChatHubPersonalAgentsPage extends BasePage {
readonly sidebar = new ChatHubSidebar(this.page.locator('#sidebar'));
readonly editModal = new ChatHubPersonalAgentModal(
this.page.getByTestId('agentEditorModal-modal'),
);

View File

@ -1,11 +1,8 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { ChatHubSidebar } from './components/ChatHubSidebar';
export class ChatHubWorkflowAgentsPage extends BasePage {
readonly sidebar = new ChatHubSidebar(this.page.locator('#sidebar'));
constructor(page: Page) {
super(page);
}

View File

@ -118,10 +118,6 @@ export class DataTableDetails extends BasePage {
await this.getAddRowTableButton().click();
}
getDataGrid() {
return this.page.getByTestId('data-table-grid');
}
getDataRows() {
return this.page.locator(
'[data-test-id="data-table-grid"] .ag-center-cols-container [row-index]',

View File

@ -4,14 +4,6 @@ import { AddResource } from './components/AddResource';
export class DataTableView extends BasePage {
readonly addResource = new AddResource(this.page);
getDataTableOverviewTab() {
return this.page.getByTestId('tab-data-tables');
}
getDataTableProjectTab() {
return this.page.getByTestId('tab-project-data-tables');
}
getEmptyStateActionBox() {
return this.page.getByTestId('empty-data-table-action-box');
}
@ -32,18 +24,10 @@ export class DataTableView extends BasePage {
return this.page.getByTestId('create-from-scratch-option');
}
getImportCsvOption() {
return this.page.getByTestId('import-csv-option');
}
getProceedFromSelectButton() {
return this.page.getByTestId('proceed-from-select-button');
}
getNewDataTableConfirmButton() {
return this.page.getByTestId('confirm-add-data-table-button');
}
getDataTableCards() {
return this.page.getByTestId('data-table-card');
}
@ -80,10 +64,6 @@ export class DataTableView extends BasePage {
return this.page.getByTestId('resources-list-pagination').locator('button.btn-next');
}
async clickDataTableOverviewTab() {
await this.clickByTestId('tab-data-tables');
}
async clickDataTableProjectTab() {
await this.clickByTestId('tab-project-data-tables');
}
@ -100,10 +80,6 @@ export class DataTableView extends BasePage {
await this.getDataTableCardActionsButton(dataTableName).click();
}
async clickDataTableCardAction(actionName: string) {
await this.getDataTableCardAction(actionName).click();
}
async clickDeleteDataTableConfirmButton() {
await this.getDeleteDataTableConfirmButton().click();
}
@ -112,8 +88,4 @@ export class DataTableView extends BasePage {
await this.getDataTablePageSizeSelect().click();
await this.getDataTablePageOption(pageSize).click();
}
async clickPaginationNextButton() {
await this.getPaginationNextButton().click();
}
}

View File

@ -103,10 +103,4 @@ export class ExecutionsPage extends BasePage {
async openFilter(): Promise<void> {
await this.getFilterButton().click();
}
async selectStatus(status: string): Promise<void> {
await this.getStatusSelect().click();
await this.page.waitForTimeout(1000);
await this.page.getByRole('option', { name: status }).click();
}
}

View File

@ -1,15 +0,0 @@
import { BasePage } from './BasePage';
export class IframePage extends BasePage {
getIframe() {
return this.page.locator('iframe');
}
getIframeBySrc(src: string) {
return this.page.locator(`iframe[src="${src}"]`);
}
async waitForIframeRequest(url: string) {
await this.page.waitForResponse(url);
}
}

View File

@ -18,18 +18,6 @@ export class MfaLoginPage extends BasePage {
return this.getForm().locator('input[name="mfaRecoveryCode"]');
}
getEnterRecoveryCodeButton(): Locator {
return this.page.getByTestId('mfa-enter-recovery-code-button');
}
getSubmitButton(): Locator {
return this.page.getByRole('button', { name: /^(Continue|Verify)$/ });
}
async goToMfaLogin(): Promise<void> {
await this.page.goto('/mfa');
}
async fillMfaCode(code: string): Promise<void> {
await this.getMfaCodeField().fill(code);
}
@ -42,10 +30,6 @@ export class MfaLoginPage extends BasePage {
await this.clickByTestId('mfa-enter-recovery-code-button');
}
async clickSubmit(): Promise<void> {
await this.getSubmitButton().click();
}
/**
* Fill MFA code and submit the form
* @param code - The MFA token to submit

View File

@ -15,18 +15,10 @@ export class MfaSetupModal extends BasePage {
return this.page.getByTestId('mfa-token-input');
}
getCopySecretToClipboardButton(): Locator {
return this.page.getByTestId('mfa-secret-button');
}
getDownloadRecoveryCodesButton(): Locator {
return this.page.getByTestId('mfa-recovery-codes-button');
}
getSaveButton(): Locator {
return this.page.getByTestId('mfa-save-button');
}
async fillToken(token: string): Promise<void> {
await this.getTokenInput().fill(token);
}

View File

@ -127,10 +127,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.page.getByTestId('run-data-pane-header');
}
getOutputTable() {
return this.getOutputPanel().getByTestId('ndv-data-container').locator('table');
}
getOutputDataContainer() {
return this.getOutputPanel().getByTestId('ndv-data-container');
}
@ -147,19 +143,6 @@ export class NodeDetailsViewPage extends BasePage {
await this.savePinnedData();
}
async pastePinnedData(data: object) {
await this.getEditPinnedDataButton().click();
const editor = this.outputPanel.get().locator('[contenteditable="true"]');
await editor.waitFor();
await editor.click();
await editor.fill('');
await this.clipboard.paste(JSON.stringify(data));
await this.savePinnedData();
}
async savePinnedData() {
await this.getRunDataPaneHeader().locator('button:visible').filter({ hasText: 'Save' }).click();
}
@ -221,10 +204,6 @@ export class NodeDetailsViewPage extends BasePage {
return await this.page.request.get(path);
}
getWebhookUrl() {
return this.page.locator('.webhook-url').textContent();
}
async clearExpressionEditor(parameterName?: string) {
const editor = this.getInlineExpressionEditorInput(parameterName);
await editor.click();
@ -279,12 +258,6 @@ export class NodeDetailsViewPage extends BasePage {
await this.selectFromVisibleDropdown(optionName);
}
async waitForParameterDropdown(parameterName: string): Promise<void> {
const dropdown = this.getParameterInput(parameterName);
await dropdown.waitFor({ state: 'visible' });
await expect(dropdown).toBeEnabled();
}
async clickFloatingNode(nodeName: string) {
await this.page.locator(`[data-test-id="floating-node"][data-node-name="${nodeName}"]`).click();
}
@ -383,66 +356,6 @@ export class NodeDetailsViewPage extends BasePage {
}
}
async setMultipleParameters(
parameters: Record<string, string | number | boolean>,
): Promise<void> {
for (const [parameterName, value] of Object.entries(parameters)) {
if (typeof value === 'string') {
const parameterType = await this.setupHelper.detectParameterType(parameterName);
if (parameterType === 'dropdown') {
await this.setParameterDropdown(parameterName, value);
} else {
await this.setParameterInput(parameterName, value);
}
} else if (typeof value === 'boolean') {
await this.setParameterSwitch(parameterName, value);
} else if (typeof value === 'number') {
await this.setParameterInput(parameterName, value.toString());
}
}
}
async getParameterValue(parameterName: string): Promise<string> {
const parameterType = await this.setupHelper.detectParameterType(parameterName);
switch (parameterType) {
case 'text':
return await this.getTextParameterValue(parameterName);
case 'dropdown':
return await this.getDropdownParameterValue(parameterName);
case 'switch':
return await this.getSwitchParameterValue(parameterName);
default:
return (await this.getParameterInput(parameterName).textContent()) ?? '';
}
}
private async getTextParameterValue(parameterName: string): Promise<string> {
const parameterContainer = this.getParameterInput(parameterName);
const input = parameterContainer.locator('input').first();
return await input.inputValue();
}
private async getDropdownParameterValue(parameterName: string): Promise<string> {
const selectedOption = this.getParameterInput(parameterName).locator('.el-select__tags-text');
return (await selectedOption.textContent()) ?? '';
}
private async getSwitchParameterValue(parameterName: string): Promise<string> {
const switchElement = this.getParameterInput(parameterName).locator('.el-switch');
const isEnabled = (await switchElement.getAttribute('aria-checked')) === 'true';
return isEnabled ? 'true' : 'false';
}
async validateParameter(parameterName: string, expectedValue: string): Promise<void> {
const actualValue = await this.getParameterValue(parameterName);
if (actualValue !== expectedValue) {
throw new Error(
`Parameter ${parameterName} has value "${actualValue}", expected "${expectedValue}"`,
);
}
}
getAssignmentCollectionContainer(paramName: string) {
return this.page.getByTestId(`assignment-collection-${paramName}`);
}
@ -453,14 +366,6 @@ export class NodeDetailsViewPage extends BasePage {
await this.page.getByRole('option', { name: nodeName }).click();
}
getInputTableHeader(index: number = 0) {
return this.getInputPanel().locator('table th').nth(index);
}
getInputTbodyCell(row: number, col: number) {
return this.getInputPanel().locator('table tbody tr').nth(row).locator('td').nth(col);
}
getAssignmentName(paramName: string, index = 0) {
return this.getAssignmentCollectionContainer(paramName)
.getByTestId('assignment')
@ -504,10 +409,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.getNodeParameters().locator('input[placeholder*="Add Value"]');
}
getCollectionAddOptionSelect() {
return this.getNodeParameters().getByTestId('collection-add-option-select');
}
getParameterSwitch(parameterName: string) {
return this.getParameterInput(parameterName).locator('.el-switch');
}
@ -577,19 +478,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.page.getByTestId('expression-modal-output');
}
async typeIntoParameterInput(parameterName: string, content: string): Promise<void> {
const input = this.getParameterInput(parameterName);
await input.type(content);
}
getInputTable() {
return this.getInputPanel().locator('table');
}
getInputTableCellSpan(row: number, col: number, dataName: string) {
return this.getInputTbodyCell(row, col).locator(`span[data-name="${dataName}"]`).first();
}
getAddFieldToSortByButton() {
return this.getNodeParameters().getByText('Add Field To Sort By');
}
@ -614,12 +502,6 @@ export class NodeDetailsViewPage extends BasePage {
await pages.nth(pageNumber - 1).click();
}
async getCurrentOutputPage(): Promise<number> {
const activePage = this.getOutputPagination().locator('.el-pager li.is-active').first();
const pageText = await activePage.textContent();
return parseInt(pageText ?? '1', 10);
}
async setParameterInputValue(parameterName: string, value: string): Promise<void> {
const input = this.getParameterInput(parameterName).locator('input');
await input.clear();
@ -632,18 +514,10 @@ export class NodeDetailsViewPage extends BasePage {
await this.page.waitForTimeout(150);
}
async clickGetBackToCanvas(): Promise<void> {
await this.clickBackToCanvasButton();
}
getRunDataInfoCallout() {
return this.page.getByTestId('run-data-callout');
}
getOutputPanelTable() {
return this.getOutputTable();
}
async checkParameterCheckboxInputByName(name: string): Promise<void> {
const checkbox = this.getParameterInput(name).locator('.el-switch.switch-input');
await checkbox.click();
@ -713,11 +587,6 @@ export class NodeDetailsViewPage extends BasePage {
return await this.outputPanel.getRunSelectorInput().inputValue();
}
async expandSchemaItem(itemText: string) {
const item = this.outputPanel.getSchemaItem(itemText);
await item.locator('.toggle').click();
}
getExecuteNodeButton() {
return this.page.getByTestId('node-execute-button');
}
@ -742,10 +611,6 @@ export class NodeDetailsViewPage extends BasePage {
await this.getCodeEditorDialog().locator('.el-dialog__close').click();
}
getWebhookTriggerListening() {
return this.page.getByTestId('trigger-listening');
}
getNodeRunSuccessIndicator() {
return this.page.getByTestId('node-run-status-success');
}
@ -795,7 +660,6 @@ export class NodeDetailsViewPage extends BasePage {
await searchInput.waitFor({ state: 'visible' });
await searchInput.fill(searchTerm);
}
/**
* Type multiple values into the first available text parameter field
* Useful for testing multiple parameter changes
@ -849,14 +713,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.page.getByTestId(`add-subnode-${connectionType}-${index}`);
}
getSubNodeConnectionGroup(connectionType: string, index: number = 0) {
return this.page.getByTestId(`subnode-connection-group-${connectionType}-${index}`);
}
getFloatingSubNodes(connectionType: string, index: number = 0) {
return this.getSubNodeConnectionGroup(connectionType, index).getByTestId('floating-subnode');
}
getNodesWithIssues() {
return this.page.locator('[class*="hasIssues"]');
}
@ -871,10 +727,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.page.getByTestId('floating-node');
}
async getNodesWithIssuesCount() {
return await this.getNodesWithIssues().count();
}
async addItemToFixedCollection(collectionName: string) {
await this.page.getByTestId(`fixed-collection-${collectionName}`).click();
}
@ -890,10 +742,6 @@ export class NodeDetailsViewPage extends BasePage {
await this.page.getByRole('option', { name: propertyName, exact: true }).click();
}
async clickParameterItemAction(actionText: string) {
await this.page.getByTestId('parameter-item').getByText(actionText).click();
}
getParameterItemWithText(text: string) {
return this.page.getByTestId('parameter-item').getByText(text);
}
@ -963,10 +811,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.page.getByTestId('ndv-input-select').locator('input');
}
getInputTableRows() {
return this.getInputTable().locator('tr');
}
getOutputRunSelectorInput() {
return this.getOutputPanel().locator('[data-test-id="run-selector"] input');
}
@ -987,18 +831,10 @@ export class NodeDetailsViewPage extends BasePage {
return this.getFilterComponent(paramName).getByTestId('filter-condition');
}
getFilterCondition(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-condition').nth(index);
}
getFilterConditionLeft(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-condition-left').nth(index);
}
getFilterConditionRight(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-condition-right').nth(index);
}
getFilterConditionOperator(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-operator-select').nth(index);
}
@ -1027,26 +863,6 @@ export class NodeDetailsViewPage extends BasePage {
return this.page.getByRole('combobox', { name: 'Add option' });
}
/**
* Adds an optional parameter from a collection dropdown
* @param optionDisplayName - The display name of the option to add (e.g., 'Response Code')
* @param parameterName - The parameter name to set after adding (e.g., 'responseCode')
* @param parameterValue - The value to set for the parameter
*/
async setOptionalParameter(
optionDisplayName: string,
parameterName: string,
parameterValue: string | boolean,
): Promise<void> {
await this.getAddOptionDropdown().click();
// Step 2: Select the option by display name
await this.page.getByRole('option', { name: optionDisplayName }).click();
// Step 3: Set the parameter value
await this.setupHelper.setParameter(parameterName, parameterValue);
}
async setInvalidExpression({
fieldName,
invalidExpression,

View File

@ -42,18 +42,6 @@ export class NotificationsPage {
return this.page.getByRole('alert').filter({ hasText: text });
}
/**
* Gets the main container locator for a notification by searching in both title and content,
* filtered to a specific node name. This is useful when multiple notifications might be present
* and you want to ensure you're checking the right one for a specific node.
* @param text The text or a regular expression to find within the notification's title or content.
* @param nodeName The name of the node to filter notifications for.
* @returns A Locator for the notification container element.
*/
getNotificationByTitleOrContentForNode(text: string | RegExp, nodeName: string): Locator {
return this.page.getByRole('alert').filter({ hasText: text }).filter({ hasText: nodeName });
}
/**
* Clicks the close button on the FIRST notification matching the text.
* Fast execution with short timeouts for snappy notifications.
@ -81,74 +69,6 @@ export class NotificationsPage {
}
}
/**
* Closes ALL currently visible notifications that match the given text.
* Uses aggressive polling for fast cleanup.
* @param text The text of the notifications to close.
* @param options Optional configuration
*/
async closeAllNotificationsWithText(
text: string | RegExp,
options: { timeout?: number; maxRetries?: number } = {},
): Promise<number> {
const { maxRetries = 15 } = options;
let closedCount = 0;
let retries = 0;
while (retries < maxRetries) {
try {
const notifications = this.getNotificationByTitle(text);
const count = await notifications.count();
if (count === 0) {
break;
}
// Close the first visible notification quickly
const firstNotification = notifications.first();
if (await firstNotification.isVisible({ timeout: 200 })) {
const closeBtn = firstNotification.locator('.el-notification__closeBtn');
await closeBtn.click({ timeout: 300 });
// Brief wait for disappearance, then continue
await firstNotification.waitFor({ state: 'hidden', timeout: 500 }).catch(() => {});
closedCount++;
} else {
// If not visible, likely already gone
break;
}
} catch (error) {
// Continue quickly on any error
break;
}
retries++;
}
return closedCount;
}
/**
* Check if a notification is visible based on text.
* Fast check with short timeout.
* @param text The text to search for in notification title.
* @param options Optional configuration
*/
async isNotificationVisible(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 500 } = options;
try {
const notification = this.getNotificationByTitle(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
/**
* Wait for a notification to appear with specific text.
* Reasonable timeout for waiting, but still faster than before.
@ -196,57 +116,6 @@ export class NotificationsPage {
}
}
/**
* Wait for all notifications to disappear.
* Fast check with short timeout.
* @param options Optional configuration
*/
async waitForAllNotificationsToDisappear(options: { timeout?: number } = {}): Promise<boolean> {
const { timeout = 2000 } = options;
try {
// Wait for no alerts to be visible
await this.page.getByRole('alert').first().waitFor({
state: 'detached',
timeout,
});
return true;
} catch {
// Check if any are still visible
const count = await this.getNotificationCount();
return count === 0;
}
}
/**
* Get the count of visible notifications.
* @param text Optional text to filter notifications
*/
async getNotificationCount(text?: string | RegExp): Promise<number> {
try {
const notifications = text ? this.getNotificationByTitle(text) : this.page.getByRole('alert');
return await notifications.count();
} catch {
return 0;
}
}
/**
* Quick utility to close any notification and continue.
* Uses the most aggressive timeouts for maximum speed.
* @param text The text of the notification to close.
*/
async quickClose(text: string | RegExp): Promise<void> {
try {
const notification = this.getNotificationByTitle(text).first();
if (await notification.isVisible({ timeout: 100 })) {
await notification.locator('.el-notification__closeBtn').click({ timeout: 200 });
}
} catch {
// Silent fail for speed
}
}
/**
* Nuclear option: Close everything as fast as possible.
* No waiting, no error handling, just close and move on.
@ -279,8 +148,4 @@ export class NotificationsPage {
getWarningNotifications(): Locator {
return this.page.locator('.el-notification:has(.el-notification--warning)');
}
getInfoNotifications(): Locator {
return this.page.locator('.el-notification:has(.el-notification--info)');
}
}

View File

@ -63,11 +63,6 @@ export class ProjectSettingsPage extends BasePage {
expect(actualCount).toBe(expectedCount);
}
async expectSearchInputValue(expectedValue: string) {
const searchInput = this.getMembersSearchInput();
await expect(searchInput).toHaveValue(expectedValue);
}
getTitle() {
return this.page.getByTestId('project-name');
}

View File

@ -11,10 +11,6 @@ export class SettingsEnvironmentPage extends BasePage {
return this.page.getByTestId('source-control-disconnect-button');
}
getSSHKeyValue(): Locator {
return this.page.getByTestId('copy-input').locator('span').first();
}
getRepoUrlInput(): Locator {
return this.page.getByPlaceholder('git@github.com:user/repository.git');
}

View File

@ -55,22 +55,6 @@ export class SettingsLogStreamingPage extends BasePage {
return this.page.getByTestId('destination-card');
}
getInlineEditPreview(): Locator {
return this.page.getByTestId('inline-edit-preview');
}
getInlineEditInput(): Locator {
return this.page.getByTestId('inline-edit-input');
}
getModalOverlay(): Locator {
return this.page.locator('.el-overlay');
}
getDropdownMenu(): Locator {
return this.page.locator('.el-dropdown-menu');
}
getDropdownMenuItem(index: number): Locator {
return this.page.locator('.el-dropdown-menu__item').nth(index);
}

View File

@ -6,22 +6,10 @@ import { BasePage } from './BasePage';
* Page object for Settings including Personal Settings where users can update their profile and manage MFA.
*/
export class SettingsPersonalPage extends BasePage {
getChangePasswordLink(): Locator {
return this.page.getByTestId('change-password-link');
}
getMenuItems() {
return this.page.getByTestId('menu-item');
}
getMenuItem(id: string) {
return this.page.getByTestId('menu-item').getByTestId(id);
}
getMenuItemByText(text: string) {
return this.page.getByTestId('menu-item').getByText(text, { exact: true });
}
async goToSettings() {
await this.page.goto('/settings');
}
@ -71,16 +59,6 @@ export class SettingsPersonalPage extends BasePage {
await this.getSaveSettingsButton().click();
}
/**
* Complete workflow to update user's email address
* @param newEmail - The new email address to set
*/
async updateEmail(newEmail: string): Promise<void> {
await this.goToPersonalSettings();
await this.fillEmail(newEmail);
await this.saveSettings();
}
/**
* Complete workflow to update user's first and last name
* @param firstName - The new first name
@ -136,34 +114,4 @@ export class SettingsPersonalPage extends BasePage {
getUpgradeCta(): Locator {
return this.page.getByTestId('public-api-upgrade-cta');
}
async changeTheme(theme: 'System default' | 'Light theme' | 'Dark theme') {
await this.page.getByTestId('theme-select').click();
await this.page.getByRole('option', { name: theme }).click();
await this.getSaveSettingsButton().click();
}
currentPassword(): Locator {
return this.page.locator('input[name="currentPassword"]');
}
newPassword(): Locator {
return this.page.locator('input[name="password"]');
}
repeatPassword(): Locator {
return this.page.locator('input[name="password2"]');
}
changePasswordModal(): Locator {
return this.page.getByTestId('changePassword-modal');
}
changePasswordButton(): Locator {
return this.changePasswordModal().getByRole('button', { name: 'Change password' });
}
emailBox(): Locator {
return this.page.getByTestId('email');
}
}

View File

@ -16,11 +16,6 @@ export class SettingsSsoPage extends BasePage {
await this.page.locator('.el-select-dropdown__item').filter({ hasText: 'OIDC' }).click();
}
async selectSamlProtocol(): Promise<void> {
await this.getProtocolSelect().locator('.el-select').click();
await this.page.locator('.el-select-dropdown__item').filter({ hasText: 'SAML' }).click();
}
getOidcDiscoveryEndpointInput(): Locator {
return this.page.getByTestId('oidc-discovery-endpoint');
}
@ -33,10 +28,6 @@ export class SettingsSsoPage extends BasePage {
return this.page.getByTestId('oidc-client-secret');
}
getOidcPromptSelect(): Locator {
return this.page.getByTestId('oidc-prompt');
}
getOidcLoginToggle(): Locator {
return this.page.getByTestId('sso-oidc-toggle');
}
@ -56,13 +47,6 @@ export class SettingsSsoPage extends BasePage {
}
}
async disableOidcLogin(): Promise<void> {
const isEnabled = await this.isOidcLoginEnabled();
if (isEnabled) {
await this.getOidcLoginToggle().click();
}
}
/** Fill the OIDC form. Discovery endpoint default value is cleared first. */
async fillOidcForm(config: {
discoveryEndpoint: string;
@ -95,20 +79,4 @@ export class SettingsSsoPage extends BasePage {
throw new Error(`OIDC config save failed: ${response.status()} - ${body}`);
}
}
getSamlSaveButton(): Locator {
return this.page.getByTestId('sso-save');
}
getSamlLoginToggle(): Locator {
return this.page.getByTestId('sso-toggle');
}
getSsoContentLicensed(): Locator {
return this.page.getByTestId('sso-content-licensed');
}
getSsoContentUnlicensed(): Locator {
return this.page.getByTestId('sso-content-unlicensed');
}
}

View File

@ -11,10 +11,6 @@ export class SettingsUsersPage extends BasePage {
return this.page.getByRole('row', { name: email });
}
getInviteButton() {
return this.page.getByRole('button', { name: 'Invite' });
}
getAccountType(email: string) {
return this.getRow(email).getByTestId('user-role-dropdown');
}

Some files were not shown because too many files have changed in this diff Show More