All skills
Skillintermediate

@n8n/playwright-janitor

Static analysis and architecture enforcement for Playwright test suites.

Claude Code Knowledge Pack7/10/2026

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:

  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

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:

  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.

// 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 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.

// 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.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
// 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