# @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. ### Test Discovery & Orchestration Discover test specs via AST analysis and distribute them across CI shards: ```bash # Discover specs and capabilities (JSON output) playwright-janitor discover # Distribute specs across shards (JSON output) playwright-janitor orchestrate --shards=14 # Get specs for a single shard (0-indexed) playwright-janitor orchestrate --shards=14 --shard-index=0 # Only include specs affected by git changes playwright-janitor orchestrate --shards=14 --impact ``` Discovery detects `test.fixme()` and `test.skip()` via AST and excludes them automatically. Capability tags (`@capability:proxy`) are extracted for grouping. ## 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[]; }; }; /** Tags that exclude specs from discovery (e.g., ['@wip', '@local-only']) */ skipTags: string[]; /** Prefix for extracting capabilities from tags (default: '@capability:') */ capabilityPrefix: string; /** Orchestration configuration for distributing specs across shards */ orchestration: { /** Path to metrics JSON file (relative to rootDir) */ metricsPath?: string; /** Default duration for specs without metrics (ms, default: 60000) */ defaultDuration: number; /** Max group duration before splitting (ms, default: 300000) */ maxGroupDuration: number; /** Only include specs matching this path prefix */ specFilter?: string; }; /** TCR configuration */ tcr: { testCommand: string; // Default: 'npx playwright test' workerCount?: number; // Default: 1 allowedTestCommands?: string[]; // Restrict --test-command to these }; } ``` ## 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' ``` ### Codebase Inventory Generate a complete inventory of your test codebase: ```typescript import { createProject, InventoryAnalyzer, formatInventoryJSON } 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}`); // JSON output const json = formatInventoryJSON(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; /** 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; } ``` ## Development ```bash pnpm install pnpm build pnpm test ``` ## License MIT