packages/testing/janitor/README.md
Static analysis and architecture enforcement for Playwright test suites.
Playwright tests are easy to write but hard to maintain at scale. Without guardrails, test code accumulates problems:
getByTestId('button') scattered across filesThe janitor catches these problems through static analysis, enforcing your architecture before bad patterns spread.
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:
getByTestId() calls don't belong in tests or flowspnpm add -D @n8n/playwright-janitor
Create a janitor.config.js in your Playwright test root:
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 }) => ...)
});
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:
// 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);
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.
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.
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.
boundary-protectionSeverity: 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
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-lockdownSeverity: error
Enforces explicit architectural intent for page objects. Each page must either:
container getter (scoped component - must use container for all locators)this.page directly)This prevents ambiguous page objects and ensures consistent patterns.
// 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:
rules: {
'scope-lockdown': {
enabled: true,
severity: 'error',
// Customize which method names indicate a standalone page
navigationMethods: ['goto', 'navigate', 'visit', 'open'],
},
}
selector-puritySeverity: error
Raw Playwright locators (getByTestId, locator, etc.) should only appear in page objects, not in tests or flows.
Catches:
page.getByTestId(), app.page.locator()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-flowSeverity: warning
Flows/composables shouldn't access page directly. They should work through page objects.
// 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 shortcutspage.evaluate() - JavaScript executionpage.waitForLoadState() - Navigation waitspage.waitForURL() - URL assertionspage.reload() - Page refreshapi-puritySeverity: 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();
});
dead-codeSeverity: warning | Fixable: yes
Detects unused public methods and properties in page objects. If nothing references a method, it's probably dead code.
export class WorkflowPage {
async usedMethod() { /* called from tests */ }
async unusedMethod() { /* nobody calls this */ } // Violation
}
deduplicationSeverity: 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-hygieneSeverity: warning
Detects:
test.json, data.json, workflow_1.jsonCAT-123.json without descriptionworkflows/
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-logicSeverity: 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:
// 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).
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
};
}
// janitor.config.js
export default defineConfig({
// ...
rules: {
'dead-code': { enabled: false },
'deduplication': { severity: 'off' },
},
});
rules: {
'no-page-in-flow': {
allowPatterns: [
/\.page\.keyboard/, // Allow keyboard shortcuts
/\.page\.evaluate/, // Allow JS execution
],
},
}
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
Extend BaseRule to create custom rules:
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());
Add to your CI pipeline:
# .github/workflows/test.yml
- name: Run Janitor
run: pnpm janitor
The janitor exits with code 1 if violations are found, failing the build.
The janitor embodies these principles:
The janitor includes tools for TCR-style development workflows, where changes are automatically committed if tests pass, or reverted if they fail.
Recommended Workflow:
pnpm janitor to identify violationsWhy 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.
Determine which tests are affected by file changes:
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
Track which tests use specific page object methods:
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}`));
Detect which methods changed in a file compared to git HEAD:
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}`);
}
Run the full TCR workflow:
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'
Generate a complete inventory of your test codebase:
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);
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<string, string[]>;
}
pnpm install
pnpm build
pnpm test
MIT