first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
# @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<string, string[]>;
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in New Issue
Block a user