@n8n/playwright-janitor
Static analysis and architecture enforcement for Playwright test suites.
Overview
@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:
- Dependencies flow downward - Tests depend on flows, flows on pages, pages on components
- No skipping layers - Tests should use flows, not reach directly into page internals
- Selectors belong in page objects - Raw
getByTestId()calls don't belong in tests or flows - One home per selector - Each test ID should be defined in exactly one page object
Quick Start
Installation
pnpm add -D @n8n/playwright-janitor
Configuration
Create a janitor.config.js in your Playwright test root:
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
const report = runAnalysis(config);
console.log(`Found ${report.summary.totalViolations} violations`);
Or create a script:
// scripts/run-janitor.ts
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:
# 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.
# 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:
# 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:
# 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.
// Bad - WorkflowPage importing SettingsPage
async openSettings() {
await this.settingsPage.open(); // Coupling between pages
}
}
// Good - Pages are independent, composition happens in flows
async getWorkflowName() {
return this.header.getByTestId('workflow-name').textContent();
}
}
scope-lockdown
Severity: error
Enforces explicit architectural intent for page objects. Each page must either:
- Have a
containergetter (scoped component - must use container for all locators) - Have a navigation method (standalone top-level page - can use
this.pagedirectly)
This prevents ambiguous page objects and ensures consistent patterns.
// Bad - Ambiguous page (neither container nor navigation method)
async toggleOption() {
await this.page.getByTestId('toggle').click(); // Is this a page or component?
}
}
// Good - Standalone page with navigation method
async goto() {
await this.page.goto('/settings');
}
async toggleOption() {
await this.page.getByTestId('toggle').click(); // OK - explicit standalone page
}
}
// Good - Scoped component with container
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
get container() { return this.page.locator('.node-panel'); }
async selectNode(name: string) {
await this.page.getByTestId('node-item').click(); // Escapes container!
}
}
Configuration:
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.
// 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.
// Bad - Flow reaching into page internals
async createAndRun() {
await this.app.page.getByTestId('run-btn').click(); // Direct page access
}
}
// Good - Flow uses page objects
async createAndRun() {
await this.app.canvas.runWorkflow(); // Through page object
}
}
Certain page-level operations are allowed (configurable via allowPatterns):
page.keyboard.*- Keyboard shortcutspage.evaluate()- JavaScript executionpage.waitForLoadState()- Navigation waitspage.waitForURL()- URL assertionspage.reload()- Page refresh
api-purity
Severity: warning
Raw HTTP calls (request.get(), fetch()) should go through API service classes, not appear directly in tests.
// 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.
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.
// 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.jsonwithout 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
// 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
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 fo