mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
test: Add Playwright janitor for test architecture enforcement (no-changelog) (#24869)
This commit is contained in:
parent
e3eafc7e87
commit
593fc27863
|
|
@ -154,6 +154,7 @@ What we use for testing and writing tests:
|
|||
- For frontend we use `vitest`
|
||||
- For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`.
|
||||
See `packages/testing/playwright/README.md` for details.
|
||||
- **For Playwright test maintenance/cleanup**, see @packages/testing/playwright/AGENTS.md (includes janitor tool for static analysis, dead code removal, architecture enforcement, and TCR workflows).
|
||||
|
||||
### Common Development Tasks
|
||||
|
||||
|
|
|
|||
|
|
@ -32,3 +32,9 @@ pre-commit:
|
|||
skip:
|
||||
- merge
|
||||
- rebase
|
||||
playwright_janitor:
|
||||
glob: 'packages/testing/playwright/**/*.ts'
|
||||
run: |
|
||||
files=$(echo {staged_files} | tr ' ' ',')
|
||||
pnpm --filter=n8n-playwright janitor --files="$files"
|
||||
skip: true # Disabled for now - enable when baseline is committed
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
"prettier": "3.6.2",
|
||||
"prompts": "^2.4.2",
|
||||
"rimraf": "catalog:",
|
||||
"ts-morph": "^26.0.0",
|
||||
"ts-morph": "catalog:",
|
||||
"typescript-eslint": "^8.35.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
4
packages/testing/janitor/.gitignore
vendored
Normal file
4
packages/testing/janitor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
dist/
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
1
packages/testing/janitor/CLAUDE.md
Normal file
1
packages/testing/janitor/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
@README.md
|
||||
790
packages/testing/janitor/README.md
Normal file
790
packages/testing/janitor/README.md
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
# @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.
|
||||
|
||||
## 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[];
|
||||
};
|
||||
};
|
||||
|
||||
/** TCR configuration */
|
||||
tcr: {
|
||||
testCommand: string; // Default: 'npx playwright test'
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 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'
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
|
||||
Continuously run TCR on file changes:
|
||||
|
||||
```typescript
|
||||
const tcr = new TcrExecutor();
|
||||
tcr.watch({ execute: true }); // Ctrl+C to stop
|
||||
```
|
||||
|
||||
### Codebase Inventory
|
||||
|
||||
Generate a complete inventory of your test codebase:
|
||||
|
||||
```typescript
|
||||
import { createProject, InventoryAnalyzer, formatInventoryConsole } 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}`);
|
||||
|
||||
// Detailed output
|
||||
formatInventoryConsole(inventory, true); // verbose mode
|
||||
|
||||
// Export as markdown
|
||||
import { formatInventoryMarkdown } from '@n8n/playwright-janitor';
|
||||
const markdown = formatInventoryMarkdown(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;
|
||||
/** Watch mode - re-run on file changes */
|
||||
watch?: boolean;
|
||||
/** 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
|
||||
21
packages/testing/janitor/eslint.config.mjs
Normal file
21
packages/testing/janitor/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig, {
|
||||
ignores: ['coverage/**'],
|
||||
}, {
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
// Allow kebab-case for rule IDs in config objects
|
||||
{
|
||||
selector: 'objectLiteralProperty',
|
||||
format: null,
|
||||
filter: {
|
||||
regex: '^[a-z]+-[a-z-]+$', // kebab-case pattern
|
||||
match: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
45
packages/testing/janitor/package.json
Normal file
45
packages/testing/janitor/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "@n8n/playwright-janitor",
|
||||
"version": "0.1.0",
|
||||
"description": "Static analysis and architecture enforcement for Playwright test suites",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"playwright-janitor": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"playwright",
|
||||
"testing",
|
||||
"static-analysis",
|
||||
"architecture",
|
||||
"linting"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "^10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-morph": ">=20.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"ts-morph": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
206
packages/testing/janitor/src/cli.test.ts
Normal file
206
packages/testing/janitor/src/cli.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
const CLI_PATH = path.join(__dirname, '..', 'dist', 'cli.js');
|
||||
|
||||
describe('CLI', () => {
|
||||
describe('--help', () => {
|
||||
it('shows help text', () => {
|
||||
const output = execSync(`node ${CLI_PATH} --help`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('Playwright Janitor');
|
||||
expect(output).toContain('Commands:');
|
||||
expect(output).toContain('baseline');
|
||||
expect(output).toContain('inventory');
|
||||
expect(output).toContain('impact');
|
||||
expect(output).toContain('tcr');
|
||||
expect(output).toContain('--ignore-baseline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--list', () => {
|
||||
it('lists available rules', () => {
|
||||
const output = execSync(`node ${CLI_PATH} --list`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('Available rules:');
|
||||
expect(output).toContain('boundary-protection');
|
||||
expect(output).toContain('selector-purity');
|
||||
expect(output).toContain('dead-code');
|
||||
expect(output).toContain('scope-lockdown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline --help', () => {
|
||||
it('shows baseline help', () => {
|
||||
const output = execSync(`node ${CLI_PATH} baseline --help`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('Baseline');
|
||||
expect(output).toContain('.janitor-baseline.json');
|
||||
expect(output).toContain('incremental cleanup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inventory --help', () => {
|
||||
it('shows inventory help', () => {
|
||||
const output = execSync(`node ${CLI_PATH} inventory --help`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('Inventory');
|
||||
expect(output).toContain('JSON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('impact --help', () => {
|
||||
it('shows impact help', () => {
|
||||
const output = execSync(`node ${CLI_PATH} impact --help`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('Impact');
|
||||
expect(output).toContain('--file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('method-impact --help', () => {
|
||||
it('shows method-impact help', () => {
|
||||
const output = execSync(`node ${CLI_PATH} method-impact --help`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('Method Impact');
|
||||
expect(output).toContain('--method');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tcr --help', () => {
|
||||
it('shows tcr help', () => {
|
||||
const output = execSync(`node ${CLI_PATH} tcr --help`, { encoding: 'utf-8' });
|
||||
|
||||
expect(output).toContain('TCR');
|
||||
expect(output).toContain('--execute');
|
||||
expect(output).toContain('--message');
|
||||
expect(output).toContain('--max-diff-lines');
|
||||
});
|
||||
});
|
||||
|
||||
describe('argument parsing', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Use realpathSync to avoid macOS /var -> /private/var symlink issues
|
||||
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-')));
|
||||
|
||||
// Create tsconfig.json (required by ts-morph)
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'tsconfig.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
strict: true,
|
||||
},
|
||||
include: ['**/*.ts'],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create minimal config as .js (Node.js can't dynamically import .ts files)
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'janitor.config.js'),
|
||||
`module.exports = {
|
||||
rootDir: '${tempDir}',
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: [],
|
||||
flows: [],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: [],
|
||||
fixtures: [],
|
||||
helpers: [],
|
||||
factories: [],
|
||||
testData: [],
|
||||
},
|
||||
excludeFromPages: [],
|
||||
facade: { file: 'pages/AppPage.ts', className: 'AppPage', excludeTypes: [] },
|
||||
fixtureObjectName: 'app',
|
||||
apiFixtureName: 'api',
|
||||
rawApiPatterns: [],
|
||||
flowLayerName: 'Composable',
|
||||
architectureLayers: [],
|
||||
rules: {},
|
||||
};`,
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, 'pages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts --json flag', () => {
|
||||
// Create a clean page file
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'CleanPage.ts'), 'export class CleanPage {}');
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`node ${CLI_PATH} --json --config=${path.join(tempDir, 'janitor.config.js')}`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
cwd: tempDir,
|
||||
},
|
||||
);
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(output) as unknown).not.toThrow();
|
||||
} catch (error) {
|
||||
// Even if there are violations, output should be JSON
|
||||
const stderr = (error as { stdout?: string }).stdout ?? '';
|
||||
if (stderr) {
|
||||
expect(() => JSON.parse(stderr) as unknown).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts --verbose flag', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'VerbosePage.ts'),
|
||||
'export class VerbosePage {}',
|
||||
);
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`node ${CLI_PATH} --verbose --config=${path.join(tempDir, 'janitor.config.js')}`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
cwd: tempDir,
|
||||
},
|
||||
);
|
||||
// Verbose output should include more detail
|
||||
expect(output).toBeDefined();
|
||||
} catch {
|
||||
// May fail with violations, that's OK for this test
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts --ignore-baseline flag', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'BaselinePage.ts'),
|
||||
'export class BaselinePage {}',
|
||||
);
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`node ${CLI_PATH} --ignore-baseline --config=${path.join(tempDir, 'janitor.config.js')}`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
cwd: tempDir,
|
||||
},
|
||||
);
|
||||
// Should not show "Using baseline" message when ignoring
|
||||
expect(output).not.toContain('Using baseline');
|
||||
} catch {
|
||||
// May fail with violations, that's OK for this test
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
477
packages/testing/janitor/src/cli.ts
Normal file
477
packages/testing/janitor/src/cli.ts
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Playwright Janitor CLI
|
||||
*
|
||||
* Usage:
|
||||
* playwright-janitor # Run all rules
|
||||
* playwright-janitor inventory # Show codebase inventory
|
||||
* playwright-janitor impact # Show impact of changes
|
||||
* playwright-janitor tcr # TCR workflow
|
||||
* playwright-janitor --help # Show help
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
parseArgs,
|
||||
type CliOptions,
|
||||
showHelp,
|
||||
showBaselineHelp,
|
||||
showImpactHelp,
|
||||
showInventoryHelp,
|
||||
showMethodImpactHelp,
|
||||
showRulesHelp,
|
||||
showTcrHelp,
|
||||
} from './cli/index.js';
|
||||
import { setConfig, getConfig, defineConfig, type JanitorConfig } from './config.js';
|
||||
import {
|
||||
generateBaseline,
|
||||
saveBaseline,
|
||||
loadBaseline,
|
||||
filterReportByBaseline,
|
||||
formatBaselineInfo,
|
||||
getBaselinePath,
|
||||
} from './core/baseline.js';
|
||||
import {
|
||||
ImpactAnalyzer,
|
||||
formatImpactConsole,
|
||||
formatImpactJSON,
|
||||
formatTestList,
|
||||
} from './core/impact-analyzer.js';
|
||||
import {
|
||||
InventoryAnalyzer,
|
||||
formatInventoryJSON,
|
||||
formatInventorySummaryJSON,
|
||||
formatInventoryCategoryJSON,
|
||||
toSummary,
|
||||
toCategory,
|
||||
filterByFile,
|
||||
type InventoryCategory,
|
||||
} from './core/inventory-analyzer.js';
|
||||
import {
|
||||
MethodUsageAnalyzer,
|
||||
formatMethodImpactConsole,
|
||||
formatMethodImpactJSON,
|
||||
formatMethodImpactTestList,
|
||||
formatMethodUsageIndexConsole,
|
||||
formatMethodUsageIndexJSON,
|
||||
} from './core/method-usage-analyzer.js';
|
||||
import { createProject } from './core/project-loader.js';
|
||||
import { toJSON, toConsole, printFixResults } from './core/reporter.js';
|
||||
import { TcrExecutor, formatTcrResultConsole, formatTcrResultJSON } from './core/tcr-executor.js';
|
||||
import { createDefaultRunner } from './index.js';
|
||||
import type { RunOptions } from './types.js';
|
||||
|
||||
async function loadConfig(configPath?: string): Promise<JanitorConfig> {
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Try to find config file (JS files only - TS requires a runtime like tsx)
|
||||
const configLocations = configPath
|
||||
? [configPath]
|
||||
: ['janitor.config.js', 'janitor.config.mjs', '.janitorrc.js'];
|
||||
|
||||
for (const location of configLocations) {
|
||||
const fullPath = path.isAbsolute(location) ? location : path.join(cwd, location);
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
const configModule = (await import(fullPath)) as {
|
||||
default?: JanitorConfig;
|
||||
} & JanitorConfig;
|
||||
const config: JanitorConfig = configModule.default ?? configModule;
|
||||
|
||||
// Ensure rootDir is set
|
||||
config.rootDir ??= path.dirname(fullPath);
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error(`Error loading config from ${fullPath}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No config file found - create minimal config
|
||||
console.warn('No janitor.config.js found, using minimal defaults');
|
||||
return defineConfig({ rootDir: cwd });
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES: InventoryCategory[] = [
|
||||
'pages',
|
||||
'components',
|
||||
'composables',
|
||||
'services',
|
||||
'fixtures',
|
||||
'helpers',
|
||||
'factories',
|
||||
'testData',
|
||||
];
|
||||
|
||||
function runInventory(options: CliOptions): void {
|
||||
const config = getConfig();
|
||||
const { project } = createProject(config.rootDir);
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
// Summary mode - minimal output for AI
|
||||
if (options.summary) {
|
||||
console.log(formatInventorySummaryJSON(toSummary(report)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Category filter - single category with method names only
|
||||
if (options.category) {
|
||||
if (!VALID_CATEGORIES.includes(options.category as InventoryCategory)) {
|
||||
console.error(`Invalid category: ${options.category}`);
|
||||
console.error(`Valid categories: ${VALID_CATEGORIES.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
formatInventoryCategoryJSON(toCategory(report, options.category as InventoryCategory)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// File filter - detailed info for single file
|
||||
if (options.files && options.files.length === 1) {
|
||||
const result = filterByFile(report, options.files[0]);
|
||||
if (result) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.error(`File not found in inventory: ${options.files[0]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: full inventory
|
||||
console.log(formatInventoryJSON(report));
|
||||
}
|
||||
|
||||
async function runImpact(options: CliOptions): Promise<void> {
|
||||
const config = getConfig();
|
||||
|
||||
// Create project
|
||||
const { project } = createProject(config.rootDir);
|
||||
|
||||
// Determine changed files
|
||||
let changedFiles = options.files ?? [];
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
// Use git status to find changed files
|
||||
const { getChangedFiles } = await import('./utils/git-operations.js');
|
||||
changedFiles = getChangedFiles({
|
||||
scopeDir: config.rootDir,
|
||||
extensions: ['.ts'],
|
||||
});
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No changed files detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Analyze impact
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(changedFiles);
|
||||
|
||||
// Output
|
||||
if (options.json) {
|
||||
console.log(formatImpactJSON(result));
|
||||
} else if (options.testList) {
|
||||
console.log(formatTestList(result));
|
||||
} else {
|
||||
formatImpactConsole(result, options.verbose);
|
||||
}
|
||||
}
|
||||
|
||||
function runMethodImpact(options: CliOptions): void {
|
||||
const config = getConfig();
|
||||
|
||||
// Create project
|
||||
const { project } = createProject(config.rootDir);
|
||||
|
||||
// Create analyzer
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
|
||||
// Build full index if requested
|
||||
if (options.methodIndex) {
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatMethodUsageIndexJSON(index));
|
||||
} else {
|
||||
formatMethodUsageIndexConsole(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Require --method for impact analysis
|
||||
if (!options.method) {
|
||||
console.error('Error: --method=ClassName.methodName is required');
|
||||
console.error('Example: --method=CanvasPage.addNode');
|
||||
console.error('\nOr use --index to build the full method usage index');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = analyzer.getMethodImpact(options.method);
|
||||
|
||||
if (options.testList) {
|
||||
console.log(formatMethodImpactTestList(result));
|
||||
} else if (options.json) {
|
||||
console.log(formatMethodImpactJSON(result));
|
||||
} else {
|
||||
formatMethodImpactConsole(result, options.verbose);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runTcr(options: CliOptions): void {
|
||||
const tcr = new TcrExecutor();
|
||||
|
||||
const result = tcr.run({
|
||||
execute: options.execute,
|
||||
commitMessage: options.message,
|
||||
baseRef: options.baseRef,
|
||||
verbose: options.verbose,
|
||||
targetBranch: options.targetBranch,
|
||||
maxDiffLines: options.maxDiffLines,
|
||||
testCommand: options.testCommand,
|
||||
});
|
||||
|
||||
// Output
|
||||
if (options.json) {
|
||||
console.log(formatTcrResultJSON(result));
|
||||
} else {
|
||||
formatTcrResultConsole(result, options.verbose);
|
||||
}
|
||||
|
||||
// Exit code based on result
|
||||
if (!result.success && options.execute) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runAnalyze(options: CliOptions): void {
|
||||
const config = getConfig();
|
||||
const runner = createDefaultRunner();
|
||||
|
||||
// Create project
|
||||
const { project, root } = createProject(config.rootDir);
|
||||
|
||||
// Build run options
|
||||
const runOptions: RunOptions = {};
|
||||
|
||||
if (options.files && options.files.length > 0) {
|
||||
runOptions.files = options.files;
|
||||
}
|
||||
|
||||
if (options.fix) {
|
||||
runOptions.fix = true;
|
||||
runOptions.write = options.write;
|
||||
}
|
||||
|
||||
// Pass rule-specific config
|
||||
if (options.allowInExpect) {
|
||||
runOptions.ruleConfig = {
|
||||
'selector-purity': { allowInExpect: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
let report = options.rule
|
||||
? runner.runRule(project, root, options.rule, runOptions)
|
||||
: runner.run(project, root, runOptions);
|
||||
|
||||
if (!report) {
|
||||
console.error('Failed to generate report');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auto-filter by baseline if present (unless --ignore-baseline or --fix)
|
||||
const baseline = loadBaseline(config.rootDir);
|
||||
let baselineFiltered = false;
|
||||
const originalViolations = report.summary.totalViolations;
|
||||
|
||||
if (baseline && !options.fix && !options.ignoreBaseline) {
|
||||
// Don't filter during fix mode or when baseline is ignored
|
||||
report = filterReportByBaseline(report, baseline, config.rootDir);
|
||||
baselineFiltered = true;
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (options.json) {
|
||||
console.log(toJSON(report));
|
||||
} else {
|
||||
if (baselineFiltered && options.verbose) {
|
||||
console.log(formatBaselineInfo(baseline!));
|
||||
console.log(
|
||||
`New violations: ${report.summary.totalViolations} (${originalViolations} total, ${originalViolations - report.summary.totalViolations} in baseline)\n`,
|
||||
);
|
||||
} else if (baselineFiltered && report.summary.totalViolations < originalViolations) {
|
||||
console.log(
|
||||
`Using baseline: ${originalViolations - report.summary.totalViolations} known violations filtered\n`,
|
||||
);
|
||||
}
|
||||
|
||||
toConsole(report, options.verbose);
|
||||
|
||||
if (options.fix) {
|
||||
printFixResults(report, options.write);
|
||||
}
|
||||
}
|
||||
|
||||
// Exit with error code if violations found
|
||||
if (report.summary.totalViolations > 0 && !(options.fix && options.write)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runBaseline(options: CliOptions): void {
|
||||
const config = getConfig();
|
||||
|
||||
// Create runner and project
|
||||
const runner = createDefaultRunner();
|
||||
const { project, root } = createProject(config.rootDir);
|
||||
|
||||
// Run full analysis (no file filter, no baseline filter)
|
||||
const report = runner.run(project, root, {});
|
||||
|
||||
if (!report) {
|
||||
console.error('Failed to generate report');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate and save baseline
|
||||
const baseline = generateBaseline(report, config.rootDir);
|
||||
saveBaseline(baseline, config.rootDir);
|
||||
|
||||
const baselinePath = getBaselinePath(config.rootDir);
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(`\nBaseline created: ${baselinePath}`);
|
||||
console.log(`Total violations: ${baseline.totalViolations}`);
|
||||
console.log(`Files with violations: ${Object.keys(baseline.violations).length}`);
|
||||
console.log('\nViolations by rule:');
|
||||
for (const result of report.results) {
|
||||
if (result.violations.length > 0) {
|
||||
console.log(` ${result.rule}: ${result.violations.length}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`Baseline created: ${baselinePath} (${baseline.totalViolations} violations)`);
|
||||
}
|
||||
|
||||
console.log('\nNext steps:');
|
||||
console.log(' git add .janitor-baseline.json');
|
||||
console.log(' git commit -m "chore: add janitor baseline"');
|
||||
}
|
||||
|
||||
function runRules(options: CliOptions): void {
|
||||
const runner = createDefaultRunner();
|
||||
const rules = runner.getRuleDetails();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(rules, null, 2));
|
||||
} else {
|
||||
console.log('\nAvailable Rules\n' + '='.repeat(50) + '\n');
|
||||
|
||||
for (const rule of rules) {
|
||||
const fixable = rule.fixable ? ' [fixable]' : '';
|
||||
const enabled = rule.enabled ? '' : ' (disabled)';
|
||||
const severity = rule.severity.toUpperCase();
|
||||
|
||||
console.log(`${rule.id}${fixable}${enabled}`);
|
||||
console.log(` Name: ${rule.name}`);
|
||||
console.log(` Severity: ${severity}`);
|
||||
console.log(` ${rule.description}`);
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(` Targets: ${rule.targetGlobs.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(`Total: ${rules.length} rules (${rules.filter((r) => r.enabled).length} enabled)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs();
|
||||
|
||||
// Handle help/list commands that don't need config
|
||||
if (options.help) {
|
||||
switch (options.command) {
|
||||
case 'tcr':
|
||||
showTcrHelp();
|
||||
break;
|
||||
case 'baseline':
|
||||
showBaselineHelp();
|
||||
break;
|
||||
case 'inventory':
|
||||
showInventoryHelp();
|
||||
break;
|
||||
case 'impact':
|
||||
showImpactHelp();
|
||||
break;
|
||||
case 'method-impact':
|
||||
showMethodImpactHelp();
|
||||
break;
|
||||
case 'rules':
|
||||
showRulesHelp();
|
||||
break;
|
||||
default:
|
||||
showHelp();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.list) {
|
||||
const runner = createDefaultRunner();
|
||||
console.log('\nAvailable rules:\n');
|
||||
for (const ruleId of runner.getRegisteredRules()) {
|
||||
const fixable = runner.isRuleFixable(ruleId) ? ' [fixable]' : '';
|
||||
console.log(` - ${ruleId}${fixable}`);
|
||||
}
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load config once for all commands that need it
|
||||
const config = await loadConfig(options.config);
|
||||
setConfig(config);
|
||||
|
||||
switch (options.command) {
|
||||
case 'tcr':
|
||||
runTcr(options);
|
||||
break;
|
||||
case 'baseline':
|
||||
runBaseline(options);
|
||||
break;
|
||||
case 'inventory':
|
||||
runInventory(options);
|
||||
break;
|
||||
case 'impact':
|
||||
await runImpact(options);
|
||||
break;
|
||||
case 'method-impact':
|
||||
runMethodImpact(options);
|
||||
break;
|
||||
case 'rules':
|
||||
runRules(options);
|
||||
break;
|
||||
default:
|
||||
runAnalyze(options);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
262
packages/testing/janitor/src/cli/arg-parser.test.ts
Normal file
262
packages/testing/janitor/src/cli/arg-parser.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { parseArgs } from './arg-parser.js';
|
||||
|
||||
describe('arg-parser', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
function setArgs(args: string[]): void {
|
||||
process.argv = ['node', 'cli.js', ...args];
|
||||
}
|
||||
|
||||
describe('subcommands', () => {
|
||||
it('defaults to analyze command', () => {
|
||||
setArgs([]);
|
||||
const result = parseArgs();
|
||||
expect(result.command).toBe('analyze');
|
||||
});
|
||||
|
||||
it('parses tcr command', () => {
|
||||
setArgs(['tcr']);
|
||||
const result = parseArgs();
|
||||
expect(result.command).toBe('tcr');
|
||||
});
|
||||
|
||||
it('parses inventory command', () => {
|
||||
setArgs(['inventory']);
|
||||
const result = parseArgs();
|
||||
expect(result.command).toBe('inventory');
|
||||
});
|
||||
|
||||
it('parses impact command', () => {
|
||||
setArgs(['impact']);
|
||||
const result = parseArgs();
|
||||
expect(result.command).toBe('impact');
|
||||
});
|
||||
|
||||
it('parses method-impact command', () => {
|
||||
setArgs(['method-impact']);
|
||||
const result = parseArgs();
|
||||
expect(result.command).toBe('method-impact');
|
||||
});
|
||||
|
||||
it('parses baseline command', () => {
|
||||
setArgs(['baseline']);
|
||||
const result = parseArgs();
|
||||
expect(result.command).toBe('baseline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean flags', () => {
|
||||
it('parses --help', () => {
|
||||
setArgs(['--help']);
|
||||
const result = parseArgs();
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
|
||||
it('parses -h', () => {
|
||||
setArgs(['-h']);
|
||||
const result = parseArgs();
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --json', () => {
|
||||
setArgs(['--json']);
|
||||
const result = parseArgs();
|
||||
expect(result.json).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --verbose', () => {
|
||||
setArgs(['--verbose']);
|
||||
const result = parseArgs();
|
||||
expect(result.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('parses -v', () => {
|
||||
setArgs(['-v']);
|
||||
const result = parseArgs();
|
||||
expect(result.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --fix', () => {
|
||||
setArgs(['--fix']);
|
||||
const result = parseArgs();
|
||||
expect(result.fix).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --write', () => {
|
||||
setArgs(['--write']);
|
||||
const result = parseArgs();
|
||||
expect(result.write).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --list', () => {
|
||||
setArgs(['--list']);
|
||||
const result = parseArgs();
|
||||
expect(result.list).toBe(true);
|
||||
});
|
||||
|
||||
it('parses -l', () => {
|
||||
setArgs(['-l']);
|
||||
const result = parseArgs();
|
||||
expect(result.list).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --execute', () => {
|
||||
setArgs(['--execute']);
|
||||
const result = parseArgs();
|
||||
expect(result.execute).toBe(true);
|
||||
});
|
||||
|
||||
it('parses -x', () => {
|
||||
setArgs(['-x']);
|
||||
const result = parseArgs();
|
||||
expect(result.execute).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --test-list', () => {
|
||||
setArgs(['--test-list']);
|
||||
const result = parseArgs();
|
||||
expect(result.testList).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --index', () => {
|
||||
setArgs(['--index']);
|
||||
const result = parseArgs();
|
||||
expect(result.methodIndex).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --allow-in-expect', () => {
|
||||
setArgs(['--allow-in-expect']);
|
||||
const result = parseArgs();
|
||||
expect(result.allowInExpect).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --ignore-baseline', () => {
|
||||
setArgs(['--ignore-baseline']);
|
||||
const result = parseArgs();
|
||||
expect(result.ignoreBaseline).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('value flags', () => {
|
||||
it('parses --config=path', () => {
|
||||
setArgs(['--config=/path/to/config.js']);
|
||||
const result = parseArgs();
|
||||
expect(result.config).toBe('/path/to/config.js');
|
||||
});
|
||||
|
||||
it('parses --rule=id', () => {
|
||||
setArgs(['--rule=dead-code']);
|
||||
const result = parseArgs();
|
||||
expect(result.rule).toBe('dead-code');
|
||||
});
|
||||
|
||||
it('parses --file=path', () => {
|
||||
setArgs(['--file=src/file.ts']);
|
||||
const result = parseArgs();
|
||||
expect(result.files).toContain('src/file.ts');
|
||||
});
|
||||
|
||||
it('parses --files=comma,separated', () => {
|
||||
setArgs(['--files=a.ts,b.ts,c.ts']);
|
||||
const result = parseArgs();
|
||||
expect(result.files).toEqual(['a.ts', 'b.ts', 'c.ts']);
|
||||
});
|
||||
|
||||
it('parses --message=text', () => {
|
||||
setArgs(['--message=Fix bug']);
|
||||
const result = parseArgs();
|
||||
expect(result.message).toBe('Fix bug');
|
||||
});
|
||||
|
||||
it('parses -m=text', () => {
|
||||
setArgs(['-m=Fix bug']);
|
||||
const result = parseArgs();
|
||||
expect(result.message).toBe('Fix bug');
|
||||
});
|
||||
|
||||
it('parses --base=ref', () => {
|
||||
setArgs(['--base=HEAD~1']);
|
||||
const result = parseArgs();
|
||||
expect(result.baseRef).toBe('HEAD~1');
|
||||
});
|
||||
|
||||
it('parses --method=Class.method', () => {
|
||||
setArgs(['--method=CanvasPage.addNode']);
|
||||
const result = parseArgs();
|
||||
expect(result.method).toBe('CanvasPage.addNode');
|
||||
});
|
||||
|
||||
it('parses --target-branch=name', () => {
|
||||
setArgs(['--target-branch=main']);
|
||||
const result = parseArgs();
|
||||
expect(result.targetBranch).toBe('main');
|
||||
});
|
||||
|
||||
it('parses --max-diff-lines=number', () => {
|
||||
setArgs(['--max-diff-lines=500']);
|
||||
const result = parseArgs();
|
||||
expect(result.maxDiffLines).toBe(500);
|
||||
});
|
||||
|
||||
it('parses --test-command=cmd', () => {
|
||||
setArgs(['--test-command=pnpm test']);
|
||||
const result = parseArgs();
|
||||
expect(result.testCommand).toBe('pnpm test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined arguments', () => {
|
||||
it('parses subcommand with flags', () => {
|
||||
setArgs(['tcr', '--execute', '--verbose', '-m=Fix bug']);
|
||||
const result = parseArgs();
|
||||
|
||||
expect(result.command).toBe('tcr');
|
||||
expect(result.execute).toBe(true);
|
||||
expect(result.verbose).toBe(true);
|
||||
expect(result.message).toBe('Fix bug');
|
||||
});
|
||||
|
||||
it('parses multiple file flags', () => {
|
||||
setArgs(['--file=a.ts', '--file=b.ts']);
|
||||
const result = parseArgs();
|
||||
|
||||
expect(result.files).toContain('a.ts');
|
||||
expect(result.files).toContain('b.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('has correct defaults', () => {
|
||||
setArgs([]);
|
||||
const result = parseArgs();
|
||||
|
||||
expect(result.command).toBe('analyze');
|
||||
expect(result.config).toBeUndefined();
|
||||
expect(result.rule).toBeUndefined();
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.json).toBe(false);
|
||||
expect(result.verbose).toBe(false);
|
||||
expect(result.fix).toBe(false);
|
||||
expect(result.write).toBe(false);
|
||||
expect(result.help).toBe(false);
|
||||
expect(result.list).toBe(false);
|
||||
expect(result.execute).toBe(false);
|
||||
expect(result.message).toBeUndefined();
|
||||
expect(result.baseRef).toBeUndefined();
|
||||
expect(result.targetBranch).toBeUndefined();
|
||||
expect(result.maxDiffLines).toBeUndefined();
|
||||
expect(result.testCommand).toBeUndefined();
|
||||
expect(result.testList).toBe(false);
|
||||
expect(result.method).toBeUndefined();
|
||||
expect(result.methodIndex).toBe(false);
|
||||
expect(result.allowInExpect).toBe(false);
|
||||
expect(result.ignoreBaseline).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
225
packages/testing/janitor/src/cli/arg-parser.ts
Normal file
225
packages/testing/janitor/src/cli/arg-parser.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* CLI Argument Parser
|
||||
*
|
||||
* Extracts argument parsing logic from main CLI.
|
||||
* Handles subcommands, flags, and options.
|
||||
*/
|
||||
|
||||
export type Command =
|
||||
| 'analyze'
|
||||
| 'tcr'
|
||||
| 'inventory'
|
||||
| 'impact'
|
||||
| 'method-impact'
|
||||
| 'baseline'
|
||||
| 'rules';
|
||||
|
||||
export interface CliOptions {
|
||||
command: Command;
|
||||
config?: string;
|
||||
rule?: string;
|
||||
files?: string[];
|
||||
json: boolean;
|
||||
verbose: boolean;
|
||||
fix: boolean;
|
||||
write: boolean;
|
||||
help: boolean;
|
||||
list: boolean;
|
||||
// TCR-specific options
|
||||
execute: boolean;
|
||||
message?: string;
|
||||
baseRef?: string;
|
||||
targetBranch?: string;
|
||||
maxDiffLines?: number;
|
||||
testCommand?: string;
|
||||
// Impact-specific options
|
||||
testList: boolean;
|
||||
// Method-impact specific options
|
||||
method?: string;
|
||||
methodIndex: boolean;
|
||||
// Rule-specific options
|
||||
allowInExpect: boolean;
|
||||
// Baseline options
|
||||
ignoreBaseline: boolean;
|
||||
// Inventory-specific options
|
||||
summary: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: Record<string, Command> = {
|
||||
tcr: 'tcr',
|
||||
inventory: 'inventory',
|
||||
impact: 'impact',
|
||||
'method-impact': 'method-impact',
|
||||
baseline: 'baseline',
|
||||
rules: 'rules',
|
||||
};
|
||||
|
||||
interface FlagHandler {
|
||||
(options: CliOptions, value?: string): void;
|
||||
}
|
||||
|
||||
const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
||||
'--help': (opts) => {
|
||||
opts.help = true;
|
||||
},
|
||||
'-h': (opts) => {
|
||||
opts.help = true;
|
||||
},
|
||||
'--json': (opts) => {
|
||||
opts.json = true;
|
||||
},
|
||||
'--verbose': (opts) => {
|
||||
opts.verbose = true;
|
||||
},
|
||||
'-v': (opts) => {
|
||||
opts.verbose = true;
|
||||
},
|
||||
'--fix': (opts) => {
|
||||
opts.fix = true;
|
||||
},
|
||||
'--write': (opts) => {
|
||||
opts.write = true;
|
||||
},
|
||||
'--list': (opts) => {
|
||||
opts.list = true;
|
||||
},
|
||||
'-l': (opts) => {
|
||||
opts.list = true;
|
||||
},
|
||||
'--execute': (opts) => {
|
||||
opts.execute = true;
|
||||
},
|
||||
'-x': (opts) => {
|
||||
opts.execute = true;
|
||||
},
|
||||
'--test-list': (opts) => {
|
||||
opts.testList = true;
|
||||
},
|
||||
'--index': (opts) => {
|
||||
opts.methodIndex = true;
|
||||
},
|
||||
'--allow-in-expect': (opts) => {
|
||||
opts.allowInExpect = true;
|
||||
},
|
||||
'--ignore-baseline': (opts) => {
|
||||
opts.ignoreBaseline = true;
|
||||
},
|
||||
'--summary': (opts) => {
|
||||
opts.summary = true;
|
||||
},
|
||||
'-s': (opts) => {
|
||||
opts.summary = true;
|
||||
},
|
||||
};
|
||||
|
||||
const VALUE_FLAG_HANDLERS: Record<string, (options: CliOptions, value: string) => void> = {
|
||||
'--config=': (opts, value) => {
|
||||
opts.config = value;
|
||||
},
|
||||
'--rule=': (opts, value) => {
|
||||
opts.rule = value;
|
||||
},
|
||||
'--file=': (opts, value) => {
|
||||
opts.files?.push(value);
|
||||
},
|
||||
'--files=': (opts, value) => {
|
||||
opts.files?.push(...value.split(','));
|
||||
},
|
||||
'--message=': (opts, value) => {
|
||||
opts.message = value;
|
||||
},
|
||||
'-m=': (opts, value) => {
|
||||
opts.message = value;
|
||||
},
|
||||
'--base=': (opts, value) => {
|
||||
opts.baseRef = value;
|
||||
},
|
||||
'--method=': (opts, value) => {
|
||||
opts.method = value;
|
||||
},
|
||||
'--target-branch=': (opts, value) => {
|
||||
opts.targetBranch = value;
|
||||
},
|
||||
'--max-diff-lines=': (opts, value) => {
|
||||
opts.maxDiffLines = Number.parseInt(value, 10);
|
||||
},
|
||||
'--test-command=': (opts, value) => {
|
||||
opts.testCommand = value;
|
||||
},
|
||||
'--category=': (opts, value) => {
|
||||
opts.category = value;
|
||||
},
|
||||
};
|
||||
|
||||
function createDefaultOptions(): CliOptions {
|
||||
return {
|
||||
command: 'analyze',
|
||||
config: undefined,
|
||||
rule: undefined,
|
||||
files: [],
|
||||
json: false,
|
||||
verbose: false,
|
||||
fix: false,
|
||||
write: false,
|
||||
help: false,
|
||||
list: false,
|
||||
execute: false,
|
||||
message: undefined,
|
||||
baseRef: undefined,
|
||||
targetBranch: undefined,
|
||||
maxDiffLines: undefined,
|
||||
testCommand: undefined,
|
||||
testList: false,
|
||||
method: undefined,
|
||||
methodIndex: false,
|
||||
allowInExpect: false,
|
||||
ignoreBaseline: false,
|
||||
summary: false,
|
||||
category: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubcommand(args: string[]): { command: Command; startIdx: number } {
|
||||
if (args[0] && !args[0].startsWith('-')) {
|
||||
const subcommand = SUBCOMMANDS[args[0]];
|
||||
if (subcommand) {
|
||||
return { command: subcommand, startIdx: 1 };
|
||||
}
|
||||
}
|
||||
return { command: 'analyze', startIdx: 0 };
|
||||
}
|
||||
|
||||
function parseFlags(args: string[], startIdx: number, options: CliOptions): void {
|
||||
for (let i = startIdx; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
// Handle simple flags
|
||||
const handler = FLAG_HANDLERS[arg];
|
||||
if (handler) {
|
||||
handler(options);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle value flags
|
||||
for (const [prefix, valueHandler] of Object.entries(VALUE_FLAG_HANDLERS)) {
|
||||
if (arg.startsWith(prefix)) {
|
||||
const value = arg.slice(prefix.length);
|
||||
valueHandler(options, value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const options = createDefaultOptions();
|
||||
|
||||
const { command, startIdx } = parseSubcommand(args);
|
||||
options.command = command;
|
||||
|
||||
parseFlags(args, startIdx, options);
|
||||
|
||||
return options;
|
||||
}
|
||||
156
packages/testing/janitor/src/cli/help.ts
Normal file
156
packages/testing/janitor/src/cli/help.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* CLI Help Messages
|
||||
*
|
||||
* Centralized help text for all commands.
|
||||
*/
|
||||
|
||||
export function showHelp(): void {
|
||||
console.log(`
|
||||
Playwright Janitor - Static analysis for Playwright test architecture
|
||||
|
||||
Usage:
|
||||
playwright-janitor [command] [options]
|
||||
|
||||
Commands:
|
||||
(default) Run static analysis rules
|
||||
rules Show detailed information about all rules (for AI agents)
|
||||
baseline Create/update baseline of known violations
|
||||
inventory Show codebase inventory (pages, components, flows, tests)
|
||||
impact Analyze impact of file changes (which tests to run)
|
||||
method-impact Find tests that use a specific method (e.g., CanvasPage.addNode)
|
||||
tcr Run TCR (Test && Commit || Revert) workflow
|
||||
|
||||
Analysis Options:
|
||||
--config=<path> Path to janitor.config.js (default: ./janitor.config.js)
|
||||
--rule=<id> Run a specific rule
|
||||
--file=<path> Analyze a specific file
|
||||
--files=<p1,p2> Analyze multiple files (comma-separated)
|
||||
--json Output as JSON
|
||||
--verbose, -v Detailed output with suggestions
|
||||
--fix Preview fixes (dry run)
|
||||
--fix --write Apply fixes to disk
|
||||
--list, -l List available rules
|
||||
--allow-in-expect Skip selector-purity violations inside expect()
|
||||
--ignore-baseline Show all violations, ignoring .janitor-baseline.json
|
||||
--help, -h Show this help
|
||||
|
||||
Examples:
|
||||
playwright-janitor # Run all rules
|
||||
playwright-janitor --rule=dead-code # Run specific rule
|
||||
playwright-janitor inventory # Show codebase structure
|
||||
playwright-janitor impact --file=pages/X # Show what tests are affected
|
||||
playwright-janitor --fix --write # Apply auto-fixes
|
||||
|
||||
For command-specific help:
|
||||
playwright-janitor rules --help
|
||||
playwright-janitor baseline --help
|
||||
playwright-janitor inventory --help
|
||||
playwright-janitor impact --help
|
||||
playwright-janitor method-impact --help
|
||||
playwright-janitor tcr --help
|
||||
`);
|
||||
}
|
||||
|
||||
export function showInventoryHelp(): void {
|
||||
console.log(`
|
||||
Inventory - Generate JSON inventory of codebase structure
|
||||
|
||||
Usage: playwright-janitor inventory [options]
|
||||
|
||||
Options:
|
||||
--summary, -s Compact output (~500 tokens) - counts, facade props, categories
|
||||
--category=<name> Filter to single category with method names only
|
||||
--file=<path> Detailed info for a single file (full method signatures)
|
||||
|
||||
Categories: pages, components, composables, services, fixtures, helpers, factories, testData
|
||||
|
||||
Examples:
|
||||
playwright-janitor inventory --summary # AI-friendly overview
|
||||
playwright-janitor inventory --category=pages # All pages, method names only
|
||||
playwright-janitor inventory --file=CanvasPage.ts # Full details for one file
|
||||
playwright-janitor inventory # Full inventory (verbose)
|
||||
|
||||
Progressive disclosure for AI:
|
||||
1. --summary to understand the landscape
|
||||
2. --category=X to explore a specific area
|
||||
3. --file=X.ts for detailed method signatures
|
||||
`);
|
||||
}
|
||||
|
||||
export function showImpactHelp(): void {
|
||||
console.log(`
|
||||
Impact - Find affected tests for changed files
|
||||
|
||||
Options: --file=<path>, --files=<p1,p2>, --json, --test-list, --verbose
|
||||
Example: playwright-janitor impact --test-list | xargs npx playwright test
|
||||
`);
|
||||
}
|
||||
|
||||
export function showMethodImpactHelp(): void {
|
||||
console.log(`
|
||||
Method Impact - Find tests using a specific method
|
||||
|
||||
Options: --method=<Class.method>, --index, --json, --test-list, --verbose
|
||||
Example: playwright-janitor method-impact --method=CanvasPage.addNode
|
||||
`);
|
||||
}
|
||||
|
||||
export function showTcrHelp(): void {
|
||||
console.log(`
|
||||
TCR - Test && Commit || Revert workflow
|
||||
|
||||
Options:
|
||||
--execute, -x Actually commit/revert (default: dry run)
|
||||
--message=<msg> Commit message
|
||||
--target-branch=<name> Branch to diff against
|
||||
--max-diff-lines=<n> Skip if diff exceeds N lines
|
||||
--test-command=<cmd> Test command (files appended)
|
||||
--json, --verbose
|
||||
|
||||
Example: playwright-janitor tcr --execute -m="Fix bug"
|
||||
`);
|
||||
}
|
||||
|
||||
export function showBaselineHelp(): void {
|
||||
console.log(`
|
||||
Baseline - Snapshot current violations for incremental cleanup
|
||||
|
||||
Creates .janitor-baseline.json with all current violations. When this file
|
||||
exists, janitor and TCR only fail on NEW violations not in the baseline.
|
||||
|
||||
Usage:
|
||||
playwright-janitor baseline # Create/update baseline
|
||||
playwright-janitor baseline --verbose # Show what's being baselined
|
||||
|
||||
Workflow:
|
||||
1. Run 'janitor baseline' to snapshot current state
|
||||
2. Commit .janitor-baseline.json
|
||||
3. Now TCR only fails on new violations
|
||||
4. As you fix violations, re-run 'janitor baseline' to update
|
||||
|
||||
Example:
|
||||
playwright-janitor baseline && git add .janitor-baseline.json
|
||||
`);
|
||||
}
|
||||
|
||||
export function showRulesHelp(): void {
|
||||
console.log(`
|
||||
Rules - Show detailed information about available rules
|
||||
|
||||
Usage:
|
||||
playwright-janitor rules # List all rules with descriptions
|
||||
playwright-janitor rules --json # Output as JSON (for AI agents)
|
||||
playwright-janitor rules --verbose # Include what each rule catches
|
||||
|
||||
Output includes:
|
||||
- Rule ID and name
|
||||
- Description
|
||||
- Severity (error/warning)
|
||||
- Whether it's fixable
|
||||
- What patterns it catches
|
||||
- Exceptions/allowed patterns
|
||||
|
||||
Example:
|
||||
playwright-janitor rules --json | jq '.[] | select(.id == "selector-purity")'
|
||||
`);
|
||||
}
|
||||
10
packages/testing/janitor/src/cli/index.ts
Normal file
10
packages/testing/janitor/src/cli/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export { parseArgs, type CliOptions, type Command } from './arg-parser.js';
|
||||
export {
|
||||
showHelp,
|
||||
showBaselineHelp,
|
||||
showImpactHelp,
|
||||
showInventoryHelp,
|
||||
showMethodImpactHelp,
|
||||
showRulesHelp,
|
||||
showTcrHelp,
|
||||
} from './help.js';
|
||||
228
packages/testing/janitor/src/config.ts
Normal file
228
packages/testing/janitor/src/config.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Janitor Configuration
|
||||
*
|
||||
* This file defines the patterns and conventions for your Playwright test architecture.
|
||||
* Customize these values to match your project's structure and naming conventions.
|
||||
*/
|
||||
|
||||
import type { FilePatterns, FacadeConfig, RuleSettings, RuleId } from './types.js';
|
||||
|
||||
/**
|
||||
* Complete janitor configuration.
|
||||
* Use `defineConfig()` to create a config with sensible defaults.
|
||||
*/
|
||||
export interface JanitorConfig {
|
||||
/**
|
||||
* Root directory for the Playwright test suite (absolute path).
|
||||
* All patterns are resolved relative to this directory.
|
||||
*/
|
||||
rootDir: string;
|
||||
|
||||
/**
|
||||
* Directory patterns for different artifact types.
|
||||
* Use glob patterns relative to rootDir.
|
||||
*/
|
||||
patterns: FilePatterns;
|
||||
|
||||
/**
|
||||
* Files to exclude from page analysis (facades, base classes).
|
||||
* @example ['BasePage.ts', 'AppPage.ts']
|
||||
*/
|
||||
excludeFromPages: string[];
|
||||
|
||||
/**
|
||||
* Facade configuration - the main aggregator that exposes page objects.
|
||||
* The facade is the central page object that tests interact with.
|
||||
*/
|
||||
facade: FacadeConfig;
|
||||
|
||||
/**
|
||||
* The fixture object name used in tests.
|
||||
* This is the object that provides page objects in test functions.
|
||||
* @example 'app' for `test('...', async ({ app }) => ...)`
|
||||
*/
|
||||
fixtureObjectName: string;
|
||||
|
||||
/**
|
||||
* The API fixture/helper object name.
|
||||
* This is the object that provides API services.
|
||||
* @example 'api' for `test('...', async ({ api }) => ...)`
|
||||
*/
|
||||
apiFixtureName: string;
|
||||
|
||||
/**
|
||||
* Patterns that indicate raw API calls that should go through services.
|
||||
* @example [/\brequest\.(get|post)\s*\(/i, /\bfetch\s*\(/]
|
||||
*/
|
||||
rawApiPatterns: RegExp[];
|
||||
|
||||
/**
|
||||
* What you call the middle layer between tests and pages.
|
||||
* Used in error messages and suggestions.
|
||||
* @example 'Composable', 'Action', 'Flow', 'Scenario'
|
||||
*/
|
||||
flowLayerName: string;
|
||||
|
||||
/**
|
||||
* Architecture layers for documentation (top to bottom).
|
||||
* @example ['Tests', 'Composables', 'Pages', 'BasePage']
|
||||
*/
|
||||
architectureLayers: string[];
|
||||
|
||||
/**
|
||||
* Rule-specific configuration.
|
||||
* Override severity, disable rules, or add allow patterns.
|
||||
*/
|
||||
rules: {
|
||||
[K in RuleId]?: RuleSettings;
|
||||
};
|
||||
|
||||
/** TCR configuration */
|
||||
tcr: {
|
||||
/** Test command. File paths will be appended. @default 'npx playwright test' */
|
||||
testCommand: string;
|
||||
/** Number of Playwright workers. Always appended to test command. @default 1 */
|
||||
workerCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal default configuration.
|
||||
* Projects should provide their own config via defineConfig().
|
||||
*/
|
||||
export const defaultConfig: Omit<JanitorConfig, 'rootDir'> = {
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts', 'utils/**/*.ts'],
|
||||
factories: ['test-data/**/*.ts', 'factories/**/*.ts'],
|
||||
testData: ['workflows/**/*', 'expectations/**/*'],
|
||||
},
|
||||
|
||||
excludeFromPages: ['BasePage.ts', 'BaseModal.ts', 'BaseComponent.ts'],
|
||||
|
||||
facade: {
|
||||
file: 'pages/AppPage.ts',
|
||||
className: 'AppPage',
|
||||
excludeTypes: ['Page', 'APIRequestContext'],
|
||||
},
|
||||
|
||||
fixtureObjectName: 'app',
|
||||
|
||||
apiFixtureName: 'api',
|
||||
|
||||
rawApiPatterns: [
|
||||
/\brequest\.(get|post|put|patch|delete|head)\s*\(/i,
|
||||
/\bfetch\s*\(/,
|
||||
/\.request\(\s*['"`]/,
|
||||
],
|
||||
|
||||
flowLayerName: 'Composable',
|
||||
|
||||
architectureLayers: ['Tests', 'Composables', 'Pages', 'BasePage'],
|
||||
|
||||
rules: {
|
||||
'boundary-protection': { enabled: true, severity: 'error' },
|
||||
'scope-lockdown': {
|
||||
enabled: true,
|
||||
severity: 'error',
|
||||
navigationMethods: ['goto', 'navigate', 'visit', 'open'],
|
||||
},
|
||||
'selector-purity': { enabled: true, severity: 'error', allowInExpect: true },
|
||||
deduplication: { enabled: true, severity: 'warning' },
|
||||
'dead-code': { enabled: true, severity: 'warning' },
|
||||
'no-page-in-flow': {
|
||||
enabled: true,
|
||||
severity: 'warning',
|
||||
allowPatterns: [
|
||||
/\.page\.keyboard/,
|
||||
/\.page\.evaluate/,
|
||||
/\.page\.waitForLoadState/,
|
||||
/\.page\.waitForURL/,
|
||||
/\.page\.reload/,
|
||||
],
|
||||
},
|
||||
'api-purity': { enabled: true, severity: 'warning' },
|
||||
'test-data-hygiene': { enabled: true, severity: 'warning' },
|
||||
// Opt-in rule: enforces facade pattern for page object access
|
||||
// Enable in projects that use a facade pattern (e.g., n8n.canvas instead of new CanvasPage())
|
||||
'no-direct-page-instantiation': { enabled: false, severity: 'error' },
|
||||
},
|
||||
|
||||
tcr: {
|
||||
testCommand: 'npx playwright test',
|
||||
},
|
||||
};
|
||||
|
||||
/** Runtime configuration holder */
|
||||
let currentConfig: JanitorConfig | null = null;
|
||||
|
||||
/**
|
||||
* Input type for defineConfig - rootDir is required, everything else optional.
|
||||
*/
|
||||
export type DefineConfigInput = Partial<JanitorConfig> & { rootDir: string };
|
||||
|
||||
/**
|
||||
* Define a janitor configuration (for use in janitor.config.ts files).
|
||||
* Merges provided config with sensible defaults.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export default defineConfig({
|
||||
* rootDir: __dirname,
|
||||
* fixtureObjectName: 'app',
|
||||
* facade: {
|
||||
* file: 'pages/AppPage.ts',
|
||||
* className: 'AppPage',
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineConfig(config: DefineConfigInput): JanitorConfig {
|
||||
return {
|
||||
...defaultConfig,
|
||||
...config,
|
||||
patterns: { ...defaultConfig.patterns, ...config.patterns },
|
||||
facade: { ...defaultConfig.facade, ...config.facade },
|
||||
rules: { ...defaultConfig.rules, ...config.rules },
|
||||
tcr: { ...defaultConfig.tcr, ...config.tcr },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current configuration.
|
||||
* @throws Error if configuration hasn't been set
|
||||
*/
|
||||
export function getConfig(): JanitorConfig {
|
||||
if (!currentConfig) {
|
||||
throw new Error(
|
||||
'Janitor configuration not initialized. Call setConfig() or load a janitor.config.ts file first.',
|
||||
);
|
||||
}
|
||||
return currentConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the runtime configuration.
|
||||
*/
|
||||
export function setConfig(config: JanitorConfig): void {
|
||||
currentConfig = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if configuration has been initialized.
|
||||
*/
|
||||
export function hasConfig(): boolean {
|
||||
return currentConfig !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset configuration (mainly for testing).
|
||||
*/
|
||||
export function resetConfig(): void {
|
||||
currentConfig = null;
|
||||
}
|
||||
353
packages/testing/janitor/src/core/ast-diff-analyzer.test.ts
Normal file
353
packages/testing/janitor/src/core/ast-diff-analyzer.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { Project } from 'ts-morph';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for AST-based method extraction and diffing logic.
|
||||
*
|
||||
* Note: We can't easily test the full diffFileMethods() function in unit tests
|
||||
* because it relies on git. Instead, we test the core extraction logic by
|
||||
* recreating the method extraction in isolation.
|
||||
*/
|
||||
|
||||
// Helper to extract methods from a source file (mirrors the internal function)
|
||||
function extractMethods(sourceFile: ReturnType<Project['createSourceFile']>): Map<string, string> {
|
||||
const methods = new Map<string, string>();
|
||||
|
||||
for (const classDecl of sourceFile.getClasses()) {
|
||||
const className = classDecl.getName() ?? 'AnonymousClass';
|
||||
|
||||
for (const method of classDecl.getMethods()) {
|
||||
const methodName = method.getName();
|
||||
const key = `${className}.${methodName}`;
|
||||
const bodyText = method.getText();
|
||||
methods.set(key, bodyText);
|
||||
}
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
// Helper to diff two method maps
|
||||
function diffMethods(
|
||||
baseMethods: Map<string, string>,
|
||||
currentMethods: Map<string, string>,
|
||||
): Array<{ className: string; methodName: string; changeType: 'added' | 'removed' | 'modified' }> {
|
||||
const changes: Array<{
|
||||
className: string;
|
||||
methodName: string;
|
||||
changeType: 'added' | 'removed' | 'modified';
|
||||
}> = [];
|
||||
|
||||
// Check for modified or removed methods
|
||||
for (const [key, baseText] of baseMethods) {
|
||||
const currentText = currentMethods.get(key);
|
||||
const [className, methodName] = key.split('.');
|
||||
|
||||
if (currentText === undefined) {
|
||||
changes.push({ className, methodName, changeType: 'removed' });
|
||||
} else if (currentText !== baseText) {
|
||||
changes.push({ className, methodName, changeType: 'modified' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for added methods
|
||||
for (const [key] of currentMethods) {
|
||||
if (!baseMethods.has(key)) {
|
||||
const [className, methodName] = key.split('.');
|
||||
changes.push({ className, methodName, changeType: 'added' });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
describe('AST Diff Analyzer', () => {
|
||||
describe('Method Extraction', () => {
|
||||
it('extracts methods from a class', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const file = project.createSourceFile(
|
||||
'test.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode(name: string) {
|
||||
await this.click(name);
|
||||
}
|
||||
|
||||
async connectNodes(from: string, to: string) {
|
||||
await this.drag(from, to);
|
||||
}
|
||||
|
||||
get container() {
|
||||
return this.page.getByTestId('canvas');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const methods = extractMethods(file);
|
||||
|
||||
expect(methods.size).toBe(2); // Only methods, not getters
|
||||
expect(methods.has('CanvasPage.addNode')).toBe(true);
|
||||
expect(methods.has('CanvasPage.connectNodes')).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts methods from multiple classes', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const file = project.createSourceFile(
|
||||
'test.ts',
|
||||
`
|
||||
export class PageA {
|
||||
methodA() { return 'a'; }
|
||||
}
|
||||
|
||||
export class PageB {
|
||||
methodB() { return 'b'; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const methods = extractMethods(file);
|
||||
|
||||
expect(methods.size).toBe(2);
|
||||
expect(methods.has('PageA.methodA')).toBe(true);
|
||||
expect(methods.has('PageB.methodB')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty class', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const file = project.createSourceFile(
|
||||
'test.ts',
|
||||
`
|
||||
export class EmptyPage {
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const methods = extractMethods(file);
|
||||
|
||||
expect(methods.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method Diffing', () => {
|
||||
it('detects added methods', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
const baseFile = project.createSourceFile(
|
||||
'base.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const currentFile = project.createSourceFile(
|
||||
'current.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {}
|
||||
async deleteNode() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const baseMethods = extractMethods(baseFile);
|
||||
const currentMethods = extractMethods(currentFile);
|
||||
const changes = diffMethods(baseMethods, currentMethods);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]).toEqual({
|
||||
className: 'CanvasPage',
|
||||
methodName: 'deleteNode',
|
||||
changeType: 'added',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects removed methods', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
const baseFile = project.createSourceFile(
|
||||
'base.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {}
|
||||
async deleteNode() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const currentFile = project.createSourceFile(
|
||||
'current.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const baseMethods = extractMethods(baseFile);
|
||||
const currentMethods = extractMethods(currentFile);
|
||||
const changes = diffMethods(baseMethods, currentMethods);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]).toEqual({
|
||||
className: 'CanvasPage',
|
||||
methodName: 'deleteNode',
|
||||
changeType: 'removed',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects modified methods', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
const baseFile = project.createSourceFile(
|
||||
'base.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {
|
||||
await this.click('add');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const currentFile = project.createSourceFile(
|
||||
'current.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {
|
||||
await this.click('add');
|
||||
await this.waitForAnimation();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const baseMethods = extractMethods(baseFile);
|
||||
const currentMethods = extractMethods(currentFile);
|
||||
const changes = diffMethods(baseMethods, currentMethods);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]).toEqual({
|
||||
className: 'CanvasPage',
|
||||
methodName: 'addNode',
|
||||
changeType: 'modified',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects no changes when methods are identical', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
const baseFile = project.createSourceFile(
|
||||
'base.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {
|
||||
await this.click('add');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const currentFile = project.createSourceFile(
|
||||
'current.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {
|
||||
await this.click('add');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const baseMethods = extractMethods(baseFile);
|
||||
const currentMethods = extractMethods(currentFile);
|
||||
const changes = diffMethods(baseMethods, currentMethods);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects multiple changes', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
const baseFile = project.createSourceFile(
|
||||
'base.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() { return 'old'; }
|
||||
async deleteNode() {}
|
||||
async oldMethod() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const currentFile = project.createSourceFile(
|
||||
'current.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() { return 'new'; }
|
||||
async deleteNode() {}
|
||||
async newMethod() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const baseMethods = extractMethods(baseFile);
|
||||
const currentMethods = extractMethods(currentFile);
|
||||
const changes = diffMethods(baseMethods, currentMethods);
|
||||
|
||||
expect(changes).toHaveLength(3);
|
||||
|
||||
const added = changes.find((c) => c.changeType === 'added');
|
||||
const removed = changes.find((c) => c.changeType === 'removed');
|
||||
const modified = changes.find((c) => c.changeType === 'modified');
|
||||
|
||||
expect(added?.methodName).toBe('newMethod');
|
||||
expect(removed?.methodName).toBe('oldMethod');
|
||||
expect(modified?.methodName).toBe('addNode');
|
||||
});
|
||||
|
||||
it('handles class rename as remove + add', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
const baseFile = project.createSourceFile(
|
||||
'base.ts',
|
||||
`
|
||||
export class OldPage {
|
||||
async doSomething() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const currentFile = project.createSourceFile(
|
||||
'current.ts',
|
||||
`
|
||||
export class NewPage {
|
||||
async doSomething() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const baseMethods = extractMethods(baseFile);
|
||||
const currentMethods = extractMethods(currentFile);
|
||||
const changes = diffMethods(baseMethods, currentMethods);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
const added = changes.find((c) => c.changeType === 'added');
|
||||
const removed = changes.find((c) => c.changeType === 'removed');
|
||||
|
||||
expect(added).toEqual({
|
||||
className: 'NewPage',
|
||||
methodName: 'doSomething',
|
||||
changeType: 'added',
|
||||
});
|
||||
expect(removed).toEqual({
|
||||
className: 'OldPage',
|
||||
methodName: 'doSomething',
|
||||
changeType: 'removed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
215
packages/testing/janitor/src/core/ast-diff-analyzer.ts
Normal file
215
packages/testing/janitor/src/core/ast-diff-analyzer.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* AST Diff Analyzer - Detects which methods changed in a file
|
||||
*
|
||||
* Uses ts-morph to compare the current file against git HEAD,
|
||||
* identifying exactly which methods were added, removed, or modified.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Project, type SourceFile, SyntaxKind } from 'ts-morph';
|
||||
|
||||
export interface MethodChange {
|
||||
className: string;
|
||||
methodName: string;
|
||||
changeType: 'added' | 'removed' | 'modified';
|
||||
}
|
||||
|
||||
export interface FileDiffResult {
|
||||
filePath: string;
|
||||
changedMethods: MethodChange[];
|
||||
isNewFile: boolean;
|
||||
isDeletedFile: boolean;
|
||||
parseTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the git root directory
|
||||
*/
|
||||
function getGitRoot(): string {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content from git at a specific ref
|
||||
*/
|
||||
function getGitFileContent(filePath: string, ref: string = 'HEAD'): string | null {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
const absolutePath = path.resolve(filePath);
|
||||
|
||||
// Get git root and make path relative to it
|
||||
const gitRoot = getGitRoot();
|
||||
const relativePath = path.relative(gitRoot, absolutePath);
|
||||
|
||||
return execFileSync('git', ['show', `${ref}:${relativePath}`], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
return null; // File doesn't exist at that ref
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract methods from a source file with their content hashes
|
||||
*/
|
||||
function extractMethods(sourceFile: SourceFile): Map<string, string> {
|
||||
const methods = new Map<string, string>();
|
||||
|
||||
// Get all classes
|
||||
const classes = sourceFile.getClasses();
|
||||
|
||||
for (const classDecl of classes) {
|
||||
const className = classDecl.getName() ?? 'AnonymousClass';
|
||||
|
||||
// Get all methods (including getters/setters)
|
||||
const classMethods = classDecl.getMethods();
|
||||
for (const method of classMethods) {
|
||||
const methodName = method.getName();
|
||||
const key = `${className}.${methodName}`;
|
||||
// Hash the method body for comparison
|
||||
const bodyText = method.getText();
|
||||
const hash = crypto.createHash('md5').update(bodyText).digest('hex');
|
||||
methods.set(key, hash);
|
||||
}
|
||||
|
||||
// Also check property declarations that are arrow functions
|
||||
const properties = classDecl.getProperties();
|
||||
for (const prop of properties) {
|
||||
const initializer = prop.getInitializer();
|
||||
if (initializer && initializer.getKind() === SyntaxKind.ArrowFunction) {
|
||||
const propName = prop.getName();
|
||||
const key = `${className}.${propName}`;
|
||||
const bodyText = initializer.getText();
|
||||
const hash = crypto.createHash('md5').update(bodyText).digest('hex');
|
||||
methods.set(key, hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions of a file and return changed methods
|
||||
*/
|
||||
export function diffFileMethods(filePath: string, baseRef: string = 'HEAD'): FileDiffResult {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Get current file content
|
||||
const currentProject = new Project({ useInMemoryFileSystem: true });
|
||||
let currentSource: SourceFile;
|
||||
|
||||
try {
|
||||
const currentContent = fs.readFileSync(filePath, 'utf-8');
|
||||
currentSource = currentProject.createSourceFile('current.ts', currentContent);
|
||||
} catch {
|
||||
// Current file doesn't exist (deleted)
|
||||
return {
|
||||
filePath,
|
||||
changedMethods: [],
|
||||
isNewFile: false,
|
||||
isDeletedFile: true,
|
||||
parseTimeMs: performance.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Get base file content from git
|
||||
const baseContent = getGitFileContent(filePath, baseRef);
|
||||
|
||||
if (baseContent === null) {
|
||||
// New file - all methods are "added"
|
||||
const currentMethods = extractMethods(currentSource);
|
||||
const changedMethods: MethodChange[] = [];
|
||||
|
||||
for (const [key] of currentMethods) {
|
||||
const [className, methodName] = key.split('.');
|
||||
changedMethods.push({ className, methodName, changeType: 'added' });
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
changedMethods,
|
||||
isNewFile: true,
|
||||
isDeletedFile: false,
|
||||
parseTimeMs: performance.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse base file
|
||||
const baseProject = new Project({ useInMemoryFileSystem: true });
|
||||
const baseSource = baseProject.createSourceFile('base.ts', baseContent);
|
||||
|
||||
// Extract methods from both versions
|
||||
const baseMethods = extractMethods(baseSource);
|
||||
const currentMethods = extractMethods(currentSource);
|
||||
|
||||
// Compare
|
||||
const changedMethods: MethodChange[] = [];
|
||||
|
||||
// Check for modified or removed methods
|
||||
for (const [key, baseHash] of baseMethods) {
|
||||
const currentHash = currentMethods.get(key);
|
||||
const [className, methodName] = key.split('.');
|
||||
|
||||
if (currentHash === undefined) {
|
||||
changedMethods.push({ className, methodName, changeType: 'removed' });
|
||||
} else if (currentHash !== baseHash) {
|
||||
changedMethods.push({ className, methodName, changeType: 'modified' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for added methods
|
||||
for (const [key] of currentMethods) {
|
||||
if (!baseMethods.has(key)) {
|
||||
const [className, methodName] = key.split('.');
|
||||
changedMethods.push({ className, methodName, changeType: 'added' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
changedMethods,
|
||||
isNewFile: false,
|
||||
isDeletedFile: false,
|
||||
parseTimeMs: performance.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format diff result for console output
|
||||
*/
|
||||
export function formatDiffConsole(result: FileDiffResult): void {
|
||||
console.log(`\nFile: ${result.filePath}`);
|
||||
console.log(`Parse time: ${result.parseTimeMs.toFixed(1)}ms`);
|
||||
|
||||
if (result.isNewFile) {
|
||||
console.log('Status: New file');
|
||||
} else if (result.isDeletedFile) {
|
||||
console.log('Status: Deleted file');
|
||||
}
|
||||
|
||||
if (result.changedMethods.length === 0) {
|
||||
console.log('No method changes detected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nChanged methods (${result.changedMethods.length}):`);
|
||||
for (const change of result.changedMethods) {
|
||||
const symbol =
|
||||
change.changeType === 'added' ? '+' : change.changeType === 'removed' ? '-' : '~';
|
||||
console.log(` ${symbol} ${change.className}.${change.methodName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format diff result as JSON
|
||||
*/
|
||||
export function formatDiffJSON(result: FileDiffResult): string {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
289
packages/testing/janitor/src/core/baseline.test.ts
Normal file
289
packages/testing/janitor/src/core/baseline.test.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
generateBaseline,
|
||||
saveBaseline,
|
||||
loadBaseline,
|
||||
hasBaseline,
|
||||
filterNewViolations,
|
||||
filterReportByBaseline,
|
||||
getBaselinePath,
|
||||
type BaselineFile,
|
||||
} from './baseline.js';
|
||||
import type { Violation, JanitorReport } from '../types.js';
|
||||
|
||||
describe('baseline', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'janitor-baseline-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getBaselinePath', () => {
|
||||
it('returns correct path', () => {
|
||||
const result = getBaselinePath('/some/project');
|
||||
expect(result).toBe('/some/project/.janitor-baseline.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBaseline', () => {
|
||||
it('returns false when no baseline exists', () => {
|
||||
expect(hasBaseline(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when baseline exists', () => {
|
||||
fs.writeFileSync(path.join(tempDir, '.janitor-baseline.json'), '{}');
|
||||
expect(hasBaseline(tempDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBaseline', () => {
|
||||
it('creates baseline from report', () => {
|
||||
const report: JanitorReport = {
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
projectRoot: tempDir,
|
||||
rules: { enabled: ['dead-code'], disabled: [] },
|
||||
results: [
|
||||
{
|
||||
rule: 'dead-code',
|
||||
violations: [
|
||||
{
|
||||
file: path.join(tempDir, 'pages/TestPage.ts'),
|
||||
line: 10,
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: oldMethod',
|
||||
severity: 'warning',
|
||||
},
|
||||
],
|
||||
filesAnalyzed: 1,
|
||||
executionTimeMs: 10,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalViolations: 1,
|
||||
byRule: { 'dead-code': 1 },
|
||||
bySeverity: { error: 0, warning: 1, info: 0 },
|
||||
filesAnalyzed: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const baseline = generateBaseline(report, tempDir);
|
||||
|
||||
expect(baseline.version).toBe(1);
|
||||
expect(baseline.totalViolations).toBe(1);
|
||||
expect(baseline.violations['pages/TestPage.ts']).toHaveLength(1);
|
||||
expect(baseline.violations['pages/TestPage.ts'][0].rule).toBe('dead-code');
|
||||
expect(baseline.violations['pages/TestPage.ts'][0].line).toBe(10);
|
||||
expect(baseline.violations['pages/TestPage.ts'][0].hash).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBaseline / loadBaseline', () => {
|
||||
it('round-trips baseline to disk', () => {
|
||||
const baseline: BaselineFile = {
|
||||
version: 1,
|
||||
generated: '2024-01-01T00:00:00Z',
|
||||
totalViolations: 2,
|
||||
violations: {
|
||||
'pages/TestPage.ts': [
|
||||
{ rule: 'dead-code', line: 10, message: 'Unused method', hash: 'abc123' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
saveBaseline(baseline, tempDir);
|
||||
const loaded = loadBaseline(tempDir);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.version).toBe(1);
|
||||
expect(loaded!.totalViolations).toBe(2);
|
||||
expect(loaded!.violations['pages/TestPage.ts']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null when no baseline exists', () => {
|
||||
const loaded = loadBaseline(tempDir);
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterNewViolations', () => {
|
||||
const baseline: BaselineFile = {
|
||||
version: 1,
|
||||
generated: '2024-01-01T00:00:00Z',
|
||||
totalViolations: 1,
|
||||
violations: {
|
||||
'pages/TestPage.ts': [
|
||||
{
|
||||
rule: 'dead-code',
|
||||
line: 10,
|
||||
message: 'Unused method: oldMethod',
|
||||
hash: 'abc123', // This hash matches the violation below
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('filters out violations in baseline', () => {
|
||||
// Create a violation that matches the baseline (same rule + message = same hash)
|
||||
const violations: Violation[] = [
|
||||
{
|
||||
file: path.join(tempDir, 'pages/TestPage.ts'),
|
||||
line: 10,
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: oldMethod',
|
||||
severity: 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
// Generate a real baseline from these violations to get matching hashes
|
||||
const report: JanitorReport = {
|
||||
timestamp: '',
|
||||
projectRoot: tempDir,
|
||||
rules: { enabled: [], disabled: [] },
|
||||
results: [{ rule: 'dead-code', violations, filesAnalyzed: 1, executionTimeMs: 0 }],
|
||||
summary: {
|
||||
totalViolations: 1,
|
||||
byRule: {},
|
||||
bySeverity: { error: 0, warning: 0, info: 0 },
|
||||
filesAnalyzed: 1,
|
||||
},
|
||||
};
|
||||
const realBaseline = generateBaseline(report, tempDir);
|
||||
|
||||
// Now filter - should find no new violations
|
||||
const newViolations = filterNewViolations(violations, realBaseline, tempDir);
|
||||
expect(newViolations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps violations not in baseline', () => {
|
||||
const violations: Violation[] = [
|
||||
{
|
||||
file: path.join(tempDir, 'pages/NewPage.ts'),
|
||||
line: 20,
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: newMethod',
|
||||
severity: 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
const newViolations = filterNewViolations(violations, baseline, tempDir);
|
||||
expect(newViolations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches by hash even if line number changed', () => {
|
||||
// Same rule + message but different line number
|
||||
const violations: Violation[] = [
|
||||
{
|
||||
file: path.join(tempDir, 'pages/TestPage.ts'),
|
||||
line: 15, // Line shifted from 10 to 15
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: oldMethod', // Same message
|
||||
severity: 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
// Generate baseline with the original violation
|
||||
const originalViolation: Violation = {
|
||||
file: path.join(tempDir, 'pages/TestPage.ts'),
|
||||
line: 10,
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: oldMethod',
|
||||
severity: 'warning',
|
||||
};
|
||||
const report: JanitorReport = {
|
||||
timestamp: '',
|
||||
projectRoot: tempDir,
|
||||
rules: { enabled: [], disabled: [] },
|
||||
results: [
|
||||
{
|
||||
rule: 'dead-code',
|
||||
violations: [originalViolation],
|
||||
filesAnalyzed: 1,
|
||||
executionTimeMs: 0,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalViolations: 1,
|
||||
byRule: {},
|
||||
bySeverity: { error: 0, warning: 0, info: 0 },
|
||||
filesAnalyzed: 1,
|
||||
},
|
||||
};
|
||||
const realBaseline = generateBaseline(report, tempDir);
|
||||
|
||||
// Filter - should match even with different line number
|
||||
const newViolations = filterNewViolations(violations, realBaseline, tempDir);
|
||||
expect(newViolations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterReportByBaseline', () => {
|
||||
it('recalculates summary after filtering', () => {
|
||||
const violations: Violation[] = [
|
||||
{
|
||||
file: path.join(tempDir, 'pages/TestPage.ts'),
|
||||
line: 10,
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: oldMethod',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
file: path.join(tempDir, 'pages/TestPage.ts'),
|
||||
line: 20,
|
||||
column: 1,
|
||||
rule: 'dead-code',
|
||||
message: 'Unused method: newMethod',
|
||||
severity: 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
const report: JanitorReport = {
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
projectRoot: tempDir,
|
||||
rules: { enabled: ['dead-code'], disabled: [] },
|
||||
results: [
|
||||
{
|
||||
rule: 'dead-code',
|
||||
violations,
|
||||
filesAnalyzed: 1,
|
||||
executionTimeMs: 10,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalViolations: 2,
|
||||
byRule: { 'dead-code': 2 },
|
||||
bySeverity: { error: 0, warning: 2, info: 0 },
|
||||
filesAnalyzed: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// Create baseline with only the first violation
|
||||
const baselineReport: JanitorReport = {
|
||||
...report,
|
||||
results: [{ ...report.results[0], violations: [violations[0]] }],
|
||||
summary: { ...report.summary, totalViolations: 1 },
|
||||
};
|
||||
const baseline = generateBaseline(baselineReport, tempDir);
|
||||
|
||||
// Filter - should have 1 new violation
|
||||
const filtered = filterReportByBaseline(report, baseline, tempDir);
|
||||
|
||||
expect(filtered.summary.totalViolations).toBe(1);
|
||||
expect(filtered.results[0].violations).toHaveLength(1);
|
||||
expect(filtered.results[0].violations[0].message).toBe('Unused method: newMethod');
|
||||
});
|
||||
});
|
||||
});
|
||||
204
packages/testing/janitor/src/core/baseline.ts
Normal file
204
packages/testing/janitor/src/core/baseline.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Baseline Management
|
||||
*
|
||||
* Stores known violations so incremental cleanup doesn't fail on pre-existing issues.
|
||||
* Only NEW violations (not in baseline) will cause failures.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { Violation, JanitorReport } from '../types.js';
|
||||
|
||||
const BASELINE_FILENAME = '.janitor-baseline.json';
|
||||
const BASELINE_VERSION = 1;
|
||||
|
||||
export interface BaselineEntry {
|
||||
rule: string;
|
||||
line: number;
|
||||
message: string;
|
||||
/** Hash of message + context for fuzzy matching when lines shift */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface BaselineFile {
|
||||
version: number;
|
||||
generated: string;
|
||||
totalViolations: number;
|
||||
violations: Record<string, BaselineEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash for a violation to enable fuzzy matching
|
||||
* when line numbers shift due to edits above the violation
|
||||
*/
|
||||
function hashViolation(violation: Violation): string {
|
||||
// Hash based on rule + message (not line number)
|
||||
const content = `${violation.rule}:${violation.message}`;
|
||||
return crypto.createHash('md5').update(content).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the baseline file path for a given root directory
|
||||
*/
|
||||
export function getBaselinePath(rootDir: string): string {
|
||||
return path.join(rootDir, BASELINE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a baseline file exists
|
||||
*/
|
||||
export function hasBaseline(rootDir: string): boolean {
|
||||
return fs.existsSync(getBaselinePath(rootDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load baseline from disk if it exists
|
||||
*/
|
||||
export function loadBaseline(rootDir: string): BaselineFile | null {
|
||||
const baselinePath = getBaselinePath(rootDir);
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(baselinePath, 'utf-8');
|
||||
const baseline = JSON.parse(content) as BaselineFile;
|
||||
|
||||
// Version check for future compatibility
|
||||
if (baseline.version !== BASELINE_VERSION) {
|
||||
console.warn(
|
||||
`Baseline version mismatch (got ${baseline.version}, expected ${BASELINE_VERSION}). Regenerate with: janitor baseline`,
|
||||
);
|
||||
}
|
||||
|
||||
return baseline;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load baseline: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a baseline from a janitor report
|
||||
*/
|
||||
export function generateBaseline(report: JanitorReport, rootDir: string): BaselineFile {
|
||||
const violations: Record<string, BaselineEntry[]> = {};
|
||||
|
||||
for (const result of report.results) {
|
||||
for (const violation of result.violations) {
|
||||
// Use relative path for portability
|
||||
const relativePath = path.relative(rootDir, violation.file);
|
||||
|
||||
if (!violations[relativePath]) {
|
||||
violations[relativePath] = [];
|
||||
}
|
||||
|
||||
violations[relativePath].push({
|
||||
rule: violation.rule,
|
||||
line: violation.line,
|
||||
message: violation.message,
|
||||
hash: hashViolation(violation),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: BASELINE_VERSION,
|
||||
generated: new Date().toISOString(),
|
||||
totalViolations: report.summary.totalViolations,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save baseline to disk
|
||||
*/
|
||||
export function saveBaseline(baseline: BaselineFile, rootDir: string): void {
|
||||
const baselinePath = getBaselinePath(rootDir);
|
||||
fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, '\t') + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a violation exists in the baseline
|
||||
* Uses fuzzy matching: same file + same hash (rule + message)
|
||||
*/
|
||||
function isInBaseline(violation: Violation, baseline: BaselineFile, rootDir: string): boolean {
|
||||
const relativePath = path.relative(rootDir, violation.file);
|
||||
const fileBaseline = baseline.violations[relativePath];
|
||||
|
||||
if (!fileBaseline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const violationHash = hashViolation(violation);
|
||||
|
||||
// Check for matching hash (same rule + message, regardless of line shift)
|
||||
return fileBaseline.some((entry) => entry.hash === violationHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter violations to only return NEW ones (not in baseline)
|
||||
*/
|
||||
export function filterNewViolations(
|
||||
violations: Violation[],
|
||||
baseline: BaselineFile,
|
||||
rootDir: string,
|
||||
): Violation[] {
|
||||
return violations.filter((v) => !isInBaseline(v, baseline, rootDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a full report to only include new violations
|
||||
*/
|
||||
export function filterReportByBaseline(
|
||||
report: JanitorReport,
|
||||
baseline: BaselineFile,
|
||||
rootDir: string,
|
||||
): JanitorReport {
|
||||
const filteredResults = report.results.map((result) => ({
|
||||
...result,
|
||||
violations: filterNewViolations(result.violations, baseline, rootDir),
|
||||
}));
|
||||
|
||||
// Recalculate summary
|
||||
let totalViolations = 0;
|
||||
const byRule: Record<string, number> = {};
|
||||
const bySeverity: Record<string, number> = { error: 0, warning: 0, info: 0 };
|
||||
|
||||
for (const result of filteredResults) {
|
||||
totalViolations += result.violations.length;
|
||||
byRule[result.rule] = result.violations.length;
|
||||
|
||||
for (const violation of result.violations) {
|
||||
bySeverity[violation.severity] = (bySeverity[violation.severity] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...report,
|
||||
results: filteredResults,
|
||||
summary: {
|
||||
...report.summary,
|
||||
totalViolations,
|
||||
byRule,
|
||||
bySeverity: bySeverity as Record<'error' | 'warning' | 'info', number>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format baseline info for console output
|
||||
*/
|
||||
export function formatBaselineInfo(baseline: BaselineFile): string {
|
||||
const lines: string[] = [
|
||||
`Baseline loaded (${baseline.totalViolations} known violations from ${baseline.generated})`,
|
||||
];
|
||||
|
||||
const fileCount = Object.keys(baseline.violations).length;
|
||||
lines.push(` Files with violations: ${fileCount}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
113
packages/testing/janitor/src/core/facade-resolver.ts
Normal file
113
packages/testing/janitor/src/core/facade-resolver.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Facade Resolver
|
||||
*
|
||||
* Parses the facade file (e.g., AppPage) to build bidirectional mappings
|
||||
* between class names and their property names in the facade.
|
||||
*
|
||||
* Used by ImpactAnalyzer and MethodUsageAnalyzer to understand the
|
||||
* relationship between page object classes and how they're accessed.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { type Project } from 'ts-morph';
|
||||
|
||||
import { getConfig } from '../config.js';
|
||||
import { getRootDir } from '../utils/paths.js';
|
||||
|
||||
export interface FacadeMapping {
|
||||
/** Map from class name to property name(s): "CanvasPage" → ["canvas"] */
|
||||
classToProperty: Map<string, string[]>;
|
||||
/** Map from property name to class name: "canvas" → "CanvasPage" */
|
||||
propertyToClass: Map<string, string>;
|
||||
/** Absolute path to the facade file */
|
||||
facadePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves facade mappings between class types and property names.
|
||||
* Parses the facade file once and provides both lookup directions.
|
||||
*/
|
||||
export class FacadeResolver {
|
||||
private mapping: FacadeMapping;
|
||||
|
||||
constructor(private project: Project) {
|
||||
this.mapping = this.buildMapping();
|
||||
}
|
||||
|
||||
/** Get property name(s) for a class: "CanvasPage" → ["canvas"] */
|
||||
getPropertiesForClass(className: string): string[] {
|
||||
return this.mapping.classToProperty.get(className) ?? [];
|
||||
}
|
||||
|
||||
/** Get class name for a property: "canvas" → "CanvasPage" */
|
||||
getClassForProperty(propertyName: string): string | undefined {
|
||||
return this.mapping.propertyToClass.get(propertyName);
|
||||
}
|
||||
|
||||
/** Get the full property→class mapping as a record */
|
||||
getPropertyToClassMap(): Record<string, string> {
|
||||
return Object.fromEntries(this.mapping.propertyToClass);
|
||||
}
|
||||
|
||||
/** Get the full class→property mapping */
|
||||
getClassToPropertyMap(): Map<string, string[]> {
|
||||
return this.mapping.classToProperty;
|
||||
}
|
||||
|
||||
/** Get the absolute path to the facade file */
|
||||
getFacadePath(): string {
|
||||
return this.mapping.facadePath;
|
||||
}
|
||||
|
||||
/** Check if a path is the facade file */
|
||||
isFacade(absolutePath: string): boolean {
|
||||
return absolutePath === this.mapping.facadePath;
|
||||
}
|
||||
|
||||
private buildMapping(): FacadeMapping {
|
||||
const config = getConfig();
|
||||
const root = getRootDir();
|
||||
const facadePath = path.join(root, config.facade.file);
|
||||
|
||||
const classToProperty = new Map<string, string[]>();
|
||||
const propertyToClass = new Map<string, string>();
|
||||
|
||||
const facadeFile = this.project.getSourceFile(facadePath);
|
||||
if (!facadeFile) {
|
||||
console.warn(`Warning: Could not find facade file at ${facadePath}`);
|
||||
return { classToProperty, propertyToClass, facadePath };
|
||||
}
|
||||
|
||||
const facadeClass = facadeFile.getClass(config.facade.className);
|
||||
if (!facadeClass) {
|
||||
console.warn(`Warning: Could not find ${config.facade.className} class`);
|
||||
return { classToProperty, propertyToClass, facadePath };
|
||||
}
|
||||
|
||||
for (const prop of facadeClass.getProperties()) {
|
||||
const propName = prop.getName();
|
||||
const propType = prop.getType();
|
||||
const typeName = extractTypeName(propType.getText());
|
||||
|
||||
if (typeName && !config.facade.excludeTypes.includes(typeName)) {
|
||||
// class → property (one class can have multiple properties)
|
||||
const existing = classToProperty.get(typeName) ?? [];
|
||||
existing.push(propName);
|
||||
classToProperty.set(typeName, existing);
|
||||
|
||||
// property → class (one-to-one)
|
||||
propertyToClass.set(propName, typeName);
|
||||
}
|
||||
}
|
||||
|
||||
return { classToProperty, propertyToClass, facadePath };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract simple type name from potentially complex type string.
|
||||
* Removes import() paths: import("./CanvasPage").CanvasPage → CanvasPage
|
||||
*/
|
||||
export function extractTypeName(typeText: string): string {
|
||||
return typeText.replace(/import\([^)]+\)\./g, '');
|
||||
}
|
||||
657
packages/testing/janitor/src/core/impact-analyzer.test.ts
Normal file
657
packages/testing/janitor/src/core/impact-analyzer.test.ts
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
import { Project } from 'ts-morph';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { ImpactAnalyzer } from './impact-analyzer.js';
|
||||
import { setConfig, resetConfig, defineConfig } from '../config.js';
|
||||
|
||||
/**
|
||||
* Tests for ImpactAnalyzer - import graph tracing and test discovery.
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. Core/base files affect all tests (via transitive imports)
|
||||
* 2. Leaf files only affect tests that import them
|
||||
* 3. Facade pattern is handled correctly
|
||||
*/
|
||||
|
||||
// Create mock functions with proper types
|
||||
const mockFindFilesRecursive = vi.fn<() => string[]>(() => []);
|
||||
const mockReadFileSync = vi.fn<(path: string) => string>(() => '');
|
||||
|
||||
// Mock fs and path helpers to avoid filesystem access
|
||||
vi.mock('../utils/paths.js', async () => {
|
||||
const actual = await vi.importActual('../utils/paths.js');
|
||||
return {
|
||||
...actual,
|
||||
getRootDir: () => '/test-root',
|
||||
findFilesRecursive: (_dir: string, _suffix: string) => mockFindFilesRecursive(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs for file reading in findTestsUsingProperties
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual('fs');
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: (filePath: string) => mockReadFileSync(filePath),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ImpactAnalyzer', () => {
|
||||
let project: Project;
|
||||
|
||||
beforeEach(() => {
|
||||
project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
setConfig(
|
||||
defineConfig({
|
||||
rootDir: '/test-root',
|
||||
facade: {
|
||||
file: 'pages/AppPage.ts',
|
||||
className: 'AppPage',
|
||||
excludeTypes: ['Page', 'APIRequestContext'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset mocks
|
||||
mockFindFilesRecursive.mockReturnValue([]);
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Import Graph Tracing', () => {
|
||||
it('traces direct imports', () => {
|
||||
// Setup: TestFile imports CanvasPage
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CanvasPage.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
`
|
||||
import { CanvasPage } from '../pages/CanvasPage';
|
||||
|
||||
test('adds node', async () => {
|
||||
const page = new CanvasPage();
|
||||
await page.addNode();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/CanvasPage.ts']);
|
||||
|
||||
expect(result.affectedFiles).toContain('tests/canvas.spec.ts');
|
||||
});
|
||||
|
||||
it('traces transitive imports (A → B → C)', () => {
|
||||
// Setup: Test imports PageB, PageB imports PageA
|
||||
// Changing PageA should affect Test
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {
|
||||
doSomething() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/PageB.ts',
|
||||
`
|
||||
import { PageA } from './PageA';
|
||||
|
||||
export class PageB extends PageA {
|
||||
doMore() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/pageB.spec.ts',
|
||||
`
|
||||
import { PageB } from '../pages/PageB';
|
||||
|
||||
test('does more', async () => {
|
||||
const page = new PageB();
|
||||
page.doMore();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/PageA.ts']);
|
||||
|
||||
// Changing PageA affects PageB, which affects pageB.spec.ts
|
||||
expect(result.affectedFiles).toContain('pages/PageB.ts');
|
||||
expect(result.affectedTests).toContain('tests/pageB.spec.ts');
|
||||
});
|
||||
|
||||
it('base file affects all dependent tests', () => {
|
||||
// Setup: BasePage is imported by multiple pages
|
||||
// Changing BasePage should affect all tests using those pages
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/BasePage.ts',
|
||||
`
|
||||
export class BasePage {
|
||||
protected click(selector: string) {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CanvasPage.ts',
|
||||
`
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class CanvasPage extends BasePage {
|
||||
async addNode() { this.click('add'); }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/WorkflowsPage.ts',
|
||||
`
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class WorkflowsPage extends BasePage {
|
||||
async createWorkflow() { this.click('create'); }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
`
|
||||
import { CanvasPage } from '../pages/CanvasPage';
|
||||
test('canvas test', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/workflows.spec.ts',
|
||||
`
|
||||
import { WorkflowsPage } from '../pages/WorkflowsPage';
|
||||
test('workflows test', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/BasePage.ts']);
|
||||
|
||||
// Both tests should be affected via transitive dependency
|
||||
expect(result.affectedTests).toContain('tests/canvas.spec.ts');
|
||||
expect(result.affectedTests).toContain('tests/workflows.spec.ts');
|
||||
});
|
||||
|
||||
it('leaf file only affects its direct consumers', () => {
|
||||
// Setup: UtilHelper is only used by one test
|
||||
project.createSourceFile(
|
||||
'/test-root/utils/helper.ts',
|
||||
`
|
||||
export function formatData(data: any) { return data; }
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/format.spec.ts',
|
||||
`
|
||||
import { formatData } from '../utils/helper';
|
||||
test('formats data', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/other.spec.ts',
|
||||
`
|
||||
// Does not import helper
|
||||
test('other test', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['utils/helper.ts']);
|
||||
|
||||
expect(result.affectedTests).toContain('tests/format.spec.ts');
|
||||
expect(result.affectedTests).not.toContain('tests/other.spec.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test File Detection', () => {
|
||||
it('identifies test files by path pattern', () => {
|
||||
project.createSourceFile('/test-root/tests/example.spec.ts', "test('example', () => {});");
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['tests/example.spec.ts']);
|
||||
|
||||
// When a test file itself changes, it's in the affected tests
|
||||
expect(result.affectedTests).toContain('tests/example.spec.ts');
|
||||
});
|
||||
|
||||
it('does not mark non-spec files as tests', () => {
|
||||
// Setup: CanvasPage has no dependents
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CanvasPage.ts',
|
||||
`
|
||||
export class CanvasPage {}
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/CanvasPage.ts']);
|
||||
|
||||
// CanvasPage.ts is in changedFiles but has no dependents
|
||||
// affectedFiles contains dependents, not the changed file itself
|
||||
expect(result.changedFiles).toContain('pages/CanvasPage.ts');
|
||||
expect(result.affectedTests).not.toContain('pages/CanvasPage.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dependency Graph', () => {
|
||||
it('builds correct dependency graph', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/PageB.ts',
|
||||
`
|
||||
import { PageA } from './PageA';
|
||||
export class PageB {}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/test.spec.ts',
|
||||
`
|
||||
import { PageB } from '../pages/PageB';
|
||||
test('test', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/PageA.ts']);
|
||||
|
||||
// Graph shows PageA is depended on by PageB, which is depended on by test
|
||||
expect(result.graph['pages/PageA.ts']).toBeDefined();
|
||||
expect(result.graph['pages/PageA.ts']).toContain('pages/PageB.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Facade-Aware Impact Analysis', () => {
|
||||
it('stops at facade and uses property-based search to find affected tests', () => {
|
||||
// This is the key integration test for facade-aware impact analysis
|
||||
// Setup:
|
||||
// - SecurityPage is imported by AppPage (facade)
|
||||
// - Multiple tests import the facade
|
||||
// - Only tests that USE app.security.* should be affected
|
||||
|
||||
// Create the page being changed
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/SecurityPage.ts',
|
||||
`
|
||||
export class SecurityPage {
|
||||
async runAudit() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Create the facade that imports the page
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { Page } from '@playwright/test';
|
||||
import { SecurityPage } from './SecurityPage';
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly page: Page;
|
||||
readonly security: SecurityPage;
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Create CanvasPage (not being changed)
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CanvasPage.ts',
|
||||
`
|
||||
export class CanvasPage {
|
||||
async addNode() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Mock findFilesRecursive to return test files
|
||||
mockFindFilesRecursive.mockReturnValue([
|
||||
'/test-root/tests/security.spec.ts',
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
'/test-root/tests/other.spec.ts',
|
||||
]);
|
||||
|
||||
// Mock reading test files - each with different property usages
|
||||
mockReadFileSync.mockImplementation((filePath) => {
|
||||
const path = String(filePath);
|
||||
if (path.includes('security.spec.ts')) {
|
||||
// Uses app.security.*
|
||||
return `
|
||||
import { test } from '@playwright/test';
|
||||
test('security audit', async ({ app }) => {
|
||||
await app.security.runAudit();
|
||||
});
|
||||
`;
|
||||
}
|
||||
if (path.includes('canvas.spec.ts')) {
|
||||
// Uses app.canvas.* (different page)
|
||||
return `
|
||||
import { test } from '@playwright/test';
|
||||
test('canvas test', async ({ app }) => {
|
||||
await app.canvas.addNode();
|
||||
});
|
||||
`;
|
||||
}
|
||||
// other.spec.ts doesn't use any page objects
|
||||
return `
|
||||
import { test } from '@playwright/test';
|
||||
test('other test', async ({ app }) => {
|
||||
// no page object calls
|
||||
});
|
||||
`;
|
||||
});
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/SecurityPage.ts']);
|
||||
|
||||
// CRITICAL: Only security.spec.ts should be affected
|
||||
// canvas.spec.ts and other.spec.ts should NOT be affected
|
||||
// even though they would all import the same facade
|
||||
expect(result.affectedTests).toContain('tests/security.spec.ts');
|
||||
expect(result.affectedTests).not.toContain('tests/canvas.spec.ts');
|
||||
expect(result.affectedTests).not.toContain('tests/other.spec.ts');
|
||||
});
|
||||
|
||||
it('base class changes affect all tests via transitive dependency through facade', () => {
|
||||
// Real n8n pattern: tests → fixtures → facade → pages → BasePage
|
||||
// Changing BasePage should affect ALL tests, not just ones using specific properties
|
||||
|
||||
// BasePage - the core base class
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/BasePage.ts',
|
||||
`
|
||||
export class BasePage {
|
||||
protected click(selector: string) {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Pages that extend BasePage
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CanvasPage.ts',
|
||||
`
|
||||
import { BasePage } from './BasePage';
|
||||
export class CanvasPage extends BasePage {
|
||||
async addNode() { this.click('add'); }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/SecurityPage.ts',
|
||||
`
|
||||
import { BasePage } from './BasePage';
|
||||
export class SecurityPage extends BasePage {
|
||||
async runAudit() { this.click('audit'); }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Facade imports all pages
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { Page } from '@playwright/test';
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
import { SecurityPage } from './SecurityPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly page: Page;
|
||||
readonly canvas: CanvasPage;
|
||||
readonly security: SecurityPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Fixtures import facade (like real n8n)
|
||||
project.createSourceFile(
|
||||
'/test-root/fixtures/base.ts',
|
||||
`
|
||||
import { AppPage } from '../pages/AppPage';
|
||||
export const test = { n8n: new AppPage() };
|
||||
`,
|
||||
);
|
||||
|
||||
// Tests import fixtures
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
test('canvas test', async ({ n8n }) => {
|
||||
await n8n.canvas.addNode();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/security.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
test('security test', async ({ n8n }) => {
|
||||
await n8n.security.runAudit();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
// Mock findFilesRecursive to return test files for property search
|
||||
mockFindFilesRecursive.mockReturnValue([
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
'/test-root/tests/security.spec.ts',
|
||||
]);
|
||||
|
||||
// Mock reading test files for property pattern matching
|
||||
mockReadFileSync.mockImplementation((filePath) => {
|
||||
const path = String(filePath);
|
||||
if (path.includes('canvas.spec.ts')) {
|
||||
return 'await app.canvas.addNode();';
|
||||
}
|
||||
if (path.includes('security.spec.ts')) {
|
||||
return 'await app.security.runAudit();';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/BasePage.ts']);
|
||||
|
||||
// BOTH tests should be affected - BasePage is core infrastructure
|
||||
// When BasePage changes:
|
||||
// 1. CanvasPage & SecurityPage are dependents (they import BasePage)
|
||||
// 2. AppPage (facade) imports CanvasPage & SecurityPage
|
||||
// 3. Facade-aware search finds tests using .canvas. and .security.
|
||||
// 4. Result: ALL tests are affected (correct!)
|
||||
expect(result.affectedTests).toContain('tests/canvas.spec.ts');
|
||||
expect(result.affectedTests).toContain('tests/security.spec.ts');
|
||||
});
|
||||
|
||||
it('fixture file changes affect all tests that import it', () => {
|
||||
// When fixtures/base.ts changes, all tests should be affected
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/fixtures/base.ts',
|
||||
`
|
||||
export const test = { n8n: {} };
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/test1.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
test('test 1', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/test2.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
test('test 2', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/standalone.spec.ts',
|
||||
`
|
||||
// Does NOT import fixtures/base
|
||||
import { test } from '@playwright/test';
|
||||
test('standalone', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['fixtures/base.ts']);
|
||||
|
||||
// Tests that import fixtures are affected
|
||||
expect(result.affectedTests).toContain('tests/test1.spec.ts');
|
||||
expect(result.affectedTests).toContain('tests/test2.spec.ts');
|
||||
// Standalone test that doesn't import fixtures is NOT affected
|
||||
expect(result.affectedTests).not.toContain('tests/standalone.spec.ts');
|
||||
});
|
||||
|
||||
it('handles page not exposed on facade (falls back to camelCase)', () => {
|
||||
// If a page isn't in the facade, we fall back to camelCase property name
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/NewFeaturePage.ts',
|
||||
`
|
||||
export class NewFeaturePage {
|
||||
async doThing() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Facade doesn't include NewFeaturePage
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/NewFeaturePage.ts']);
|
||||
|
||||
// Should still work - falls back to camelCase: newFeaturePage
|
||||
expect(result.changedFiles).toContain('pages/NewFeaturePage.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles file not found gracefully', () => {
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['nonexistent/file.ts']);
|
||||
|
||||
// Should not throw, just warn
|
||||
expect(result.changedFiles).toEqual(['nonexistent/file.ts']);
|
||||
expect(result.affectedTests).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles circular imports', () => {
|
||||
// Setup circular dependency: A imports B, B imports A
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CircularA.ts',
|
||||
`
|
||||
import { CircularB } from './CircularB';
|
||||
export class CircularA {}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/CircularB.ts',
|
||||
`
|
||||
import { CircularA } from './CircularA';
|
||||
export class CircularB {}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/circular.spec.ts',
|
||||
`
|
||||
import { CircularA } from '../pages/CircularA';
|
||||
test('circular', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
|
||||
// Should not infinite loop
|
||||
const result = analyzer.analyze(['pages/CircularA.ts']);
|
||||
|
||||
expect(result.affectedTests).toContain('tests/circular.spec.ts');
|
||||
});
|
||||
|
||||
it('handles multiple changed files', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/PageB.ts',
|
||||
`
|
||||
export class PageB {}
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/testA.spec.ts',
|
||||
`
|
||||
import { PageA } from '../pages/PageA';
|
||||
test('a', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
project.createSourceFile(
|
||||
'/test-root/tests/testB.spec.ts',
|
||||
`
|
||||
import { PageB } from '../pages/PageB';
|
||||
test('b', () => {});
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new ImpactAnalyzer(project);
|
||||
const result = analyzer.analyze(['pages/PageA.ts', 'pages/PageB.ts']);
|
||||
|
||||
expect(result.changedFiles).toHaveLength(2);
|
||||
expect(result.affectedTests).toContain('tests/testA.spec.ts');
|
||||
expect(result.affectedTests).toContain('tests/testB.spec.ts');
|
||||
});
|
||||
});
|
||||
});
|
||||
241
packages/testing/janitor/src/core/impact-analyzer.ts
Normal file
241
packages/testing/janitor/src/core/impact-analyzer.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* Impact Analyzer
|
||||
*
|
||||
* Analyzes the impact of file changes - which tests need to run?
|
||||
* Uses import graph tracing with facade-aware property-based search.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { type Project, type SourceFile } from 'ts-morph';
|
||||
|
||||
import { FacadeResolver } from './facade-resolver.js';
|
||||
import { getRootDir, findFilesRecursive, getRelativePath, isTestFile } from '../utils/paths.js';
|
||||
|
||||
export interface ImpactResult {
|
||||
changedFiles: string[];
|
||||
affectedFiles: string[];
|
||||
affectedTests: string[];
|
||||
graph: Record<string, string[]>; // file -> files that depend on it
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze the impact of file changes - what tests need to run?
|
||||
*/
|
||||
export class ImpactAnalyzer {
|
||||
private root: string;
|
||||
private facade: FacadeResolver;
|
||||
|
||||
constructor(private project: Project) {
|
||||
this.root = getRootDir();
|
||||
this.facade = new FacadeResolver(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of changed files, determine which test files are affected
|
||||
*/
|
||||
analyze(changedFiles: string[]): ImpactResult {
|
||||
const absolutePaths = changedFiles.map((f) =>
|
||||
path.isAbsolute(f) ? f : path.join(this.root, f),
|
||||
);
|
||||
|
||||
const affectedSet = new Set<string>();
|
||||
const graph: Record<string, string[]> = {};
|
||||
|
||||
// For each changed file, find all files that depend on it
|
||||
for (const filePath of absolutePaths) {
|
||||
const sourceFile = this.project.getSourceFile(filePath);
|
||||
if (!sourceFile) {
|
||||
console.warn(`Warning: File not found in project: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = getRelativePath(filePath);
|
||||
|
||||
// If the changed file is itself a test, it's affected
|
||||
if (isTestFile(relativePath)) {
|
||||
affectedSet.add(filePath);
|
||||
}
|
||||
|
||||
// Find property names this file exposes (for property-based search)
|
||||
const propertyNames = this.extractPropertyNames(sourceFile);
|
||||
|
||||
const dependents = this.findAllDependents(sourceFile, new Set(), propertyNames);
|
||||
|
||||
graph[relativePath] = dependents.map((f) => getRelativePath(f));
|
||||
|
||||
for (const dep of dependents) {
|
||||
affectedSet.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to relative paths and filter
|
||||
const allAffected = Array.from(affectedSet).map((f) => getRelativePath(f));
|
||||
const affectedTests = allAffected
|
||||
.filter((f) => isTestFile(f))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return {
|
||||
changedFiles: absolutePaths.map((f) => getRelativePath(f)),
|
||||
affectedFiles: allAffected.sort((a, b) => a.localeCompare(b)),
|
||||
affectedTests,
|
||||
graph,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract property names that a file's exports are exposed as in the facade
|
||||
* Uses the pre-built facade property map for accurate lookup
|
||||
*/
|
||||
private extractPropertyNames(file: SourceFile): string[] {
|
||||
const names: string[] = [];
|
||||
|
||||
// Get exported class names and look up their property names in facade
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
const className = classDecl.getName();
|
||||
if (className) {
|
||||
// Look up actual property name(s) from facade
|
||||
const facadeProps = this.facade.getPropertiesForClass(className);
|
||||
if (facadeProps.length > 0) {
|
||||
names.push(...facadeProps);
|
||||
} else {
|
||||
// Fallback to camelCase conversion if not in facade
|
||||
const propertyName = className.charAt(0).toLowerCase() + className.slice(1);
|
||||
names.push(propertyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all files that depend on a source file
|
||||
* Stops at facades and switches to property-based search
|
||||
*/
|
||||
private findAllDependents(
|
||||
file: SourceFile,
|
||||
visited: Set<string>,
|
||||
propertyNames: string[],
|
||||
): string[] {
|
||||
const filePath = file.getFilePath();
|
||||
if (visited.has(filePath)) {
|
||||
return [];
|
||||
}
|
||||
visited.add(filePath);
|
||||
|
||||
const dependents: string[] = [];
|
||||
|
||||
// Get direct dependents (files that import this file)
|
||||
const directDependents = file.getReferencingSourceFiles();
|
||||
|
||||
for (const dep of directDependents) {
|
||||
const depPath = dep.getFilePath();
|
||||
|
||||
// If we hit a facade, stop import tracing and switch to property search
|
||||
if (this.facade.isFacade(depPath)) {
|
||||
// Find tests that actually USE the property, not just import the facade
|
||||
const testsUsingProperty = this.findTestsUsingProperties(propertyNames);
|
||||
dependents.push(...testsUsingProperty);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a facade - continue normal import tracing
|
||||
dependents.push(depPath);
|
||||
|
||||
// For the next level, track what property this file is exposed as
|
||||
const nextPropertyNames = this.extractPropertyNames(dep);
|
||||
const combinedProperties = [...propertyNames, ...nextPropertyNames];
|
||||
|
||||
const transitiveDeps = this.findAllDependents(dep, visited, combinedProperties);
|
||||
dependents.push(...transitiveDeps);
|
||||
}
|
||||
|
||||
return dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find test files that actually use the given property names
|
||||
* Uses grep-style search for .propertyName. patterns
|
||||
*/
|
||||
private findTestsUsingProperties(propertyNames: string[]): string[] {
|
||||
if (propertyNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const testsDir = path.join(this.root, 'tests');
|
||||
const matchingTests = new Set<string>();
|
||||
|
||||
// Build regex pattern to match property access: .logsPanel. or .logsPanel)
|
||||
const patterns = propertyNames.map((name) => new RegExp(`\\.${name}[.)]`));
|
||||
|
||||
// Recursively find all test files
|
||||
const testFiles = findFilesRecursive(testsDir, '.spec.ts');
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
try {
|
||||
const content = fs.readFileSync(testFile, 'utf-8');
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(content)) {
|
||||
matchingTests.add(testFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File read error, skip
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(matchingTests);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format impact result for console output
|
||||
*/
|
||||
export function formatImpactConsole(result: ImpactResult, verbose = false): void {
|
||||
console.log('\n====================================');
|
||||
console.log(' IMPACT ANALYSIS REPORT');
|
||||
console.log('====================================\n');
|
||||
|
||||
console.log(`Changed files: ${result.changedFiles.length}`);
|
||||
result.changedFiles.forEach((f) => console.log(` - ${f}`));
|
||||
|
||||
console.log(`\nAffected test files: ${result.affectedTests.length}`);
|
||||
if (result.affectedTests.length === 0) {
|
||||
console.log(' (none - changes do not affect any tests)');
|
||||
} else {
|
||||
result.affectedTests.forEach((f) => console.log(` - ${f}`));
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(`\nAll affected files: ${result.affectedFiles.length}`);
|
||||
result.affectedFiles.forEach((f) => console.log(` - ${f}`));
|
||||
|
||||
console.log('\nDependency graph:');
|
||||
for (const [file, deps] of Object.entries(result.graph)) {
|
||||
if (deps.length > 0) {
|
||||
console.log(` ${file}:`);
|
||||
deps.forEach((d) => console.log(` → ${d}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format impact result as JSON
|
||||
*/
|
||||
export function formatImpactJSON(result: ImpactResult): string {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format as simple list of test files (for piping to playwright)
|
||||
*/
|
||||
export function formatTestList(result: ImpactResult): string {
|
||||
return result.affectedTests.join('\n');
|
||||
}
|
||||
421
packages/testing/janitor/src/core/inventory-analyzer.test.ts
Normal file
421
packages/testing/janitor/src/core/inventory-analyzer.test.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { Project } from 'ts-morph';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
InventoryAnalyzer,
|
||||
formatInventoryJSON,
|
||||
toSummary,
|
||||
toCategory,
|
||||
filterByFile,
|
||||
} from './inventory-analyzer.js';
|
||||
import { setConfig, resetConfig, defineConfig } from '../config.js';
|
||||
|
||||
/**
|
||||
* Create a project with the given files written to disk
|
||||
*/
|
||||
function createTestProject(tempDir: string, files: Record<string, string>): Project {
|
||||
// Write files to disk
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const fullPath = path.join(tempDir, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
// Create project
|
||||
return new Project({
|
||||
tsConfigFilePath: path.join(tempDir, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('InventoryAnalyzer', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use realpathSync to avoid macOS /var -> /private/var symlink issues
|
||||
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-test-')));
|
||||
|
||||
// Create directory structure
|
||||
fs.mkdirSync(path.join(tempDir, 'pages', 'components'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'composables'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'tests', 'e2e'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'services'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'workflows'), { recursive: true });
|
||||
|
||||
// Create tsconfig.json
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'tsconfig.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
strict: true,
|
||||
},
|
||||
include: ['**/*.ts'],
|
||||
}),
|
||||
);
|
||||
|
||||
// Set up config
|
||||
const config = defineConfig({
|
||||
rootDir: tempDir,
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts'],
|
||||
factories: ['factories/**/*.ts'],
|
||||
testData: ['workflows/**/*'],
|
||||
},
|
||||
excludeFromPages: ['BasePage.ts', 'AppPage.ts'],
|
||||
facade: {
|
||||
file: 'pages/AppPage.ts',
|
||||
className: 'AppPage',
|
||||
excludeTypes: ['Page'],
|
||||
},
|
||||
fixtureObjectName: 'app',
|
||||
});
|
||||
setConfig(config);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
describe('Page Detection', () => {
|
||||
it('detects page classes', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/WorkflowPage.ts': `
|
||||
export class WorkflowPage {
|
||||
async openWorkflow(name: string) {}
|
||||
async saveWorkflow() {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.pages.length).toBe(1);
|
||||
expect(report.pages[0].name).toBe('WorkflowPage');
|
||||
expect(report.pages[0].methods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('extracts method parameters', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/CanvasPage.ts': `
|
||||
export class CanvasPage {
|
||||
async addNode(type: string, position?: { x: number; y: number }) {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
const method = report.pages[0].methods[0];
|
||||
expect(method.name).toBe('addNode');
|
||||
expect(method.params.length).toBe(2);
|
||||
expect(method.params[0].name).toBe('type');
|
||||
expect(method.params[0].type).toBe('string');
|
||||
});
|
||||
|
||||
it('excludes configured files from pages', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/BasePage.ts': 'export class BasePage {}',
|
||||
'pages/AppPage.ts': 'export class AppPage {}',
|
||||
'pages/RealPage.ts': 'export class RealPage {}',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.pages.length).toBe(1);
|
||||
expect(report.pages[0].name).toBe('RealPage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Detection', () => {
|
||||
it('detects component classes', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/components/NodePanel.ts': `
|
||||
export class NodePanel {
|
||||
async selectNode(name: string) {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.components.length).toBe(1);
|
||||
expect(report.components[0].name).toBe('NodePanel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Composable Detection', () => {
|
||||
it('detects composable classes', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'composables/WorkflowComposer.ts': `
|
||||
export class WorkflowComposer {
|
||||
async createAndRun(name: string) {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.composables.length).toBe(1);
|
||||
expect(report.composables[0].name).toBe('WorkflowComposer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Detection', () => {
|
||||
it('detects service classes', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'services/ApiHelper.ts': `
|
||||
export class ApiHelper {
|
||||
async createWorkflow(data: object) {}
|
||||
async deleteWorkflow(id: string) {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.services.length).toBe(1);
|
||||
expect(report.services[0].name).toBe('ApiHelper');
|
||||
expect(report.services[0].methods.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Data Detection', () => {
|
||||
it('detects workflow files', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'workflows', 'simple-webhook.json'),
|
||||
JSON.stringify({ name: 'Simple Webhook' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'workflows', 'complex-flow.json'),
|
||||
JSON.stringify({ name: 'Complex Flow' }),
|
||||
);
|
||||
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.testData.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary', () => {
|
||||
it('calculates correct counts', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/PageA.ts': 'export class PageA { method1() {} method2() {} }',
|
||||
'pages/PageB.ts': 'export class PageB { method1() {} }',
|
||||
'pages/components/CompA.ts': 'export class CompA { method1() {} }',
|
||||
'composables/FlowA.ts': 'export class FlowA { method1() {} }',
|
||||
'services/ServiceA.ts': 'export class ServiceA { method1() {} }',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
|
||||
expect(report.summary.pages).toBe(2);
|
||||
expect(report.summary.components).toBe(1);
|
||||
expect(report.summary.composables).toBe(1);
|
||||
expect(report.summary.services).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON Output', () => {
|
||||
it('produces valid JSON', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/TestPage.ts': 'export class TestPage { doSomething() {} }',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const json = formatInventoryJSON(report);
|
||||
|
||||
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
|
||||
expect(() => JSON.parse(json) as unknown).not.toThrow();
|
||||
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
|
||||
const parsed = JSON.parse(json) as { pages?: unknown; summary?: unknown };
|
||||
expect(parsed.pages).toBeDefined();
|
||||
expect(parsed.summary).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSummary', () => {
|
||||
it('produces compact output with counts and categories', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/PageA.ts': 'export class PageA { method1() {} }',
|
||||
'pages/PageB.ts': 'export class PageB { method1() {} }',
|
||||
'composables/FlowA.ts': 'export class FlowA { method1() {} }',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const summary = toSummary(report);
|
||||
|
||||
expect(summary.counts.pages).toBe(2);
|
||||
expect(summary.counts.composables).toBe(1);
|
||||
expect(summary.categories).toContain('pages');
|
||||
expect(summary.categories).toContain('composables');
|
||||
});
|
||||
|
||||
it('includes facade property names when facade exists', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/AppPage.ts': `
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
export class AppPage {
|
||||
canvas = new CanvasPage();
|
||||
settings = new SettingsPage();
|
||||
}
|
||||
`,
|
||||
'pages/CanvasPage.ts': 'export class CanvasPage {}',
|
||||
'pages/SettingsPage.ts': 'export class SettingsPage {}',
|
||||
});
|
||||
|
||||
// Update config to use this facade
|
||||
const config = defineConfig({
|
||||
rootDir: tempDir,
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts'],
|
||||
factories: ['factories/**/*.ts'],
|
||||
testData: ['workflows/**/*'],
|
||||
},
|
||||
excludeFromPages: ['AppPage.ts'],
|
||||
facade: {
|
||||
file: 'pages/AppPage.ts',
|
||||
className: 'AppPage',
|
||||
excludeTypes: ['Page'],
|
||||
},
|
||||
fixtureObjectName: 'app',
|
||||
});
|
||||
setConfig(config);
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const summary = toSummary(report);
|
||||
|
||||
expect(summary.facade).toContain('canvas');
|
||||
expect(summary.facade).toContain('settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCategory', () => {
|
||||
it('returns category items with method names only', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/CanvasPage.ts': `
|
||||
export class CanvasPage {
|
||||
async addNode(type: string) {}
|
||||
async deleteNode(id: string) {}
|
||||
private helperMethod() {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const view = toCategory(report, 'pages');
|
||||
|
||||
expect(view.category).toBe('pages');
|
||||
expect(view.items.length).toBe(1);
|
||||
expect(view.items[0].name).toBe('CanvasPage');
|
||||
expect(view.items[0].methods).toEqual(['addNode', 'deleteNode']);
|
||||
});
|
||||
|
||||
it('handles testData category without methods', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'workflows', 'test-workflow.json'),
|
||||
JSON.stringify({ name: 'Test' }),
|
||||
);
|
||||
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const view = toCategory(report, 'testData');
|
||||
|
||||
expect(view.category).toBe('testData');
|
||||
expect(view.items.length).toBe(1);
|
||||
expect(view.items[0].name).toBe('test-workflow.json');
|
||||
expect(view.items[0].methods).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByFile', () => {
|
||||
it('finds page by filename', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/CanvasPage.ts': `
|
||||
export class CanvasPage {
|
||||
async addNode(type: string) {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const result = filterByFile(report, 'CanvasPage.ts');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('CanvasPage');
|
||||
// Page objects have methods array
|
||||
expect((result as { methods?: unknown[] })?.methods?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('finds page by class name', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/CanvasPage.ts': 'export class CanvasPage { method1() {} }',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const result = filterByFile(report, 'CanvasPage');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('CanvasPage');
|
||||
});
|
||||
|
||||
it('returns null for non-existent file', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'pages/CanvasPage.ts': 'export class CanvasPage {}',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const result = filterByFile(report, 'NonExistent.ts');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('finds composables', () => {
|
||||
const project = createTestProject(tempDir, {
|
||||
'composables/WorkflowComposer.ts': 'export class WorkflowComposer { create() {} }',
|
||||
});
|
||||
|
||||
const analyzer = new InventoryAnalyzer(project);
|
||||
const report = analyzer.generate();
|
||||
const result = filterByFile(report, 'WorkflowComposer');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('WorkflowComposer');
|
||||
});
|
||||
});
|
||||
});
|
||||
828
packages/testing/janitor/src/core/inventory-analyzer.ts
Normal file
828
packages/testing/janitor/src/core/inventory-analyzer.ts
Normal file
|
|
@ -0,0 +1,828 @@
|
|||
/**
|
||||
* Inventory Analyzer - Generates inventory of Playwright test codebase
|
||||
*/
|
||||
|
||||
import { glob } from 'glob';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
SyntaxKind,
|
||||
type Project,
|
||||
type SourceFile,
|
||||
type ClassDeclaration,
|
||||
type MethodDeclaration,
|
||||
type PropertyDeclaration,
|
||||
type FunctionDeclaration,
|
||||
type CallExpression,
|
||||
} from 'ts-morph';
|
||||
|
||||
import { getConfig } from '../config.js';
|
||||
import { FacadeResolver } from './facade-resolver.js';
|
||||
import { getSourceFiles } from './project-loader.js';
|
||||
import { getRootDir, getRelativePath } from '../utils/paths.js';
|
||||
|
||||
export interface ParameterInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface MethodInfo {
|
||||
name: string;
|
||||
params: ParameterInfo[];
|
||||
returnType: string;
|
||||
async: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PropertyInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
container: string | null;
|
||||
methods: MethodInfo[];
|
||||
properties: PropertyInfo[];
|
||||
extendsClass?: string;
|
||||
}
|
||||
|
||||
export interface ComponentInfo extends PageInfo {
|
||||
mountedIn: string[];
|
||||
}
|
||||
|
||||
export interface ServiceMethodInfo extends MethodInfo {
|
||||
httpMethod?: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
methods: ServiceMethodInfo[];
|
||||
}
|
||||
|
||||
export interface FixtureInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
provides: Record<string, string>;
|
||||
extendsFixture?: string;
|
||||
}
|
||||
|
||||
export interface HelperInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
functions: MethodInfo[];
|
||||
}
|
||||
|
||||
export interface FactoryInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
methods: MethodInfo[];
|
||||
builds: string;
|
||||
}
|
||||
|
||||
export interface ComposableInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
methods: MethodInfo[];
|
||||
usesPages: string[];
|
||||
}
|
||||
|
||||
export interface TestDataInfo {
|
||||
name: string;
|
||||
file: string;
|
||||
category: string;
|
||||
sizeKb: number;
|
||||
}
|
||||
|
||||
export interface FacadePropertyInfo {
|
||||
property: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface FacadeInfo {
|
||||
file: string;
|
||||
className: string;
|
||||
exposedPages: FacadePropertyInfo[];
|
||||
unexposedPages: string[];
|
||||
}
|
||||
|
||||
export interface InventoryReport {
|
||||
timestamp: string;
|
||||
rootDir: string;
|
||||
summary: {
|
||||
pages: number;
|
||||
components: number;
|
||||
composables: number;
|
||||
services: number;
|
||||
fixtures: number;
|
||||
helpers: number;
|
||||
factories: number;
|
||||
testData: number;
|
||||
};
|
||||
facade: FacadeInfo | null;
|
||||
pages: PageInfo[];
|
||||
components: ComponentInfo[];
|
||||
composables: ComposableInfo[];
|
||||
services: ServiceInfo[];
|
||||
fixtures: FixtureInfo[];
|
||||
helpers: HelperInfo[];
|
||||
factories: FactoryInfo[];
|
||||
testData: TestDataInfo[];
|
||||
}
|
||||
|
||||
/** Compact summary for AI consumption - minimal tokens */
|
||||
export interface InventorySummary {
|
||||
counts: {
|
||||
pages: number;
|
||||
components: number;
|
||||
composables: number;
|
||||
services: number;
|
||||
helpers: number;
|
||||
testData: number;
|
||||
};
|
||||
facade: string[] | null;
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export type InventoryCategory =
|
||||
| 'pages'
|
||||
| 'components'
|
||||
| 'composables'
|
||||
| 'services'
|
||||
| 'fixtures'
|
||||
| 'helpers'
|
||||
| 'factories'
|
||||
| 'testData';
|
||||
|
||||
/** Filtered view of a single category - method names only */
|
||||
export interface InventoryCategoryView {
|
||||
category: InventoryCategory;
|
||||
items: Array<{
|
||||
name: string;
|
||||
file: string;
|
||||
methods?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export class InventoryAnalyzer {
|
||||
private root: string;
|
||||
private facade: FacadeResolver;
|
||||
|
||||
constructor(private project: Project) {
|
||||
this.root = getRootDir();
|
||||
this.facade = new FacadeResolver(project);
|
||||
}
|
||||
|
||||
generate(): InventoryReport {
|
||||
const pages = this.analyzePages();
|
||||
const components = this.analyzeComponents(pages);
|
||||
const composables = this.analyzeComposables();
|
||||
const services = this.analyzeServices();
|
||||
const fixtures = this.analyzeFixtures();
|
||||
const helpers = this.analyzeHelpers();
|
||||
const factories = this.analyzeFactories();
|
||||
const testData = this.analyzeTestData();
|
||||
const facade = this.analyzeFacade(pages);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
rootDir: this.root,
|
||||
summary: {
|
||||
pages: pages.length,
|
||||
components: components.length,
|
||||
composables: composables.length,
|
||||
services: services.length,
|
||||
fixtures: fixtures.length,
|
||||
helpers: helpers.length,
|
||||
factories: factories.length,
|
||||
testData: testData.length,
|
||||
},
|
||||
facade,
|
||||
pages,
|
||||
components,
|
||||
composables,
|
||||
services,
|
||||
fixtures,
|
||||
helpers,
|
||||
factories,
|
||||
testData,
|
||||
};
|
||||
}
|
||||
|
||||
private analyzePages(): PageInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.pages);
|
||||
const pages: PageInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = getRelativePath(file.getFilePath());
|
||||
|
||||
if (filePath.includes('/components/')) continue;
|
||||
|
||||
const shouldExclude = config.excludeFromPages.some((exclude) => filePath.endsWith(exclude));
|
||||
if (shouldExclude) continue;
|
||||
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
pages.push(this.extractPageInfo(classDecl, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private extractPageInfo(classDecl: ClassDeclaration, file: SourceFile): PageInfo {
|
||||
return {
|
||||
name: classDecl.getName() ?? 'Anonymous',
|
||||
file: getRelativePath(file.getFilePath()),
|
||||
container: this.extractContainer(classDecl),
|
||||
methods: this.extractMethods(classDecl),
|
||||
properties: this.extractProperties(classDecl),
|
||||
extendsClass: classDecl.getExtends()?.getText(),
|
||||
};
|
||||
}
|
||||
|
||||
private extractContainer(classDecl: ClassDeclaration): string | null {
|
||||
const getter = classDecl.getGetAccessor('container');
|
||||
if (getter) {
|
||||
const body = getter.getBody()?.getText() ?? '';
|
||||
const containerPattern = /getByTestId\(['"]([^'"]+)['"]\)/;
|
||||
const match = containerPattern.exec(body);
|
||||
return match ? `getByTestId('${match[1]}')` : 'custom';
|
||||
}
|
||||
|
||||
const prop = classDecl.getProperty('container');
|
||||
if (prop) {
|
||||
const initializer = prop.getInitializer()?.getText();
|
||||
return initializer ?? 'defined';
|
||||
}
|
||||
|
||||
const method = classDecl.getMethod('getContainer');
|
||||
if (method) return 'getContainer()';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private analyzeComponents(_pages: PageInfo[]): ComponentInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.components);
|
||||
const components: ComponentInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = getRelativePath(file.getFilePath());
|
||||
|
||||
if (filePath.endsWith('BaseModal.ts') || filePath.endsWith('BaseComponent.ts')) continue;
|
||||
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
const componentName = classDecl.getName() ?? '';
|
||||
const mountedIn = this.findComponentUsage(componentName);
|
||||
|
||||
components.push({
|
||||
...this.extractPageInfo(classDecl, file),
|
||||
mountedIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return components.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private findComponentUsage(componentName: string): string[] {
|
||||
const config = getConfig();
|
||||
const usedIn: string[] = [];
|
||||
const pageFiles = getSourceFiles(this.project, config.patterns.pages);
|
||||
|
||||
for (const file of pageFiles) {
|
||||
const filePath = getRelativePath(file.getFilePath());
|
||||
if (filePath.includes('/components/')) continue;
|
||||
|
||||
const content = file.getText();
|
||||
|
||||
if (content.includes(`new ${componentName}(`)) {
|
||||
for (const classDecl of file.getClasses()) {
|
||||
const pageName = classDecl.getName();
|
||||
if (pageName && !usedIn.includes(pageName)) {
|
||||
usedIn.push(pageName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usedIn.sort();
|
||||
}
|
||||
|
||||
private analyzeComposables(): ComposableInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.flows);
|
||||
const composables: ComposableInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
const composableInfo = this.extractComposableInfo(classDecl, file);
|
||||
composables.push(composableInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return composables.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private extractComposableInfo(classDecl: ClassDeclaration, file: SourceFile): ComposableInfo {
|
||||
return {
|
||||
name: classDecl.getName() ?? 'Anonymous',
|
||||
file: getRelativePath(file.getFilePath()),
|
||||
methods: this.extractMethods(classDecl),
|
||||
usesPages: this.findPageObjectUsage(classDecl),
|
||||
};
|
||||
}
|
||||
|
||||
private findPageObjectUsage(classDecl: ClassDeclaration): string[] {
|
||||
const config = getConfig();
|
||||
const usedPages = new Set<string>();
|
||||
const content = classDecl.getText();
|
||||
|
||||
const pattern = new RegExp(`this\\.${config.fixtureObjectName}\\.(\\w+)`, 'g');
|
||||
const matches = content.matchAll(pattern);
|
||||
|
||||
for (const match of matches) {
|
||||
const pageName = match[1];
|
||||
// 'page' is raw Playwright, not a page object
|
||||
if (pageName !== 'page') {
|
||||
usedPages.add(pageName);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(usedPages).sort();
|
||||
}
|
||||
|
||||
private analyzeServices(): ServiceInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.services);
|
||||
const services: ServiceInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
services.push(this.extractServiceInfo(classDecl, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private extractServiceInfo(classDecl: ClassDeclaration, file: SourceFile): ServiceInfo {
|
||||
const methods = classDecl
|
||||
.getMethods()
|
||||
.filter((m) => !m.hasModifier(SyntaxKind.PrivateKeyword))
|
||||
.map((m) => this.extractServiceMethod(m));
|
||||
|
||||
return {
|
||||
name: classDecl.getName() ?? 'Anonymous',
|
||||
file: getRelativePath(file.getFilePath()),
|
||||
methods,
|
||||
};
|
||||
}
|
||||
|
||||
private extractServiceMethod(method: MethodDeclaration): ServiceMethodInfo {
|
||||
const name = method.getName();
|
||||
const baseInfo = this.extractMethodInfo(method);
|
||||
const httpMethod = this.inferHttpMethod(name);
|
||||
const endpoint = this.extractEndpoint(method);
|
||||
|
||||
return { ...baseInfo, httpMethod, endpoint };
|
||||
}
|
||||
|
||||
private inferHttpMethod(methodName: string): string | undefined {
|
||||
const lower = methodName.toLowerCase();
|
||||
if (lower.startsWith('get') || lower.startsWith('fetch') || lower.startsWith('list'))
|
||||
return 'GET';
|
||||
if (lower.startsWith('create') || lower.startsWith('add') || lower.startsWith('post'))
|
||||
return 'POST';
|
||||
if (lower.startsWith('update') || lower.startsWith('edit') || lower.startsWith('put'))
|
||||
return 'PUT';
|
||||
if (lower.startsWith('delete') || lower.startsWith('remove')) return 'DELETE';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractEndpoint(method: MethodDeclaration): string | undefined {
|
||||
const jsDocs = method.getJsDocs();
|
||||
const endpointPattern = /@endpoint\s+(\S+)/;
|
||||
for (const doc of jsDocs) {
|
||||
const text = doc.getText();
|
||||
const match = endpointPattern.exec(text);
|
||||
if (match) return match[1];
|
||||
}
|
||||
|
||||
const body = method.getBody()?.getText() ?? '';
|
||||
const urlPattern = /['"`](\/api\/[^'"`]+)['"`]/;
|
||||
const urlMatch = urlPattern.exec(body);
|
||||
if (urlMatch) return urlMatch[1];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private analyzeFixtures(): FixtureInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.fixtures);
|
||||
const fixtures: FixtureInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = getRelativePath(file.getFilePath());
|
||||
const extendCalls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
|
||||
for (const call of extendCalls) {
|
||||
const expr = call.getExpression().getText();
|
||||
if (expr.endsWith('.extend')) {
|
||||
const fixtureInfo = this.extractFixtureInfo(call, file);
|
||||
if (fixtureInfo) fixtures.push(fixtureInfo);
|
||||
}
|
||||
}
|
||||
|
||||
for (const varDecl of file.getVariableDeclarations()) {
|
||||
if (varDecl.isExported() && varDecl.getName().toLowerCase().includes('fixture')) {
|
||||
fixtures.push({ name: varDecl.getName(), file: filePath, provides: {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
private extractFixtureInfo(call: CallExpression, file: SourceFile): FixtureInfo | null {
|
||||
const args = call.getArguments();
|
||||
if (args.length === 0) return null;
|
||||
|
||||
const firstArg = args[0];
|
||||
const provides: Record<string, string> = {};
|
||||
|
||||
if (firstArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
||||
const objLit = firstArg.asKind(SyntaxKind.ObjectLiteralExpression);
|
||||
for (const prop of objLit?.getProperties() ?? []) {
|
||||
if (prop.getKind() === SyntaxKind.PropertyAssignment) {
|
||||
const propAssign = prop.asKind(SyntaxKind.PropertyAssignment);
|
||||
const name = propAssign?.getName() ?? '';
|
||||
const init = propAssign?.getInitializer()?.getText() ?? '';
|
||||
provides[name] = this.inferFixtureType(init);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name: 'test', file: getRelativePath(file.getFilePath()), provides };
|
||||
}
|
||||
|
||||
private inferFixtureType(initializerText: string): string {
|
||||
const newPattern = /new\s+(\w+)/;
|
||||
const newMatch = newPattern.exec(initializerText);
|
||||
if (newMatch) return newMatch[1];
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private analyzeHelpers(): HelperInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.helpers);
|
||||
const helpers: HelperInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const functions: MethodInfo[] = [];
|
||||
|
||||
for (const func of file.getFunctions()) {
|
||||
if (func.isExported()) {
|
||||
functions.push(this.extractFunctionInfo(func));
|
||||
}
|
||||
}
|
||||
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
functions.push(...this.extractMethods(classDecl));
|
||||
}
|
||||
}
|
||||
|
||||
if (functions.length > 0) {
|
||||
helpers.push({
|
||||
name: this.getClassName(file) ?? path.basename(file.getFilePath(), '.ts'),
|
||||
file: getRelativePath(file.getFilePath()),
|
||||
functions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return helpers.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private getClassName(file: SourceFile): string | undefined {
|
||||
return file.getClasses()[0]?.getName();
|
||||
}
|
||||
|
||||
private extractFunctionInfo(func: FunctionDeclaration): MethodInfo {
|
||||
return {
|
||||
name: func.getName() ?? 'anonymous',
|
||||
params: func.getParameters().map((p) => ({
|
||||
name: p.getName(),
|
||||
type: this.simplifyType(p.getType().getText()),
|
||||
optional: p.isOptional(),
|
||||
})),
|
||||
returnType: this.simplifyType(func.getReturnType().getText()),
|
||||
async: func.isAsync(),
|
||||
description: this.extractJsDocDescription(func),
|
||||
};
|
||||
}
|
||||
|
||||
private analyzeFactories(): FactoryInfo[] {
|
||||
const config = getConfig();
|
||||
const files = getSourceFiles(this.project, config.patterns.factories);
|
||||
const factories: FactoryInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
for (const classDecl of file.getClasses()) {
|
||||
if (classDecl.isExported()) {
|
||||
const name = classDecl.getName() ?? '';
|
||||
factories.push({
|
||||
name,
|
||||
file: getRelativePath(file.getFilePath()),
|
||||
methods: this.extractMethods(classDecl),
|
||||
builds: this.inferFactoryProduct(classDecl),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const func of file.getFunctions()) {
|
||||
if (func.isExported() && func.getName()?.toLowerCase().includes('factory')) {
|
||||
factories.push({
|
||||
name: func.getName() ?? 'anonymous',
|
||||
file: getRelativePath(file.getFilePath()),
|
||||
methods: [this.extractFunctionInfo(func)],
|
||||
builds: this.simplifyType(func.getReturnType().getText()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return factories.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private inferFactoryProduct(classDecl: ClassDeclaration): string {
|
||||
const buildMethod = classDecl.getMethod('build') ?? classDecl.getMethod('create');
|
||||
if (buildMethod) return this.simplifyType(buildMethod.getReturnType().getText());
|
||||
|
||||
const name = classDecl.getName() ?? '';
|
||||
if (name.endsWith('Factory')) return name.replace('Factory', '');
|
||||
if (name.endsWith('Builder')) return name.replace('Builder', '');
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private analyzeTestData(): TestDataInfo[] {
|
||||
const config = getConfig();
|
||||
const testData: TestDataInfo[] = [];
|
||||
|
||||
for (const pattern of config.patterns.testData) {
|
||||
try {
|
||||
const files = glob.sync(pattern, { cwd: this.root, nodir: true });
|
||||
|
||||
for (const file of files) {
|
||||
const absolutePath = path.join(this.root, file);
|
||||
try {
|
||||
const stats = fs.statSync(absolutePath);
|
||||
testData.push({
|
||||
name: path.basename(file),
|
||||
file,
|
||||
category: path.dirname(file),
|
||||
sizeKb: Math.round((stats.size / 1024) * 10) / 10,
|
||||
});
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip patterns that fail
|
||||
}
|
||||
}
|
||||
|
||||
return testData.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private analyzeFacade(pages: PageInfo[]): FacadeInfo | null {
|
||||
const config = getConfig();
|
||||
const classToPropertyMap = this.facade.getClassToPropertyMap();
|
||||
|
||||
// No facade configured or found
|
||||
if (classToPropertyMap.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build exposed pages from facade resolver
|
||||
const exposedPages: FacadePropertyInfo[] = [];
|
||||
const exposedTypes = new Set<string>();
|
||||
|
||||
for (const [className, properties] of classToPropertyMap) {
|
||||
exposedTypes.add(className);
|
||||
for (const property of properties) {
|
||||
exposedPages.push({ property, type: className });
|
||||
}
|
||||
}
|
||||
|
||||
// Find pages not exposed on the facade
|
||||
const unexposedPages = pages
|
||||
.filter((page) => !exposedTypes.has(page.name))
|
||||
.map((page) => page.name)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
file: config.facade.file,
|
||||
className: config.facade.className,
|
||||
exposedPages: exposedPages.sort((a, b) => a.property.localeCompare(b.property)),
|
||||
unexposedPages,
|
||||
};
|
||||
}
|
||||
|
||||
private extractMethods(classDecl: ClassDeclaration): MethodInfo[] {
|
||||
return classDecl
|
||||
.getMethods()
|
||||
.filter((m) => !m.hasModifier(SyntaxKind.PrivateKeyword))
|
||||
.map((m) => this.extractMethodInfo(m));
|
||||
}
|
||||
|
||||
private extractMethodInfo(method: MethodDeclaration): MethodInfo {
|
||||
return {
|
||||
name: method.getName(),
|
||||
params: method.getParameters().map((p) => ({
|
||||
name: p.getName(),
|
||||
type: this.simplifyType(p.getType().getText()),
|
||||
optional: p.isOptional(),
|
||||
})),
|
||||
returnType: this.simplifyType(method.getReturnType().getText()),
|
||||
async: method.isAsync(),
|
||||
description: this.extractJsDocDescription(method),
|
||||
};
|
||||
}
|
||||
|
||||
private extractProperties(classDecl: ClassDeclaration): PropertyInfo[] {
|
||||
const properties: PropertyInfo[] = [];
|
||||
|
||||
for (const prop of classDecl.getProperties()) {
|
||||
if (!prop.hasModifier(SyntaxKind.PrivateKeyword)) {
|
||||
properties.push(this.extractPropertyInfo(prop));
|
||||
}
|
||||
}
|
||||
|
||||
for (const getter of classDecl.getGetAccessors()) {
|
||||
if (!getter.hasModifier(SyntaxKind.PrivateKeyword)) {
|
||||
properties.push({
|
||||
name: getter.getName(),
|
||||
type: this.simplifyType(getter.getReturnType().getText()),
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private extractPropertyInfo(prop: PropertyDeclaration): PropertyInfo {
|
||||
return {
|
||||
name: prop.getName(),
|
||||
type: this.simplifyType(prop.getType().getText()),
|
||||
readonly: prop.hasModifier(SyntaxKind.ReadonlyKeyword),
|
||||
};
|
||||
}
|
||||
|
||||
private extractJsDocDescription(
|
||||
node: MethodDeclaration | FunctionDeclaration,
|
||||
): string | undefined {
|
||||
const jsDocs = node.getJsDocs();
|
||||
if (jsDocs.length === 0) return undefined;
|
||||
return jsDocs[0].getDescription()?.trim() || undefined;
|
||||
}
|
||||
|
||||
private simplifyType(type: string): string {
|
||||
let simplified = type.replace(/import\([^)]+\)\./g, '');
|
||||
simplified = simplified.replace(/Promise<(.+)>/, '$1');
|
||||
if (simplified.length > 50) simplified = simplified.slice(0, 47) + '...';
|
||||
return simplified;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatInventoryJSON(inventory: InventoryReport): string {
|
||||
return JSON.stringify(inventory, null, 2);
|
||||
}
|
||||
|
||||
export function formatInventorySummaryJSON(summary: InventorySummary): string {
|
||||
return JSON.stringify(summary, null, 2);
|
||||
}
|
||||
|
||||
export function formatInventoryCategoryJSON(view: InventoryCategoryView): string {
|
||||
return JSON.stringify(view, null, 2);
|
||||
}
|
||||
|
||||
/** Extract compact summary from full report */
|
||||
export function toSummary(report: InventoryReport): InventorySummary {
|
||||
return {
|
||||
counts: {
|
||||
pages: report.summary.pages,
|
||||
components: report.summary.components,
|
||||
composables: report.summary.composables,
|
||||
services: report.summary.services,
|
||||
helpers: report.summary.helpers,
|
||||
testData: report.summary.testData,
|
||||
},
|
||||
facade: report.facade?.exposedPages.map((p) => p.property) ?? null,
|
||||
categories: ['pages', 'components', 'composables', 'services', 'helpers', 'testData'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract single category with method names only */
|
||||
export function toCategory(
|
||||
report: InventoryReport,
|
||||
category: InventoryCategory,
|
||||
): InventoryCategoryView {
|
||||
const extractMethods = (
|
||||
items: Array<{ name: string; file: string; methods?: MethodInfo[] }>,
|
||||
): InventoryCategoryView['items'] =>
|
||||
items.map((item) => ({
|
||||
name: item.name,
|
||||
file: item.file,
|
||||
methods: item.methods?.map((m) => m.name),
|
||||
}));
|
||||
|
||||
switch (category) {
|
||||
case 'pages':
|
||||
return { category, items: extractMethods(report.pages) };
|
||||
case 'components':
|
||||
return { category, items: extractMethods(report.components) };
|
||||
case 'composables':
|
||||
return { category, items: extractMethods(report.composables) };
|
||||
case 'services':
|
||||
return { category, items: extractMethods(report.services) };
|
||||
case 'helpers':
|
||||
return {
|
||||
category,
|
||||
items: report.helpers.map((h) => ({
|
||||
name: h.name,
|
||||
file: h.file,
|
||||
methods: h.functions.map((f) => f.name),
|
||||
})),
|
||||
};
|
||||
case 'fixtures':
|
||||
return {
|
||||
category,
|
||||
items: report.fixtures.map((f) => ({ name: f.name, file: f.file })),
|
||||
};
|
||||
case 'factories':
|
||||
return { category, items: extractMethods(report.factories) };
|
||||
case 'testData':
|
||||
return {
|
||||
category,
|
||||
items: report.testData.map((t) => ({ name: t.name, file: t.file })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** All possible return types from filterByFile */
|
||||
export type InventoryItem =
|
||||
| PageInfo
|
||||
| ComponentInfo
|
||||
| ComposableInfo
|
||||
| ServiceInfo
|
||||
| HelperInfo
|
||||
| FixtureInfo
|
||||
| FactoryInfo
|
||||
| TestDataInfo;
|
||||
|
||||
/** Filter report to single file - returns detailed info for that file only */
|
||||
export function filterByFile(report: InventoryReport, fileName: string): InventoryItem | null {
|
||||
const normalizedName = fileName
|
||||
.replace(/^.*\//, '')
|
||||
.replace(/\.ts$/, '')
|
||||
.replace(/\.json$/, '');
|
||||
|
||||
const collections: Array<{ items: Array<{ name: string; file: string }> }> = [
|
||||
{ items: report.pages },
|
||||
{ items: report.components },
|
||||
{ items: report.composables },
|
||||
{ items: report.services },
|
||||
{ items: report.helpers },
|
||||
{ items: report.fixtures },
|
||||
{ items: report.factories },
|
||||
{ items: report.testData },
|
||||
];
|
||||
|
||||
for (const { items } of collections) {
|
||||
for (const item of items) {
|
||||
if (item.file.includes(fileName) || item.name === normalizedName) {
|
||||
return item as InventoryItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
517
packages/testing/janitor/src/core/method-usage-analyzer.test.ts
Normal file
517
packages/testing/janitor/src/core/method-usage-analyzer.test.ts
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import { Project } from 'ts-morph';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { MethodUsageAnalyzer } from './method-usage-analyzer.js';
|
||||
import { setConfig, resetConfig, defineConfig } from '../config.js';
|
||||
|
||||
/**
|
||||
* Tests for MethodUsageAnalyzer - fixture mapping and method usage tracking.
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. Fixture properties are correctly mapped to class names from facade
|
||||
* 2. Method calls in tests are correctly identified and tracked
|
||||
* 3. Method impact analysis returns correct affected test files
|
||||
*/
|
||||
|
||||
// Create mock functions with proper types
|
||||
const mockFindFilesRecursive = vi.fn<() => string[]>(() => []);
|
||||
const mockReadFileSync = vi.fn<(path: string) => string>(() => '');
|
||||
|
||||
// Mock fs and path helpers to avoid filesystem access
|
||||
vi.mock('../utils/paths.js', async () => {
|
||||
const actual = await vi.importActual('../utils/paths.js');
|
||||
return {
|
||||
...actual,
|
||||
getRootDir: () => '/test-root',
|
||||
findFilesRecursive: (_dir: string, _suffix: string) => mockFindFilesRecursive(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs for file reading in extractMethodUsages
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual('fs');
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: (filePath: string) => mockReadFileSync(filePath),
|
||||
};
|
||||
});
|
||||
|
||||
describe('MethodUsageAnalyzer', () => {
|
||||
let project: Project;
|
||||
|
||||
beforeEach(() => {
|
||||
project = new Project({ useInMemoryFileSystem: true });
|
||||
|
||||
setConfig(
|
||||
defineConfig({
|
||||
rootDir: '/test-root',
|
||||
fixtureObjectName: 'app',
|
||||
facade: {
|
||||
file: 'pages/AppPage.ts',
|
||||
className: 'AppPage',
|
||||
excludeTypes: ['Page', 'APIRequestContext'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset mocks
|
||||
mockFindFilesRecursive.mockReturnValue([]);
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Fixture Mapping Extraction', () => {
|
||||
it('extracts fixture property to class mapping from facade', () => {
|
||||
// Create the facade file
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { Page } from '@playwright/test';
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
import { WorkflowsPage } from './WorkflowsPage';
|
||||
import { NodeEditorPage } from './NodeEditorPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly page: Page;
|
||||
readonly canvas: CanvasPage;
|
||||
readonly workflows: WorkflowsPage;
|
||||
readonly nodeEditor: NodeEditorPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
// Verify fixture mappings (excluding 'page' which is in excludeTypes)
|
||||
expect(index.fixtureMapping.canvas).toBe('CanvasPage');
|
||||
expect(index.fixtureMapping.workflows).toBe('WorkflowsPage');
|
||||
expect(index.fixtureMapping.nodeEditor).toBe('NodeEditorPage');
|
||||
expect(index.fixtureMapping.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty facade gracefully', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
export class AppPage {
|
||||
// No properties
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
expect(Object.keys(index.fixtureMapping)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters excluded types from mapping', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { Page, APIRequestContext } from '@playwright/test';
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly page: Page;
|
||||
readonly request: APIRequestContext;
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
// Only canvas should be mapped (page and request are excluded)
|
||||
expect(Object.keys(index.fixtureMapping)).toContain('canvas');
|
||||
expect(Object.keys(index.fixtureMapping)).not.toContain('page');
|
||||
expect(Object.keys(index.fixtureMapping)).not.toContain('request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method Usage Extraction', () => {
|
||||
it('extracts method calls from test files', () => {
|
||||
// Setup facade
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Mock finding test files
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
|
||||
|
||||
// Mock reading the test file
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('adds a node', async ({ app }) => {
|
||||
await app.canvas.addNode('Manual Trigger');
|
||||
await app.canvas.connectNodes('A', 'B');
|
||||
});
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
expect(index.methods['CanvasPage.addNode']).toBeDefined();
|
||||
expect(index.methods['CanvasPage.addNode']).toHaveLength(1);
|
||||
expect(index.methods['CanvasPage.connectNodes']).toBeDefined();
|
||||
expect(index.methods['CanvasPage.connectNodes']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('tracks multiple usages of same method', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
test('workflow test', async ({ app }) => {
|
||||
await app.canvas.addNode('Trigger');
|
||||
await app.canvas.addNode('HTTP');
|
||||
await app.canvas.addNode('Set');
|
||||
});
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
expect(index.methods['CanvasPage.addNode']).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('tracks usages across multiple test files', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue([
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
'/test-root/tests/nodes.spec.ts',
|
||||
]);
|
||||
|
||||
mockReadFileSync.mockImplementation((filePath: unknown) => {
|
||||
if (String(filePath).includes('canvas.spec.ts')) {
|
||||
return `
|
||||
test('canvas test', async ({ app }) => {
|
||||
await app.canvas.addNode('Trigger');
|
||||
});
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
test('nodes test', async ({ app }) => {
|
||||
await app.canvas.addNode('HTTP');
|
||||
});
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
const usages = index.methods['CanvasPage.addNode'];
|
||||
expect(usages).toHaveLength(2);
|
||||
|
||||
const testFiles = new Set(usages.map((u) => u.testFile));
|
||||
expect(testFiles.size).toBe(2);
|
||||
});
|
||||
|
||||
it('captures line and column information', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockReturnValue(`line1
|
||||
line2
|
||||
await app.canvas.addNode('Trigger');
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
const usage = index.methods['CanvasPage.addNode'][0];
|
||||
expect(usage.line).toBe(3);
|
||||
expect(usage.column).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('captures full call text', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/canvas.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
await app.canvas.addNode('Manual Trigger');
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
const usage = index.methods['CanvasPage.addNode'][0];
|
||||
expect(usage.fullCall).toContain('app.canvas.addNode');
|
||||
expect(usage.fullCall).toContain('Manual Trigger');
|
||||
});
|
||||
|
||||
it('ignores unknown fixture properties', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
|
||||
|
||||
// 'unknown' is not in the fixture mapping
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
await app.unknown.someMethod();
|
||||
await app.canvas.addNode();
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
// Only CanvasPage.addNode should be tracked
|
||||
expect(Object.keys(index.methods)).toEqual(['CanvasPage.addNode']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method Impact Analysis', () => {
|
||||
it('returns affected test files for a method', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue([
|
||||
'/test-root/tests/canvas.spec.ts',
|
||||
'/test-root/tests/other.spec.ts',
|
||||
]);
|
||||
|
||||
mockReadFileSync.mockImplementation((filePath: unknown) => {
|
||||
if (String(filePath).includes('canvas.spec.ts')) {
|
||||
return "await app.canvas.addNode('X');";
|
||||
} else {
|
||||
return '// no canvas calls';
|
||||
}
|
||||
});
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const result = analyzer.getMethodImpact('CanvasPage.addNode');
|
||||
|
||||
expect(result.className).toBe('CanvasPage');
|
||||
expect(result.methodName).toBe('addNode');
|
||||
expect(result.affectedTestFiles).toContain('tests/canvas.spec.ts');
|
||||
expect(result.affectedTestFiles).not.toContain('tests/other.spec.ts');
|
||||
});
|
||||
|
||||
it('returns empty when method has no usages', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue([]);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const result = analyzer.getMethodImpact('CanvasPage.unusedMethod');
|
||||
|
||||
expect(result.usages).toHaveLength(0);
|
||||
expect(result.affectedTestFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws on invalid method format', () => {
|
||||
project.createSourceFile('/test-root/pages/AppPage.ts', 'export class AppPage {}');
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue([]);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
|
||||
expect(() => analyzer.getMethodImpact('invalidFormat')).toThrow('Invalid format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('List Used Methods', () => {
|
||||
it('returns methods sorted by usage count', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
import { WorkflowsPage } from './WorkflowsPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
readonly workflows: WorkflowsPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
app.canvas.addNode('A');
|
||||
app.canvas.addNode('B');
|
||||
app.canvas.addNode('C');
|
||||
app.workflows.create();
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const methods = analyzer.listUsedMethods();
|
||||
|
||||
expect(methods[0].method).toBe('CanvasPage.addNode');
|
||||
expect(methods[0].usageCount).toBe(3);
|
||||
expect(methods[1].method).toBe('WorkflowsPage.create');
|
||||
expect(methods[1].usageCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles missing facade file gracefully', () => {
|
||||
// Don't create the facade file
|
||||
mockFindFilesRecursive.mockReturnValue([]);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
|
||||
// Should not throw, just return empty index
|
||||
const index = analyzer.buildIndex();
|
||||
expect(Object.keys(index.fixtureMapping)).toHaveLength(0);
|
||||
expect(Object.keys(index.methods)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles file read errors gracefully', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
|
||||
// Should not throw
|
||||
const index = analyzer.buildIndex();
|
||||
expect(Object.keys(index.methods)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles nested parentheses in method calls', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
await app.canvas.addNode(getNodeName('trigger', { type: 'manual' }));
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
const usage = index.methods['CanvasPage.addNode'][0];
|
||||
expect(usage.fullCall).toContain('app.canvas.addNode');
|
||||
// The full call should include the nested parens
|
||||
expect(usage.fullCall).toContain('getNodeName');
|
||||
});
|
||||
|
||||
it('handles multiple fixture calls on same line', () => {
|
||||
project.createSourceFile(
|
||||
'/test-root/pages/AppPage.ts',
|
||||
`
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
export class AppPage {
|
||||
readonly canvas: CanvasPage;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
mockFindFilesRecursive.mockReturnValue(['/test-root/tests/test.spec.ts']);
|
||||
|
||||
mockReadFileSync.mockReturnValue(`
|
||||
await app.canvas.addNode('A'); await app.canvas.deleteNode('B');
|
||||
`);
|
||||
|
||||
const analyzer = new MethodUsageAnalyzer(project);
|
||||
const index = analyzer.buildIndex();
|
||||
|
||||
expect(index.methods['CanvasPage.addNode']).toHaveLength(1);
|
||||
expect(index.methods['CanvasPage.deleteNode']).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
241
packages/testing/janitor/src/core/method-usage-analyzer.ts
Normal file
241
packages/testing/janitor/src/core/method-usage-analyzer.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* Method Usage Analyzer - Builds index of page object method usages in tests
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { type Project } from 'ts-morph';
|
||||
|
||||
import { getConfig } from '../config.js';
|
||||
import { FacadeResolver } from './facade-resolver.js';
|
||||
import { getRootDir, findFilesRecursive, getRelativePath } from '../utils/paths.js';
|
||||
|
||||
export interface MethodUsage {
|
||||
testFile: string;
|
||||
line: number;
|
||||
column: number;
|
||||
fullCall: string;
|
||||
}
|
||||
|
||||
export interface MethodUsageIndex {
|
||||
methods: Record<string, MethodUsage[]>;
|
||||
fixtureMapping: Record<string, string>;
|
||||
timestamp: string;
|
||||
testFilesAnalyzed: number;
|
||||
}
|
||||
|
||||
export interface MethodImpactResult {
|
||||
className: string;
|
||||
methodName: string;
|
||||
usages: MethodUsage[];
|
||||
affectedTestFiles: string[];
|
||||
}
|
||||
|
||||
export class MethodUsageAnalyzer {
|
||||
private facade: FacadeResolver;
|
||||
private fixtureMapping: Record<string, string> = {};
|
||||
|
||||
constructor(project: Project) {
|
||||
this.facade = new FacadeResolver(project);
|
||||
}
|
||||
|
||||
buildIndex(): MethodUsageIndex {
|
||||
this.fixtureMapping = this.facade.getPropertyToClassMap();
|
||||
const testFiles = this.findTestFiles();
|
||||
const methods: Record<string, MethodUsage[]> = {};
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const usages = this.extractMethodUsages(testFile);
|
||||
|
||||
for (const usage of usages) {
|
||||
const key = usage.key;
|
||||
if (!methods[key]) methods[key] = [];
|
||||
methods[key].push({
|
||||
testFile: usage.testFile,
|
||||
line: usage.line,
|
||||
column: usage.column,
|
||||
fullCall: usage.fullCall,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
methods,
|
||||
fixtureMapping: this.fixtureMapping,
|
||||
timestamp: new Date().toISOString(),
|
||||
testFilesAnalyzed: testFiles.length,
|
||||
};
|
||||
}
|
||||
|
||||
getMethodImpact(classAndMethod: string): MethodImpactResult {
|
||||
const [className, methodName] = this.parseClassMethod(classAndMethod);
|
||||
const index = this.buildIndex();
|
||||
const key = `${className}.${methodName}`;
|
||||
const usages = index.methods[key] || [];
|
||||
const affectedTestFiles = [...new Set(usages.map((u) => u.testFile))].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
return { className, methodName, usages, affectedTestFiles };
|
||||
}
|
||||
|
||||
listUsedMethods(): Array<{ method: string; usageCount: number; testFileCount: number }> {
|
||||
const index = this.buildIndex();
|
||||
|
||||
return Object.entries(index.methods)
|
||||
.map(([method, usages]) => ({
|
||||
method,
|
||||
usageCount: usages.length,
|
||||
testFileCount: new Set(usages.map((u) => u.testFile)).size,
|
||||
}))
|
||||
.sort((a, b) => b.usageCount - a.usageCount);
|
||||
}
|
||||
|
||||
private findTestFiles(): string[] {
|
||||
const root = getRootDir();
|
||||
const testsDir = path.join(root, 'tests');
|
||||
return findFilesRecursive(testsDir, '.spec.ts');
|
||||
}
|
||||
|
||||
private extractMethodUsages(
|
||||
testFilePath: string,
|
||||
): Array<{ key: string; testFile: string; line: number; column: number; fullCall: string }> {
|
||||
const usages: Array<{
|
||||
key: string;
|
||||
testFile: string;
|
||||
line: number;
|
||||
column: number;
|
||||
fullCall: string;
|
||||
}> = [];
|
||||
const config = getConfig();
|
||||
const relativePath = getRelativePath(testFilePath);
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(testFilePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const fixtureObjectName = config.fixtureObjectName;
|
||||
const pattern = new RegExp(`${fixtureObjectName}\\.(\\w+)\\.(\\w+)\\s*\\(`, 'g');
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||
const line = lines[lineIndex];
|
||||
let match: RegExpExecArray | null;
|
||||
pattern.lastIndex = 0;
|
||||
|
||||
while ((match = pattern.exec(line)) !== null) {
|
||||
const propertyName = match[1];
|
||||
const methodName = match[2];
|
||||
const className = this.fixtureMapping[propertyName];
|
||||
if (!className) continue;
|
||||
|
||||
const callStart = match.index;
|
||||
const fullCall = this.extractFullCall(line, callStart);
|
||||
|
||||
usages.push({
|
||||
key: `${className}.${methodName}`,
|
||||
testFile: relativePath,
|
||||
line: lineIndex + 1,
|
||||
column: match.index + 1,
|
||||
fullCall,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
|
||||
return usages;
|
||||
}
|
||||
|
||||
private extractFullCall(line: string, startIndex: number): string {
|
||||
let depth = 0;
|
||||
let endIndex = startIndex;
|
||||
|
||||
for (let i = startIndex; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '(') depth++;
|
||||
else if (char === ')') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
endIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === startIndex) endIndex = Math.min(startIndex + 60, line.length);
|
||||
|
||||
const call = line.slice(startIndex, endIndex).trim();
|
||||
return call.length > 80 ? call.slice(0, 77) + '...' : call;
|
||||
}
|
||||
|
||||
private parseClassMethod(input: string): [string, string] {
|
||||
const parts = input.split('.');
|
||||
if (parts.length === 2) return [parts[0], parts[1]];
|
||||
|
||||
throw new Error(
|
||||
`Invalid format: "${input}". Expected "ClassName.methodName" (e.g., "CanvasPage.addNode")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMethodImpactConsole(result: MethodImpactResult, verbose = false): void {
|
||||
console.log('\n====================================');
|
||||
console.log(' METHOD IMPACT ANALYSIS');
|
||||
console.log('====================================\n');
|
||||
|
||||
console.log(`Method: ${result.className}.${result.methodName}()`);
|
||||
console.log(`Total usages: ${result.usages.length}`);
|
||||
console.log(`Affected test files: ${result.affectedTestFiles.length}`);
|
||||
|
||||
if (result.affectedTestFiles.length === 0) {
|
||||
console.log('\n (no test files use this method)');
|
||||
} else {
|
||||
console.log('\nAffected tests:');
|
||||
for (const testFile of result.affectedTestFiles) {
|
||||
const usagesInFile = result.usages.filter((u) => u.testFile === testFile);
|
||||
console.log(` - ${testFile} (${usagesInFile.length} usages)`);
|
||||
|
||||
if (verbose) {
|
||||
for (const usage of usagesInFile) {
|
||||
console.log(` L${usage.line}: ${usage.fullCall}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function formatMethodImpactJSON(result: MethodImpactResult): string {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
export function formatMethodImpactTestList(result: MethodImpactResult): string {
|
||||
return result.affectedTestFiles.join('\n');
|
||||
}
|
||||
|
||||
export function formatMethodUsageIndexConsole(index: MethodUsageIndex, limit = 20): void {
|
||||
console.log('\n====================================');
|
||||
console.log(' METHOD USAGE INDEX');
|
||||
console.log('====================================\n');
|
||||
|
||||
console.log(`Test files analyzed: ${index.testFilesAnalyzed}`);
|
||||
console.log(`Unique methods tracked: ${Object.keys(index.methods).length}`);
|
||||
console.log(`Fixture properties mapped: ${Object.keys(index.fixtureMapping).length}`);
|
||||
|
||||
console.log('\nTop used methods:');
|
||||
const sorted = Object.entries(index.methods)
|
||||
.map(([method, usages]) => ({ method, count: usages.length }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
|
||||
for (const { method, count } of sorted) {
|
||||
console.log(` ${method.padEnd(50)} ${count} usages`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function formatMethodUsageIndexJSON(index: MethodUsageIndex): string {
|
||||
return JSON.stringify(index, null, 2);
|
||||
}
|
||||
142
packages/testing/janitor/src/core/project-loader.test.ts
Normal file
142
packages/testing/janitor/src/core/project-loader.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { createProject, createInMemoryProject } from './project-loader.js';
|
||||
|
||||
describe('project-loader', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'janitor-project-loader-test-'));
|
||||
|
||||
// Create a minimal tsconfig.json
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
strict: true,
|
||||
},
|
||||
include: ['**/*.ts'],
|
||||
};
|
||||
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('createProject', () => {
|
||||
it('creates a project with the given root directory', () => {
|
||||
const { project, root } = createProject(tempDir);
|
||||
|
||||
expect(root).toBe(tempDir);
|
||||
expect(project).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a ts-morph Project instance', () => {
|
||||
const { project } = createProject(tempDir);
|
||||
|
||||
// Verify it has ts-morph Project methods
|
||||
expect(typeof project.getSourceFiles).toBe('function');
|
||||
expect(typeof project.addSourceFilesAtPaths).toBe('function');
|
||||
expect(typeof project.createSourceFile).toBe('function');
|
||||
});
|
||||
|
||||
it('can load source files from the project directory', () => {
|
||||
// Create a test file
|
||||
const pagesDir = path.join(tempDir, 'pages');
|
||||
fs.mkdirSync(pagesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pagesDir, 'TestPage.ts'), 'export class TestPage { foo() {} }');
|
||||
|
||||
const { project } = createProject(tempDir);
|
||||
|
||||
// Add source files matching the glob
|
||||
const files = project.addSourceFilesAtPaths(path.join(tempDir, 'pages/**/*.ts'));
|
||||
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0].getFilePath()).toContain('TestPage.ts');
|
||||
});
|
||||
|
||||
it('project can parse TypeScript classes and methods', () => {
|
||||
// Create a test file with a class
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'TestClass.ts'),
|
||||
`export class TestClass {
|
||||
publicMethod() { return 1; }
|
||||
private privateMethod() { return 2; }
|
||||
}`,
|
||||
);
|
||||
|
||||
const { project } = createProject(tempDir);
|
||||
const files = project.addSourceFilesAtPaths(path.join(tempDir, '*.ts'));
|
||||
|
||||
expect(files.length).toBe(1);
|
||||
|
||||
const classes = files[0].getClasses();
|
||||
expect(classes.length).toBe(1);
|
||||
expect(classes[0].getName()).toBe('TestClass');
|
||||
|
||||
const methods = classes[0].getMethods();
|
||||
expect(methods.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInMemoryProject', () => {
|
||||
it('creates an in-memory project for testing', () => {
|
||||
const project = createInMemoryProject();
|
||||
|
||||
expect(project).toBeDefined();
|
||||
expect(typeof project.getSourceFiles).toBe('function');
|
||||
});
|
||||
|
||||
it('can add and retrieve source files', () => {
|
||||
const project = createInMemoryProject();
|
||||
|
||||
const sourceFile = project.createSourceFile('test.ts', 'export const foo = 42;');
|
||||
|
||||
expect(sourceFile).toBeDefined();
|
||||
expect(sourceFile.getFilePath()).toContain('test.ts');
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
expect(files.length).toBe(1);
|
||||
});
|
||||
|
||||
it('can parse TypeScript AST in memory', () => {
|
||||
const project = createInMemoryProject();
|
||||
|
||||
const sourceFile = project.createSourceFile(
|
||||
'pages/TestPage.ts',
|
||||
`export class TestPage {
|
||||
container = this.page.locator('.container');
|
||||
async click() { await this.container.click(); }
|
||||
}`,
|
||||
);
|
||||
|
||||
const classes = sourceFile.getClasses();
|
||||
expect(classes.length).toBe(1);
|
||||
expect(classes[0].getName()).toBe('TestPage');
|
||||
|
||||
const properties = classes[0].getProperties();
|
||||
expect(properties.length).toBe(1);
|
||||
expect(properties[0].getName()).toBe('container');
|
||||
});
|
||||
|
||||
it('supports multiple files with imports', () => {
|
||||
const project = createInMemoryProject();
|
||||
|
||||
project.createSourceFile('base.ts', 'export class BasePage { protected page: any; }');
|
||||
|
||||
project.createSourceFile(
|
||||
'derived.ts',
|
||||
`import { BasePage } from './base';
|
||||
export class DerivedPage extends BasePage {}`,
|
||||
);
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
expect(files.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
packages/testing/janitor/src/core/project-loader.ts
Normal file
71
packages/testing/janitor/src/core/project-loader.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import * as path from 'node:path';
|
||||
import { Project, type SourceFile } from 'ts-morph';
|
||||
|
||||
import { getConfig } from '../config.js';
|
||||
import { getRelativePath as getRelPath } from '../utils/paths.js';
|
||||
|
||||
export interface ProjectContext {
|
||||
project: Project;
|
||||
root: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ts-morph Project with the configured root directory
|
||||
*/
|
||||
export function createProject(rootDir?: string): ProjectContext {
|
||||
const root = rootDir ?? getConfig().rootDir;
|
||||
const tsconfigPath = path.join(root, 'tsconfig.json');
|
||||
|
||||
const project = new Project({
|
||||
tsConfigFilePath: tsconfigPath,
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory project for testing
|
||||
*/
|
||||
export function createInMemoryProject(): Project {
|
||||
return new Project({ useInMemoryFileSystem: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source files matching the given glob patterns
|
||||
* @param project - ts-morph Project instance
|
||||
* @param globs - Array of glob patterns relative to root
|
||||
* @returns Matching source files
|
||||
*/
|
||||
export function getSourceFiles(project: Project, globs: string[]): SourceFile[] {
|
||||
const root = getConfig().rootDir;
|
||||
const absoluteGlobs = globs.map((glob) => path.join(root, glob));
|
||||
|
||||
// Add files to project if not already present, then return them
|
||||
// This ensures files matching globs are loaded even if not in tsconfig
|
||||
const addedFiles = project.addSourceFilesAtPaths(absoluteGlobs);
|
||||
|
||||
// Also get any existing files that match (in case already loaded via tsconfig)
|
||||
const existingFiles: SourceFile[] = [];
|
||||
for (const glob of absoluteGlobs) {
|
||||
existingFiles.push(...project.getSourceFiles(glob));
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const uniqueFiles = new Map<string, SourceFile>();
|
||||
for (const file of [...addedFiles, ...existingFiles]) {
|
||||
uniqueFiles.set(file.getFilePath(), file);
|
||||
}
|
||||
|
||||
return Array.from(uniqueFiles.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative path from root directory
|
||||
* Re-exported for convenience
|
||||
*/
|
||||
export function getRelativePath(filePath: string): string {
|
||||
return getRelPath(filePath);
|
||||
}
|
||||
199
packages/testing/janitor/src/core/reporter.ts
Normal file
199
packages/testing/janitor/src/core/reporter.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import type { JanitorReport, Violation, FixResult } from '../types.js';
|
||||
import { getRelativePath } from './project-loader.js';
|
||||
|
||||
/**
|
||||
* Output report as JSON (for LLM consumption)
|
||||
*/
|
||||
export function toJSON(report: JanitorReport): string {
|
||||
// Convert absolute paths to relative for cleaner output
|
||||
const cleanedReport = {
|
||||
...report,
|
||||
results: report.results.map((result) => ({
|
||||
...result,
|
||||
violations: result.violations.map((v) => ({
|
||||
...v,
|
||||
file: getRelativePath(v.file),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return JSON.stringify(cleanedReport, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output report to console (human-readable)
|
||||
*/
|
||||
export function toConsole(report: JanitorReport, verbose = false): void {
|
||||
const { summary, results } = report;
|
||||
|
||||
// Header
|
||||
console.log('\n====================================');
|
||||
console.log(' JANITOR ANALYSIS REPORT');
|
||||
console.log('====================================\n');
|
||||
|
||||
console.log(`Timestamp: ${report.timestamp}`);
|
||||
console.log(`Files analyzed: ${summary.filesAnalyzed}`);
|
||||
console.log(`Rules enabled: ${report.rules.enabled.join(', ') || 'none'}`);
|
||||
if (report.rules.disabled.length > 0) {
|
||||
console.log(`Rules disabled: ${report.rules.disabled.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Summary
|
||||
if (summary.totalViolations === 0) {
|
||||
console.log('All clean! No violations found.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${summary.totalViolations} violation(s)\n`);
|
||||
|
||||
// By severity
|
||||
console.log('By severity:');
|
||||
if (summary.bySeverity.error > 0) {
|
||||
console.log(` Errors: ${summary.bySeverity.error}`);
|
||||
}
|
||||
if (summary.bySeverity.warning > 0) {
|
||||
console.log(` Warnings: ${summary.bySeverity.warning}`);
|
||||
}
|
||||
if (summary.bySeverity.info > 0) {
|
||||
console.log(` Info: ${summary.bySeverity.info}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// By rule
|
||||
console.log('By rule:');
|
||||
for (const [rule, count] of Object.entries(summary.byRule)) {
|
||||
if (count > 0) {
|
||||
console.log(` ${rule}: ${count}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Detailed violations
|
||||
if (verbose) {
|
||||
console.log('------------------------------------');
|
||||
console.log('DETAILED VIOLATIONS');
|
||||
console.log('------------------------------------\n');
|
||||
|
||||
for (const result of results) {
|
||||
if (result.violations.length === 0) continue;
|
||||
|
||||
console.log(
|
||||
`${result.rule} (${result.violations.length} violations, ${result.executionTimeMs}ms)`,
|
||||
);
|
||||
console.log('');
|
||||
|
||||
for (const violation of result.violations) {
|
||||
printViolation(violation);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
} else {
|
||||
// Non-verbose: group by file
|
||||
const byFile = new Map<string, Violation[]>();
|
||||
|
||||
for (const result of results) {
|
||||
for (const violation of result.violations) {
|
||||
const relativePath = getRelativePath(violation.file);
|
||||
if (!byFile.has(relativePath)) {
|
||||
byFile.set(relativePath, []);
|
||||
}
|
||||
byFile.get(relativePath)!.push(violation);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('------------------------------------');
|
||||
console.log('VIOLATIONS BY FILE');
|
||||
console.log('------------------------------------\n');
|
||||
|
||||
for (const [file, violations] of byFile) {
|
||||
console.log(`${file} (${violations.length})`);
|
||||
for (const v of violations) {
|
||||
const icon = getSeverityIcon(v.severity);
|
||||
console.log(` ${icon} L${v.line}: [${v.rule}] ${v.message}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('Use --verbose for detailed output with suggestions.\n');
|
||||
}
|
||||
}
|
||||
|
||||
function printViolation(v: Violation): void {
|
||||
const relativePath = getRelativePath(v.file);
|
||||
const icon = getSeverityIcon(v.severity);
|
||||
|
||||
console.log(` ${icon} ${relativePath}:${v.line}:${v.column}`);
|
||||
console.log(` ${v.message}`);
|
||||
if (v.suggestion) {
|
||||
console.log(` Suggestion: ${v.suggestion}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return '[ERR]';
|
||||
case 'warning':
|
||||
return '[WARN]';
|
||||
case 'info':
|
||||
return '[INFO]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output fix results to console
|
||||
*/
|
||||
export function printFixResults(report: JanitorReport, write: boolean): void {
|
||||
const allFixes: FixResult[] = [];
|
||||
for (const result of report.results) {
|
||||
if (result.fixes) {
|
||||
allFixes.push(...result.fixes);
|
||||
}
|
||||
}
|
||||
|
||||
if (allFixes.length === 0) {
|
||||
console.log('\nNo fixable violations found.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n------------------------------------');
|
||||
console.log(write ? ' FIXES APPLIED' : ' FIX PREVIEW (dry run)');
|
||||
console.log('------------------------------------\n');
|
||||
|
||||
for (const fix of allFixes) {
|
||||
const icon = fix.applied ? '[OK]' : '[PREVIEW]';
|
||||
const actionLabel = getActionLabel(fix.action);
|
||||
const target = fix.target ? ` ${fix.target}` : '';
|
||||
console.log(` ${icon} ${actionLabel}${target}`);
|
||||
console.log(` ${fix.file}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
if (!write) {
|
||||
console.log(`${allFixes.length} fix(es) would be applied.`);
|
||||
console.log('Use --fix --write to apply changes.\n');
|
||||
} else {
|
||||
console.log(`${allFixes.length} fix(es) applied.`);
|
||||
console.log('Run: pnpm typecheck && pnpm lint\n');
|
||||
}
|
||||
}
|
||||
|
||||
function getActionLabel(action: FixResult['action']): string {
|
||||
switch (action) {
|
||||
case 'remove-file':
|
||||
return 'DELETE FILE';
|
||||
case 'remove-method':
|
||||
return 'REMOVE METHOD';
|
||||
case 'remove-property':
|
||||
return 'REMOVE PROPERTY';
|
||||
case 'edit':
|
||||
return 'EDIT';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
278
packages/testing/janitor/src/core/rule-runner.ts
Normal file
278
packages/testing/janitor/src/core/rule-runner.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import * as path from 'node:path';
|
||||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import type { BaseRule } from '../rules/base-rule.js';
|
||||
import type {
|
||||
JanitorReport,
|
||||
RuleResult,
|
||||
Severity,
|
||||
RunOptions,
|
||||
RuleConfig,
|
||||
RuleInfo,
|
||||
} from '../types.js';
|
||||
import { getSourceFiles } from './project-loader.js';
|
||||
import { getConfig } from '../config.js';
|
||||
|
||||
export class RuleRunner {
|
||||
private rules: Map<string, BaseRule> = new Map();
|
||||
private enabledRules: Set<string> = new Set();
|
||||
private ruleConfigs: Map<string, RuleConfig> = new Map();
|
||||
|
||||
/**
|
||||
* Register a rule with the runner
|
||||
*/
|
||||
registerRule(rule: BaseRule): void {
|
||||
this.rules.set(rule.id, rule);
|
||||
this.enabledRules.add(rule.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a specific rule
|
||||
*/
|
||||
enableRule(ruleId: string): void {
|
||||
if (this.rules.has(ruleId)) {
|
||||
this.enabledRules.add(ruleId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a specific rule
|
||||
*/
|
||||
disableRule(ruleId: string): void {
|
||||
this.enabledRules.delete(ruleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable only specific rules (disable all others)
|
||||
*/
|
||||
enableOnly(ruleIds: string[]): void {
|
||||
this.enabledRules.clear();
|
||||
for (const id of ruleIds) {
|
||||
if (this.rules.has(id)) {
|
||||
this.enabledRules.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered rule IDs
|
||||
*/
|
||||
getRegisteredRules(): string[] {
|
||||
return Array.from(this.rules.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule is fixable
|
||||
*/
|
||||
isRuleFixable(ruleId: string): boolean {
|
||||
return this.rules.get(ruleId)?.fixable ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled rule IDs
|
||||
*/
|
||||
getEnabledRules(): string[] {
|
||||
return Array.from(this.enabledRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disabled rule IDs
|
||||
*/
|
||||
getDisabledRules(): string[] {
|
||||
return Array.from(this.rules.keys()).filter((id) => !this.enabledRules.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about all registered rules
|
||||
*/
|
||||
getRuleDetails(): RuleInfo[] {
|
||||
return Array.from(this.rules.values()).map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
severity: rule.severity,
|
||||
fixable: rule.fixable,
|
||||
enabled: this.enabledRules.has(rule.id),
|
||||
targetGlobs: rule.getTargetGlobs(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific rule
|
||||
*/
|
||||
getRuleInfo(ruleId: string): RuleInfo | undefined {
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (!rule) return undefined;
|
||||
|
||||
return {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
severity: rule.severity,
|
||||
fixable: rule.fixable,
|
||||
enabled: this.enabledRules.has(rule.id),
|
||||
targetGlobs: rule.getTargetGlobs(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a specific rule
|
||||
*/
|
||||
configureRule(ruleId: string, config: RuleConfig): void {
|
||||
this.ruleConfigs.set(ruleId, config);
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (rule) {
|
||||
rule.configure(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files for a rule, optionally filtered by specific file paths
|
||||
*/
|
||||
private getFilesForRule(project: Project, rule: BaseRule, targetFiles?: string[]): SourceFile[] {
|
||||
const root = getConfig().rootDir;
|
||||
|
||||
if (targetFiles && targetFiles.length > 0) {
|
||||
// Filter to only the specified files that match rule's globs
|
||||
const ruleGlobs = rule.getTargetGlobs();
|
||||
const allRuleFiles = getSourceFiles(project, ruleGlobs);
|
||||
const allRuleFilePaths = new Set(allRuleFiles.map((f) => f.getFilePath()));
|
||||
|
||||
// Resolve target files to absolute paths and filter
|
||||
return targetFiles
|
||||
.map((f) => {
|
||||
const absolutePath = path.isAbsolute(f) ? f : path.join(root, f);
|
||||
return project.getSourceFile(absolutePath);
|
||||
})
|
||||
.filter((f): f is SourceFile => f !== undefined && allRuleFilePaths.has(f.getFilePath()));
|
||||
}
|
||||
|
||||
return getSourceFiles(project, rule.getTargetGlobs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all enabled rules and return results
|
||||
*/
|
||||
run(project: Project, projectRoot: string, options?: RunOptions): JanitorReport {
|
||||
const results: RuleResult[] = [];
|
||||
const allFilesAnalyzed = new Set<string>();
|
||||
|
||||
// Apply rule configurations from options
|
||||
if (options?.ruleConfig) {
|
||||
for (const [ruleId, config] of Object.entries(options.ruleConfig)) {
|
||||
this.configureRule(ruleId, config);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ruleId of this.enabledRules) {
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (!rule) continue;
|
||||
|
||||
// Get files for this rule (optionally filtered)
|
||||
const files = this.getFilesForRule(project, rule, options?.files);
|
||||
|
||||
// Track all files analyzed
|
||||
for (const file of files) {
|
||||
allFilesAnalyzed.add(file.getFilePath());
|
||||
}
|
||||
|
||||
// Execute the rule
|
||||
const result = rule.execute(project, files);
|
||||
|
||||
// Apply fixes if in fix mode and rule supports it
|
||||
if (options?.fix && rule.fixable) {
|
||||
const fixableViolations = result.violations.filter((v) => v.fixable);
|
||||
if (fixableViolations.length > 0) {
|
||||
result.fixes = rule.fix(project, fixableViolations, options.write ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Build summary
|
||||
const summary = this.buildSummary(results, allFilesAnalyzed.size);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
rules: {
|
||||
enabled: this.getEnabledRules(),
|
||||
disabled: this.getDisabledRules(),
|
||||
},
|
||||
results,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a specific rule by ID
|
||||
*/
|
||||
runRule(
|
||||
project: Project,
|
||||
projectRoot: string,
|
||||
ruleId: string,
|
||||
options?: RunOptions,
|
||||
): JanitorReport | null {
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (!rule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apply rule configuration from options
|
||||
if (options?.ruleConfig?.[ruleId]) {
|
||||
rule.configure(options.ruleConfig[ruleId]);
|
||||
}
|
||||
|
||||
const files = this.getFilesForRule(project, rule, options?.files);
|
||||
const result = rule.execute(project, files);
|
||||
|
||||
// Apply fixes if in fix mode and rule supports it
|
||||
if (options?.fix && rule.fixable) {
|
||||
const fixableViolations = result.violations.filter((v) => v.fixable);
|
||||
if (fixableViolations.length > 0) {
|
||||
result.fixes = rule.fix(project, fixableViolations, options.write ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = this.buildSummary([result], files.length);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
rules: {
|
||||
enabled: [ruleId],
|
||||
disabled: this.getRegisteredRules().filter((id) => id !== ruleId),
|
||||
},
|
||||
results: [result],
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSummary(results: RuleResult[], filesAnalyzed: number): JanitorReport['summary'] {
|
||||
const byRule: Record<string, number> = {};
|
||||
const bySeverity: Record<Severity, number> = {
|
||||
error: 0,
|
||||
warning: 0,
|
||||
info: 0,
|
||||
};
|
||||
|
||||
let totalViolations = 0;
|
||||
|
||||
for (const result of results) {
|
||||
byRule[result.rule] = result.violations.length;
|
||||
totalViolations += result.violations.length;
|
||||
|
||||
for (const violation of result.violations) {
|
||||
bySeverity[violation.severity]++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalViolations,
|
||||
byRule,
|
||||
bySeverity,
|
||||
filesAnalyzed,
|
||||
};
|
||||
}
|
||||
}
|
||||
504
packages/testing/janitor/src/core/tcr-executor.test.ts
Normal file
504
packages/testing/janitor/src/core/tcr-executor.test.ts
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { saveBaseline, type BaselineFile } from './baseline.js';
|
||||
import { TcrExecutor } from './tcr-executor.js';
|
||||
import { setConfig, resetConfig, defineConfig } from '../config.js';
|
||||
|
||||
describe('TcrExecutor', () => {
|
||||
let tempDir: string;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
// Use realpathSync to avoid macOS /var -> /private/var symlink issues
|
||||
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tcr-test-')));
|
||||
|
||||
// Initialize git repo
|
||||
execSync('git init', { cwd: tempDir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Create minimal structure with placeholder files (git doesn't track empty dirs)
|
||||
fs.mkdirSync(path.join(tempDir, 'pages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', '.gitkeep'), '');
|
||||
fs.writeFileSync(path.join(tempDir, 'tests', '.gitkeep'), '');
|
||||
|
||||
// Create tsconfig.json
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'tsconfig.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
noEmit: true,
|
||||
},
|
||||
include: ['**/*.ts'],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create package.json with typecheck script
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'tcr-test',
|
||||
scripts: {
|
||||
typecheck: 'tsc --noEmit',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Set up config
|
||||
const config = defineConfig({
|
||||
rootDir: tempDir,
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts'],
|
||||
factories: ['factories/**/*.ts'],
|
||||
testData: ['workflows/**/*'],
|
||||
},
|
||||
excludeFromPages: ['BasePage.ts'],
|
||||
facade: {
|
||||
file: 'pages/AppPage.ts',
|
||||
className: 'AppPage',
|
||||
excludeTypes: ['Page'],
|
||||
},
|
||||
fixtureObjectName: 'app',
|
||||
});
|
||||
setConfig(config);
|
||||
|
||||
// Initial commit
|
||||
execSync('git add -A', { cwd: tempDir, stdio: 'pipe' });
|
||||
execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
describe('getChangedFiles', () => {
|
||||
it('detects new files', () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'NewPage.ts'), 'export class NewPage {}');
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result.changedFiles.length).toBeGreaterThan(0);
|
||||
expect(result.changedFiles.some((f) => f.includes('NewPage.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects modified files', () => {
|
||||
// Create and commit a file first
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'TestPage.ts'), 'export class TestPage {}');
|
||||
execSync('git add -A && git commit -m "Add TestPage"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Now modify it
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'TestPage.ts'),
|
||||
'export class TestPage { modified() {} }',
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result.changedFiles.some((f) => f.includes('TestPage.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array when no changes', () => {
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result.changedFiles).toHaveLength(0);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('detects new test files in new directories (unstaged)', () => {
|
||||
// Create a new directory with a test file (not staged)
|
||||
// This reproduces the bug where git status shows "?? new-dir/" instead of the file
|
||||
const newDir = path.join(tempDir, 'tests', 'e2e', 'new-feature');
|
||||
fs.mkdirSync(newDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(newDir, 'new-feature.spec.ts'),
|
||||
'import { test } from "@playwright/test"; test("new feature", () => {});',
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// Should detect the actual test file, not just the directory
|
||||
expect(result.changedFiles.some((f) => f.includes('new-feature.spec.ts'))).toBe(true);
|
||||
// Should include the test in affected tests (as a directly changed test file)
|
||||
// Note: affectedTests uses relative paths from root
|
||||
const hasTestInChanged = result.changedFiles.some((f) => f.endsWith('.spec.ts'));
|
||||
const hasTestInAffected = result.affectedTests.length > 0;
|
||||
expect(hasTestInChanged || hasTestInAffected).toBe(true);
|
||||
});
|
||||
|
||||
it('detects new test files in new directories (staged)', () => {
|
||||
// Create a new directory with a test file and stage it
|
||||
const newDir = path.join(tempDir, 'tests', 'e2e', 'staged-feature');
|
||||
fs.mkdirSync(newDir, { recursive: true });
|
||||
// Use valid TypeScript without external imports to pass typecheck
|
||||
fs.writeFileSync(
|
||||
path.join(newDir, 'staged-feature.spec.ts'),
|
||||
'export const test = { name: "staged feature test" };\n',
|
||||
);
|
||||
|
||||
// Stage the new file
|
||||
execSync('git add -A', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// Should detect the actual test file, not just the directory
|
||||
expect(result.changedFiles.some((f) => f.includes('staged-feature.spec.ts'))).toBe(true);
|
||||
// Should NOT include directory paths
|
||||
expect(result.changedFiles.some((f) => f.endsWith('staged-feature/'))).toBe(false);
|
||||
// Should include in affected tests
|
||||
expect(result.affectedTests.some((t) => t.includes('staged-feature.spec.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects deleted files', () => {
|
||||
// Create and commit a file first
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'ToDelete.ts'),
|
||||
'export class ToDelete { method() {} }',
|
||||
);
|
||||
execSync('git add -A && git commit -m "Add ToDelete"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Now delete it
|
||||
fs.unlinkSync(path.join(tempDir, 'pages', 'ToDelete.ts'));
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result.changedFiles.some((f) => f.includes('ToDelete.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects renamed files', () => {
|
||||
// Create and commit a file first
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'OldName.ts'),
|
||||
'export class OldName { method() {} }',
|
||||
);
|
||||
execSync('git add -A && git commit -m "Add OldName"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Rename it using git mv
|
||||
execSync('git mv pages/OldName.ts pages/NewName.ts', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// Should detect the new name
|
||||
expect(result.changedFiles.some((f) => f.includes('NewName.ts'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changedMethods', () => {
|
||||
it('detects added methods', () => {
|
||||
// Put file at root level (not in any rule pattern, but still analyzed for methods)
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'MethodClass.ts'),
|
||||
`export class MethodClass {
|
||||
existing() {}
|
||||
}`,
|
||||
);
|
||||
execSync('git add -A && git commit -m "Add MethodClass"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Add a new method
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'MethodClass.ts'),
|
||||
`export class MethodClass {
|
||||
existing() {}
|
||||
newMethod() {}
|
||||
}`,
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// changedMethods is now populated early in the flow, even if later checks fail
|
||||
expect(result.changedMethods.some((m) => m.methodName === 'newMethod')).toBe(true);
|
||||
expect(result.changedMethods.find((m) => m.methodName === 'newMethod')?.changeType).toBe(
|
||||
'added',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects removed methods', () => {
|
||||
// Put file at root level (not in any rule pattern, but still analyzed for methods)
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'RemoveClass.ts'),
|
||||
`export class RemoveClass {
|
||||
keepMe() {}
|
||||
removeMe() {}
|
||||
}`,
|
||||
);
|
||||
execSync('git add -A && git commit -m "Add RemoveClass"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Remove one method
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'RemoveClass.ts'),
|
||||
`export class RemoveClass {
|
||||
keepMe() {}
|
||||
}`,
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// changedMethods is now populated early in the flow, even if later checks fail
|
||||
expect(result.changedMethods.some((m) => m.methodName === 'removeMe')).toBe(true);
|
||||
expect(result.changedMethods.find((m) => m.methodName === 'removeMe')?.changeType).toBe(
|
||||
'removed',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects modified methods', () => {
|
||||
// Put file at root level (not in any rule pattern, but still analyzed for methods)
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'ModifyClass.ts'),
|
||||
`export class ModifyClass {
|
||||
myMethod() { return 1; }
|
||||
}`,
|
||||
);
|
||||
execSync('git add -A && git commit -m "Add ModifyClass"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Modify the method body
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'ModifyClass.ts'),
|
||||
`export class ModifyClass {
|
||||
myMethod() { return 2; }
|
||||
}`,
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// changedMethods is now populated early in the flow, even if later checks fail
|
||||
expect(result.changedMethods.some((m) => m.methodName === 'myMethod')).toBe(true);
|
||||
expect(result.changedMethods.find((m) => m.methodName === 'myMethod')?.changeType).toBe(
|
||||
'modified',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxDiffLines', () => {
|
||||
it('skips when diff exceeds max lines', () => {
|
||||
// Create a file and commit it first
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'LargePage.ts'), 'export class LargePage {}');
|
||||
execSync('git add -A && git commit -m "Add LargePage"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Now modify it with many lines - git diff counts changes to tracked files
|
||||
const largeContent = Array(100).fill('export const x = 1;').join('\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'LargePage.ts'), largeContent);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ maxDiffLines: 10, verbose: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStep).toBe('diff-too-large');
|
||||
expect(result.totalDiffLines).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('proceeds when diff is under max lines', () => {
|
||||
// Create a file and commit it first
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'SmallPage.ts'), 'export class SmallPage {}');
|
||||
execSync('git add -A && git commit -m "Add SmallPage"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Now make a small modification
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'SmallPage.ts'),
|
||||
'export class SmallPage { x = 1; }',
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ maxDiffLines: 100, verbose: false });
|
||||
|
||||
// May fail on rules but not on diff size
|
||||
expect(result.failedStep).not.toBe('diff-too-large');
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline protection', () => {
|
||||
it('blocks when baseline file is modified', () => {
|
||||
// Create and commit a baseline file first
|
||||
const baseline: BaselineFile = {
|
||||
version: 1,
|
||||
generated: new Date().toISOString(),
|
||||
totalViolations: 0,
|
||||
violations: {},
|
||||
};
|
||||
saveBaseline(baseline, tempDir);
|
||||
execSync('git add -A && git commit -m "Add baseline"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Now modify the baseline
|
||||
const modifiedBaseline: BaselineFile = {
|
||||
...baseline,
|
||||
totalViolations: 5,
|
||||
violations: { 'some/file.ts': [] },
|
||||
};
|
||||
saveBaseline(modifiedBaseline, tempDir);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStep).toBe('baseline-modified');
|
||||
expect(result.action).toBe('dry-run');
|
||||
});
|
||||
|
||||
it('allows changes when baseline is not modified', () => {
|
||||
// Create and commit a baseline file
|
||||
const baseline: BaselineFile = {
|
||||
version: 1,
|
||||
generated: new Date().toISOString(),
|
||||
totalViolations: 0,
|
||||
violations: {},
|
||||
};
|
||||
saveBaseline(baseline, tempDir);
|
||||
execSync('git add -A && git commit -m "Add baseline"', { cwd: tempDir, stdio: 'pipe' });
|
||||
|
||||
// Modify a different file (not baseline)
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'OtherPage.ts'), 'export class OtherPage {}');
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// Should not fail on baseline-modified
|
||||
expect(result.failedStep).not.toBe('baseline-modified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline filtering', () => {
|
||||
it('filters known violations from baseline', () => {
|
||||
// Create a file with a violation (importing another page)
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'pages', 'PageA.ts'),
|
||||
`import { PageB } from './PageB';
|
||||
export class PageA {}`,
|
||||
);
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'PageB.ts'), 'export class PageB {}');
|
||||
|
||||
// Create baseline with this violation
|
||||
const baseline: BaselineFile = {
|
||||
version: 1,
|
||||
generated: new Date().toISOString(),
|
||||
totalViolations: 1,
|
||||
violations: {
|
||||
'pages/PageA.ts': [
|
||||
{
|
||||
rule: 'boundary-protection',
|
||||
line: 1,
|
||||
message: "Page 'PageA' imports another page 'PageB'",
|
||||
hash: 'abc123',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
saveBaseline(baseline, tempDir);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
// The violation should be filtered by baseline
|
||||
// Note: This test verifies the baseline is loaded, actual filtering depends on hash matching
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
// Result depends on whether hash matches - mainly testing that baseline is loaded
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dry run vs execute', () => {
|
||||
it('does not commit in dry run mode', () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'DryRunPage.ts'), 'export class DryRunPage {}');
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ execute: false, verbose: false });
|
||||
|
||||
expect(result.action).toBe('dry-run');
|
||||
|
||||
// File should still be uncommitted
|
||||
const status = execSync('git status --porcelain', { cwd: tempDir, encoding: 'utf-8' });
|
||||
expect(status).toContain('DryRunPage.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('result structure', () => {
|
||||
it('returns complete result object', () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'pages', 'ResultPage.ts'), 'export class ResultPage {}');
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('changedFiles');
|
||||
expect(result).toHaveProperty('changedMethods');
|
||||
expect(result).toHaveProperty('affectedTests');
|
||||
expect(result).toHaveProperty('testsRun');
|
||||
expect(result).toHaveProperty('testsPassed');
|
||||
expect(result).toHaveProperty('ruleViolations');
|
||||
expect(result).toHaveProperty('typecheckPassed');
|
||||
expect(result).toHaveProperty('action');
|
||||
expect(result).toHaveProperty('durationMs');
|
||||
expect(typeof result.durationMs).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testCommand config', () => {
|
||||
it('uses testCommand from config when set', () => {
|
||||
// Update config with custom test command
|
||||
const config = defineConfig({
|
||||
rootDir: tempDir,
|
||||
tcr: {
|
||||
testCommand: 'echo "custom test command"',
|
||||
},
|
||||
});
|
||||
setConfig(config);
|
||||
|
||||
// Create a test file to trigger test execution
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'tests', 'example.spec.ts'),
|
||||
'import { test } from "@playwright/test"; test("example", () => {});',
|
||||
);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
// With no affected tests, the test command won't run
|
||||
// but we can verify config is read by checking the executor initializes
|
||||
const result = tcr.run({ verbose: false });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('CLI testCommand overrides config', () => {
|
||||
// Set config with one command
|
||||
const config = defineConfig({
|
||||
rootDir: tempDir,
|
||||
tcr: {
|
||||
testCommand: 'echo "config command"',
|
||||
},
|
||||
});
|
||||
setConfig(config);
|
||||
|
||||
const tcr = new TcrExecutor();
|
||||
// CLI option should take precedence (tested indirectly through options)
|
||||
const result = tcr.run({ testCommand: 'echo "cli command"', verbose: false });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
574
packages/testing/janitor/src/core/tcr-executor.ts
Normal file
574
packages/testing/janitor/src/core/tcr-executor.ts
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
/**
|
||||
* TCR Executor - Test && Commit || Revert workflow
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
import { Project } from 'ts-morph';
|
||||
|
||||
import { diffFileMethods, type MethodChange } from './ast-diff-analyzer.js';
|
||||
import { loadBaseline, filterNewViolations } from './baseline.js';
|
||||
import { MethodUsageAnalyzer, type MethodUsageIndex } from './method-usage-analyzer.js';
|
||||
import { createProject } from './project-loader.js';
|
||||
import { RuleRunner } from './rule-runner.js';
|
||||
import { ApiPurityRule } from '../rules/api-purity.rule.js';
|
||||
import { BoundaryProtectionRule } from '../rules/boundary-protection.rule.js';
|
||||
import { DeadCodeRule } from '../rules/dead-code.rule.js';
|
||||
import { DeduplicationRule } from '../rules/deduplication.rule.js';
|
||||
import { NoPageInFlowRule } from '../rules/no-page-in-flow.rule.js';
|
||||
import { ScopeLockdownRule } from '../rules/scope-lockdown.rule.js';
|
||||
import { SelectorPurityRule } from '../rules/selector-purity.rule.js';
|
||||
import { TestDataHygieneRule } from '../rules/test-data-hygiene.rule.js';
|
||||
import {
|
||||
getChangedFiles as gitGetChangedFiles,
|
||||
getTotalDiffLines as gitGetTotalDiffLines,
|
||||
commit as gitCommit,
|
||||
revert as gitRevert,
|
||||
} from '../utils/git-operations.js';
|
||||
import { createLogger, type Logger } from '../utils/logger.js';
|
||||
import { getRootDir } from '../utils/paths.js';
|
||||
import { buildTestCommand } from '../utils/test-command.js';
|
||||
|
||||
export interface TcrOptions {
|
||||
baseRef?: string;
|
||||
execute?: boolean;
|
||||
commitMessage?: string;
|
||||
verbose?: boolean;
|
||||
targetBranch?: string;
|
||||
maxDiffLines?: number;
|
||||
testCommand?: string;
|
||||
}
|
||||
|
||||
export interface TcrResult {
|
||||
success: boolean;
|
||||
failedStep?: 'rules' | 'typecheck' | 'tests' | 'diff-too-large' | 'baseline-modified';
|
||||
changedFiles: string[];
|
||||
changedMethods: MethodChange[];
|
||||
affectedTests: string[];
|
||||
testsRun: string[];
|
||||
testsPassed: boolean;
|
||||
ruleViolations: number;
|
||||
typecheckPassed: boolean;
|
||||
action: 'commit' | 'revert' | 'dry-run';
|
||||
durationMs: number;
|
||||
totalDiffLines?: number;
|
||||
testCommand?: string;
|
||||
}
|
||||
|
||||
export class TcrExecutor {
|
||||
private project: Project;
|
||||
private methodUsageIndex: MethodUsageIndex | null = null;
|
||||
private root: string;
|
||||
private logger: Logger = createLogger();
|
||||
|
||||
constructor() {
|
||||
this.root = getRootDir();
|
||||
this.project = new Project({
|
||||
tsConfigFilePath: path.join(this.root, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: false,
|
||||
});
|
||||
}
|
||||
|
||||
run(options: TcrOptions = {}): TcrResult {
|
||||
const startTime = performance.now();
|
||||
const {
|
||||
baseRef = 'HEAD',
|
||||
execute = false,
|
||||
verbose = false,
|
||||
targetBranch,
|
||||
maxDiffLines,
|
||||
testCommand,
|
||||
} = options;
|
||||
|
||||
this.logger = createLogger({ verbose });
|
||||
|
||||
const changedFiles = this.getChangedFiles(targetBranch);
|
||||
|
||||
// Validation: diff size
|
||||
const diffValidation = this.validateDiffSize(changedFiles, maxDiffLines, targetBranch);
|
||||
if (diffValidation) {
|
||||
return this.buildResult({ ...diffValidation, durationMs: performance.now() - startTime });
|
||||
}
|
||||
|
||||
// Validation: baseline not modified (prevents AI from "fixing" violations by updating baseline)
|
||||
const baselineValidation = this.validateBaselineNotModified(changedFiles);
|
||||
if (baselineValidation) {
|
||||
return this.buildResult({ ...baselineValidation, durationMs: performance.now() - startTime });
|
||||
}
|
||||
|
||||
// No changes
|
||||
if (changedFiles.length === 0) {
|
||||
this.logger.debug('No changes detected');
|
||||
return this.buildResult({
|
||||
success: true,
|
||||
action: 'dry-run',
|
||||
durationMs: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug(`Changed files: ${changedFiles.length}`);
|
||||
this.logger.debugList(changedFiles);
|
||||
|
||||
// Analyze changed methods early (useful for understanding the change even if later checks fail)
|
||||
const changedMethods = this.extractChangedMethods(changedFiles, baseRef);
|
||||
this.logChangedMethods(changedMethods);
|
||||
|
||||
// Validation: rules
|
||||
this.logger.debug('\nRunning janitor rules...');
|
||||
const ruleViolations = this.runRules(changedFiles);
|
||||
|
||||
if (ruleViolations > 0) {
|
||||
this.logger.debug(`\n\u2717 Found ${ruleViolations} rule violation(s)`);
|
||||
if (execute) this.doRevert();
|
||||
return this.buildResult({
|
||||
success: false,
|
||||
failedStep: 'rules',
|
||||
changedFiles,
|
||||
changedMethods,
|
||||
ruleViolations,
|
||||
action: execute ? 'revert' : 'dry-run',
|
||||
durationMs: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
this.logger.debug('\u2713 Rules passed');
|
||||
|
||||
// Validation: typecheck
|
||||
this.logger.debug('\nRunning typecheck...');
|
||||
const typecheckPassed = this.runTypecheck();
|
||||
|
||||
if (!typecheckPassed) {
|
||||
this.logger.debug('\u2717 Typecheck failed');
|
||||
if (execute) this.doRevert();
|
||||
return this.buildResult({
|
||||
success: false,
|
||||
failedStep: 'typecheck',
|
||||
changedFiles,
|
||||
changedMethods,
|
||||
ruleViolations,
|
||||
typecheckPassed: false,
|
||||
action: execute ? 'revert' : 'dry-run',
|
||||
durationMs: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
this.logger.debug('\u2713 Typecheck passed');
|
||||
|
||||
// Find affected tests
|
||||
const affectedTests = this.findAffectedTests(changedFiles, changedMethods);
|
||||
this.logger.debug(`\nAffected tests: ${affectedTests.length}`);
|
||||
this.logger.debugList(affectedTests);
|
||||
|
||||
// No tests affected
|
||||
if (affectedTests.length === 0) {
|
||||
this.logger.debug('\nNo tests affected by changes');
|
||||
if (execute) {
|
||||
this.doCommit(options.commitMessage ?? 'TCR: No tests affected');
|
||||
return this.buildResult({
|
||||
success: true,
|
||||
changedFiles,
|
||||
changedMethods,
|
||||
ruleViolations,
|
||||
typecheckPassed,
|
||||
action: 'commit',
|
||||
durationMs: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
return this.buildResult({
|
||||
success: true,
|
||||
changedFiles,
|
||||
changedMethods,
|
||||
ruleViolations,
|
||||
typecheckPassed,
|
||||
action: 'dry-run',
|
||||
durationMs: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Run tests
|
||||
const testsPassed = this.runTests(affectedTests, testCommand);
|
||||
|
||||
// Commit or revert based on test results
|
||||
const action = this.determineAction(execute, testsPassed, options.commitMessage);
|
||||
|
||||
return this.buildResult({
|
||||
success: testsPassed,
|
||||
failedStep: testsPassed ? undefined : 'tests',
|
||||
changedFiles,
|
||||
changedMethods,
|
||||
affectedTests,
|
||||
testsRun: affectedTests,
|
||||
testsPassed,
|
||||
ruleViolations,
|
||||
typecheckPassed,
|
||||
action,
|
||||
durationMs: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Result Builder ---
|
||||
|
||||
private buildResult(params: Partial<TcrResult>): TcrResult {
|
||||
return {
|
||||
success: params.success ?? false,
|
||||
failedStep: params.failedStep,
|
||||
changedFiles: params.changedFiles ?? [],
|
||||
changedMethods: params.changedMethods ?? [],
|
||||
affectedTests: params.affectedTests ?? [],
|
||||
testsRun: params.testsRun ?? [],
|
||||
testsPassed: params.testsPassed ?? false,
|
||||
ruleViolations: params.ruleViolations ?? 0,
|
||||
typecheckPassed: params.typecheckPassed ?? true,
|
||||
action: params.action ?? 'dry-run',
|
||||
durationMs: params.durationMs ?? 0,
|
||||
totalDiffLines: params.totalDiffLines,
|
||||
testCommand: params.testCommand,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Validation Methods ---
|
||||
|
||||
private validateDiffSize(
|
||||
changedFiles: string[],
|
||||
maxDiffLines: number | undefined,
|
||||
targetBranch: string | undefined,
|
||||
): Partial<TcrResult> | null {
|
||||
if (!maxDiffLines || changedFiles.length === 0) return null;
|
||||
|
||||
const totalDiffLines = gitGetTotalDiffLines(this.root, targetBranch);
|
||||
if (totalDiffLines > maxDiffLines) {
|
||||
this.logger.debug(
|
||||
`\n\u2717 Diff too large: ${totalDiffLines} lines exceeds max ${maxDiffLines}`,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
failedStep: 'diff-too-large',
|
||||
changedFiles,
|
||||
totalDiffLines,
|
||||
action: 'dry-run',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(`Diff size: ${totalDiffLines} lines (max: ${maxDiffLines})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
private validateBaselineNotModified(changedFiles: string[]): Partial<TcrResult> | null {
|
||||
// Check git status directly for baseline file (not relying on changedFiles which filters by .ts)
|
||||
try {
|
||||
const status = execSync('git status --porcelain', {
|
||||
cwd: this.root,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const hasBaselineChange = status
|
||||
.split('\n')
|
||||
.some((line) => line.includes('.janitor-baseline.json'));
|
||||
|
||||
if (!hasBaselineChange) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug('\n✗ Cannot commit baseline changes via TCR');
|
||||
this.logger.debug(' Baseline updates must be done manually:');
|
||||
this.logger.debug(' git checkout .janitor-baseline.json');
|
||||
this.logger.debug(' # Fix the actual violations, then:');
|
||||
this.logger.debug(' pnpm janitor baseline && git add .janitor-baseline.json');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
failedStep: 'baseline-modified',
|
||||
changedFiles,
|
||||
action: 'dry-run',
|
||||
};
|
||||
}
|
||||
|
||||
// --- Action Helpers ---
|
||||
|
||||
private determineAction(
|
||||
execute: boolean,
|
||||
testsPassed: boolean,
|
||||
commitMessage?: string,
|
||||
): 'commit' | 'revert' | 'dry-run' {
|
||||
if (!execute) return 'dry-run';
|
||||
|
||||
if (testsPassed) {
|
||||
this.doCommit(commitMessage ?? 'TCR: Tests passed');
|
||||
return 'commit';
|
||||
}
|
||||
|
||||
this.doRevert();
|
||||
return 'revert';
|
||||
}
|
||||
|
||||
private doCommit(message: string): void {
|
||||
if (gitCommit(message, this.root)) {
|
||||
this.logger.info(`\n\u2713 Committed: ${message}`);
|
||||
} else {
|
||||
this.logger.error('Failed to commit');
|
||||
}
|
||||
}
|
||||
|
||||
private doRevert(): void {
|
||||
if (gitRevert(this.root)) {
|
||||
this.logger.info('\n\u2717 Reverted changes');
|
||||
} else {
|
||||
this.logger.error('Failed to revert');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Method Analysis ---
|
||||
|
||||
private extractChangedMethods(changedFiles: string[], baseRef: string): MethodChange[] {
|
||||
const changedMethods: MethodChange[] = [];
|
||||
for (const file of changedFiles) {
|
||||
if (file.endsWith('.ts') && !file.endsWith('.spec.ts')) {
|
||||
const diff = diffFileMethods(file, baseRef);
|
||||
changedMethods.push(...diff.changedMethods);
|
||||
}
|
||||
}
|
||||
return changedMethods;
|
||||
}
|
||||
|
||||
private logChangedMethods(changedMethods: MethodChange[]): void {
|
||||
if (changedMethods.length === 0) return;
|
||||
this.logger.debug(`\nChanged methods: ${changedMethods.length}`);
|
||||
for (const change of changedMethods) {
|
||||
const symbol =
|
||||
change.changeType === 'added' ? '+' : change.changeType === 'removed' ? '-' : '~';
|
||||
this.logger.debug(` ${symbol} ${change.className}.${change.methodName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private runRules(changedFiles: string[]): number {
|
||||
const runner = this.createRuleRunner();
|
||||
const { project } = createProject(this.root);
|
||||
|
||||
const tsFiles = changedFiles
|
||||
.filter((f) => f.endsWith('.ts'))
|
||||
.map((f) => path.relative(this.root, f));
|
||||
|
||||
if (tsFiles.length === 0) return 0;
|
||||
|
||||
const report = runner.run(project, this.root, { files: tsFiles });
|
||||
return this.countNewViolations(report);
|
||||
}
|
||||
|
||||
private createRuleRunner(): RuleRunner {
|
||||
const runner = new RuleRunner();
|
||||
runner.registerRule(new BoundaryProtectionRule());
|
||||
runner.registerRule(new ScopeLockdownRule());
|
||||
runner.registerRule(new SelectorPurityRule());
|
||||
runner.registerRule(new NoPageInFlowRule());
|
||||
runner.registerRule(new ApiPurityRule());
|
||||
runner.registerRule(new DeadCodeRule());
|
||||
runner.registerRule(new DeduplicationRule());
|
||||
runner.registerRule(new TestDataHygieneRule());
|
||||
return runner;
|
||||
}
|
||||
|
||||
private countNewViolations(report: ReturnType<RuleRunner['run']>): number {
|
||||
const baseline = loadBaseline(this.root);
|
||||
|
||||
if (!baseline) {
|
||||
this.logViolationsWithoutBaseline(report);
|
||||
return report.summary.totalViolations;
|
||||
}
|
||||
|
||||
return this.countFilteredViolations(report, baseline);
|
||||
}
|
||||
|
||||
private logViolationsWithoutBaseline(report: ReturnType<RuleRunner['run']>): void {
|
||||
if (report.summary.totalViolations === 0) return;
|
||||
|
||||
this.logger.debug('\nViolations in changed files:');
|
||||
for (const result of report.results) {
|
||||
if (result.violations.length > 0) {
|
||||
this.logger.debug(` ${result.rule}: ${result.violations.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private countFilteredViolations(
|
||||
report: ReturnType<RuleRunner['run']>,
|
||||
baseline: NonNullable<ReturnType<typeof loadBaseline>>,
|
||||
): number {
|
||||
let filteredCount = 0;
|
||||
|
||||
for (const result of report.results) {
|
||||
const newViolations = filterNewViolations(result.violations, baseline, this.root);
|
||||
filteredCount += newViolations.length;
|
||||
|
||||
if (newViolations.length > 0) {
|
||||
this.logger.debug(
|
||||
`\n${result.rule}: ${newViolations.length} new (${result.violations.length} total)`,
|
||||
);
|
||||
for (const v of newViolations) {
|
||||
this.logger.debug(` ${path.relative(this.root, v.file)}:${v.line} - ${v.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baselinedCount = report.summary.totalViolations - filteredCount;
|
||||
this.logger.debug(`\nBaseline: ${baselinedCount} known violations filtered`);
|
||||
|
||||
return filteredCount;
|
||||
}
|
||||
|
||||
private runTypecheck(): boolean {
|
||||
try {
|
||||
const stdio = this.logger.isVerbose() ? 'inherit' : 'pipe';
|
||||
execSync('pnpm typecheck', { cwd: this.root, stdio });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getChangedFiles(targetBranch?: string): string[] {
|
||||
return gitGetChangedFiles({ targetBranch, scopeDir: this.root, extensions: ['.ts'] });
|
||||
}
|
||||
|
||||
private findAffectedTests(changedFiles: string[], changedMethods: MethodChange[]): string[] {
|
||||
const affectedTests = new Set<string>();
|
||||
|
||||
const changedTestFiles = this.getChangedTestFiles(changedFiles);
|
||||
const methodIndex = this.getMethodUsageIndex();
|
||||
|
||||
// Find tests using modified/removed methods
|
||||
this.addTestsUsingMethods(
|
||||
changedMethods.filter((m) => m.changeType !== 'added'),
|
||||
methodIndex,
|
||||
affectedTests,
|
||||
);
|
||||
|
||||
// Find tests using newly added methods
|
||||
this.addTestsUsingNewMethods(
|
||||
changedMethods.filter((m) => m.changeType === 'added'),
|
||||
changedTestFiles,
|
||||
affectedTests,
|
||||
);
|
||||
|
||||
// Include directly changed test files
|
||||
for (const testFile of changedTestFiles) {
|
||||
affectedTests.add(testFile);
|
||||
}
|
||||
|
||||
return Array.from(affectedTests).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
private getChangedTestFiles(changedFiles: string[]): string[] {
|
||||
return changedFiles
|
||||
.filter((f) => f.endsWith('.spec.ts'))
|
||||
.map((f) => path.relative(this.root, f));
|
||||
}
|
||||
|
||||
private getMethodUsageIndex(): MethodUsageIndex {
|
||||
if (!this.methodUsageIndex) {
|
||||
const analyzer = new MethodUsageAnalyzer(this.project);
|
||||
this.methodUsageIndex = analyzer.buildIndex();
|
||||
}
|
||||
return this.methodUsageIndex;
|
||||
}
|
||||
|
||||
private addTestsUsingMethods(
|
||||
methods: MethodChange[],
|
||||
methodIndex: MethodUsageIndex,
|
||||
affectedTests: Set<string>,
|
||||
): void {
|
||||
for (const change of methods) {
|
||||
const key = `${change.className}.${change.methodName}`;
|
||||
const usages = methodIndex.methods[key] ?? [];
|
||||
for (const usage of usages) {
|
||||
affectedTests.add(usage.testFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addTestsUsingNewMethods(
|
||||
addedMethods: MethodChange[],
|
||||
changedTestFiles: string[],
|
||||
affectedTests: Set<string>,
|
||||
): void {
|
||||
if (addedMethods.length === 0 || changedTestFiles.length === 0) return;
|
||||
|
||||
for (const testFile of changedTestFiles) {
|
||||
const fullPath = path.join(this.root, testFile);
|
||||
const sourceFile = this.project.getSourceFile(fullPath);
|
||||
if (!sourceFile) continue;
|
||||
|
||||
const content = sourceFile.getFullText();
|
||||
for (const method of addedMethods) {
|
||||
const methodPattern = new RegExp(`\\.${method.methodName}\\s*\\(`);
|
||||
if (methodPattern.test(content)) {
|
||||
affectedTests.add(testFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runTests(testFiles: string[], testCommand?: string): boolean {
|
||||
this.logger.debug(`\nRunning ${testFiles.length} test file(s)...`);
|
||||
|
||||
try {
|
||||
const cmd = buildTestCommand(testFiles, testCommand);
|
||||
this.logger.debug(`Command: ${cmd}`);
|
||||
|
||||
const stdio = this.logger.isVerbose() ? 'inherit' : 'pipe';
|
||||
execSync(cmd, { cwd: this.root, stdio });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTcrResultConsole(result: TcrResult, verbose = false): void {
|
||||
console.log('\n====================================');
|
||||
console.log(' TCR RESULT');
|
||||
console.log('====================================\n');
|
||||
|
||||
console.log(`Duration: ${(result.durationMs / 1000).toFixed(2)}s`);
|
||||
console.log(`Action taken: ${result.action}`);
|
||||
console.log(`Overall: ${result.success ? '✓ Success' : '✗ Failed'}`);
|
||||
|
||||
if (result.failedStep) console.log(`Failed at: ${result.failedStep}`);
|
||||
|
||||
if (result.changedFiles.length > 0) {
|
||||
console.log(`\nChanged files (${result.changedFiles.length}):`);
|
||||
result.changedFiles.forEach((f) => console.log(` - ${f}`));
|
||||
}
|
||||
|
||||
console.log('\nChecks:');
|
||||
if (result.totalDiffLines !== undefined) {
|
||||
const diffStatus = result.failedStep === 'diff-too-large' ? '✗ Too large' : '✓ OK';
|
||||
console.log(` Diff size: ${diffStatus} (${result.totalDiffLines} lines)`);
|
||||
}
|
||||
if (result.failedStep === 'baseline-modified') {
|
||||
console.log(' Baseline: ✗ Modified (baseline updates must be done manually)');
|
||||
}
|
||||
console.log(
|
||||
` Rules: ${result.ruleViolations === 0 ? '✓ Passed' : `✗ ${result.ruleViolations} violation(s)`}`,
|
||||
);
|
||||
console.log(` Typecheck: ${result.typecheckPassed ? '✓ Passed' : '✗ Failed'}`);
|
||||
console.log(` Tests: ${result.testsPassed ? '✓ Passed' : '✗ Failed'}`);
|
||||
|
||||
if (verbose && result.changedMethods.length > 0) {
|
||||
console.log(`\nChanged methods (${result.changedMethods.length}):`);
|
||||
for (const change of result.changedMethods) {
|
||||
const symbol =
|
||||
change.changeType === 'added' ? '+' : change.changeType === 'removed' ? '-' : '~';
|
||||
console.log(` ${symbol} ${change.className}.${change.methodName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.affectedTests.length > 0) {
|
||||
console.log(`\nAffected tests (${result.affectedTests.length}):`);
|
||||
result.affectedTests.forEach((t) => console.log(` - ${t}`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function formatTcrResultJSON(result: TcrResult): string {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
230
packages/testing/janitor/src/index.ts
Normal file
230
packages/testing/janitor/src/index.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* @n8n/playwright-janitor
|
||||
*
|
||||
* Static analysis and architecture enforcement for Playwright test suites.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { defineConfig, runAnalysis, createDefaultRunner } from '@n8n/playwright-janitor';
|
||||
*
|
||||
* const config = defineConfig({
|
||||
* rootDir: __dirname,
|
||||
* fixtureObjectName: 'app',
|
||||
* facade: {
|
||||
* file: 'pages/AppPage.ts',
|
||||
* className: 'AppPage',
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* const report = runAnalysis(config);
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
defineConfig,
|
||||
getConfig,
|
||||
setConfig,
|
||||
hasConfig,
|
||||
resetConfig,
|
||||
defaultConfig,
|
||||
type JanitorConfig,
|
||||
type DefineConfigInput,
|
||||
} from './config.js';
|
||||
|
||||
export type {
|
||||
Severity,
|
||||
BuiltInRuleId,
|
||||
RuleId,
|
||||
RuleSettings,
|
||||
RuleSettingsMap,
|
||||
RuleConfig,
|
||||
RunOptions,
|
||||
Violation,
|
||||
FixData,
|
||||
MethodFixData,
|
||||
PropertyFixData,
|
||||
ClassFixData,
|
||||
EditFixData,
|
||||
FixAction,
|
||||
FixResult,
|
||||
RuleResult,
|
||||
ReportSummary,
|
||||
JanitorReport,
|
||||
Rule,
|
||||
RuleInfo,
|
||||
FilePatterns,
|
||||
FacadeConfig,
|
||||
} from './types.js';
|
||||
|
||||
export { isMethodFix, isPropertyFix, isClassFix, isEditFix } from './types.js';
|
||||
|
||||
export type {
|
||||
Project,
|
||||
SourceFile,
|
||||
CallExpression,
|
||||
ClassDeclaration,
|
||||
MethodDeclaration,
|
||||
PropertyDeclaration,
|
||||
Node,
|
||||
} from './types.js';
|
||||
|
||||
export { SyntaxKind } from './types.js';
|
||||
|
||||
export { RuleRunner } from './core/rule-runner.js';
|
||||
export { FacadeResolver, extractTypeName, type FacadeMapping } from './core/facade-resolver.js';
|
||||
export {
|
||||
createProject,
|
||||
createInMemoryProject,
|
||||
getSourceFiles,
|
||||
getRelativePath,
|
||||
type ProjectContext,
|
||||
} from './core/project-loader.js';
|
||||
export { toJSON, toConsole, printFixResults } from './core/reporter.js';
|
||||
|
||||
export {
|
||||
BaseRule,
|
||||
BoundaryProtectionRule,
|
||||
ScopeLockdownRule,
|
||||
SelectorPurityRule,
|
||||
DeadCodeRule,
|
||||
ApiPurityRule,
|
||||
NoPageInFlowRule,
|
||||
DeduplicationRule,
|
||||
TestDataHygieneRule,
|
||||
DuplicateLogicRule,
|
||||
NoDirectPageInstantiationRule,
|
||||
} from './rules/index.js';
|
||||
|
||||
export {
|
||||
LOCATOR_METHODS,
|
||||
PAGE_LEVEL_METHODS,
|
||||
hasContainerMember,
|
||||
getCallExpressions,
|
||||
isLocatorCall,
|
||||
isUnscopedPageCall,
|
||||
isPageLevelMethod,
|
||||
getMethodName,
|
||||
getImportPaths,
|
||||
isPageImport,
|
||||
getTestIdArgument,
|
||||
truncateText,
|
||||
} from './utils/ast-helpers.js';
|
||||
|
||||
export {
|
||||
getRootDir,
|
||||
resolvePath,
|
||||
getRelativePath as getRelativePathFromRoot,
|
||||
findFilesRecursive,
|
||||
matchesPatterns,
|
||||
isExcludedPage,
|
||||
isPageFile,
|
||||
isComponentFile,
|
||||
isFlowFile,
|
||||
isTestFile,
|
||||
} from './utils/paths.js';
|
||||
|
||||
export {
|
||||
TcrExecutor,
|
||||
formatTcrResultConsole,
|
||||
formatTcrResultJSON,
|
||||
type TcrOptions,
|
||||
type TcrResult,
|
||||
} from './core/tcr-executor.js';
|
||||
|
||||
export {
|
||||
diffFileMethods,
|
||||
formatDiffConsole,
|
||||
formatDiffJSON,
|
||||
type MethodChange,
|
||||
type FileDiffResult,
|
||||
} from './core/ast-diff-analyzer.js';
|
||||
|
||||
export {
|
||||
MethodUsageAnalyzer,
|
||||
formatMethodImpactConsole,
|
||||
formatMethodImpactJSON,
|
||||
formatMethodImpactTestList,
|
||||
formatMethodUsageIndexConsole,
|
||||
formatMethodUsageIndexJSON,
|
||||
type MethodUsage,
|
||||
type MethodUsageIndex,
|
||||
type MethodImpactResult,
|
||||
} from './core/method-usage-analyzer.js';
|
||||
|
||||
export {
|
||||
ImpactAnalyzer,
|
||||
formatImpactConsole,
|
||||
formatImpactJSON,
|
||||
formatTestList,
|
||||
type ImpactResult,
|
||||
} from './core/impact-analyzer.js';
|
||||
|
||||
export {
|
||||
InventoryAnalyzer,
|
||||
formatInventoryJSON,
|
||||
type MethodInfo,
|
||||
type ParameterInfo,
|
||||
type PropertyInfo,
|
||||
type PageInfo,
|
||||
type ComponentInfo,
|
||||
type ComposableInfo,
|
||||
type ServiceInfo,
|
||||
type ServiceMethodInfo,
|
||||
type FixtureInfo,
|
||||
type HelperInfo,
|
||||
type FactoryInfo,
|
||||
type TestDataInfo,
|
||||
type FacadeInfo,
|
||||
type FacadePropertyInfo,
|
||||
type InventoryReport,
|
||||
} from './core/inventory-analyzer.js';
|
||||
|
||||
export {
|
||||
generateBaseline,
|
||||
saveBaseline,
|
||||
loadBaseline,
|
||||
hasBaseline,
|
||||
filterNewViolations,
|
||||
filterReportByBaseline,
|
||||
formatBaselineInfo,
|
||||
getBaselinePath,
|
||||
type BaselineEntry,
|
||||
type BaselineFile,
|
||||
} from './core/baseline.js';
|
||||
|
||||
import { setConfig, type JanitorConfig } from './config.js';
|
||||
import { createProject } from './core/project-loader.js';
|
||||
import { RuleRunner } from './core/rule-runner.js';
|
||||
import { ApiPurityRule } from './rules/api-purity.rule.js';
|
||||
import { BoundaryProtectionRule } from './rules/boundary-protection.rule.js';
|
||||
import { DeadCodeRule } from './rules/dead-code.rule.js';
|
||||
import { DeduplicationRule } from './rules/deduplication.rule.js';
|
||||
import { DuplicateLogicRule } from './rules/duplicate-logic.rule.js';
|
||||
import { NoDirectPageInstantiationRule } from './rules/no-direct-page-instantiation.rule.js';
|
||||
import { NoPageInFlowRule } from './rules/no-page-in-flow.rule.js';
|
||||
import { ScopeLockdownRule } from './rules/scope-lockdown.rule.js';
|
||||
import { SelectorPurityRule } from './rules/selector-purity.rule.js';
|
||||
import { TestDataHygieneRule } from './rules/test-data-hygiene.rule.js';
|
||||
import type { JanitorReport, RunOptions } from './types.js';
|
||||
|
||||
export function createDefaultRunner(): RuleRunner {
|
||||
const runner = new RuleRunner();
|
||||
runner.registerRule(new BoundaryProtectionRule());
|
||||
runner.registerRule(new ScopeLockdownRule());
|
||||
runner.registerRule(new SelectorPurityRule());
|
||||
runner.registerRule(new NoPageInFlowRule());
|
||||
runner.registerRule(new ApiPurityRule());
|
||||
runner.registerRule(new DeadCodeRule());
|
||||
runner.registerRule(new DeduplicationRule());
|
||||
runner.registerRule(new TestDataHygieneRule());
|
||||
runner.registerRule(new DuplicateLogicRule());
|
||||
runner.registerRule(new NoDirectPageInstantiationRule());
|
||||
return runner;
|
||||
}
|
||||
|
||||
export function runAnalysis(config: JanitorConfig, options?: RunOptions): JanitorReport {
|
||||
setConfig(config);
|
||||
const { project, root } = createProject(config.rootDir);
|
||||
const runner = createDefaultRunner();
|
||||
return runner.run(project, root, options);
|
||||
}
|
||||
135
packages/testing/janitor/src/rules/api-purity.rule.test.ts
Normal file
135
packages/testing/janitor/src/rules/api-purity.rule.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { ApiPurityRule } from './api-purity.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('ApiPurityRule', () => {
|
||||
const rule = new ApiPurityRule();
|
||||
|
||||
test('allows API service usage', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('creates workflow', async ({ n8n, api }) => {
|
||||
await api.workflows.create({ name: 'Test' });
|
||||
await api.credentials.list();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects request.get() calls', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('gets workflow', async ({ request }) => {
|
||||
const response = await request.get('/api/workflows');
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('request.get');
|
||||
});
|
||||
|
||||
test('detects request.post() calls', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('creates workflow', async ({ request }) => {
|
||||
await request.post('/api/workflows', { data: { name: 'Test' } });
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].suggestion).toContain('api');
|
||||
});
|
||||
|
||||
test('detects fetch() calls', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('fetches data', async () => {
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('fetch');
|
||||
});
|
||||
|
||||
test('detects multiple raw API calls', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('multiple API calls', async ({ request }) => {
|
||||
await request.get('/api/workflows');
|
||||
await request.post('/api/workflows', { data: {} });
|
||||
await request.delete('/api/workflows/1');
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('detects raw API calls in composables', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
async createAndRun(request: any) {
|
||||
await request.post('/api/workflows', { data: {} });
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reports correct line numbers', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`import { test } from '../fixtures/base';
|
||||
|
||||
test('test', async ({ request }) => {
|
||||
// line 4
|
||||
// line 5
|
||||
await request.get('/api/test'); // line 6
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].line).toBe(6);
|
||||
});
|
||||
});
|
||||
119
packages/testing/janitor/src/rules/api-purity.rule.ts
Normal file
119
packages/testing/janitor/src/rules/api-purity.rule.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { truncateText } from '../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* API Purity Rule
|
||||
*
|
||||
* Tests and composables should use API service helpers, not raw HTTP calls.
|
||||
* This enforces the two-layer API architecture:
|
||||
*
|
||||
* Tests/Composables → API Services
|
||||
*
|
||||
* Why this matters:
|
||||
* - Builds a reusable, typed API interface over time
|
||||
* - New devs can discover available endpoints via --list-services
|
||||
* - Consistent error handling and response parsing
|
||||
* - Easy to mock in tests
|
||||
*
|
||||
* Violations:
|
||||
* - request.get(), request.post(), etc. in tests/composables
|
||||
* - fetch() calls in tests/composables
|
||||
* - Direct HTTP method calls that bypass services
|
||||
*
|
||||
* Exceptions:
|
||||
* - API service files themselves (services/**) can make raw calls
|
||||
* - Fixture setup files can make raw calls
|
||||
*/
|
||||
export class ApiPurityRule extends BaseRule {
|
||||
readonly id = 'api-purity';
|
||||
readonly name = 'API Purity';
|
||||
readonly description = 'Tests and composables should use API services, not raw HTTP calls';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return [...config.patterns.tests, ...config.patterns.flows];
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const config = getConfig();
|
||||
|
||||
for (const file of files) {
|
||||
const content = file.getText();
|
||||
|
||||
// Check each raw API pattern
|
||||
for (const pattern of config.rawApiPatterns) {
|
||||
// Reset regex state for global patterns
|
||||
const regex = new RegExp(
|
||||
pattern.source,
|
||||
pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g',
|
||||
);
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
// Get some context around the match for allowPattern checking
|
||||
const contextStart = Math.max(0, match.index - 50);
|
||||
const contextEnd = Math.min(content.length, match.index + match[0].length + 50);
|
||||
const context = content.substring(contextStart, contextEnd);
|
||||
|
||||
// Check if this matches any allow patterns
|
||||
if (this.isAllowed(context)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the line number for this match
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const line = beforeMatch.split('\n').length;
|
||||
const lastNewline = beforeMatch.lastIndexOf('\n');
|
||||
const column = match.index - lastNewline;
|
||||
|
||||
// Get context around the match for the message
|
||||
const matchText = match[0];
|
||||
const suggestion = this.getSuggestion(matchText);
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
line,
|
||||
column,
|
||||
`Raw API call detected: ${truncateText(matchText, 40)}`,
|
||||
suggestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate suggestion based on the API call pattern
|
||||
*/
|
||||
private getSuggestion(matchText: string): string {
|
||||
const config = getConfig();
|
||||
const apiName = config.apiFixtureName;
|
||||
|
||||
if (matchText.includes('request.get')) {
|
||||
return `Use an API service method: this.${apiName}.workflows.get(), this.${apiName}.credentials.list(), etc.`;
|
||||
}
|
||||
if (matchText.includes('request.post')) {
|
||||
return `Use an API service method: this.${apiName}.workflows.create(), this.${apiName}.credentials.create(), etc.`;
|
||||
}
|
||||
if (matchText.includes('request.put') || matchText.includes('request.patch')) {
|
||||
return `Use an API service method: this.${apiName}.workflows.update(), etc.`;
|
||||
}
|
||||
if (matchText.includes('request.delete')) {
|
||||
return `Use an API service method: this.${apiName}.workflows.delete(), etc.`;
|
||||
}
|
||||
if (matchText.includes('fetch')) {
|
||||
return 'Use an API service instead of fetch(). Add the endpoint to the appropriate service class.';
|
||||
}
|
||||
return `Add this endpoint to an API service class and use it via this.${apiName}.*`;
|
||||
}
|
||||
}
|
||||
127
packages/testing/janitor/src/rules/base-rule.ts
Normal file
127
packages/testing/janitor/src/rules/base-rule.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Severity, Violation, RuleResult, RuleConfig, FixResult, FixData } from '../types.js';
|
||||
|
||||
export abstract class BaseRule {
|
||||
abstract readonly id: string;
|
||||
abstract readonly name: string;
|
||||
abstract readonly description: string;
|
||||
abstract readonly severity: Severity;
|
||||
|
||||
/** Whether this rule supports auto-fixing */
|
||||
readonly fixable: boolean = false;
|
||||
|
||||
/** Rule-specific configuration */
|
||||
protected config: RuleConfig = {};
|
||||
|
||||
/**
|
||||
* Get the effective severity for this rule (may be overridden in config)
|
||||
*/
|
||||
getEffectiveSeverity(): Severity {
|
||||
const ruleConfig = getConfig().rules?.[this.id];
|
||||
if (ruleConfig?.severity === 'off') {
|
||||
return 'info'; // Will be filtered out
|
||||
}
|
||||
if (ruleConfig?.severity) {
|
||||
return ruleConfig.severity;
|
||||
}
|
||||
return this.severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this rule is enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
const ruleConfig = getConfig().rules?.[this.id];
|
||||
if (ruleConfig?.enabled === false) return false;
|
||||
if (ruleConfig?.severity === 'off') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text matches any of the allow patterns for this rule
|
||||
*/
|
||||
protected isAllowed(text: string): boolean {
|
||||
const ruleConfig = getConfig().rules?.[this.id];
|
||||
const allowPatterns = ruleConfig?.allowPatterns ?? [];
|
||||
return allowPatterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define which file patterns this rule should analyze
|
||||
* @returns Array of glob patterns relative to root
|
||||
*/
|
||||
abstract getTargetGlobs(): string[];
|
||||
|
||||
/**
|
||||
* Analyze files and return violations
|
||||
* @param project - ts-morph Project instance
|
||||
* @param files - Source files matching the target globs
|
||||
* @returns Array of violations found
|
||||
*/
|
||||
abstract analyze(project: Project, files: SourceFile[]): Violation[];
|
||||
|
||||
/**
|
||||
* Apply fixes for violations. Override in fixable rules.
|
||||
* @param project - ts-morph Project instance
|
||||
* @param violations - Violations to fix
|
||||
* @param write - Whether to write changes to disk
|
||||
* @returns Array of fix results
|
||||
*/
|
||||
fix(_project: Project, _violations: Violation[], _write: boolean): FixResult[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the rule with options
|
||||
*/
|
||||
configure(config: RuleConfig): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the rule with timing instrumentation
|
||||
* @param project - ts-morph Project instance
|
||||
* @param files - Source files matching the target globs
|
||||
* @returns Rule result with violations and metadata
|
||||
*/
|
||||
execute(project: Project, files: SourceFile[]): RuleResult {
|
||||
const startTime = performance.now();
|
||||
const violations = this.analyze(project, files);
|
||||
const endTime = performance.now();
|
||||
|
||||
return {
|
||||
rule: this.id,
|
||||
violations,
|
||||
filesAnalyzed: files.length,
|
||||
executionTimeMs: Math.round((endTime - startTime) * 100) / 100,
|
||||
fixable: this.fixable,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a violation with consistent structure
|
||||
*/
|
||||
protected createViolation(
|
||||
file: SourceFile,
|
||||
line: number,
|
||||
column: number,
|
||||
message: string,
|
||||
suggestion?: string,
|
||||
fixable?: boolean,
|
||||
fixData?: FixData,
|
||||
): Violation {
|
||||
return {
|
||||
file: file.getFilePath(),
|
||||
line,
|
||||
column,
|
||||
rule: this.id,
|
||||
message,
|
||||
severity: this.getEffectiveSeverity(),
|
||||
suggestion,
|
||||
fixable,
|
||||
fixData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { BoundaryProtectionRule } from './boundary-protection.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('BoundaryProtectionRule', () => {
|
||||
const rule = new BoundaryProtectionRule();
|
||||
|
||||
test('should allow imports from components', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/CanvasPage.ts',
|
||||
`
|
||||
import { NodePanel } from './components/NodePanel';
|
||||
|
||||
export class CanvasPage {
|
||||
readonly nodePanel = new NodePanel();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should flag direct page imports', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/CanvasPage.ts',
|
||||
`
|
||||
import { WorkflowPage } from './WorkflowPage';
|
||||
|
||||
export class CanvasPage {
|
||||
readonly workflows = new WorkflowPage();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Also create the imported file so ts-morph can resolve it
|
||||
createFile(
|
||||
'/pages/WorkflowPage.ts',
|
||||
`
|
||||
export class WorkflowPage {}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('imports another page');
|
||||
expect(violations[0].rule).toBe('boundary-protection');
|
||||
});
|
||||
|
||||
test('should allow imports from base classes', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/CanvasPage.ts',
|
||||
`
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class CanvasPage extends BasePage {
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should skip excluded files', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/BasePage.ts',
|
||||
`
|
||||
import { SomePage } from './SomePage';
|
||||
|
||||
export class BasePage {
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should allow external package imports', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/CanvasPage.ts',
|
||||
`
|
||||
import { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export class CanvasPage {
|
||||
constructor(private page: Page) {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { getImportPaths, isPageImport } from '../utils/ast-helpers.js';
|
||||
import { isExcludedPage } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Boundary Protection Rule
|
||||
*
|
||||
* Pages should not import other pages directly.
|
||||
* This prevents tight coupling between page objects.
|
||||
*
|
||||
* Allowed:
|
||||
* - Imports from components directory
|
||||
* - Imports from base classes (BasePage, BaseModal, etc.)
|
||||
* - Imports from external packages
|
||||
*
|
||||
* Violations:
|
||||
* - import { CanvasPage } from './CanvasPage' (in a page file)
|
||||
* - import { WorkflowPage } from '../WorkflowPage'
|
||||
*/
|
||||
export class BoundaryProtectionRule extends BaseRule {
|
||||
readonly id = 'boundary-protection';
|
||||
readonly name = 'Boundary Protection';
|
||||
readonly description = 'Pages should not import other pages directly';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
// Only analyze page files (excluding components)
|
||||
return config.patterns.pages.filter((p) => !p.includes('components'));
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = file.getFilePath();
|
||||
|
||||
// Skip excluded files (facades, base classes)
|
||||
if (isExcludedPage(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if file is in components directory
|
||||
if (filePath.includes('/components/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importPaths = getImportPaths(file);
|
||||
|
||||
for (const importPath of importPaths) {
|
||||
if (isPageImport(importPath, filePath)) {
|
||||
const importDecl = file
|
||||
.getImportDeclarations()
|
||||
.find((d) => d.getModuleSpecifierValue() === importPath);
|
||||
|
||||
if (importDecl) {
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
importDecl.getStartLineNumber(),
|
||||
0,
|
||||
`Page imports another page: ${importPath}`,
|
||||
'Use composition through composables or inject via constructor instead',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
189
packages/testing/janitor/src/rules/dead-code.rule.test.ts
Normal file
189
packages/testing/janitor/src/rules/dead-code.rule.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { DeadCodeRule } from './dead-code.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
import { isMethodFix } from '../types.js';
|
||||
|
||||
describe('DeadCodeRule', () => {
|
||||
const rule = new DeadCodeRule();
|
||||
|
||||
test('allows methods with external references', ({ project, createFile }) => {
|
||||
createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
async clickButton() {
|
||||
// implementation
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
createFile(
|
||||
'/tests/test.spec.ts',
|
||||
`
|
||||
import { TestPage } from '../pages/TestPage';
|
||||
|
||||
const page = new TestPage();
|
||||
page.clickButton();
|
||||
`,
|
||||
);
|
||||
|
||||
const pageFile = project.getSourceFile('/pages/TestPage.ts')!;
|
||||
const violations = rule.analyze(project, [pageFile]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects unused method', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
async usedMethod() {}
|
||||
async unusedMethod() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
createFile(
|
||||
'/tests/test.spec.ts',
|
||||
`
|
||||
import { TestPage } from '../pages/TestPage';
|
||||
const page = new TestPage();
|
||||
page.usedMethod();
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('unusedMethod');
|
||||
expect(violations[0].fixable).toBe(true);
|
||||
});
|
||||
|
||||
test('detects unused property', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
usedProp = 'used';
|
||||
unusedProp = 'unused';
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
createFile(
|
||||
'/tests/test.spec.ts',
|
||||
`
|
||||
import { TestPage } from '../pages/TestPage';
|
||||
const page = new TestPage();
|
||||
console.log(page.usedProp);
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('unusedProp');
|
||||
});
|
||||
|
||||
test('detects dead class with no references', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/DeadPage.ts',
|
||||
`
|
||||
export class DeadPage {
|
||||
async doSomething() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('Dead class');
|
||||
expect(violations[0].message).toContain('DeadPage');
|
||||
});
|
||||
|
||||
test('skips private methods', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
async publicMethod() {
|
||||
this.privateHelper();
|
||||
}
|
||||
private privateHelper() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
createFile(
|
||||
'/tests/test.spec.ts',
|
||||
`
|
||||
import { TestPage } from '../pages/TestPage';
|
||||
const page = new TestPage();
|
||||
page.publicMethod();
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips protected properties', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
protected container = 'div';
|
||||
async publicMethod() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
createFile(
|
||||
'/tests/test.spec.ts',
|
||||
`
|
||||
import { TestPage } from '../pages/TestPage';
|
||||
const page = new TestPage();
|
||||
page.publicMethod();
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('provides correct fix data', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
async unusedMethod() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
createFile(
|
||||
'/tests/test.spec.ts',
|
||||
`
|
||||
import { TestPage } from '../pages/TestPage';
|
||||
const page = new TestPage();
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
const fixData = violations[0].fixData;
|
||||
expect(fixData).toBeDefined();
|
||||
expect(fixData?.type).toBe('method');
|
||||
if (fixData && isMethodFix(fixData)) {
|
||||
expect(fixData.className).toBe('TestPage');
|
||||
expect(fixData.memberName).toBe('unusedMethod');
|
||||
}
|
||||
});
|
||||
});
|
||||
294
packages/testing/janitor/src/rules/dead-code.rule.ts
Normal file
294
packages/testing/janitor/src/rules/dead-code.rule.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import * as fs from 'node:fs';
|
||||
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation, FixResult } from '../types.js';
|
||||
import { getRelativePath } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Dead Code Rule
|
||||
*
|
||||
* Finds and optionally removes unused methods, properties, and classes.
|
||||
* Uses reference tracing to detect code that isn't used anywhere in the codebase.
|
||||
*
|
||||
* Detects:
|
||||
* - Unused public methods (no external references)
|
||||
* - Unused public properties (no external references)
|
||||
* - Dead classes (entire class not referenced externally)
|
||||
* - Empty classes (no public members)
|
||||
*
|
||||
* Supports auto-fixing with --fix --write
|
||||
*/
|
||||
export class DeadCodeRule extends BaseRule {
|
||||
readonly id = 'dead-code';
|
||||
readonly name = 'Dead Code';
|
||||
readonly description = 'Find and remove unused methods, properties, and classes';
|
||||
readonly severity = 'warning' as const;
|
||||
readonly fixable = true;
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return [
|
||||
...config.patterns.pages,
|
||||
...config.patterns.flows,
|
||||
...config.patterns.helpers,
|
||||
...config.patterns.services,
|
||||
];
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const fileViolations = this.analyzeFile(file);
|
||||
violations.push(...fileViolations);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private analyzeFile(file: SourceFile): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const classDecl of file.getClasses()) {
|
||||
const className = classDecl.getName();
|
||||
if (!className) continue;
|
||||
|
||||
// Check if entire class is unused
|
||||
if (!this.hasExternalReferences(file, classDecl)) {
|
||||
violations.push(this.createDeadClassViolation(file, classDecl, className));
|
||||
continue; // Don't report individual members if whole class is dead
|
||||
}
|
||||
|
||||
// Check individual members
|
||||
violations.push(...this.checkUnusedMethods(file, classDecl, className));
|
||||
violations.push(...this.checkUnusedProperties(file, classDecl, className));
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private createDeadClassViolation(
|
||||
file: SourceFile,
|
||||
classDecl: ReturnType<SourceFile['getClasses']>[0],
|
||||
className: string,
|
||||
): Violation {
|
||||
return this.createViolation(
|
||||
file,
|
||||
classDecl.getStartLineNumber(),
|
||||
0,
|
||||
`Dead class: ${className} has no external references`,
|
||||
'Remove the entire class or file',
|
||||
true,
|
||||
{ type: 'class', className },
|
||||
);
|
||||
}
|
||||
|
||||
private checkUnusedMethods(
|
||||
file: SourceFile,
|
||||
classDecl: ReturnType<SourceFile['getClasses']>[0],
|
||||
className: string,
|
||||
): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const method of classDecl.getMethods()) {
|
||||
if (method.hasModifier(SyntaxKind.PrivateKeyword)) continue;
|
||||
|
||||
const methodName = method.getName();
|
||||
if (!this.hasExternalReferences(file, method)) {
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
method.getStartLineNumber(),
|
||||
0,
|
||||
`Unused method: ${className}.${methodName}()`,
|
||||
'Remove the method or make it private',
|
||||
true,
|
||||
{ type: 'method', className, memberName: methodName },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private checkUnusedProperties(
|
||||
file: SourceFile,
|
||||
classDecl: ReturnType<SourceFile['getClasses']>[0],
|
||||
className: string,
|
||||
): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const prop of classDecl.getProperties()) {
|
||||
if (this.isPrivateOrProtected(prop)) continue;
|
||||
|
||||
const propName = prop.getName();
|
||||
if (!this.hasExternalReferences(file, prop)) {
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
prop.getStartLineNumber(),
|
||||
0,
|
||||
`Unused property: ${className}.${propName}`,
|
||||
'Remove the property or make it private',
|
||||
true,
|
||||
{ type: 'property', className, memberName: propName },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private isPrivateOrProtected(
|
||||
prop: ReturnType<ReturnType<SourceFile['getClasses']>[0]['getProperties']>[0],
|
||||
): boolean {
|
||||
return (
|
||||
prop.hasModifier(SyntaxKind.PrivateKeyword) || prop.hasModifier(SyntaxKind.ProtectedKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
private hasExternalReferences(
|
||||
sourceFile: SourceFile,
|
||||
node: { findReferencesAsNodes: () => unknown[]; getStartLineNumber: () => number },
|
||||
): boolean {
|
||||
const refs = node.findReferencesAsNodes() as Array<{
|
||||
getSourceFile: () => SourceFile;
|
||||
getStartLineNumber: () => number;
|
||||
}>;
|
||||
|
||||
return refs.some(
|
||||
(ref) =>
|
||||
ref.getSourceFile() !== sourceFile ||
|
||||
ref.getStartLineNumber() !== node.getStartLineNumber(),
|
||||
);
|
||||
}
|
||||
|
||||
fix(project: Project, violations: Violation[], write: boolean): FixResult[] {
|
||||
const results: FixResult[] = [];
|
||||
const byFile = this.groupViolationsByFile(violations);
|
||||
const filesToDelete = new Set<string>();
|
||||
|
||||
for (const [filePath, fileViolations] of byFile) {
|
||||
const sourceFile = project.getSourceFile(filePath);
|
||||
if (!sourceFile) continue;
|
||||
|
||||
// Check if entire file should be deleted
|
||||
if (this.shouldDeleteFile(sourceFile, fileViolations)) {
|
||||
filesToDelete.add(filePath);
|
||||
results.push(this.createFixResult(filePath, 'remove-file', write));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process individual member removals
|
||||
results.push(...this.processFileViolations(sourceFile, filePath, fileViolations, write));
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
if (write) {
|
||||
project.saveSync();
|
||||
this.deleteFiles(filesToDelete);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private groupViolationsByFile(violations: Violation[]): Map<string, Violation[]> {
|
||||
const byFile = new Map<string, Violation[]>();
|
||||
for (const v of violations) {
|
||||
if (!v.fixable || !v.fixData) continue;
|
||||
const existing = byFile.get(v.file) ?? [];
|
||||
existing.push(v);
|
||||
byFile.set(v.file, existing);
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
|
||||
private shouldDeleteFile(sourceFile: SourceFile, fileViolations: Violation[]): boolean {
|
||||
const deadClassViolations = fileViolations.filter((v) => v.fixData?.type === 'class');
|
||||
const allClassesInFile = sourceFile.getClasses();
|
||||
return deadClassViolations.length === allClassesInFile.length && allClassesInFile.length > 0;
|
||||
}
|
||||
|
||||
private createFixResult(
|
||||
filePath: string,
|
||||
action: 'remove-file' | 'remove-method' | 'remove-property',
|
||||
write: boolean,
|
||||
target?: string,
|
||||
): FixResult {
|
||||
return {
|
||||
file: getRelativePath(filePath),
|
||||
action,
|
||||
target,
|
||||
applied: write,
|
||||
};
|
||||
}
|
||||
|
||||
private processFileViolations(
|
||||
sourceFile: SourceFile,
|
||||
filePath: string,
|
||||
fileViolations: Violation[],
|
||||
write: boolean,
|
||||
): FixResult[] {
|
||||
const results: FixResult[] = [];
|
||||
|
||||
for (const violation of fileViolations) {
|
||||
const fixData = violation.fixData;
|
||||
if (!fixData || fixData.type === 'class' || fixData.type === 'edit') continue;
|
||||
|
||||
const classDecl = sourceFile.getClass(fixData.className);
|
||||
if (!classDecl) continue;
|
||||
|
||||
const result = this.removeMember(classDecl, filePath, fixData, write);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private removeMember(
|
||||
classDecl: ReturnType<SourceFile['getClass']>,
|
||||
filePath: string,
|
||||
fixData: { type: 'method' | 'property'; className: string; memberName: string },
|
||||
write: boolean,
|
||||
): FixResult | null {
|
||||
if (!classDecl) return null;
|
||||
|
||||
if (fixData.type === 'method') {
|
||||
const method = classDecl.getMethod(fixData.memberName);
|
||||
if (method) {
|
||||
if (write) method.remove();
|
||||
return this.createFixResult(
|
||||
filePath,
|
||||
'remove-method',
|
||||
write,
|
||||
`${fixData.className}.${fixData.memberName}()`,
|
||||
);
|
||||
}
|
||||
} else if (fixData.type === 'property') {
|
||||
const prop = classDecl.getProperty(fixData.memberName);
|
||||
if (prop) {
|
||||
if (write) prop.remove();
|
||||
return this.createFixResult(
|
||||
filePath,
|
||||
'remove-property',
|
||||
write,
|
||||
`${fixData.className}.${fixData.memberName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private deleteFiles(filesToDelete: Set<string>): void {
|
||||
for (const filePath of filesToDelete) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
packages/testing/janitor/src/rules/deduplication.rule.test.ts
Normal file
138
packages/testing/janitor/src/rules/deduplication.rule.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { DeduplicationRule } from './deduplication.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('DeduplicationRule', () => {
|
||||
const rule = new DeduplicationRule();
|
||||
|
||||
test('detects duplicate test IDs across files', ({ project, createFile }) => {
|
||||
const file1 = createFile(
|
||||
'/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {
|
||||
getSomething() {
|
||||
return this.page.getByTestId('my-button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const file2 = createFile(
|
||||
'/pages/PageB.ts',
|
||||
`
|
||||
export class PageB {
|
||||
getButton() {
|
||||
return this.page.getByTestId('my-button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file1, file2]);
|
||||
|
||||
expect(violations.length).toBeGreaterThanOrEqual(1);
|
||||
expect(violations[0].message).toContain('my-button');
|
||||
});
|
||||
|
||||
test('allows unique test IDs', ({ project, createFile }) => {
|
||||
const file1 = createFile(
|
||||
'/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {
|
||||
getSomething() {
|
||||
return this.page.getByTestId('page-a-button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const file2 = createFile(
|
||||
'/pages/PageB.ts',
|
||||
`
|
||||
export class PageB {
|
||||
getButton() {
|
||||
return this.page.getByTestId('page-b-button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file1, file2]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('allows same test ID within same file', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {
|
||||
getButton1() {
|
||||
return this.container.getByTestId('button');
|
||||
}
|
||||
|
||||
getButton2() {
|
||||
return this.container.getByTestId('button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('separates components and pages scopes', ({ project, createFile }) => {
|
||||
const pageFile = createFile(
|
||||
'/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {
|
||||
getSomething() {
|
||||
return this.page.getByTestId('shared-id');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const componentFile = createFile(
|
||||
'/pages/components/ComponentA.ts',
|
||||
`
|
||||
export class ComponentA {
|
||||
getSomething() {
|
||||
return this.root.getByTestId('shared-id');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [pageFile, componentFile]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips dynamic test IDs (template literals)', ({ project, createFile }) => {
|
||||
const file1 = createFile(
|
||||
'/pages/PageA.ts',
|
||||
`
|
||||
export class PageA {
|
||||
getSomething(id: string) {
|
||||
return this.page.getByTestId(\`item-\${id}\`);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const file2 = createFile(
|
||||
'/pages/PageB.ts',
|
||||
`
|
||||
export class PageB {
|
||||
getItem(id: string) {
|
||||
return this.page.getByTestId(\`item-\${id}\`);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file1, file2]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
175
packages/testing/janitor/src/rules/deduplication.rule.ts
Normal file
175
packages/testing/janitor/src/rules/deduplication.rule.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { SyntaxKind, type Project, type SourceFile, type CallExpression } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { isComponentFile, getRelativePath } from '../utils/paths.js';
|
||||
|
||||
interface TestIdUsage {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
root: string; // The locator root (e.g., "this.page", "this.container", "this.getPanel()")
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplication Rule
|
||||
*
|
||||
* Ensures getByTestId() calls accessing the SAME DOM element are consolidated.
|
||||
* Uses AST analysis to determine the locator "root" - two calls are only duplicates
|
||||
* if they have the same root AND the same test ID.
|
||||
*
|
||||
* Examples:
|
||||
* - this.page.getByTestId('modal') in FileA and FileB → DUPLICATE (same root, same ID)
|
||||
* - this.container.getByTestId('view') vs this.page.getByTestId('view') → NOT duplicate
|
||||
* - this.getOutputPanel().getByTestId('view') vs this.getInputPanel().getByTestId('view') → NOT duplicate
|
||||
*
|
||||
* Scopes:
|
||||
* - pages/ (excluding components) = one scope
|
||||
* - pages/components/ = separate scope
|
||||
*
|
||||
* Only flags when the same {root}:{testId} combination appears in multiple files.
|
||||
*/
|
||||
export class DeduplicationRule extends BaseRule {
|
||||
readonly id = 'deduplication';
|
||||
readonly name = 'Deduplication';
|
||||
readonly description = 'Test IDs should be unique within their scope';
|
||||
readonly severity = 'warning' as const;
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return config.patterns.pages;
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const { pagesScope, componentsScope } = this.buildSelectorMaps(files);
|
||||
|
||||
return [
|
||||
...this.findDuplicates(pagesScope, 'pages', files),
|
||||
...this.findDuplicates(componentsScope, 'components', files),
|
||||
];
|
||||
}
|
||||
|
||||
private buildSelectorMaps(files: SourceFile[]): {
|
||||
pagesScope: Map<string, TestIdUsage[]>;
|
||||
componentsScope: Map<string, TestIdUsage[]>;
|
||||
} {
|
||||
const pagesScope = new Map<string, TestIdUsage[]>();
|
||||
const componentsScope = new Map<string, TestIdUsage[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = file.getFilePath();
|
||||
const scope = isComponentFile(filePath) ? componentsScope : pagesScope;
|
||||
this.collectTestIdUsages(file, scope);
|
||||
}
|
||||
|
||||
return { pagesScope, componentsScope };
|
||||
}
|
||||
|
||||
private collectTestIdUsages(file: SourceFile, scope: Map<string, TestIdUsage[]>): void {
|
||||
const filePath = file.getFilePath();
|
||||
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
|
||||
for (const call of calls) {
|
||||
const usage = this.extractTestIdUsage(call, filePath);
|
||||
if (!usage) continue;
|
||||
|
||||
const key = `${usage.root}:${usage.testId}`;
|
||||
const existing = scope.get(key) ?? [];
|
||||
existing.push({
|
||||
file: usage.file,
|
||||
line: usage.line,
|
||||
column: usage.column,
|
||||
root: usage.root,
|
||||
});
|
||||
scope.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
private extractTestIdUsage(
|
||||
call: CallExpression,
|
||||
filePath: string,
|
||||
): (TestIdUsage & { testId: string }) | null {
|
||||
const expr = call.getExpression();
|
||||
const text = expr.getText();
|
||||
|
||||
if (!text.endsWith('.getByTestId')) return null;
|
||||
|
||||
const args = call.getArguments();
|
||||
if (args.length === 0) return null;
|
||||
|
||||
const firstArg = args[0];
|
||||
if (firstArg.getKind() !== SyntaxKind.StringLiteral) return null;
|
||||
|
||||
const stringLit = firstArg.asKind(SyntaxKind.StringLiteral);
|
||||
if (!stringLit) return null;
|
||||
|
||||
return {
|
||||
file: filePath,
|
||||
line: call.getStartLineNumber(),
|
||||
column: call.getStart() - call.getStartLinePos(),
|
||||
root: this.extractLocatorRoot(text),
|
||||
testId: stringLit.getLiteralText(),
|
||||
};
|
||||
}
|
||||
|
||||
private findDuplicates(
|
||||
scope: Map<string, TestIdUsage[]>,
|
||||
scopeName: string,
|
||||
files: SourceFile[],
|
||||
): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const [key, usages] of scope) {
|
||||
// Only flag if same root:testId appears in multiple files
|
||||
const uniqueFiles = new Set(usages.map((u) => u.file));
|
||||
if (uniqueFiles.size <= 1) continue;
|
||||
|
||||
// Extract testId from key (format: "root:testId")
|
||||
const testId = key.substring(key.lastIndexOf(':') + 1);
|
||||
const root = usages[0].root;
|
||||
|
||||
// Create a violation for each usage except the first
|
||||
const sortedUsages = [...usages].sort((a, b) => a.file.localeCompare(b.file));
|
||||
const firstFile = getRelativePath(sortedUsages[0].file);
|
||||
|
||||
for (let i = 1; i < sortedUsages.length; i++) {
|
||||
const usage = sortedUsages[i];
|
||||
const file = files.find((f) => f.getFilePath() === usage.file);
|
||||
if (!file) continue;
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
usage.line,
|
||||
usage.column,
|
||||
`Duplicate locator: ${root}.getByTestId("${testId}") in ${scopeName} scope`,
|
||||
`Also used in ${firstFile}. Consider consolidating to a shared component.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the locator root from a getByTestId call expression.
|
||||
* E.g., "this.page.getByTestId" → "this.page"
|
||||
* "this.container.getByTestId" → "this.container"
|
||||
* "this.getPanel().getByTestId" → "this.getPanel()"
|
||||
*/
|
||||
private extractLocatorRoot(exprText: string): string {
|
||||
// Remove the trailing .getByTestId
|
||||
const root = exprText.replace(/\.getByTestId$/, '');
|
||||
|
||||
// Normalize common patterns for better grouping
|
||||
// this.page.locator(...).* chains should be considered scoped
|
||||
if (root.includes('.locator(')) {
|
||||
return 'scoped'; // Mark as scoped - won't match other scoped calls
|
||||
}
|
||||
|
||||
// Normalize method calls to include parentheses for clarity
|
||||
return root;
|
||||
}
|
||||
}
|
||||
240
packages/testing/janitor/src/rules/duplicate-logic.rule.test.ts
Normal file
240
packages/testing/janitor/src/rules/duplicate-logic.rule.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { Project } from 'ts-morph';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { setConfig, resetConfig, defaultConfig } from '../config.js';
|
||||
import { DuplicateLogicRule } from './duplicate-logic.rule.js';
|
||||
|
||||
describe('DuplicateLogicRule', () => {
|
||||
const rule = new DuplicateLogicRule();
|
||||
|
||||
function createTestProject(files: Record<string, string>): Project {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
project.createSourceFile(path, content);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setConfig({
|
||||
...defaultConfig,
|
||||
rootDir: '/test-root',
|
||||
patterns: {
|
||||
...defaultConfig.patterns,
|
||||
pages: ['pages/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
describe('duplicate methods detection', () => {
|
||||
it('detects duplicate methods across page objects', () => {
|
||||
const project = createTestProject({
|
||||
// Use paths that match pages/**/*.ts pattern
|
||||
'/test-root/pages/workflow/PageA.ts': `
|
||||
export class PageA {
|
||||
async duplicateMethod() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
await this.page.waitForSelector('.done');
|
||||
}
|
||||
}
|
||||
`,
|
||||
'/test-root/pages/workflow/PageB.ts': `
|
||||
export class PageB {
|
||||
async anotherDuplicate() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
await this.page.waitForSelector('.done');
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
expect(violations.length).toBe(1);
|
||||
expect(violations[0].message).toContain('Duplicate method logic');
|
||||
expect(violations[0].message).toContain('PageB.anotherDuplicate()');
|
||||
expect(violations[0].message).toContain('PageA.duplicateMethod()');
|
||||
});
|
||||
|
||||
it('ignores methods with different structure', () => {
|
||||
const project = createTestProject({
|
||||
'/test-root/pages/workflow/PageA.ts': `
|
||||
export class PageA {
|
||||
async methodA() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
await this.page.waitForSelector('.done');
|
||||
}
|
||||
}
|
||||
`,
|
||||
'/test-root/pages/workflow/PageB.ts': `
|
||||
export class PageB {
|
||||
async methodB() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'different');
|
||||
// Different - extra statement
|
||||
console.log('extra');
|
||||
await this.page.waitForSelector('.done');
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
expect(violations.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores methods in the same file', () => {
|
||||
const project = createTestProject({
|
||||
'/test-root/pages/workflow/PageA.ts': `
|
||||
export class PageA {
|
||||
async methodA() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
await this.page.waitForSelector('.done');
|
||||
}
|
||||
|
||||
async methodB() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
await this.page.waitForSelector('.done');
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
// Same file duplicates are allowed (may be intentional)
|
||||
expect(violations.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate test detection', () => {
|
||||
it('detects duplicate test bodies', () => {
|
||||
const project = createTestProject({
|
||||
// Use subdirectory to match tests/**/*.spec.ts pattern
|
||||
'/test-root/tests/e2e/test1.spec.ts': `
|
||||
test('first test', async ({ page }) => {
|
||||
await page.goto('/home');
|
||||
await page.click('button');
|
||||
await page.waitForSelector('.done');
|
||||
});
|
||||
`,
|
||||
'/test-root/tests/e2e/test2.spec.ts': `
|
||||
test('second test', async ({ page }) => {
|
||||
await page.goto('/home');
|
||||
await page.click('button');
|
||||
await page.waitForSelector('.done');
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
expect(violations.length).toBe(1);
|
||||
expect(violations[0].message).toContain('Duplicate test logic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('test duplicating method', () => {
|
||||
it('detects when test duplicates page object method', () => {
|
||||
const project = createTestProject({
|
||||
// Use subdirectory to match pages/**/*.ts pattern
|
||||
'/test-root/pages/auth/HomePage.ts': `
|
||||
export class HomePage {
|
||||
async login() {
|
||||
await this.page.fill('#user', 'test');
|
||||
await this.page.fill('#pass', 'pass');
|
||||
await this.page.click('#submit');
|
||||
}
|
||||
}
|
||||
`,
|
||||
// Use subdirectory to match tests/**/*.spec.ts pattern
|
||||
'/test-root/tests/e2e/login.spec.ts': `
|
||||
test('should login', async ({ page }) => {
|
||||
await page.fill('#user', 'test');
|
||||
await page.fill('#pass', 'pass');
|
||||
await page.click('#submit');
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
// Should detect that test duplicates HomePage.login()
|
||||
expect(violations.length).toBe(1);
|
||||
expect(violations[0].message).toContain('Test duplicates existing method');
|
||||
expect(violations[0].message).toContain('HomePage.login()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('minStatements threshold', () => {
|
||||
it('ignores methods with fewer than 2 statements', () => {
|
||||
const project = createTestProject({
|
||||
'/test-root/pages/workflow/PageA.ts': `
|
||||
export class PageA {
|
||||
async shortMethod() {
|
||||
await this.page.click('button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
'/test-root/pages/workflow/PageB.ts': `
|
||||
export class PageB {
|
||||
async anotherShort() {
|
||||
await this.page.click('button');
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
// Only 1 statement, below threshold of 2
|
||||
expect(violations.length).toBe(0);
|
||||
});
|
||||
|
||||
it('detects duplicates with exactly 2 statements', () => {
|
||||
const project = createTestProject({
|
||||
'/test-root/pages/workflow/PageA.ts': `
|
||||
export class PageA {
|
||||
async twoStatements() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
}
|
||||
}
|
||||
`,
|
||||
'/test-root/pages/workflow/PageB.ts': `
|
||||
export class PageB {
|
||||
async alsoTwoStatements() {
|
||||
await this.page.click('button');
|
||||
await this.page.fill('input', 'text');
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const files = project.getSourceFiles();
|
||||
const violations = rule.analyze(project, files);
|
||||
|
||||
// 2 statements meets the threshold
|
||||
expect(violations.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
519
packages/testing/janitor/src/rules/duplicate-logic.rule.ts
Normal file
519
packages/testing/janitor/src/rules/duplicate-logic.rule.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
import {
|
||||
SyntaxKind,
|
||||
type Project,
|
||||
type SourceFile,
|
||||
type Node,
|
||||
type MethodDeclaration,
|
||||
} from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { getRelativePath, matchesPatterns } from '../utils/paths.js';
|
||||
|
||||
/** Playwright fixture names normalized to FIXTURE token */
|
||||
const PLAYWRIGHT_FIXTURES = ['page', 'request', 'context', 'browser'];
|
||||
|
||||
interface MethodFingerprint {
|
||||
file: string;
|
||||
className: string;
|
||||
methodName: string;
|
||||
fingerprint: string;
|
||||
line: number;
|
||||
statementCount: number;
|
||||
}
|
||||
|
||||
interface TestFingerprint {
|
||||
file: string;
|
||||
testName: string;
|
||||
fingerprint: string;
|
||||
line: number;
|
||||
statementCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects duplicate code using AST structural fingerprinting.
|
||||
* Normalizes code to structural patterns, ignoring variable names and literals.
|
||||
*/
|
||||
export class DuplicateLogicRule extends BaseRule {
|
||||
readonly id = 'duplicate-logic';
|
||||
readonly name = 'Duplicate Logic';
|
||||
readonly description = 'Detects duplicate code patterns across tests and page objects';
|
||||
readonly severity = 'warning' as const;
|
||||
|
||||
private minStatements = 2;
|
||||
private methodIndex = new Map<string, MethodFingerprint[]>();
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return [
|
||||
...config.patterns.tests,
|
||||
...config.patterns.pages,
|
||||
...config.patterns.flows,
|
||||
...config.patterns.helpers,
|
||||
];
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const config = getConfig();
|
||||
|
||||
// Separate files by type
|
||||
const pageFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.pages));
|
||||
const flowFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.flows));
|
||||
const helperFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.helpers));
|
||||
const testFiles = files.filter((f) => this.fileMatchesPatterns(f, config.patterns.tests));
|
||||
|
||||
// Build method index from pages, flows, helpers
|
||||
this.buildMethodIndex([...pageFiles, ...flowFiles, ...helperFiles]);
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
// 1. Find duplicate methods within pages/flows/helpers
|
||||
violations.push(...this.findDuplicateMethods(files));
|
||||
|
||||
// 2. Find tests that duplicate existing page object methods
|
||||
violations.push(...this.findTestsDuplicatingMethods(testFiles, files));
|
||||
|
||||
// 3. Find duplicate test bodies
|
||||
violations.push(...this.findDuplicateTests(testFiles, files));
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private fileMatchesPatterns(file: SourceFile, patterns: string[]): boolean {
|
||||
return matchesPatterns(file.getFilePath(), patterns);
|
||||
}
|
||||
|
||||
private buildMethodIndex(files: SourceFile[]): void {
|
||||
this.methodIndex.clear();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = file.getFilePath();
|
||||
const classes = file.getClasses();
|
||||
|
||||
for (const cls of classes) {
|
||||
const className = cls.getName() ?? 'Anonymous';
|
||||
const methods = cls.getMethods();
|
||||
|
||||
for (const method of methods) {
|
||||
// Skip private methods, getters, setters
|
||||
if (method.hasModifier(SyntaxKind.PrivateKeyword)) continue;
|
||||
|
||||
const result = this.fingerprintMethod(method);
|
||||
if (!result) continue;
|
||||
|
||||
const entry: MethodFingerprint = {
|
||||
file: filePath,
|
||||
className,
|
||||
methodName: method.getName(),
|
||||
fingerprint: result.hash,
|
||||
line: method.getStartLineNumber(),
|
||||
statementCount: result.statementCount,
|
||||
};
|
||||
|
||||
const existing = this.methodIndex.get(result.hash) ?? [];
|
||||
existing.push(entry);
|
||||
this.methodIndex.set(result.hash, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findDuplicateMethods(files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const [_hash, methods] of this.methodIndex) {
|
||||
// Only flag if same fingerprint in multiple files
|
||||
const uniqueFiles = new Set(methods.map((m) => m.file));
|
||||
if (uniqueFiles.size <= 1) continue;
|
||||
|
||||
// Sort by file path for consistent reporting
|
||||
const sorted = [...methods].sort((a, b) => a.file.localeCompare(b.file));
|
||||
const first = sorted[0];
|
||||
const firstLocation = `${getRelativePath(first.file)}:${first.className}.${first.methodName}()`;
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const method = sorted[i];
|
||||
const file = files.find((f) => f.getFilePath() === method.file);
|
||||
if (!file) continue;
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
method.line,
|
||||
0,
|
||||
`Duplicate method logic: ${method.className}.${method.methodName}() has identical structure to ${firstLocation}`,
|
||||
'Consider extracting to a shared base class or utility function',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private findTestsDuplicatingMethods(
|
||||
testFiles: SourceFile[],
|
||||
allFiles: SourceFile[],
|
||||
): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const tests = this.extractTestBodies(testFile);
|
||||
|
||||
for (const test of tests) {
|
||||
// Check if test body matches any indexed method
|
||||
const match = this.findMatchingMethod(test.fingerprint);
|
||||
if (!match) continue;
|
||||
|
||||
// Don't report if same file
|
||||
if (match.file === testFile.getFilePath()) continue;
|
||||
|
||||
const file = allFiles.find((f) => f.getFilePath() === testFile.getFilePath());
|
||||
if (!file) continue;
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
test.line,
|
||||
0,
|
||||
`Test duplicates existing method: "${test.testName}" contains logic similar to ${match.className}.${match.methodName}()`,
|
||||
`Use the existing method from ${getRelativePath(match.file)} instead of duplicating`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private findDuplicateTests(testFiles: SourceFile[], allFiles: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const testIndex = new Map<string, TestFingerprint[]>();
|
||||
|
||||
// Build test fingerprint index
|
||||
for (const file of testFiles) {
|
||||
const tests = this.extractTestBodies(file);
|
||||
|
||||
for (const test of tests) {
|
||||
const existing = testIndex.get(test.fingerprint) ?? [];
|
||||
existing.push(test);
|
||||
testIndex.set(test.fingerprint, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Find duplicates
|
||||
for (const [_hash, tests] of testIndex) {
|
||||
// Only flag if same fingerprint in multiple files
|
||||
const uniqueFiles = new Set(tests.map((t) => t.file));
|
||||
if (uniqueFiles.size <= 1) continue;
|
||||
|
||||
const sorted = [...tests].sort((a, b) => a.file.localeCompare(b.file));
|
||||
const first = sorted[0];
|
||||
const firstLocation = `${getRelativePath(first.file)}:"${first.testName}"`;
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const test = sorted[i];
|
||||
const file = allFiles.find((f) => f.getFilePath() === test.file);
|
||||
if (!file) continue;
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
test.line,
|
||||
0,
|
||||
`Duplicate test logic: "${test.testName}" has identical structure to ${firstLocation}`,
|
||||
'Consider extracting shared setup to a helper function or fixture',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private extractTestBodies(file: SourceFile): TestFingerprint[] {
|
||||
const results: TestFingerprint[] = [];
|
||||
const filePath = file.getFilePath();
|
||||
|
||||
// Find test() and test.describe() calls
|
||||
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
|
||||
for (const call of calls) {
|
||||
const expr = call.getExpression();
|
||||
const text = expr.getText();
|
||||
|
||||
// Match test('name', ...) or it('name', ...)
|
||||
if (text !== 'test' && text !== 'it') continue;
|
||||
|
||||
const args = call.getArguments();
|
||||
if (args.length < 2) continue;
|
||||
|
||||
// Get test name
|
||||
const nameArg = args[0];
|
||||
let testName = 'anonymous';
|
||||
if (nameArg.getKind() === SyntaxKind.StringLiteral) {
|
||||
const stringLit = nameArg.asKind(SyntaxKind.StringLiteral);
|
||||
if (stringLit) testName = stringLit.getLiteralText();
|
||||
}
|
||||
|
||||
// Get test body (second argument should be arrow function or function)
|
||||
const bodyArg = args[1];
|
||||
const result = this.fingerprintNode(bodyArg);
|
||||
if (!result || result.statementCount < this.minStatements) continue;
|
||||
|
||||
results.push({
|
||||
file: filePath,
|
||||
testName,
|
||||
fingerprint: result.hash,
|
||||
line: call.getStartLineNumber(),
|
||||
statementCount: result.statementCount,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private findMatchingMethod(testFingerprint: string): MethodFingerprint | null {
|
||||
const matches = this.methodIndex.get(testFingerprint);
|
||||
return matches?.[0] ?? null;
|
||||
}
|
||||
|
||||
private fingerprintMethod(
|
||||
method: MethodDeclaration,
|
||||
): { hash: string; statementCount: number } | null {
|
||||
const body = method.getBody();
|
||||
if (!body) return null;
|
||||
|
||||
// Body should be a Block
|
||||
const block = body.asKind(SyntaxKind.Block);
|
||||
if (!block) return null;
|
||||
|
||||
const statements = block.getStatements();
|
||||
if (statements.length < this.minStatements) return null;
|
||||
|
||||
const normalized = statements.map((s) => this.normalizeNode(s)).join('|');
|
||||
return {
|
||||
hash: this.hashString(normalized),
|
||||
statementCount: statements.length,
|
||||
};
|
||||
}
|
||||
|
||||
private fingerprintNode(node: Node): { hash: string; statementCount: number } | null {
|
||||
let body: Node | undefined;
|
||||
|
||||
if (node.getKind() === SyntaxKind.ArrowFunction) {
|
||||
body = node.asKind(SyntaxKind.ArrowFunction)?.getBody();
|
||||
} else if (node.getKind() === SyntaxKind.FunctionExpression) {
|
||||
body = node.asKind(SyntaxKind.FunctionExpression)?.getBody();
|
||||
}
|
||||
|
||||
if (!body) return null;
|
||||
|
||||
const block = body.asKind(SyntaxKind.Block);
|
||||
if (!block) return null;
|
||||
|
||||
const statements = block.getStatements();
|
||||
if (statements.length < this.minStatements) return null;
|
||||
|
||||
const normalized = statements.map((s) => this.normalizeNode(s)).join('|');
|
||||
return {
|
||||
hash: this.hashString(normalized),
|
||||
statementCount: statements.length,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeNode(node: Node): string {
|
||||
const kind = node.getKind();
|
||||
|
||||
switch (kind) {
|
||||
// Await expression
|
||||
case SyntaxKind.AwaitExpression: {
|
||||
const children = node.getChildren();
|
||||
const expr = children.find((c) => c.getKind() !== SyntaxKind.AwaitKeyword);
|
||||
return `await(${expr ? this.normalizeNode(expr) : ''})`;
|
||||
}
|
||||
|
||||
// Call expression - core pattern matching
|
||||
case SyntaxKind.CallExpression: {
|
||||
const call = node.asKind(SyntaxKind.CallExpression);
|
||||
if (!call) return 'call';
|
||||
|
||||
const expr = call.getExpression();
|
||||
const normalizedExpr = this.normalizeExpression(expr);
|
||||
const argCount = call.getArguments().length;
|
||||
|
||||
return `call(${normalizedExpr},args=${argCount})`;
|
||||
}
|
||||
|
||||
// Property access - normalize but keep structure
|
||||
case SyntaxKind.PropertyAccessExpression: {
|
||||
const propAccess = node.asKind(SyntaxKind.PropertyAccessExpression);
|
||||
if (!propAccess) return 'prop';
|
||||
|
||||
const obj = propAccess.getExpression();
|
||||
const name = propAccess.getName();
|
||||
|
||||
// Keep method/property names but normalize the object
|
||||
return `${this.normalizeNode(obj)}.${name}`;
|
||||
}
|
||||
|
||||
// Variable declaration
|
||||
case SyntaxKind.VariableStatement: {
|
||||
const varStmt = node.asKind(SyntaxKind.VariableStatement);
|
||||
if (!varStmt) return 'var';
|
||||
|
||||
const decls = varStmt.getDeclarationList().getDeclarations();
|
||||
const patterns = decls.map((d) => {
|
||||
const init = d.getInitializer();
|
||||
return `VAR=${init ? this.normalizeNode(init) : 'undefined'}`;
|
||||
});
|
||||
return `var(${patterns.join(',')})`;
|
||||
}
|
||||
|
||||
// Expression statement
|
||||
case SyntaxKind.ExpressionStatement: {
|
||||
const exprStmt = node.asKind(SyntaxKind.ExpressionStatement);
|
||||
if (!exprStmt) return 'expr';
|
||||
return this.normalizeNode(exprStmt.getExpression());
|
||||
}
|
||||
|
||||
// Return statement
|
||||
case SyntaxKind.ReturnStatement: {
|
||||
const retStmt = node.asKind(SyntaxKind.ReturnStatement);
|
||||
if (!retStmt) return 'return';
|
||||
const expr = retStmt.getExpression();
|
||||
return `return(${expr ? this.normalizeNode(expr) : ''})`;
|
||||
}
|
||||
|
||||
// If statement
|
||||
case SyntaxKind.IfStatement: {
|
||||
const ifStmt = node.asKind(SyntaxKind.IfStatement);
|
||||
if (!ifStmt) return 'if';
|
||||
|
||||
const condition = this.normalizeNode(ifStmt.getExpression());
|
||||
const thenBranch = ifStmt.getThenStatement();
|
||||
const elseBranch = ifStmt.getElseStatement();
|
||||
|
||||
let pattern = `if(${condition})`;
|
||||
if (thenBranch) pattern += `then(${this.countStatements(thenBranch)})`;
|
||||
if (elseBranch) pattern += `else(${this.countStatements(elseBranch)})`;
|
||||
return pattern;
|
||||
}
|
||||
|
||||
// For loop
|
||||
case SyntaxKind.ForStatement:
|
||||
case SyntaxKind.ForOfStatement:
|
||||
case SyntaxKind.ForInStatement: {
|
||||
const forStmt =
|
||||
node.asKind(SyntaxKind.ForOfStatement) ??
|
||||
node.asKind(SyntaxKind.ForInStatement) ??
|
||||
node.asKind(SyntaxKind.ForStatement);
|
||||
if (!forStmt) return 'for';
|
||||
|
||||
const body = forStmt.getStatement();
|
||||
return `for(body=${this.countStatements(body)})`;
|
||||
}
|
||||
|
||||
// Identifiers - normalize to generic token
|
||||
case SyntaxKind.Identifier:
|
||||
return 'ID';
|
||||
|
||||
// String literals - normalize to generic token
|
||||
case SyntaxKind.StringLiteral:
|
||||
return 'STR';
|
||||
|
||||
// Number literals
|
||||
case SyntaxKind.NumericLiteral:
|
||||
return 'NUM';
|
||||
|
||||
// Template literals
|
||||
case SyntaxKind.TemplateExpression:
|
||||
case SyntaxKind.NoSubstitutionTemplateLiteral:
|
||||
return 'TMPL';
|
||||
|
||||
// Object/array literals
|
||||
case SyntaxKind.ObjectLiteralExpression:
|
||||
return 'OBJ';
|
||||
|
||||
case SyntaxKind.ArrayLiteralExpression:
|
||||
return 'ARR';
|
||||
|
||||
// Binary expressions
|
||||
case SyntaxKind.BinaryExpression: {
|
||||
const binExpr = node.asKind(SyntaxKind.BinaryExpression);
|
||||
if (!binExpr) return 'bin';
|
||||
|
||||
const op = binExpr.getOperatorToken().getText();
|
||||
const left = this.normalizeNode(binExpr.getLeft());
|
||||
const right = this.normalizeNode(binExpr.getRight());
|
||||
return `bin(${left},${op},${right})`;
|
||||
}
|
||||
|
||||
// Default - use kind name
|
||||
default:
|
||||
return SyntaxKind[kind] ?? 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeExpression(node: Node): string {
|
||||
const kind = node.getKind();
|
||||
const config = getConfig();
|
||||
|
||||
if (kind === SyntaxKind.PropertyAccessExpression) {
|
||||
const propAccess = node.asKind(SyntaxKind.PropertyAccessExpression);
|
||||
if (!propAccess) return 'prop';
|
||||
|
||||
const obj = propAccess.getExpression();
|
||||
const name = propAccess.getName();
|
||||
|
||||
// Normalize this.page, this.request etc to FIXTURE for cross-layer matching
|
||||
if (obj.getKind() === SyntaxKind.ThisKeyword && PLAYWRIGHT_FIXTURES.includes(name)) {
|
||||
return 'FIXTURE';
|
||||
}
|
||||
|
||||
const objNorm = this.normalizeExpression(obj);
|
||||
return `${objNorm}.${name}`;
|
||||
}
|
||||
|
||||
if (kind === SyntaxKind.CallExpression) {
|
||||
const call = node.asKind(SyntaxKind.CallExpression);
|
||||
if (!call) return 'call';
|
||||
|
||||
const expr = call.getExpression();
|
||||
return `${this.normalizeExpression(expr)}()`;
|
||||
}
|
||||
|
||||
if (kind === SyntaxKind.ThisKeyword) {
|
||||
return 'this';
|
||||
}
|
||||
|
||||
if (kind === SyntaxKind.Identifier) {
|
||||
const name = node.asKind(SyntaxKind.Identifier)?.getText() ?? 'id';
|
||||
|
||||
if (PLAYWRIGHT_FIXTURES.includes(name)) return 'FIXTURE';
|
||||
if (name === config.fixtureObjectName || name === config.apiFixtureName) return 'APP';
|
||||
return 'VAR';
|
||||
}
|
||||
|
||||
return 'expr';
|
||||
}
|
||||
|
||||
private countStatements(node: Node): number {
|
||||
if (node.getKind() === SyntaxKind.Block) {
|
||||
const block = node.asKind(SyntaxKind.Block);
|
||||
return block?.getStatements().length ?? 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** djb2 hash for fingerprint strings */
|
||||
private hashString(str: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
// Convert to hex string for readability
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
}
|
||||
14
packages/testing/janitor/src/rules/index.ts
Normal file
14
packages/testing/janitor/src/rules/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export { BaseRule } from './base-rule.js';
|
||||
export { BoundaryProtectionRule } from './boundary-protection.rule.js';
|
||||
export { ScopeLockdownRule } from './scope-lockdown.rule.js';
|
||||
export { SelectorPurityRule } from './selector-purity.rule.js';
|
||||
export { DeadCodeRule } from './dead-code.rule.js';
|
||||
export { ApiPurityRule } from './api-purity.rule.js';
|
||||
export { NoPageInFlowRule } from './no-page-in-flow.rule.js';
|
||||
export { DeduplicationRule } from './deduplication.rule.js';
|
||||
export { TestDataHygieneRule } from './test-data-hygiene.rule.js';
|
||||
export { DuplicateLogicRule } from './duplicate-logic.rule.js';
|
||||
export { NoDirectPageInstantiationRule } from './no-direct-page-instantiation.rule.js';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { Violation, FixResult, RuleResult, RuleConfig } from '../types.js';
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { NoDirectPageInstantiationRule } from './no-direct-page-instantiation.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('NoDirectPageInstantiationRule', () => {
|
||||
const rule = new NoDirectPageInstantiationRule();
|
||||
|
||||
test('detects new CanvasPage() in test file', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/canvas.spec.ts',
|
||||
`
|
||||
import { CanvasPage } from '../../pages/CanvasPage';
|
||||
|
||||
test('my test', async ({ page }) => {
|
||||
const canvas = new CanvasPage(page);
|
||||
await canvas.addNode('Code');
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('Direct page instantiation');
|
||||
expect(violations[0].message).toContain('CanvasPage');
|
||||
});
|
||||
|
||||
test('detects multiple page instantiations', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/workflow.spec.ts',
|
||||
`
|
||||
import { CanvasPage } from '../../pages/CanvasPage';
|
||||
import { SettingsPage } from '../../pages/SettingsPage';
|
||||
|
||||
test('my test', async ({ page }) => {
|
||||
const canvas = new CanvasPage(page);
|
||||
const settings = new SettingsPage(page);
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('provides helpful suggestion with fixture name', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/canvas.spec.ts',
|
||||
`
|
||||
test('my test', async ({ page }) => {
|
||||
const canvas = new CanvasPage(page);
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].suggestion).toContain('n8n.canvas');
|
||||
expect(violations[0].suggestion).toContain('instead of new CanvasPage()');
|
||||
});
|
||||
|
||||
test('allows non-page class instantiation', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/utils.spec.ts',
|
||||
`
|
||||
test('my test', async () => {
|
||||
const helper = new TestHelper();
|
||||
const date = new Date();
|
||||
const map = new Map();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('targets only test files via getTargetGlobs', ({ project: _project }) => {
|
||||
// project fixture initializes config
|
||||
const globs = rule.getTargetGlobs();
|
||||
|
||||
// Should only target test files
|
||||
expect(globs).toEqual(['tests/**/*.spec.ts']);
|
||||
// Should NOT include pages, composables, etc.
|
||||
expect(globs.some((g) => g.includes('pages'))).toBe(false);
|
||||
expect(globs.some((g) => g.includes('composables'))).toBe(false);
|
||||
});
|
||||
|
||||
test('handles complex class names', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/settings.spec.ts',
|
||||
`
|
||||
test('my test', async ({ page }) => {
|
||||
const personal = new SettingsPersonalPage(page);
|
||||
const security = new SettingsSecurityAuditPage(page);
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(2);
|
||||
expect(violations[0].suggestion).toContain('n8n.settingsPersonal');
|
||||
expect(violations[1].suggestion).toContain('n8n.settingsSecurityAudit');
|
||||
});
|
||||
|
||||
test('does not match classes that start with Page', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/utils.spec.ts',
|
||||
`
|
||||
test('my test', async () => {
|
||||
const paginator = new Paginator();
|
||||
const pageSize = new PageSize();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('matches only exact Page suffix pattern', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/various.spec.ts',
|
||||
`
|
||||
test('my test', async ({ page }) => {
|
||||
// Should match - ends with Page
|
||||
const canvas = new CanvasPage(page);
|
||||
|
||||
// Should NOT match - doesn't match pattern
|
||||
const pages = new MyPages(page);
|
||||
const pager = new PageHandler(page);
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('CanvasPage');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* No Direct Page Instantiation Rule
|
||||
*
|
||||
* Enforces the facade pattern by preventing direct instantiation of page objects in test files.
|
||||
* Tests should access pages through the fixture object (e.g., n8n.canvas) instead of
|
||||
* creating new instances directly (e.g., new CanvasPage()).
|
||||
*
|
||||
* This rule is OPT-IN (disabled by default) since not all projects use the facade pattern.
|
||||
* Enable it in your janitor.config.ts:
|
||||
*
|
||||
* ```typescript
|
||||
* rules: {
|
||||
* 'no-direct-page-instantiation': { enabled: true }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Violations:
|
||||
* - `new CanvasPage(page)` in test files
|
||||
* - `new SettingsPage(...)` in test files
|
||||
*
|
||||
* Allowed:
|
||||
* - Page instantiation in fixture files (where the facade is set up)
|
||||
* - Page instantiation in page object files (for composition)
|
||||
* - Page instantiation in composables (if they need to create pages)
|
||||
*/
|
||||
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { truncateText } from '../utils/ast-helpers.js';
|
||||
|
||||
export class NoDirectPageInstantiationRule extends BaseRule {
|
||||
readonly id = 'no-direct-page-instantiation';
|
||||
readonly name = 'No Direct Page Instantiation';
|
||||
readonly description =
|
||||
'Tests should access pages through the fixture facade, not instantiate directly';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return config.patterns.tests;
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const newExpressions = file.getDescendantsOfKind(SyntaxKind.NewExpression);
|
||||
|
||||
for (const newExpr of newExpressions) {
|
||||
const expr = newExpr.getExpression();
|
||||
const className = expr.getText();
|
||||
|
||||
if (this.isPageInstantiation(className)) {
|
||||
if (this.isAllowed(className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startLine = newExpr.getStartLineNumber();
|
||||
const startColumn = newExpr.getStart() - newExpr.getStartLinePos();
|
||||
const truncated = truncateText(newExpr.getText(), 60);
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
startLine,
|
||||
startColumn,
|
||||
`Direct page instantiation: ${truncated}`,
|
||||
this.getSuggestion(className),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a class name looks like a page object instantiation
|
||||
* Matches patterns like: CanvasPage, SettingsPage, WorkflowListPage, etc.
|
||||
*/
|
||||
private isPageInstantiation(className: string): boolean {
|
||||
// Match PascalCase names ending with "Page"
|
||||
return /^[A-Z][a-zA-Z]*Page$/.test(className);
|
||||
}
|
||||
|
||||
private getSuggestion(className: string): string {
|
||||
const config = getConfig();
|
||||
const fixtureName = config.fixtureObjectName;
|
||||
|
||||
// Convert CanvasPage -> canvas, SettingsPersonalPage -> settingsPersonal
|
||||
const propertyName = this.classNameToPropertyName(className);
|
||||
|
||||
return `Access through ${fixtureName} facade: ${fixtureName}.${propertyName} instead of new ${className}()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert page class name to likely property name
|
||||
* CanvasPage -> canvas
|
||||
* SettingsPersonalPage -> settingsPersonal
|
||||
*/
|
||||
private classNameToPropertyName(className: string): string {
|
||||
// Remove 'Page' suffix
|
||||
const withoutPage = className.replace(/Page$/, '');
|
||||
// Convert PascalCase to camelCase
|
||||
return withoutPage.charAt(0).toLowerCase() + withoutPage.slice(1);
|
||||
}
|
||||
}
|
||||
149
packages/testing/janitor/src/rules/no-page-in-flow.rule.test.ts
Normal file
149
packages/testing/janitor/src/rules/no-page-in-flow.rule.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { NoPageInFlowRule } from './no-page-in-flow.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('NoPageInFlowRule', () => {
|
||||
const rule = new NoPageInFlowRule();
|
||||
|
||||
test('allows page object usage', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async createWorkflow() {
|
||||
await this.n8n.canvas.openNewWorkflow();
|
||||
await this.n8n.ndv.setName('Test');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects direct page.getByTestId access', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async clickButton() {
|
||||
await this.n8n.page.getByTestId('button').click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('page.getByTestId');
|
||||
});
|
||||
|
||||
test('detects page.locator access', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async findElement() {
|
||||
return this.n8n.page.locator('.my-class');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects page.goto access', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async navigate() {
|
||||
await this.n8n.page.goto('/workflows');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].suggestion).toContain('navigation');
|
||||
});
|
||||
|
||||
test('detects multiple direct page accesses', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async doStuff() {
|
||||
await this.n8n.page.getByTestId('a').click();
|
||||
await this.n8n.page.locator('.b').fill('text');
|
||||
await this.n8n.page.getByRole('button').click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('reports correct line numbers', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async method() {
|
||||
// line 5
|
||||
await this.n8n.page.getByTestId('x').click(); // line 6
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].line).toBe(6);
|
||||
});
|
||||
|
||||
test('provides context-specific suggestions', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/WorkflowComposer.ts',
|
||||
`
|
||||
export class WorkflowComposer {
|
||||
constructor(private n8n: any) {}
|
||||
|
||||
async method() {
|
||||
await this.n8n.page.getByTestId('x').click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].suggestion).toBeDefined();
|
||||
expect(violations[0].suggestion).toContain('page object');
|
||||
});
|
||||
});
|
||||
122
packages/testing/janitor/src/rules/no-page-in-flow.rule.ts
Normal file
122
packages/testing/janitor/src/rules/no-page-in-flow.rule.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { LOCATOR_METHODS } from '../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* No Page In Flow Rule
|
||||
*
|
||||
* Flows/Composables should interact ONLY through page objects, never directly
|
||||
* with fixture.page. This enforces the four-layer architecture:
|
||||
*
|
||||
* Tests → Flows/Composables → Pages → BasePage
|
||||
*
|
||||
* Even "legitimate" page methods like keyboard, evaluate, reload should
|
||||
* be wrapped in page object helpers for flows.
|
||||
*
|
||||
* Why this matters:
|
||||
* - Keeps flows focused on high-level test workflows
|
||||
* - All DOM/page interaction is abstracted through page objects
|
||||
* - Makes tests more readable and maintainable
|
||||
*
|
||||
* Violations:
|
||||
* - this.fixture.page.anything()
|
||||
* - this.fixture.page (property access)
|
||||
*
|
||||
* This is stricter than selector-purity, which allows page-level methods
|
||||
* like keyboard, evaluate, etc. in both composables and tests.
|
||||
*/
|
||||
export class NoPageInFlowRule extends BaseRule {
|
||||
readonly id = 'no-page-in-flow';
|
||||
readonly name = 'No Page In Flow';
|
||||
readonly severity = 'warning' as const;
|
||||
|
||||
get description(): string {
|
||||
const config = getConfig();
|
||||
return `${config.flowLayerName}s should use page objects, not direct page access`;
|
||||
}
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
return getConfig().patterns.flows;
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const config = getConfig();
|
||||
const fixtureName = config.fixtureObjectName;
|
||||
const flowLayerName = config.flowLayerName.toLowerCase();
|
||||
|
||||
// Build regex to match this.<fixture>.page.<method>
|
||||
const pageAccessRegex = new RegExp(`^this\\.${fixtureName}\\.page\\.(\\w+)`);
|
||||
|
||||
for (const file of files) {
|
||||
const propAccesses = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);
|
||||
const flaggedPositions = new Set<string>();
|
||||
|
||||
for (const propAccess of propAccesses) {
|
||||
const text = propAccess.getText();
|
||||
const match = text.match(pageAccessRegex);
|
||||
|
||||
if (match) {
|
||||
// Check if this matches any allow patterns
|
||||
if (this.isAllowed(text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startLine = propAccess.getStartLineNumber();
|
||||
const startColumn = propAccess.getStart() - propAccess.getStartLinePos();
|
||||
const posKey = `${startLine}:${startColumn}`;
|
||||
|
||||
if (flaggedPositions.has(posKey)) {
|
||||
continue;
|
||||
}
|
||||
flaggedPositions.add(posKey);
|
||||
|
||||
const methodName = match[1];
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
startLine,
|
||||
startColumn,
|
||||
`Direct page access in ${flowLayerName}: this.${fixtureName}.page.${methodName}`,
|
||||
this.getSuggestion(methodName),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate suggestion based on the method being called
|
||||
*/
|
||||
private getSuggestion(methodName: string): string {
|
||||
if (methodName === 'keyboard') {
|
||||
return 'Create a helper method in the relevant page object for keyboard interactions';
|
||||
}
|
||||
if (methodName === 'evaluate') {
|
||||
return 'Create a helper method in the relevant page object for evaluate calls';
|
||||
}
|
||||
if (methodName === 'reload') {
|
||||
return 'Use a page object method to handle page reload with proper waiting';
|
||||
}
|
||||
if (methodName === 'goto') {
|
||||
return 'Use the page object navigation method instead';
|
||||
}
|
||||
if (methodName.startsWith('waitFor')) {
|
||||
return 'Use a page object method for waiting operations';
|
||||
}
|
||||
if (LOCATOR_METHODS.includes(methodName)) {
|
||||
return 'Use page object methods instead of direct locators';
|
||||
}
|
||||
if (methodName === 'url') {
|
||||
return 'Use a page object method to check URL state';
|
||||
}
|
||||
return 'Wrap this page interaction in a page object method';
|
||||
}
|
||||
}
|
||||
177
packages/testing/janitor/src/rules/scope-lockdown.rule.test.ts
Normal file
177
packages/testing/janitor/src/rules/scope-lockdown.rule.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { ScopeLockdownRule } from './scope-lockdown.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('ScopeLockdownRule', () => {
|
||||
const rule = new ScopeLockdownRule();
|
||||
|
||||
test('detects unscoped locator calls when container exists', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
get container() {
|
||||
return this.page.getByTestId('root');
|
||||
}
|
||||
|
||||
getSomething() {
|
||||
return this.page.getByTestId('x');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
const unscopedCall = violations.find((v) => v.message.includes('Unscoped locator'));
|
||||
expect(unscopedCall).toBeDefined();
|
||||
});
|
||||
|
||||
test('allows properly scoped locators', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
get container() {
|
||||
return this.page.getByTestId('root');
|
||||
}
|
||||
|
||||
getSomething() {
|
||||
return this.container.getByTestId('x');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('allows page-level methods like goto and waitForResponse', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/TestPage.ts',
|
||||
`
|
||||
export class TestPage {
|
||||
get container() {
|
||||
return this.page.getByTestId('root');
|
||||
}
|
||||
|
||||
async navigate() {
|
||||
await this.page.goto('/test');
|
||||
await this.page.waitForResponse('/api/test');
|
||||
await this.page.reload();
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips component files', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/components/TestComponent.ts',
|
||||
`
|
||||
export class TestComponent {
|
||||
constructor(private root: Locator) {}
|
||||
|
||||
getSomething() {
|
||||
return this.root.getByTestId('x');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips BasePage.ts', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/BasePage.ts',
|
||||
`
|
||||
export class BasePage {
|
||||
protected clickByTestId(testId: string) {
|
||||
return this.page.getByTestId(testId).click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('allows standalone pages with navigation method', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/LoginPage.ts',
|
||||
`
|
||||
export class LoginPage {
|
||||
async goto() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login() {
|
||||
await this.page.getByTestId('email').fill('test@test.com');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
// Has navigation method = explicit standalone page, no violations
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects ambiguous pages without container or navigation method', ({
|
||||
project,
|
||||
createFile,
|
||||
}) => {
|
||||
const file = createFile(
|
||||
'/pages/AmbiguousPage.ts',
|
||||
`
|
||||
export class AmbiguousPage {
|
||||
async doSomething() {
|
||||
await this.page.getByTestId('email').fill('test@test.com');
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
// No container AND no navigation method = ambiguous
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('Ambiguous page');
|
||||
});
|
||||
|
||||
test('allows custom navigation method names from config', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/pages/SettingsPage.ts',
|
||||
`
|
||||
export class SettingsPage {
|
||||
async navigate() {
|
||||
await this.page.goto('/settings');
|
||||
}
|
||||
|
||||
async toggleOption() {
|
||||
await this.page.getByTestId('toggle').click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
// 'navigate' is in default config, so this should pass
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
167
packages/testing/janitor/src/rules/scope-lockdown.rule.ts
Normal file
167
packages/testing/janitor/src/rules/scope-lockdown.rule.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import {
|
||||
SyntaxKind,
|
||||
type Project,
|
||||
type SourceFile,
|
||||
type CallExpression,
|
||||
type ClassDeclaration,
|
||||
} from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import {
|
||||
hasContainerMember,
|
||||
getCallExpressions,
|
||||
isUnscopedPageCall,
|
||||
isPageLevelMethod,
|
||||
isLocatorCall,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import { isExcludedPage, isComponentFile } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Check if a call expression is inside a container getter or property definition
|
||||
*/
|
||||
function isInsideContainerDefinition(call: CallExpression): boolean {
|
||||
let ancestor = call.getParent();
|
||||
while (ancestor) {
|
||||
// Check if we're inside a getter accessor named 'container'
|
||||
if (ancestor.getKind() === SyntaxKind.GetAccessor) {
|
||||
const getter = ancestor.asKind(SyntaxKind.GetAccessor);
|
||||
if (getter?.getName() === 'container') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check if we're inside a property declaration named 'container'
|
||||
if (ancestor.getKind() === SyntaxKind.PropertyDeclaration) {
|
||||
const prop = ancestor.asKind(SyntaxKind.PropertyDeclaration);
|
||||
if (prop?.getName() === 'container') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
ancestor = ancestor.getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a class has a navigation method (indicating it's a standalone top-level page)
|
||||
*/
|
||||
function hasNavigationMethod(classDecl: ClassDeclaration, methodNames: string[]): boolean {
|
||||
const methods = classDecl.getMethods();
|
||||
return methods.some((method) => methodNames.includes(method.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope Lockdown Rule
|
||||
*
|
||||
* Page classes with a container must scope all locators to that container.
|
||||
* This ensures isolation and prevents selector conflicts.
|
||||
*
|
||||
* A page must either:
|
||||
* 1. Have a container property/getter and use it for all locators (scoped component/section)
|
||||
* 2. Have a navigation method (standalone top-level page, can use this.page directly)
|
||||
*
|
||||
* Violations:
|
||||
* - Page with container using this.page.getByTestId() instead of this.container.getByTestId()
|
||||
* - Page with neither container nor navigation method (ambiguous architecture)
|
||||
*
|
||||
* Allowed:
|
||||
* - this.page.goto(), this.page.waitForURL() etc. (page-level operations)
|
||||
* - Pages with navigation methods (explicit standalone pages)
|
||||
*/
|
||||
export class ScopeLockdownRule extends BaseRule {
|
||||
readonly id = 'scope-lockdown';
|
||||
readonly name = 'Scope Lockdown';
|
||||
readonly description = 'Page locators must be scoped to container';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
private getNavigationMethods(): string[] {
|
||||
const config = getConfig();
|
||||
return config.rules?.['scope-lockdown']?.navigationMethods ?? ['goto'];
|
||||
}
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return config.patterns.pages;
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const navigationMethods = this.getNavigationMethods();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = file.getFilePath();
|
||||
|
||||
// Skip excluded files
|
||||
if (isExcludedPage(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip component files (they use a different pattern with root locator)
|
||||
if (isComponentFile(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const classDecl of file.getClasses()) {
|
||||
const className = classDecl.getName() ?? 'Anonymous';
|
||||
const hasContainer = hasContainerMember(classDecl);
|
||||
const hasNavMethod = hasNavigationMethod(classDecl, navigationMethods);
|
||||
|
||||
// Check for ambiguous pages (neither container nor navigation method)
|
||||
if (!hasContainer && !hasNavMethod) {
|
||||
const startLine = classDecl.getStartLineNumber();
|
||||
const startColumn = classDecl.getStart() - classDecl.getStartLinePos();
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
startLine,
|
||||
startColumn,
|
||||
`${className}: Ambiguous page - add a container (for scoped components) or a navigation method (for top-level pages)`,
|
||||
`Add a 'container' getter or a navigation method (${navigationMethods.join(', ')})`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip classes without container but with navigation method (explicit standalone pages)
|
||||
if (!hasContainer && hasNavMethod) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For classes with container, check for unscoped locator calls
|
||||
const calls = getCallExpressions(classDecl);
|
||||
|
||||
for (const call of calls) {
|
||||
// Skip page-level methods (goto, waitForURL, etc.)
|
||||
if (isPageLevelMethod(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip locator calls inside container definitions (e.g., get container() { return this.page.locator(...) })
|
||||
if (isInsideContainerDefinition(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for unscoped page locator calls
|
||||
if (isUnscopedPageCall(call) && isLocatorCall(call)) {
|
||||
const startLine = call.getStartLineNumber();
|
||||
const startColumn = call.getStart() - call.getStartLinePos();
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
startLine,
|
||||
startColumn,
|
||||
`${className}: Unscoped locator - use this.container instead of this.page`,
|
||||
'Replace this.page.getByTestId(...) with this.container.getByTestId(...)',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
166
packages/testing/janitor/src/rules/selector-purity.rule.test.ts
Normal file
166
packages/testing/janitor/src/rules/selector-purity.rule.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { describe } from 'vitest';
|
||||
|
||||
import { SelectorPurityRule } from './selector-purity.rule.js';
|
||||
import { test, expect } from '../test/fixtures.js';
|
||||
|
||||
describe('SelectorPurityRule', () => {
|
||||
const rule = new SelectorPurityRule();
|
||||
|
||||
test('detects direct n8n.page.getByTestId in composable', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/TestComposer.ts',
|
||||
`
|
||||
export class TestComposer {
|
||||
constructor(private n8n: n8nPage) {}
|
||||
|
||||
async doSomething() {
|
||||
await this.n8n.page.getByTestId('something').click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('allows page object method calls', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/TestComposer.ts',
|
||||
`
|
||||
export class TestComposer {
|
||||
constructor(private n8n: n8nPage) {}
|
||||
|
||||
async doSomething() {
|
||||
await this.n8n.canvas.openNode('Code');
|
||||
await this.n8n.ndv.close();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('allows page.keyboard and page.evaluate', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/TestComposer.ts',
|
||||
`
|
||||
export class TestComposer {
|
||||
constructor(private n8n: n8nPage) {}
|
||||
|
||||
async doSomething() {
|
||||
await this.n8n.page.keyboard.press('Enter');
|
||||
await this.n8n.page.evaluate(() => console.log('test'));
|
||||
await this.n8n.page.reload();
|
||||
await this.n8n.page.waitForLoadState();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects direct page.getByTestId in test files', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/my-test.spec.ts',
|
||||
`
|
||||
test('my test', async ({ n8n }) => {
|
||||
await n8n.page.getByTestId('something').click();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('detects various locator methods', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/composables/TestComposer.ts',
|
||||
`
|
||||
export class TestComposer {
|
||||
constructor(private n8n: n8nPage) {}
|
||||
|
||||
async doSomething() {
|
||||
await this.n8n.page.locator('.class').click();
|
||||
await this.n8n.page.getByRole('button').click();
|
||||
await this.n8n.page.getByText('text').click();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('detects chained locator calls on variables in test files', ({ project, createFile }) => {
|
||||
const file = createFile(
|
||||
'/tests/e2e/my-test.spec.ts',
|
||||
`
|
||||
test('my test', async ({ n8n }) => {
|
||||
const category = n8n.settingsPage.getCategory('credentials');
|
||||
const links = category.locator('a[href*="/workflow/"]');
|
||||
await expect(links.first()).toBeVisible();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations.length).toBeGreaterThan(0);
|
||||
expect(violations[0].message).toContain('Chained locator call');
|
||||
});
|
||||
|
||||
test('detects chained getByRole on variables in test files', ({ project, createFile }) => {
|
||||
// Use a simpler test case that mirrors the working test more closely
|
||||
const file = createFile(
|
||||
'/tests/e2e/another-test.spec.ts',
|
||||
`
|
||||
test('my test', async ({ n8n }) => {
|
||||
const category = n8n.settingsPage.getCategory('credentials');
|
||||
const button = category.getByRole('button');
|
||||
await expect(button).toBeVisible();
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations.length).toBeGreaterThan(0);
|
||||
expect(violations[0].message).toContain('Chained locator call');
|
||||
});
|
||||
|
||||
test('allows this.container.locator in page objects (not test files)', ({
|
||||
project,
|
||||
createFile,
|
||||
}) => {
|
||||
// This is a composable file, not a test file, so chained locator detection doesn't apply
|
||||
const file = createFile(
|
||||
'/composables/TestComposer.ts',
|
||||
`
|
||||
export class TestComposer {
|
||||
constructor(private n8n: n8nPage) {}
|
||||
|
||||
async doSomething() {
|
||||
// This uses page object methods, not direct locators
|
||||
await this.n8n.modal.getSubmitButton();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const violations = rule.analyze(project, [file]);
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
314
packages/testing/janitor/src/rules/selector-purity.rule.ts
Normal file
314
packages/testing/janitor/src/rules/selector-purity.rule.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { SyntaxKind, type Project, type SourceFile, type CallExpression } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation } from '../types.js';
|
||||
import { LOCATOR_METHODS, PAGE_LEVEL_METHODS, truncateText } from '../utils/ast-helpers.js';
|
||||
import { matchesPatterns } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Selector Purity Rule
|
||||
*
|
||||
* Ensures composables and tests don't use direct page.locator() calls.
|
||||
* All selector interactions should go through page objects.
|
||||
*
|
||||
* Violations:
|
||||
* - fixture.page.getByTestId(), this.fixture.page.locator(), etc.
|
||||
* - page.getByTestId() in test files
|
||||
* - Chained locator calls: someLocator.locator(), someLocator.getByRole(), etc.
|
||||
* (where someLocator is a variable holding a Locator returned from a page object)
|
||||
*
|
||||
* Exceptions (legitimate page-level operations):
|
||||
* - page.keyboard.*
|
||||
* - page.evaluate()
|
||||
* - page.reload()
|
||||
* - page.waitForURL()
|
||||
* - page.waitForLoadState()
|
||||
* - page.goto()
|
||||
*
|
||||
* Configuration:
|
||||
* - allowInExpect: Skip violations inside expect() calls (default: false)
|
||||
*/
|
||||
export class SelectorPurityRule extends BaseRule {
|
||||
readonly id = 'selector-purity';
|
||||
readonly name = 'Selector Purity';
|
||||
readonly description = 'Composables and tests should use page objects, not direct locators';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
return [...config.patterns.flows, ...config.patterns.tests];
|
||||
}
|
||||
|
||||
analyze(_project: Project, files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const config = getConfig();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = file.getFilePath();
|
||||
const isTestFile = matchesPatterns(filePath, config.patterns.tests);
|
||||
|
||||
// Find all call expressions
|
||||
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
|
||||
for (const call of calls) {
|
||||
const expr = call.getExpression();
|
||||
const text = expr.getText();
|
||||
|
||||
// Check for direct page locator patterns
|
||||
if (this.isDirectPageLocator(text)) {
|
||||
// Skip Playwright assertion methods (toBeVisible, etc.)
|
||||
if (this.isPlaywrightAssertionCall(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if it's inside an expect() call with page object
|
||||
if (this.isExpectWithPageObject(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip violations inside expect() if allowInExpect is enabled
|
||||
// Check both runtime config and config file settings
|
||||
const ruleSettings = getConfig().rules?.[this.id];
|
||||
const allowInExpect = this.config.allowInExpect ?? ruleSettings?.allowInExpect;
|
||||
if (allowInExpect && this.isInsideExpect(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startLine = call.getStartLineNumber();
|
||||
const startColumn = call.getStart() - call.getStartLinePos();
|
||||
const truncated = truncateText(call.getText(), 60);
|
||||
|
||||
// Determine the source pattern for better suggestion
|
||||
const suggestion = this.getSuggestion(text);
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
startLine,
|
||||
startColumn,
|
||||
`Direct page locator call: ${truncated}`,
|
||||
suggestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Check for chained locator calls in test files (e.g., someLocator.locator())
|
||||
if (isTestFile && this.isChainedLocatorCallAST(call)) {
|
||||
// Skip Playwright assertion methods
|
||||
if (this.isPlaywrightAssertionCall(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip violations inside expect() if allowInExpect is enabled
|
||||
const ruleSettings = getConfig().rules?.[this.id];
|
||||
const allowInExpect = this.config.allowInExpect ?? ruleSettings?.allowInExpect;
|
||||
if (allowInExpect && this.isInsideExpect(call)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startLine = call.getStartLineNumber();
|
||||
const startColumn = call.getStart() - call.getStartLinePos();
|
||||
const truncated = truncateText(call.getText(), 60);
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
file,
|
||||
startLine,
|
||||
startColumn,
|
||||
`Chained locator call in test: ${truncated}`,
|
||||
'Move selector to page object method instead of chaining .locator() in tests',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if expression is a direct page locator call
|
||||
*/
|
||||
private isDirectPageLocator(text: string): boolean {
|
||||
const config = getConfig();
|
||||
const fixtureName = config.fixtureObjectName;
|
||||
|
||||
for (const method of LOCATOR_METHODS) {
|
||||
// Fixture.page.* patterns (e.g., app.page.getByTestId)
|
||||
if (text.includes(`.${fixtureName}.page.${method}`)) {
|
||||
return true;
|
||||
}
|
||||
// Direct page.* patterns (tests)
|
||||
const directPagePattern = new RegExp(`(?<!\\w)page\\.${method}`);
|
||||
if (directPagePattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
const thisPagePattern = new RegExp(`this\\.page\\.${method}`);
|
||||
if (thisPagePattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the call is inside an expect() with a page object locator
|
||||
*/
|
||||
private isExpectWithPageObject(call: ReturnType<SourceFile['getDescendantsOfKind']>[0]): boolean {
|
||||
const text = call.getText();
|
||||
|
||||
// If the call itself uses page object and not direct page locator, it's fine
|
||||
if (!text.includes('.page.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for legitimate page-level methods
|
||||
for (const method of PAGE_LEVEL_METHODS) {
|
||||
if (text.includes(`.page.${method}`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Playwright assertion methods that chain after expect() */
|
||||
private static ASSERTION_METHODS = [
|
||||
'toBeVisible',
|
||||
'toBeHidden',
|
||||
'toBeEnabled',
|
||||
'toBeDisabled',
|
||||
'toBeChecked',
|
||||
'toBeEditable',
|
||||
'toBeEmpty',
|
||||
'toBeFocused',
|
||||
'toBeAttached',
|
||||
'toContainText',
|
||||
'toHaveText',
|
||||
'toHaveValue',
|
||||
'toHaveValues',
|
||||
'toHaveAttribute',
|
||||
'toHaveClass',
|
||||
'toHaveCount',
|
||||
'toHaveCSS',
|
||||
'toHaveId',
|
||||
'toHaveJSProperty',
|
||||
'toHaveScreenshot',
|
||||
'toHaveTitle',
|
||||
'toHaveURL',
|
||||
'not',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the call is inside an expect() call
|
||||
*/
|
||||
private isInsideExpect(call: CallExpression): boolean {
|
||||
let current = call.getParent();
|
||||
while (current) {
|
||||
if (current.getKind() === SyntaxKind.CallExpression) {
|
||||
const callExpr = current.asKind(SyntaxKind.CallExpression);
|
||||
const exprText = callExpr?.getExpression().getText();
|
||||
if (exprText === 'expect') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this call is a Playwright assertion method
|
||||
*/
|
||||
private isPlaywrightAssertionCall(call: CallExpression): boolean {
|
||||
const expr = call.getExpression();
|
||||
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
||||
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
|
||||
const methodName = propAccess?.getName();
|
||||
if (methodName && SelectorPurityRule.ASSERTION_METHODS.includes(methodName)) {
|
||||
// Verify this is chained from expect()
|
||||
const exprText = propAccess?.getExpression().getText() ?? '';
|
||||
if (exprText.startsWith('expect(') || exprText.startsWith('expect.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a call expression is a chained locator call on a variable (not page/container)
|
||||
* e.g., someLocator.locator('...'), category.getByRole('...')
|
||||
*
|
||||
* Uses AST analysis to properly identify the call structure.
|
||||
*/
|
||||
private isChainedLocatorCallAST(call: CallExpression): boolean {
|
||||
const expr = call.getExpression();
|
||||
|
||||
// Must be a property access (e.g., something.locator)
|
||||
if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
|
||||
if (!propAccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const methodName = propAccess.getName();
|
||||
|
||||
// Must be a locator method
|
||||
if (!LOCATOR_METHODS.includes(methodName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the object the method is called on
|
||||
const objectExpr = propAccess.getExpression();
|
||||
const objectText = objectExpr.getText();
|
||||
|
||||
// Skip direct page access (handled by isDirectPageLocator)
|
||||
if (objectText === 'page' || objectText === 'this.page') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip fixture.page access
|
||||
const config = getConfig();
|
||||
if (objectText === `${config.fixtureObjectName}.page`) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip container-based calls (legitimate in page objects)
|
||||
if (objectText === 'container' || objectText === 'this.container') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if object starts with 'this.' (page object internal method)
|
||||
// e.g., this.getContainer().locator() is fine
|
||||
if (objectText.startsWith('this.') && !objectText.includes('.page')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// At this point, we have a locator method called on something else
|
||||
// This is a chained call on a variable holding a Locator
|
||||
// e.g., const category = n8n.settingsPage.getCategory(); category.locator('...')
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate suggestion based on the pattern
|
||||
*/
|
||||
private getSuggestion(text: string): string {
|
||||
const config = getConfig();
|
||||
const fixtureName = config.fixtureObjectName;
|
||||
|
||||
if (text.includes(`.${fixtureName}.page.`)) {
|
||||
return `Use page object methods instead: ${fixtureName}.<pageObject>.<method>()`;
|
||||
}
|
||||
if (text.includes('page.getByTestId')) {
|
||||
return `Use page object methods from ${fixtureName} fixture instead of direct locators`;
|
||||
}
|
||||
return 'Extract selector to appropriate page object class';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { Project, type SourceFile } from 'ts-morph';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { TestDataHygieneRule } from './test-data-hygiene.rule.js';
|
||||
import { setConfig, resetConfig, defineConfig } from '../config.js';
|
||||
|
||||
/** Test interface to access private members of TestDataHygieneRule */
|
||||
interface TestableTestDataHygieneRule {
|
||||
badNamePatterns: RegExp[];
|
||||
buildReferenceIndex(files: SourceFile[]): {
|
||||
fileNames: Set<string>;
|
||||
folderNames: Set<string>;
|
||||
};
|
||||
isOrphaned(
|
||||
relativePath: string,
|
||||
fileName: string,
|
||||
dirName: string,
|
||||
references: { fileNames: Set<string>; folderNames: Set<string> },
|
||||
): boolean;
|
||||
}
|
||||
|
||||
describe('TestDataHygieneRule', () => {
|
||||
let project: Project;
|
||||
let rule: TestDataHygieneRule;
|
||||
|
||||
beforeEach(() => {
|
||||
project = new Project({ useInMemoryFileSystem: true });
|
||||
rule = new TestDataHygieneRule();
|
||||
|
||||
setConfig(
|
||||
defineConfig({
|
||||
rootDir: '/',
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts'],
|
||||
factories: ['factories/**/*.ts'],
|
||||
testData: ['workflows/**/*', 'expectations/**/*'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
it('detects generic workflow names', () => {
|
||||
// Access private property for testing the patterns
|
||||
const badNames = [
|
||||
'Test_workflow_1.json',
|
||||
'test.json',
|
||||
'data.json',
|
||||
'workflow_2.json',
|
||||
'cat-1801.json',
|
||||
];
|
||||
|
||||
const patterns = (rule as unknown as TestableTestDataHygieneRule).badNamePatterns;
|
||||
|
||||
for (const name of badNames) {
|
||||
const matches = patterns.some((p) => p.test(name));
|
||||
expect(matches).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows descriptive workflow names', () => {
|
||||
const goodNames = [
|
||||
'webhook-with-wait.json',
|
||||
'ai-agent-tool-call.json',
|
||||
'merge-node-multiple-inputs.json',
|
||||
'simple-http-request.json',
|
||||
'subworkflow-child-workflow.json',
|
||||
];
|
||||
|
||||
const patterns = (rule as unknown as TestableTestDataHygieneRule).badNamePatterns;
|
||||
|
||||
for (const name of goodNames) {
|
||||
const matches = patterns.some((p) => p.test(name));
|
||||
expect(matches).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('detects ticket-only names', () => {
|
||||
const ticketNames = ['cat-1801.json', 'CAT-123.json', 'ado-456.json', 'SUG-38.json'];
|
||||
|
||||
const patterns = (rule as unknown as TestableTestDataHygieneRule).badNamePatterns;
|
||||
|
||||
for (const name of ticketNames) {
|
||||
const matches = patterns.some((p) => p.test(name));
|
||||
expect(matches).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds workflow file references', () => {
|
||||
const file = project.createSourceFile(
|
||||
'/tests/workflow.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('imports workflow', async ({ n8n }) => {
|
||||
await n8n.workflows.import('my-workflow.json');
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const refs = (rule as unknown as TestableTestDataHygieneRule).buildReferenceIndex([file]);
|
||||
|
||||
expect(refs.fileNames.has('my-workflow.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('finds loadExpectations folder references', () => {
|
||||
const file = project.createSourceFile(
|
||||
'/tests/proxy.spec.ts',
|
||||
`
|
||||
import { test } from '../fixtures/base';
|
||||
|
||||
test('loads expectations', async ({ proxyServer }) => {
|
||||
await proxyServer.loadExpectations('langchain');
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const refs = (rule as unknown as TestableTestDataHygieneRule).buildReferenceIndex([file]);
|
||||
|
||||
expect(refs.folderNames.has('langchain')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects orphaned expectation folders', () => {
|
||||
const refs = { fileNames: new Set<string>(), folderNames: new Set(['langchain']) };
|
||||
|
||||
// langchain folder is referenced
|
||||
const langchainOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
|
||||
'expectations/langchain/test.json',
|
||||
'test.json',
|
||||
'expectations/langchain',
|
||||
refs,
|
||||
);
|
||||
expect(langchainOrphaned).toBe(false);
|
||||
|
||||
// unused-folder is not referenced
|
||||
const unusedOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
|
||||
'expectations/unused-folder/test.json',
|
||||
'test.json',
|
||||
'expectations/unused-folder',
|
||||
refs,
|
||||
);
|
||||
expect(unusedOrphaned).toBe(true);
|
||||
});
|
||||
|
||||
it('detects orphaned workflow files', () => {
|
||||
const refs = { fileNames: new Set(['used-workflow.json']), folderNames: new Set<string>() };
|
||||
|
||||
const usedOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
|
||||
'workflows/used-workflow.json',
|
||||
'used-workflow.json',
|
||||
'workflows',
|
||||
refs,
|
||||
);
|
||||
expect(usedOrphaned).toBe(false);
|
||||
|
||||
const unusedOrphaned = (rule as unknown as TestableTestDataHygieneRule).isOrphaned(
|
||||
'workflows/unused-workflow.json',
|
||||
'unused-workflow.json',
|
||||
'workflows',
|
||||
refs,
|
||||
);
|
||||
expect(unusedOrphaned).toBe(true);
|
||||
});
|
||||
});
|
||||
256
packages/testing/janitor/src/rules/test-data-hygiene.rule.ts
Normal file
256
packages/testing/janitor/src/rules/test-data-hygiene.rule.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { SyntaxKind, type Project, type SourceFile } from 'ts-morph';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { getConfig } from '../config.js';
|
||||
import type { Violation, FixResult } from '../types.js';
|
||||
import { getRootDir, getRelativePath, getTestDataFiles } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Test Data Hygiene Rule
|
||||
*
|
||||
* Ensures test data files (workflows, expectations, etc.) are:
|
||||
* 1. Well-named (descriptive, not generic or hash-based)
|
||||
* 2. Actually used (not orphaned)
|
||||
*
|
||||
* Naming violations:
|
||||
* - Generic names: Test_workflow_1.json, test.json, data.json
|
||||
* - Ticket-only names: cat-1801.json (no description)
|
||||
* - Hash/timestamp names: 1766027608058-...-2ef5ce20.json
|
||||
*
|
||||
* Orphan detection:
|
||||
* - Workflows: filename must be referenced in test files
|
||||
* - Expectations: folder must be referenced via loadExpectations()
|
||||
*/
|
||||
export class TestDataHygieneRule extends BaseRule {
|
||||
readonly id = 'test-data-hygiene';
|
||||
readonly name = 'Test Data Hygiene';
|
||||
readonly description = 'Ensure test data files are well-named and actually used';
|
||||
readonly severity = 'warning' as const;
|
||||
readonly fixable = true;
|
||||
|
||||
// Patterns that indicate poor naming
|
||||
private readonly badNamePatterns = [
|
||||
// Generic names
|
||||
/^test[_-]?\d*\.json$/i,
|
||||
/^data[_-]?\d*\.json$/i,
|
||||
/^workflow[_-]?\d*\.json$/i,
|
||||
/^Test_workflow_\d+\.json$/,
|
||||
// Ticket-only names (e.g., cat-1801.json, CAT-123.json)
|
||||
/^[a-z]{2,5}[_-]\d+\.json$/i,
|
||||
// Timestamp/hash soup (proxy-generated)
|
||||
/^\d{10,}-.*-[a-f0-9]{6,}\.json$/i,
|
||||
];
|
||||
|
||||
// Folders that use folder-based loading (expectations)
|
||||
private readonly folderBasedPaths = ['expectations'];
|
||||
|
||||
getTargetGlobs(): string[] {
|
||||
const config = getConfig();
|
||||
// We analyze test files, flows, fixtures, and helpers to find references
|
||||
return [
|
||||
...config.patterns.tests,
|
||||
...config.patterns.flows,
|
||||
...config.patterns.fixtures,
|
||||
...config.patterns.helpers,
|
||||
];
|
||||
}
|
||||
|
||||
analyze(project: Project, _files: SourceFile[]): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const config = getConfig();
|
||||
const root = getRootDir();
|
||||
|
||||
// Get all test data files
|
||||
const testDataFiles = getTestDataFiles(config.patterns.testData);
|
||||
|
||||
// Always load ALL test/flow/fixture/helper files for reference checking
|
||||
// This ensures orphan detection works correctly even in targeted file mode (TCR)
|
||||
const allReferenceGlobs = [
|
||||
...config.patterns.tests,
|
||||
...config.patterns.flows,
|
||||
...config.patterns.fixtures,
|
||||
...config.patterns.helpers,
|
||||
];
|
||||
const allReferenceFiles = this.loadAllFiles(project, allReferenceGlobs, root);
|
||||
|
||||
// Build reference index from ALL test files
|
||||
const references = this.buildReferenceIndex(allReferenceFiles);
|
||||
|
||||
for (const dataFile of testDataFiles) {
|
||||
const relativePath = path.relative(root, dataFile);
|
||||
const fileName = path.basename(dataFile);
|
||||
const dirName = path.dirname(relativePath);
|
||||
|
||||
// Check naming
|
||||
const namingViolation = this.checkNaming(fileName, relativePath, root);
|
||||
if (namingViolation) {
|
||||
violations.push(namingViolation);
|
||||
}
|
||||
|
||||
// Check if orphaned
|
||||
const isOrphaned = this.isOrphaned(relativePath, fileName, dirName, references);
|
||||
if (isOrphaned) {
|
||||
violations.push(
|
||||
this.createViolationForFile(
|
||||
relativePath,
|
||||
root,
|
||||
`Orphaned test data: ${fileName} is not referenced in any test`,
|
||||
'Remove the file or add a test that uses it',
|
||||
true, // fixable
|
||||
{ filePath: dataFile },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private buildReferenceIndex(files: SourceFile[]): {
|
||||
fileNames: Set<string>;
|
||||
folderNames: Set<string>;
|
||||
} {
|
||||
const fileNames = new Set<string>();
|
||||
const folderNames = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
// Use AST to find actual string literals (excludes comments)
|
||||
const stringLiterals = file.getDescendantsOfKind(SyntaxKind.StringLiteral);
|
||||
|
||||
for (const literal of stringLiterals) {
|
||||
const value = literal.getLiteralText();
|
||||
|
||||
// Check for .json file references
|
||||
if (value.endsWith('.json')) {
|
||||
const fileName = path.basename(value);
|
||||
fileNames.add(fileName);
|
||||
}
|
||||
|
||||
// Check for loadExpectations calls
|
||||
const parent = literal.getParent();
|
||||
if (parent?.isKind(SyntaxKind.CallExpression)) {
|
||||
const callExpr = parent.asKindOrThrow(SyntaxKind.CallExpression);
|
||||
const exprText = callExpr.getExpression().getText();
|
||||
if (exprText.endsWith('loadExpectations')) {
|
||||
folderNames.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fileNames, folderNames };
|
||||
}
|
||||
|
||||
private checkNaming(fileName: string, relativePath: string, _root: string): Violation | null {
|
||||
// Skip checking expectations - they're auto-generated
|
||||
if (relativePath.startsWith('expectations/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const pattern of this.badNamePatterns) {
|
||||
if (pattern.test(fileName)) {
|
||||
return this.createViolationForFile(
|
||||
relativePath,
|
||||
getRootDir(),
|
||||
`Poorly named test data: ${fileName}`,
|
||||
'Use a descriptive name like "webhook-with-wait.json" or "ai-agent-tool-call.json"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private loadAllFiles(project: Project, globs: string[], root: string): SourceFile[] {
|
||||
const files: SourceFile[] = [];
|
||||
for (const glob of globs) {
|
||||
const absoluteGlob = path.join(root, glob);
|
||||
const added = project.addSourceFilesAtPaths(absoluteGlob);
|
||||
files.push(...added);
|
||||
}
|
||||
// Deduplicate
|
||||
const uniqueFiles = new Map<string, SourceFile>();
|
||||
for (const file of files) {
|
||||
uniqueFiles.set(file.getFilePath(), file);
|
||||
}
|
||||
return Array.from(uniqueFiles.values());
|
||||
}
|
||||
|
||||
private isOrphaned(
|
||||
relativePath: string,
|
||||
fileName: string,
|
||||
_dirName: string,
|
||||
references: { fileNames: Set<string>; folderNames: Set<string> },
|
||||
): boolean {
|
||||
// Check if this is a folder-based path (expectations)
|
||||
const isFolderBased = this.folderBasedPaths.some((p) => relativePath.startsWith(p + '/'));
|
||||
|
||||
if (isFolderBased) {
|
||||
// For expectations, check if the immediate parent folder is referenced
|
||||
// expectations/langchain/file.json -> check for 'langchain'
|
||||
const parts = relativePath.split('/');
|
||||
if (parts.length >= 2) {
|
||||
const expectationFolder = parts[1]; // e.g., 'langchain'
|
||||
return !references.folderNames.has(expectationFolder);
|
||||
}
|
||||
}
|
||||
|
||||
// For workflows and other files, check if filename is referenced
|
||||
return !references.fileNames.has(fileName);
|
||||
}
|
||||
|
||||
private createViolationForFile(
|
||||
relativePath: string,
|
||||
root: string,
|
||||
message: string,
|
||||
suggestion: string,
|
||||
fixable?: boolean,
|
||||
fixData?: { filePath: string },
|
||||
): Violation {
|
||||
return {
|
||||
file: path.join(root, relativePath),
|
||||
line: 1,
|
||||
column: 0,
|
||||
rule: this.id,
|
||||
message,
|
||||
severity: this.severity,
|
||||
suggestion,
|
||||
fixable,
|
||||
fixData: fixData ? { type: 'edit', replacement: JSON.stringify(fixData) } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
fix(_project: Project, violations: Violation[], write: boolean): FixResult[] {
|
||||
const results: FixResult[] = [];
|
||||
|
||||
for (const violation of violations) {
|
||||
if (!violation.fixable || !violation.fixData) continue;
|
||||
|
||||
// Parse the fixData
|
||||
if (violation.fixData.type !== 'edit') continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(violation.fixData.replacement) as { filePath: string };
|
||||
const filePath = data.filePath;
|
||||
const relativePath = getRelativePath(filePath);
|
||||
|
||||
results.push({
|
||||
file: relativePath,
|
||||
action: 'remove-file',
|
||||
target: path.basename(filePath),
|
||||
applied: write,
|
||||
});
|
||||
|
||||
if (write && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid fix data
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
45
packages/testing/janitor/src/test/fixtures.ts
Normal file
45
packages/testing/janitor/src/test/fixtures.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Project, type SourceFile } from 'ts-morph';
|
||||
import { test as base, expect } from 'vitest';
|
||||
|
||||
import { setConfig, resetConfig, defineConfig, type DefineConfigInput } from '../config.js';
|
||||
|
||||
export interface RuleTestContext {
|
||||
project: Project;
|
||||
createFile: (path: string, code: string) => SourceFile;
|
||||
}
|
||||
|
||||
const defaultTestConfig: DefineConfigInput = {
|
||||
rootDir: '/',
|
||||
excludeFromPages: ['BasePage.ts'],
|
||||
fixtureObjectName: 'n8n',
|
||||
apiFixtureName: 'api',
|
||||
flowLayerName: 'Composable',
|
||||
rawApiPatterns: [/\brequest\.(get|post|put|patch|delete|head)\s*\(/i, /\bfetch\s*\(/],
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: ['helpers/**/*.ts'],
|
||||
factories: ['factories/**/*.ts'],
|
||||
testData: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const test = base.extend<RuleTestContext>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
project: async ({ task: _ }, use) => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
setConfig(defineConfig(defaultTestConfig));
|
||||
await use(project);
|
||||
resetConfig();
|
||||
},
|
||||
|
||||
createFile: async ({ project }, use) => {
|
||||
await use((path: string, code: string) => project.createSourceFile(path, code));
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
190
packages/testing/janitor/src/types.ts
Normal file
190
packages/testing/janitor/src/types.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Core Types for @n8n/playwright-janitor
|
||||
*/
|
||||
|
||||
export type {
|
||||
Project,
|
||||
SourceFile,
|
||||
CallExpression,
|
||||
ClassDeclaration,
|
||||
MethodDeclaration,
|
||||
PropertyDeclaration,
|
||||
Node,
|
||||
} from 'ts-morph';
|
||||
export { SyntaxKind } from 'ts-morph';
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type BuiltInRuleId =
|
||||
| 'boundary-protection'
|
||||
| 'scope-lockdown'
|
||||
| 'selector-purity'
|
||||
| 'no-page-in-flow'
|
||||
| 'api-purity'
|
||||
| 'dead-code'
|
||||
| 'deduplication'
|
||||
| 'test-data-hygiene'
|
||||
| 'duplicate-logic'
|
||||
| 'no-direct-page-instantiation';
|
||||
|
||||
// Allow any string for custom rules, while BuiltInRuleId provides type-safe hints for built-in rules
|
||||
export type RuleId = string;
|
||||
|
||||
export interface RuleSettings {
|
||||
enabled?: boolean;
|
||||
severity?: Severity | 'off';
|
||||
allowPatterns?: RegExp[];
|
||||
allowInExpect?: boolean;
|
||||
/** Method names that indicate a standalone/top-level page (for scope-lockdown rule) */
|
||||
navigationMethods?: string[];
|
||||
}
|
||||
|
||||
export interface RuleConfig {
|
||||
allowInExpect?: boolean;
|
||||
navigationMethod?: string;
|
||||
}
|
||||
|
||||
export type RuleSettingsMap = {
|
||||
[K in RuleId]?: RuleSettings;
|
||||
};
|
||||
|
||||
export interface RunOptions {
|
||||
files?: string[];
|
||||
ruleConfig?: Record<string, RuleConfig>;
|
||||
fix?: boolean;
|
||||
write?: boolean;
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
rule: string;
|
||||
message: string;
|
||||
severity: Severity;
|
||||
suggestion?: string;
|
||||
fixable?: boolean;
|
||||
fixData?: FixData;
|
||||
}
|
||||
|
||||
export interface MethodFixData {
|
||||
type: 'method';
|
||||
className: string;
|
||||
memberName: string;
|
||||
}
|
||||
|
||||
export interface PropertyFixData {
|
||||
type: 'property';
|
||||
className: string;
|
||||
memberName: string;
|
||||
}
|
||||
|
||||
export interface ClassFixData {
|
||||
type: 'class';
|
||||
className: string;
|
||||
}
|
||||
|
||||
export interface EditFixData {
|
||||
type: 'edit';
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
export type FixData = MethodFixData | PropertyFixData | ClassFixData | EditFixData;
|
||||
|
||||
export function isMethodFix(data: FixData): data is MethodFixData {
|
||||
return data.type === 'method';
|
||||
}
|
||||
|
||||
export function isPropertyFix(data: FixData): data is PropertyFixData {
|
||||
return data.type === 'property';
|
||||
}
|
||||
|
||||
export function isClassFix(data: FixData): data is ClassFixData {
|
||||
return data.type === 'class';
|
||||
}
|
||||
|
||||
export function isEditFix(data: FixData): data is EditFixData {
|
||||
return data.type === 'edit';
|
||||
}
|
||||
|
||||
export type FixAction = 'remove-method' | 'remove-property' | 'remove-file' | 'edit';
|
||||
|
||||
export interface FixResult {
|
||||
file: string;
|
||||
action: FixAction;
|
||||
target?: string;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export interface RuleResult {
|
||||
rule: string;
|
||||
violations: Violation[];
|
||||
filesAnalyzed: number;
|
||||
executionTimeMs: number;
|
||||
fixable?: boolean;
|
||||
fixes?: FixResult[];
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
totalViolations: number;
|
||||
byRule: Record<string, number>;
|
||||
bySeverity: Record<Severity, number>;
|
||||
filesAnalyzed: number;
|
||||
}
|
||||
|
||||
export interface JanitorReport {
|
||||
timestamp: string;
|
||||
projectRoot: string;
|
||||
rules: {
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
};
|
||||
results: RuleResult[];
|
||||
summary: ReportSummary;
|
||||
}
|
||||
|
||||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
export interface Rule {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly severity: Severity;
|
||||
readonly fixable: boolean;
|
||||
|
||||
getTargetGlobs(): string[];
|
||||
analyze(project: Project, files: SourceFile[]): Violation[];
|
||||
fix?(project: Project, violations: Violation[], write: boolean): FixResult[];
|
||||
isEnabled(): boolean;
|
||||
getEffectiveSeverity(): Severity;
|
||||
configure(config: RuleConfig): void;
|
||||
execute(project: Project, files: SourceFile[]): RuleResult;
|
||||
}
|
||||
|
||||
export interface FilePatterns {
|
||||
pages: string[];
|
||||
components: string[];
|
||||
flows: string[];
|
||||
tests: string[];
|
||||
services: string[];
|
||||
fixtures: string[];
|
||||
helpers: string[];
|
||||
factories: string[];
|
||||
testData: string[];
|
||||
}
|
||||
|
||||
export interface FacadeConfig {
|
||||
file: string;
|
||||
className: string;
|
||||
excludeTypes: string[];
|
||||
}
|
||||
|
||||
export interface RuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
severity: Severity;
|
||||
fixable: boolean;
|
||||
enabled: boolean;
|
||||
targetGlobs: string[];
|
||||
}
|
||||
180
packages/testing/janitor/src/utils/ast-helpers.ts
Normal file
180
packages/testing/janitor/src/utils/ast-helpers.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { SyntaxKind, type ClassDeclaration, type CallExpression, type SourceFile } from 'ts-morph';
|
||||
|
||||
/** Locator method names that we track for scoping */
|
||||
export const LOCATOR_METHODS = [
|
||||
'getByTestId',
|
||||
'getByRole',
|
||||
'getByText',
|
||||
'getByLabel',
|
||||
'getByPlaceholder',
|
||||
'getByAltText',
|
||||
'getByTitle',
|
||||
'locator',
|
||||
];
|
||||
|
||||
/** Page-level methods that are legitimate to call on page directly */
|
||||
export const PAGE_LEVEL_METHODS = [
|
||||
'goto',
|
||||
'waitForResponse',
|
||||
'waitForURL',
|
||||
'waitForLoadState',
|
||||
'waitForTimeout',
|
||||
'waitForSelector',
|
||||
'waitForNavigation',
|
||||
'evaluate',
|
||||
'keyboard',
|
||||
'mouse',
|
||||
'reload',
|
||||
'goBack',
|
||||
'goForward',
|
||||
'route',
|
||||
'unroute',
|
||||
'setViewportSize',
|
||||
'screenshot',
|
||||
'close',
|
||||
'isClosed',
|
||||
'url',
|
||||
'title',
|
||||
'content',
|
||||
'bringToFront',
|
||||
'context',
|
||||
'on',
|
||||
'once',
|
||||
'off',
|
||||
'removeListener',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a class has a container property, getter, or getContainer method
|
||||
*/
|
||||
export function hasContainerMember(classDecl: ClassDeclaration): boolean {
|
||||
const hasProperty = classDecl.getProperty('container') !== undefined;
|
||||
const hasGetter = classDecl.getGetAccessor('container') !== undefined;
|
||||
const hasMethod = classDecl.getMethod('getContainer') !== undefined;
|
||||
|
||||
return hasProperty || hasGetter || hasMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all call expressions within a class
|
||||
*/
|
||||
export function getCallExpressions(classDecl: ClassDeclaration): CallExpression[] {
|
||||
return classDecl.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a call expression is a locator call (getByTestId, locator, etc.)
|
||||
*/
|
||||
export function isLocatorCall(call: CallExpression): boolean {
|
||||
const expr = call.getExpression();
|
||||
const text = expr.getText();
|
||||
|
||||
return LOCATOR_METHODS.some((method) => text.endsWith(`.${method}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a call expression starts with this.page (unscoped locator)
|
||||
*/
|
||||
export function isUnscopedPageCall(call: CallExpression): boolean {
|
||||
const expr = call.getExpression();
|
||||
const text = expr.getText();
|
||||
|
||||
// Check for this.page.getByTestId, this.page.locator, etc.
|
||||
return LOCATOR_METHODS.some((method) => text.startsWith(`this.page.${method}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a call is a page-level method (legitimate to call on page)
|
||||
*/
|
||||
export function isPageLevelMethod(call: CallExpression): boolean {
|
||||
const expr = call.getExpression();
|
||||
const text = expr.getText();
|
||||
|
||||
// Check for this.page.goto, this.page.waitForResponse, etc.
|
||||
return PAGE_LEVEL_METHODS.some(
|
||||
(method) => text.startsWith(`this.page.${method}`) || text === `this.page.${method}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the method name from a call expression
|
||||
*/
|
||||
export function getMethodName(call: CallExpression): string | undefined {
|
||||
const expr = call.getExpression();
|
||||
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
||||
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
|
||||
return propAccess?.getName();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all import paths from a source file
|
||||
*/
|
||||
export function getImportPaths(file: SourceFile): string[] {
|
||||
return file.getImportDeclarations().map((imp) => imp.getModuleSpecifierValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an import path points to a pages file (excluding components)
|
||||
*/
|
||||
export function isPageImport(importPath: string, currentFilePath: string): boolean {
|
||||
// Relative imports starting with ./pages/ or ../pages/
|
||||
if (importPath.includes('/pages/') && !importPath.includes('/pages/components/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle relative imports from within pages directory
|
||||
const normalized = importPath.replace(/\\/g, '/');
|
||||
if (normalized.startsWith('./') || normalized.startsWith('../')) {
|
||||
// If current file is in pages/, check if import is also a page (not component)
|
||||
if (currentFilePath.includes('/pages/') && !currentFilePath.includes('/pages/components/')) {
|
||||
// Import to a sibling page file (not going into components/)
|
||||
if (!normalized.includes('/components/') && !normalized.includes('/components')) {
|
||||
const parts = normalized.split('/');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
// If it's a direct sibling import (like ./WorkflowPage or ../SomePage)
|
||||
// and not going into a subdirectory like ./components/
|
||||
if (
|
||||
parts.length === 2 &&
|
||||
(parts[0] === '.' || parts[0] === '..') &&
|
||||
!lastPart.startsWith('Base')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string literal argument from getByTestId calls
|
||||
*/
|
||||
export function getTestIdArgument(call: CallExpression): string | undefined {
|
||||
const methodName = getMethodName(call);
|
||||
if (methodName !== 'getByTestId') return undefined;
|
||||
|
||||
const args = call.getArguments();
|
||||
if (args.length === 0) return undefined;
|
||||
|
||||
const firstArg = args[0];
|
||||
if (firstArg.getKind() === SyntaxKind.StringLiteral) {
|
||||
const stringLit = firstArg.asKind(SyntaxKind.StringLiteral);
|
||||
return stringLit?.getLiteralText();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text for display, collapsing whitespace
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
const singleLine = text.replace(/\s+/g, ' ');
|
||||
if (singleLine.length <= maxLength) {
|
||||
return singleLine;
|
||||
}
|
||||
return singleLine.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
123
packages/testing/janitor/src/utils/git-operations.test.ts
Normal file
123
packages/testing/janitor/src/utils/git-operations.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { parseGitStatus, parseGitDiff } from './git-operations.js';
|
||||
|
||||
describe('git-operations', () => {
|
||||
const gitRoot = '/repo';
|
||||
const scopeDir = '/repo/packages/testing';
|
||||
|
||||
describe('parseGitStatus', () => {
|
||||
it('parses modified files', () => {
|
||||
const output = ' M packages/testing/src/file.ts\n';
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
|
||||
});
|
||||
|
||||
it('parses added files', () => {
|
||||
const output = 'A packages/testing/src/new-file.ts\n';
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/src/new-file.ts']);
|
||||
});
|
||||
|
||||
it('parses renamed files (takes new path)', () => {
|
||||
const output = 'R packages/testing/old.ts -> packages/testing/new.ts\n';
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/new.ts']);
|
||||
});
|
||||
|
||||
it('filters by scope directory', () => {
|
||||
const output = [' M packages/testing/src/file.ts', ' M packages/other/src/file.ts'].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
|
||||
});
|
||||
|
||||
it('filters by extension', () => {
|
||||
const output = [' M packages/testing/src/file.ts', ' M packages/testing/src/file.json'].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir, ['.ts']);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
|
||||
});
|
||||
|
||||
it('allows all extensions when empty array', () => {
|
||||
const output = [' M packages/testing/src/file.ts', ' M packages/testing/src/file.json'].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir, []);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty output', () => {
|
||||
const result = parseGitStatus('', gitRoot, scopeDir);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles whitespace-only lines', () => {
|
||||
const output = ' \n\n \n';
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles directory paths with trailing slash (filters them out when extension specified)', () => {
|
||||
// If git status somehow returns a directory path, it should be filtered by extension
|
||||
const output = '?? packages/testing/tests/new-feature/\n';
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir, ['.ts']);
|
||||
|
||||
// Directory paths don't end in .ts, so they're filtered out
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles directory paths with trailing slash (included when no extension filter)', () => {
|
||||
const output = '?? packages/testing/tests/new-feature/\n';
|
||||
const result = parseGitStatus(output, gitRoot, scopeDir, []);
|
||||
|
||||
// With no extension filter, directory paths are included as-is
|
||||
expect(result).toEqual(['/repo/packages/testing/tests/new-feature/']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGitDiff', () => {
|
||||
it('parses file paths', () => {
|
||||
const output = 'packages/testing/src/file.ts\n';
|
||||
const result = parseGitDiff(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
|
||||
});
|
||||
|
||||
it('filters by scope directory', () => {
|
||||
const output = ['packages/testing/src/file.ts', 'packages/other/src/file.ts'].join('\n');
|
||||
|
||||
const result = parseGitDiff(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toEqual(['/repo/packages/testing/src/file.ts']);
|
||||
});
|
||||
|
||||
it('handles empty output', () => {
|
||||
const result = parseGitDiff('', gitRoot, scopeDir);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiple files', () => {
|
||||
const output = [
|
||||
'packages/testing/src/a.ts',
|
||||
'packages/testing/src/b.ts',
|
||||
'packages/testing/src/c.ts',
|
||||
].join('\n');
|
||||
|
||||
const result = parseGitDiff(output, gitRoot, scopeDir);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
145
packages/testing/janitor/src/utils/git-operations.ts
Normal file
145
packages/testing/janitor/src/utils/git-operations.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Git Operations Utility
|
||||
*
|
||||
* Centralized git operations for the janitor tool.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface GitChangedFilesOptions {
|
||||
targetBranch?: string;
|
||||
scopeDir: string;
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
export function getGitRoot(cwd: string): string {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
} catch {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitStatus(
|
||||
output: string,
|
||||
gitRoot: string,
|
||||
scopeDir: string,
|
||||
extensions: string[] = ['.ts'],
|
||||
): string[] {
|
||||
const files: string[] = [];
|
||||
const gitStatusPattern = /^.{2}\s+(.+)$/;
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const match = gitStatusPattern.exec(line);
|
||||
if (match) {
|
||||
const filePath = match[1];
|
||||
// Handle renamed files (old -> new)
|
||||
const actualPath = filePath.includes(' -> ') ? filePath.split(' -> ')[1] : filePath;
|
||||
const fullPath = path.join(gitRoot, actualPath);
|
||||
|
||||
// Filter by scope directory
|
||||
if (!fullPath.startsWith(scopeDir)) continue;
|
||||
|
||||
// Filter by extension if specified
|
||||
if (extensions.length > 0) {
|
||||
const hasValidExtension = extensions.some((ext) => actualPath.endsWith(ext));
|
||||
if (!hasValidExtension) continue;
|
||||
}
|
||||
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function parseGitDiff(output: string, gitRoot: string, scopeDir: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const fullPath = path.join(gitRoot, line);
|
||||
if (fullPath.startsWith(scopeDir)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function getChangedFiles(options: GitChangedFilesOptions): string[] {
|
||||
const { targetBranch, scopeDir, extensions = [] } = options;
|
||||
const gitRoot = getGitRoot(scopeDir);
|
||||
|
||||
try {
|
||||
if (targetBranch) {
|
||||
const output = execFileSync('git', ['diff', '--name-only', `${targetBranch}...HEAD`], {
|
||||
cwd: gitRoot,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return parseGitDiff(output, gitRoot, scopeDir);
|
||||
}
|
||||
|
||||
// Use -uall to show individual files in new directories instead of just "dir/"
|
||||
const status = execFileSync('git', ['status', '--porcelain', '-uall'], {
|
||||
cwd: gitRoot,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return parseGitStatus(status, gitRoot, scopeDir, extensions);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalDiffLines(scopeDir: string, targetBranch?: string): number {
|
||||
const gitRoot = getGitRoot(scopeDir);
|
||||
|
||||
try {
|
||||
const args = targetBranch
|
||||
? ['diff', '--stat', `${targetBranch}...HEAD`]
|
||||
: ['diff', '--stat', 'HEAD'];
|
||||
const output = execFileSync('git', args, { cwd: gitRoot, encoding: 'utf-8' });
|
||||
|
||||
const lines = output.trim().split('\n');
|
||||
const summaryLine = lines[lines.length - 1];
|
||||
|
||||
let total = 0;
|
||||
const insertionsPattern = /(\d+)\s+insertions?\(\+\)/;
|
||||
const deletionsPattern = /(\d+)\s+deletions?\(-\)/;
|
||||
const insertionsMatch = insertionsPattern.exec(summaryLine);
|
||||
const deletionsMatch = deletionsPattern.exec(summaryLine);
|
||||
|
||||
if (insertionsMatch) total += Number.parseInt(insertionsMatch[1], 10);
|
||||
if (deletionsMatch) total += Number.parseInt(deletionsMatch[1], 10);
|
||||
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function commit(message: string, cwd: string): boolean {
|
||||
const gitRoot = getGitRoot(cwd);
|
||||
try {
|
||||
execFileSync('git', ['add', '-A'], { cwd: gitRoot });
|
||||
execFileSync('git', ['commit', '-m', message], { cwd: gitRoot });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function revert(cwd: string): boolean {
|
||||
const gitRoot = getGitRoot(cwd);
|
||||
try {
|
||||
execFileSync('git', ['checkout', '--', '.'], { cwd: gitRoot });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
packages/testing/janitor/src/utils/index.ts
Normal file
5
packages/testing/janitor/src/utils/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './ast-helpers.js';
|
||||
export * from './git-operations.js';
|
||||
export * from './logger.js';
|
||||
export * from './paths.js';
|
||||
export * from './test-command.js';
|
||||
157
packages/testing/janitor/src/utils/logger.test.ts
Normal file
157
packages/testing/janitor/src/utils/logger.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { Logger, createLogger } from './logger.js';
|
||||
|
||||
describe('Logger', () => {
|
||||
let consoleSpy: {
|
||||
log: ReturnType<typeof vi.spyOn>;
|
||||
warn: ReturnType<typeof vi.spyOn>;
|
||||
error: ReturnType<typeof vi.spyOn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('info', () => {
|
||||
it('always logs the message', () => {
|
||||
const logger = new Logger();
|
||||
logger.info('test message');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('test message');
|
||||
});
|
||||
|
||||
it('includes prefix when set', () => {
|
||||
const logger = new Logger({ prefix: '[PREFIX]' });
|
||||
logger.info('test message');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('[PREFIX] test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug', () => {
|
||||
it('does not log when verbose is false', () => {
|
||||
const logger = new Logger({ verbose: false });
|
||||
logger.debug('test message');
|
||||
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs when verbose is true', () => {
|
||||
const logger = new Logger({ verbose: true });
|
||||
logger.debug('test message');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('test message');
|
||||
});
|
||||
|
||||
it('includes prefix when set', () => {
|
||||
const logger = new Logger({ verbose: true, prefix: '[DEBUG]' });
|
||||
logger.debug('test message');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('[DEBUG] test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('warn', () => {
|
||||
it('always logs to console.warn', () => {
|
||||
const logger = new Logger();
|
||||
logger.warn('warning message');
|
||||
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith('warning message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
it('always logs to console.error', () => {
|
||||
const logger = new Logger();
|
||||
logger.error('error message');
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith('error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
it('logs with checkmark prefix', () => {
|
||||
const logger = new Logger();
|
||||
logger.success('done');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('\u2713 done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail', () => {
|
||||
it('logs with X prefix', () => {
|
||||
const logger = new Logger();
|
||||
logger.fail('failed');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('\u2717 failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('logs each item with default indent', () => {
|
||||
const logger = new Logger();
|
||||
logger.list(['item1', 'item2']);
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(' - item1');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(' - item2');
|
||||
});
|
||||
|
||||
it('uses custom indent', () => {
|
||||
const logger = new Logger();
|
||||
logger.list(['item'], 4);
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(' - item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debugList', () => {
|
||||
it('does not log when verbose is false', () => {
|
||||
const logger = new Logger({ verbose: false });
|
||||
logger.debugList(['item1', 'item2']);
|
||||
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs when verbose is true', () => {
|
||||
const logger = new Logger({ verbose: true });
|
||||
logger.debugList(['item1', 'item2']);
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(' - item1');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(' - item2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVerbose', () => {
|
||||
it('returns false by default', () => {
|
||||
const logger = new Logger();
|
||||
expect(logger.isVerbose()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when verbose is set', () => {
|
||||
const logger = new Logger({ verbose: true });
|
||||
expect(logger.isVerbose()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLogger', () => {
|
||||
it('creates a logger with default options', () => {
|
||||
const logger = createLogger();
|
||||
expect(logger).toBeInstanceOf(Logger);
|
||||
expect(logger.isVerbose()).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a logger with custom options', () => {
|
||||
const logger = createLogger({ verbose: true, prefix: 'test' });
|
||||
expect(logger.isVerbose()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
packages/testing/janitor/src/utils/logger.ts
Normal file
67
packages/testing/janitor/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Logger Utility - Centralized logging with verbose mode support.
|
||||
*/
|
||||
|
||||
export interface LoggerOptions {
|
||||
verbose?: boolean;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private verbose: boolean;
|
||||
private prefix: string;
|
||||
|
||||
constructor(options: LoggerOptions = {}) {
|
||||
this.verbose = options.verbose ?? false;
|
||||
this.prefix = options.prefix ?? '';
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
const output = this.prefix ? `${this.prefix} ${message}` : message;
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
if (!this.verbose) return;
|
||||
const output = this.prefix ? `${this.prefix} ${message}` : message;
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
const output = this.prefix ? `${this.prefix} ${message}` : message;
|
||||
console.warn(output);
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
const output = this.prefix ? `${this.prefix} ${message}` : message;
|
||||
console.error(output);
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
this.info(`\u2713 ${message}`);
|
||||
}
|
||||
|
||||
fail(message: string): void {
|
||||
this.info(`\u2717 ${message}`);
|
||||
}
|
||||
|
||||
list(items: string[], indent = 2): void {
|
||||
const padding = ' '.repeat(indent);
|
||||
for (const item of items) {
|
||||
this.info(`${padding}- ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
debugList(items: string[], indent = 2): void {
|
||||
if (!this.verbose) return;
|
||||
this.list(items, indent);
|
||||
}
|
||||
|
||||
isVerbose(): boolean {
|
||||
return this.verbose;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||
return new Logger(options);
|
||||
}
|
||||
141
packages/testing/janitor/src/utils/paths.ts
Normal file
141
packages/testing/janitor/src/utils/paths.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { glob } from 'glob';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { getConfig, hasConfig } from '../config.js';
|
||||
|
||||
/**
|
||||
* Get the root directory for the Playwright test suite
|
||||
* Uses the configured rootDir from janitor config
|
||||
*/
|
||||
export function getRootDir(): string {
|
||||
return getConfig().rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is within the pages directory (excluding components)
|
||||
*/
|
||||
export function isPageFile(filePath: string): boolean {
|
||||
const normalized = path.normalize(filePath).replaceAll('\\', '/');
|
||||
return normalized.includes('/pages/') && !normalized.includes('/pages/components/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is within the pages/components directory
|
||||
*/
|
||||
export function isComponentFile(filePath: string): boolean {
|
||||
const normalized = path.normalize(filePath).replaceAll('\\', '/');
|
||||
return normalized.includes('/pages/components/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is within the flows directory (composables/actions/flows)
|
||||
*/
|
||||
export function isFlowFile(filePath: string): boolean {
|
||||
const normalized = path.normalize(filePath).replaceAll('\\', '/');
|
||||
const config = getConfig();
|
||||
|
||||
return config.patterns.flows.some((pattern) => {
|
||||
const baseDir = pattern.replace('**/*.ts', '').replace('/**/*.ts', '');
|
||||
return normalized.includes(`/${baseDir}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is a test file (in tests directory with .spec.ts extension)
|
||||
*/
|
||||
export function isTestFile(filePath: string): boolean {
|
||||
const normalized = path.normalize(filePath).replaceAll('\\', '/');
|
||||
return (
|
||||
(normalized.startsWith('tests/') || normalized.includes('/tests/')) &&
|
||||
normalized.endsWith('.spec.ts')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be excluded from page analysis
|
||||
*/
|
||||
export function isExcludedPage(filePath: string): boolean {
|
||||
const normalized = path.normalize(filePath).replaceAll('\\', '/');
|
||||
const config = getConfig();
|
||||
|
||||
return config.excludeFromPages.some((exclude) => normalized.endsWith(exclude));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files matching glob patterns from test data config
|
||||
*/
|
||||
export function getTestDataFiles(patterns: string[]): string[] {
|
||||
const root = getRootDir();
|
||||
const files: string[] = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = glob.sync(pattern, { cwd: root, nodir: true, absolute: true });
|
||||
files.push(...matches);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path relative to the root directory
|
||||
*/
|
||||
export function resolvePath(relativePath: string): string {
|
||||
const root = getRootDir();
|
||||
return path.isAbsolute(relativePath) ? relativePath : path.join(root, relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative path from root directory
|
||||
*/
|
||||
export function getRelativePath(absolutePath: string): string {
|
||||
if (!hasConfig()) {
|
||||
return absolutePath;
|
||||
}
|
||||
return path.relative(getRootDir(), absolutePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find files matching a suffix in a directory
|
||||
*/
|
||||
export function findFilesRecursive(dir: string, suffix: string): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...findFilesRecursive(fullPath, suffix));
|
||||
} else if (entry.name.endsWith(suffix)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory read error, skip
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path matches any of the given glob patterns
|
||||
*/
|
||||
export function matchesPatterns(filePath: string, patterns: string[]): boolean {
|
||||
const relativePath = getRelativePath(filePath);
|
||||
|
||||
for (const pattern of patterns) {
|
||||
// Simple glob matching (supports ** and *)
|
||||
const regexPattern = pattern
|
||||
.replace(/\*\*/g, '<<<DOUBLESTAR>>>')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/<<<DOUBLESTAR>>>/g, '.*');
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(relativePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
108
packages/testing/janitor/src/utils/test-command.test.ts
Normal file
108
packages/testing/janitor/src/utils/test-command.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { resolveTestCommand, buildTestCommand } from './test-command.js';
|
||||
import * as config from '../config.js';
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
hasConfig: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('test-command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('resolveTestCommand', () => {
|
||||
it('appends default workers to override command', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(false);
|
||||
const result = resolveTestCommand('custom test command');
|
||||
expect(result).toBe('custom test command --workers=1');
|
||||
});
|
||||
|
||||
it('appends default workers to config command', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(true);
|
||||
vi.mocked(config.getConfig).mockReturnValue({
|
||||
tcr: { testCommand: 'pnpm test' },
|
||||
} as ReturnType<typeof config.getConfig>);
|
||||
|
||||
const result = resolveTestCommand();
|
||||
expect(result).toBe('pnpm test --workers=1');
|
||||
});
|
||||
|
||||
it('uses custom worker count from config', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(true);
|
||||
vi.mocked(config.getConfig).mockReturnValue({
|
||||
tcr: { testCommand: 'pnpm test', workerCount: 4 },
|
||||
} as ReturnType<typeof config.getConfig>);
|
||||
|
||||
const result = resolveTestCommand();
|
||||
expect(result).toBe('pnpm test --workers=4');
|
||||
});
|
||||
|
||||
it('uses config worker count with override command', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(true);
|
||||
vi.mocked(config.getConfig).mockReturnValue({
|
||||
tcr: { testCommand: 'pnpm test', workerCount: 2 },
|
||||
} as ReturnType<typeof config.getConfig>);
|
||||
|
||||
const result = resolveTestCommand('pnpm test:container');
|
||||
expect(result).toBe('pnpm test:container --workers=2');
|
||||
});
|
||||
|
||||
it('returns default when no override and no config', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(false);
|
||||
|
||||
const result = resolveTestCommand();
|
||||
expect(result).toBe('npx playwright test --workers=1');
|
||||
});
|
||||
|
||||
it('returns default when config exists but no testCommand', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(true);
|
||||
vi.mocked(config.getConfig).mockReturnValue({
|
||||
tcr: {},
|
||||
} as ReturnType<typeof config.getConfig>);
|
||||
|
||||
const result = resolveTestCommand();
|
||||
expect(result).toBe('npx playwright test --workers=1');
|
||||
});
|
||||
|
||||
it('returns default when config exists but no tcr section', () => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(true);
|
||||
vi.mocked(config.getConfig).mockReturnValue({} as ReturnType<typeof config.getConfig>);
|
||||
|
||||
const result = resolveTestCommand();
|
||||
expect(result).toBe('npx playwright test --workers=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTestCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(config.hasConfig).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('builds command with test files', () => {
|
||||
const result = buildTestCommand(['test1.spec.ts', 'test2.spec.ts']);
|
||||
expect(result).toBe('npx playwright test --workers=1 test1.spec.ts test2.spec.ts');
|
||||
});
|
||||
|
||||
it('uses override command with workers appended', () => {
|
||||
const result = buildTestCommand(['test.spec.ts'], 'pnpm test');
|
||||
expect(result).toBe('pnpm test --workers=1 test.spec.ts');
|
||||
});
|
||||
|
||||
it('handles empty file list', () => {
|
||||
const result = buildTestCommand([]);
|
||||
expect(result).toBe('npx playwright test --workers=1 ');
|
||||
});
|
||||
|
||||
it('handles single file', () => {
|
||||
const result = buildTestCommand(['single.spec.ts']);
|
||||
expect(result).toBe('npx playwright test --workers=1 single.spec.ts');
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/testing/janitor/src/utils/test-command.ts
Normal file
46
packages/testing/janitor/src/utils/test-command.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Test Command Resolver
|
||||
*
|
||||
* Priority: CLI override > config file > fallback default
|
||||
* Worker count is always appended from config (default: 1)
|
||||
*/
|
||||
|
||||
import { getConfig, hasConfig } from '../config.js';
|
||||
|
||||
const DEFAULT_TEST_COMMAND = 'npx playwright test';
|
||||
const DEFAULT_WORKERS = 1;
|
||||
|
||||
function getWorkerCount(): number {
|
||||
if (hasConfig()) {
|
||||
const config = getConfig();
|
||||
return config.tcr?.workerCount ?? DEFAULT_WORKERS;
|
||||
}
|
||||
return DEFAULT_WORKERS;
|
||||
}
|
||||
|
||||
function getBaseCommand(override?: string): string {
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
if (hasConfig()) {
|
||||
const config = getConfig();
|
||||
if (config.tcr?.testCommand) {
|
||||
return config.tcr.testCommand;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_TEST_COMMAND;
|
||||
}
|
||||
|
||||
export function resolveTestCommand(override?: string): string {
|
||||
const baseCommand = getBaseCommand(override);
|
||||
const workers = getWorkerCount();
|
||||
return `${baseCommand} --workers=${workers}`;
|
||||
}
|
||||
|
||||
export function buildTestCommand(testFiles: string[], override?: string): string {
|
||||
const baseCommand = resolveTestCommand(override);
|
||||
const fileArgs = testFiles.join(' ');
|
||||
return `${baseCommand} ${fileArgs}`;
|
||||
}
|
||||
20
packages/testing/janitor/tsconfig.json
Normal file
20
packages/testing/janitor/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "**/__tests__/**"]
|
||||
}
|
||||
15
packages/testing/janitor/vitest.config.ts
Normal file
15
packages/testing/janitor/vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts', 'src/index.ts', 'src/cli.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
3665
packages/testing/playwright/.janitor-baseline.json
Normal file
3665
packages/testing/playwright/.janitor-baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,146 @@ pnpm --filter=n8n-playwright typecheck
|
|||
|
||||
Always trim output: `--reporter=list 2>&1 | tail -50`
|
||||
|
||||
## Test Maintenance (Janitor)
|
||||
|
||||
Static analysis for Playwright test architecture. Catches problems before they spread.
|
||||
|
||||
> **CRITICAL: Always use TCR for code changes.**
|
||||
> When janitor identifies violations and you fix them, use `pnpm janitor tcr --execute` to safely commit. Never manually commit janitor-related fixes - TCR ensures tests pass before the commit lands.
|
||||
|
||||
### Golden Rules
|
||||
|
||||
1. **Analysis only?** Run `pnpm janitor` (no TCR needed)
|
||||
2. **Making code changes?** Use TCR: `pnpm janitor tcr --execute -m="chore: ..."`
|
||||
3. **Never** manually `git commit` janitor-related fixes - always go through TCR
|
||||
4. **Never** modify `.janitor-baseline.json` via TCR - baseline updates must be done manually
|
||||
|
||||
### When to Use
|
||||
|
||||
| User Says | Intent | Approach |
|
||||
|-----------|--------|----------|
|
||||
| "Clean up the test codebase" | Incremental cleanup | Create baseline first, then use `--max-diff-lines=500` for small PRs. |
|
||||
| "Start tracking violations" | Enable incremental cleanup | Run `janitor baseline` to snapshot current state, commit `.janitor-baseline.json`. |
|
||||
| "Add a test for X" | New test following patterns | After writing, run janitor to verify architecture compliance. |
|
||||
| "Fix architecture drift" | Enforce layered architecture | Run `selector-purity` and `no-page-in-flow` rules. |
|
||||
| "Find dead code" | Remove unused methods | Run `dead-code` rule with `--fix --write` for auto-removal. |
|
||||
| "Find copy-paste code" | Detect duplicates | Run `duplicate-logic` rule to find structural duplicates. |
|
||||
| "This file is messy" | Targeted cleanup | Analyze specific file, fix issues, TCR to safely commit. |
|
||||
| "Refactor this page object" | Safe refactoring | Use TCR - changes commit if tests pass, revert if they fail. |
|
||||
| "What tests would break?" | Impact analysis | Run `impact` command before changing shared code. |
|
||||
| "Prepare for PR" | Pre-commit check | Run janitor on changed files to catch violations early. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
The janitor enforces a layered architecture:
|
||||
|
||||
```
|
||||
Tests → Flows/Composables → Page Objects → Components → Playwright API
|
||||
```
|
||||
|
||||
| Rule | What It Catches |
|
||||
|------|-----------------|
|
||||
| `selector-purity` | Raw locators in tests/flows: `page.getByTestId()`, `someLocator.locator()` |
|
||||
| `no-page-in-flow` | Flows accessing `page` directly (should use page objects) |
|
||||
| `boundary-protection` | Pages importing other pages (creates coupling) |
|
||||
| `scope-lockdown` | Unscoped locators that escape their container |
|
||||
| `dead-code` | Unused public methods in page objects |
|
||||
| `deduplication` | Same selector defined in multiple files |
|
||||
| `duplicate-logic` | Copy-pasted code across tests/pages (AST fingerprinting) |
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Analyze entire codebase
|
||||
pnpm janitor
|
||||
|
||||
# Analyze specific file
|
||||
pnpm janitor --file=pages/CanvasPage.ts --verbose
|
||||
|
||||
# Run specific rule
|
||||
pnpm janitor --rule=dead-code
|
||||
|
||||
# Auto-fix (dead-code only)
|
||||
pnpm janitor:fix --rule=dead-code
|
||||
|
||||
# List all rules (short)
|
||||
pnpm janitor --list
|
||||
|
||||
# Show detailed rule info (for AI agents)
|
||||
pnpm janitor rules --json
|
||||
|
||||
# JSON output
|
||||
pnpm janitor --json
|
||||
```
|
||||
|
||||
### Baseline (Incremental Cleanup)
|
||||
|
||||
For codebases with existing violations, create a baseline to enable incremental cleanup:
|
||||
|
||||
```bash
|
||||
# Create baseline - snapshots current violations
|
||||
pnpm janitor baseline
|
||||
|
||||
# Commit the baseline
|
||||
git add .janitor-baseline.json && git commit -m "chore: add janitor baseline"
|
||||
```
|
||||
|
||||
Once baseline exists, janitor and TCR **only fail on NEW violations**. Pre-existing violations are tracked but don't block work.
|
||||
|
||||
> **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 be done manually after fixing violations.
|
||||
|
||||
```bash
|
||||
# Update baseline after fixing violations (manual commit required)
|
||||
pnpm janitor baseline
|
||||
git add .janitor-baseline.json && git commit -m "chore: update baseline after cleanup"
|
||||
```
|
||||
|
||||
### Incremental Cleanup Strategy
|
||||
|
||||
For large cleanups, keep diffs small and reviewable:
|
||||
|
||||
```bash
|
||||
# Show ALL violations (ignoring baseline) for cleanup work
|
||||
pnpm janitor --ignore-baseline --json
|
||||
|
||||
# Find easiest files to fix (lowest violation count)
|
||||
pnpm janitor --ignore-baseline --json 2>/dev/null | jq '.fileReports | sort_by(.violationCount) | .[:5]'
|
||||
|
||||
# TCR with max diff size (skip if changes are too large)
|
||||
pnpm janitor tcr --max-diff-lines=500 --execute -m="chore: cleanup"
|
||||
```
|
||||
|
||||
**AI Cleanup Workflow:**
|
||||
1. Use `--ignore-baseline` to see all violations (not just new ones)
|
||||
2. Pick small fixes from the list
|
||||
3. Fix violations, then TCR to safely commit
|
||||
4. After fixing, run `pnpm janitor baseline` to update the baseline
|
||||
|
||||
### TCR Workflow (Test && Commit || Revert)
|
||||
|
||||
Safe refactoring loop: make changes, run affected tests, auto-commit or auto-revert.
|
||||
|
||||
```bash
|
||||
# Dry run - see what would happen
|
||||
pnpm janitor tcr --verbose
|
||||
|
||||
# Execute - actually commit/revert
|
||||
pnpm janitor tcr --execute -m="chore: remove dead code"
|
||||
|
||||
# With guardrails - skip if diff too large
|
||||
pnpm janitor tcr --execute --max-diff-lines=500 -m="chore: cleanup"
|
||||
```
|
||||
|
||||
### After Writing New Tests
|
||||
|
||||
Always run janitor after adding or modifying tests to catch architecture violations early:
|
||||
|
||||
```bash
|
||||
pnpm janitor --file=tests/my-new-test.spec.ts --verbose
|
||||
```
|
||||
|
||||
See `packages/testing/janitor/README.md` for full documentation.
|
||||
|
||||
## Entry Points
|
||||
|
||||
All tests should start with `n8n.start.*` methods. See `composables/TestEntryComposer.ts`.
|
||||
|
|
@ -233,6 +373,51 @@ test('API-only test', async ({ api }) => {
|
|||
});
|
||||
```
|
||||
|
||||
### Feature Flag Overrides
|
||||
|
||||
To test features behind feature flags (experiments), use `TestRequirements` with storage overrides:
|
||||
|
||||
```typescript
|
||||
import type { TestRequirements } from '../config/TestRequirements';
|
||||
|
||||
const requirements: TestRequirements = {
|
||||
storage: {
|
||||
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ 'your_experiment': true }),
|
||||
},
|
||||
};
|
||||
|
||||
test.use({ requirements });
|
||||
|
||||
test('test with feature flag enabled', async ({ n8n }) => {
|
||||
// Feature flag is now active for this test
|
||||
});
|
||||
```
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
```typescript
|
||||
// Single experiment
|
||||
{ storage: { N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ '025_new_canvas': true }) } }
|
||||
|
||||
// Multiple experiments
|
||||
{ storage: { N8N_EXPERIMENT_OVERRIDES: JSON.stringify({
|
||||
'025_new_canvas': true,
|
||||
'026_another_feature': 'variant_a'
|
||||
}) } }
|
||||
|
||||
// Combined with other requirements
|
||||
const requirements: TestRequirements = {
|
||||
storage: {
|
||||
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ 'your_experiment': true }),
|
||||
},
|
||||
capability: {
|
||||
env: { TEST_ISOLATION: 'my-test-suite' },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Reference:** `config/TestRequirements.ts` for full interface definition.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use specialized locators: `page.getByRole('button')` over `page.locator('[role=button]')`
|
||||
|
|
|
|||
1
packages/testing/playwright/CLAUDE.md
Normal file
1
packages/testing/playwright/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
@AGENTS.md
|
||||
|
|
@ -315,5 +315,89 @@ node scripts/import-victoria-data.mjs --start victoria-metrics-export.jsonl vict
|
|||
- **Metrics UI:** http://localhost:8428/vmui/
|
||||
- **Logs UI:** http://localhost:9428/select/vmui/
|
||||
|
||||
## Janitor (Static Analysis & Inventory)
|
||||
|
||||
Janitor enforces test architecture patterns and provides codebase discovery for devs and AI.
|
||||
|
||||
### Quick Commands
|
||||
|
||||
```bash
|
||||
# Static analysis (run all rules)
|
||||
npx tsx scripts/janitor/index.ts
|
||||
|
||||
# Dead code removal
|
||||
npx tsx scripts/janitor/index.ts --rule=dead-code # Find unused
|
||||
npx tsx scripts/janitor/index.ts --rule=dead-code --fix # Preview
|
||||
npx tsx scripts/janitor/index.ts --rule=dead-code --fix --write # Remove
|
||||
|
||||
# Inventory (codebase discovery)
|
||||
npx tsx scripts/janitor/index.ts --inventory # Summary
|
||||
npx tsx scripts/janitor/index.ts --inventory --verbose # Full details
|
||||
npx tsx scripts/janitor/index.ts --describe=CanvasPage # Single class
|
||||
npx tsx scripts/janitor/index.ts --list-pages # All page objects
|
||||
|
||||
# File-level impact analysis (find affected tests)
|
||||
npx tsx scripts/janitor/index.ts --impact=pages/CanvasPage.ts
|
||||
npx tsx scripts/janitor/index.ts --impact=pages/CanvasPage.ts --tests | xargs playwright test
|
||||
|
||||
# Method-level impact analysis (precise test selection)
|
||||
npx tsx scripts/janitor/index.ts --method-impact=CanvasPage.addNode # Find tests using method
|
||||
npx tsx scripts/janitor/index.ts --method-impact=WorkflowsPage.addFolder --verbose # Show line-by-line usages
|
||||
npx tsx scripts/janitor/index.ts --method-impact=CanvasPage.addNode --tests # For piping to playwright
|
||||
npx tsx scripts/janitor/index.ts --method-index # Full method usage index
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
| Rule | What it enforces |
|
||||
|------|------------------|
|
||||
| `selector-purity` | Tests use page objects, not raw locators |
|
||||
| `scope-lockdown` | Page locators scoped to container |
|
||||
| `boundary-protection` | Pages don't import other pages |
|
||||
| `dead-code` | No unused methods/properties [fixable] |
|
||||
|
||||
Run `--list` for all rules and options.
|
||||
|
||||
### Method-Level Impact Analysis
|
||||
|
||||
When modifying a page object method, use `--method-impact` to find exactly which tests need to run:
|
||||
|
||||
```bash
|
||||
# Example: modifying WorkflowsPage.addFolder()
|
||||
npx tsx scripts/janitor/index.ts --method-impact=WorkflowsPage.addFolder
|
||||
|
||||
# Output:
|
||||
# Method: WorkflowsPage.addFolder()
|
||||
# Total usages: 7
|
||||
# Affected test files: 2
|
||||
# - tests/e2e/projects/folders-basic.spec.ts (3 usages)
|
||||
# - tests/e2e/projects/folders-operations.spec.ts (4 usages)
|
||||
|
||||
# Run only affected tests
|
||||
npx tsx scripts/janitor/index.ts --method-impact=WorkflowsPage.addFolder --tests | xargs playwright test
|
||||
```
|
||||
|
||||
This works by understanding the fixture pattern (`n8n.workflows.addFolder()` → `WorkflowsPage.addFolder`) and scanning test files for actual method calls, not just imports.
|
||||
|
||||
Use `--method-index` to see all tracked methods and their usage counts across the test suite.
|
||||
|
||||
### For AI-Assisted Test Writing
|
||||
|
||||
```bash
|
||||
# Generate context for AI
|
||||
npx tsx scripts/janitor/index.ts --inventory --json > .playwright-inventory.json
|
||||
npx tsx scripts/janitor/index.ts --describe=CanvasPage
|
||||
|
||||
# Validate AI output
|
||||
npx tsx scripts/janitor/index.ts --file=tests/new-test.spec.ts
|
||||
```
|
||||
|
||||
### Key Conventions
|
||||
|
||||
- **Entry points**: All tests start with `n8n.start.*` (see `TestEntryComposer`)
|
||||
- **Page objects**: UI interactions go through `n8n.canvas`, `n8n.ndv`, etc.
|
||||
- **API helpers**: Backend operations via `n8n.api.*`
|
||||
- **Composables**: Multi-step flows via `n8n.flows.*`
|
||||
|
||||
## Writing Tests
|
||||
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
|
|
|||
|
|
@ -15,15 +15,6 @@ export class CanvasComposer {
|
|||
await this.n8n.ndv.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a node and wait for success toast notification
|
||||
* @param nodeName - The node to execute
|
||||
*/
|
||||
async executeNodeAndWaitForToast(nodeName: string): Promise<void> {
|
||||
await this.n8n.canvas.executeNode(nodeName);
|
||||
await this.n8n.notifications.waitForNotificationAndClose('Node executed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy selected nodes and verify success toast
|
||||
*/
|
||||
|
|
@ -62,8 +53,8 @@ export class CanvasComposer {
|
|||
* Switch between editor and workflow history and back
|
||||
*/
|
||||
async switchBetweenEditorAndHistory(): Promise<void> {
|
||||
await this.n8n.page.getByTestId('workflow-history-button').click();
|
||||
await this.n8n.page.getByTestId('workflow-history-close-button').click();
|
||||
await this.n8n.canvas.openWorkflowHistory();
|
||||
await this.n8n.canvas.closeWorkflowHistory();
|
||||
await this.n8n.page.waitForLoadState();
|
||||
await expect(this.n8n.canvas.getCanvasNodes().first()).toBeVisible();
|
||||
await expect(this.n8n.canvas.getCanvasNodes().last()).toBeVisible();
|
||||
|
|
@ -73,7 +64,7 @@ export class CanvasComposer {
|
|||
* Switch between editor and workflow list and back
|
||||
*/
|
||||
async switchBetweenEditorAndWorkflowList(): Promise<void> {
|
||||
await this.n8n.page.getByTestId('menu-item').first().click();
|
||||
await this.n8n.sideBar.clickHomeButton();
|
||||
await this.n8n.workflows.cards.getWorkflows().first().click();
|
||||
await expect(this.n8n.canvas.getCanvasNodes().first()).toBeVisible();
|
||||
await expect(this.n8n.canvas.getCanvasNodes().last()).toBeVisible();
|
||||
|
|
@ -116,32 +107,6 @@ export class CanvasComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay workflow GET request to simulate loading during page reload.
|
||||
* Useful for testing save-blocking behavior during real loading states.
|
||||
*
|
||||
* @param workflowId - The workflow ID to delay loading for
|
||||
* @param delayMs - Delay in milliseconds (default: 2000)
|
||||
*/
|
||||
async delayWorkflowLoad(workflowId: string, delayMs: number = 2000): Promise<void> {
|
||||
await this.n8n.page.route(`**/rest/workflows/${workflowId}`, async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the workflow load delay route handler.
|
||||
* Should be called after delayWorkflowLoad() when testing is complete.
|
||||
*
|
||||
* @param workflowId - The workflow ID to stop delaying
|
||||
*/
|
||||
async undelayWorkflowLoad(workflowId: string): Promise<void> {
|
||||
await this.n8n.page.unroute(`**/rest/workflows/${workflowId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a node using keyboard shortcut
|
||||
* @param oldName - The current name of the node
|
||||
|
|
|
|||
|
|
@ -48,18 +48,4 @@ export class CredentialsComposer {
|
|||
async createFromApi(payload: CreateCredentialDto & { projectId?: string }) {
|
||||
return await this.n8n.api.credentials.createCredential(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a credential to a different project.
|
||||
* @param credentialName - The name of the credential to move
|
||||
* @param projectNameOrEmail - The destination project name or user email
|
||||
*/
|
||||
async moveToProject(credentialName: string, projectNameOrEmail: string): Promise<void> {
|
||||
const credentialCard = this.n8n.credentials.cards.getCredential(credentialName);
|
||||
await this.n8n.credentials.cards.openCardActions(credentialCard);
|
||||
await this.n8n.credentials.cards.getCardAction('move').click();
|
||||
await this.n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
|
||||
await this.n8n.resourceMoveModal.selectProjectOption(projectNameOrEmail);
|
||||
await this.n8n.resourceMoveModal.clickMoveCredentialButton();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import type { Request, Page } from '@playwright/test';
|
||||
|
||||
import type { n8nPage } from '../pages/n8nPage';
|
||||
|
||||
/**
|
||||
|
|
@ -65,24 +63,4 @@ export class NodeDetailsViewComposer {
|
|||
|
||||
await addResourceItem.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sub-workflow with redirect handling
|
||||
* @param paramName - The parameter name for the resource locator
|
||||
* @returns Promise with request data and new window page
|
||||
*/
|
||||
async createNewSubworkflowWithRedirect(
|
||||
paramName: string,
|
||||
): Promise<{ request: Request; page: Page }> {
|
||||
const subWorkflowPagePromise = this.n8n.page.waitForEvent('popup');
|
||||
|
||||
const [request] = await Promise.all([
|
||||
this.n8n.page.waitForRequest('**/rest/workflows'),
|
||||
this.createNewSubworkflow(paramName),
|
||||
]);
|
||||
|
||||
const page = await subWorkflowPagePromise;
|
||||
|
||||
return { request, page };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { n8nPage } from '../pages/n8nPage';
|
||||
|
|
@ -74,39 +73,6 @@ export class WorkflowComposer {
|
|||
return { workflowName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new workflow by importing from a URL
|
||||
* @param url - The URL to import the workflow from
|
||||
* @returns Promise that resolves when the import is complete
|
||||
*/
|
||||
async importWorkflowFromURL(url: string): Promise<void> {
|
||||
await this.n8n.workflows.addResource.workflow();
|
||||
await this.n8n.canvas.clickWorkflowMenu();
|
||||
await this.n8n.canvas.clickImportFromURL();
|
||||
await this.n8n.canvas.fillImportURLInput(url);
|
||||
await this.n8n.canvas.clickConfirmImportURL();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the import from URL dialog and then dismisses it by clicking outside
|
||||
*/
|
||||
async openAndDismissImportFromURLDialog(): Promise<void> {
|
||||
await this.n8n.workflows.addResource.workflow();
|
||||
await this.n8n.canvas.clickWorkflowMenu();
|
||||
await this.n8n.canvas.clickImportFromURL();
|
||||
await this.n8n.canvas.clickOutsideModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the import from URL dialog and then cancels it
|
||||
*/
|
||||
async openAndCancelImportFromURLDialog(): Promise<void> {
|
||||
await this.n8n.workflows.addResource.workflow();
|
||||
await this.n8n.canvas.clickWorkflowMenu();
|
||||
await this.n8n.canvas.clickImportFromURL();
|
||||
await this.n8n.canvas.clickCancelImportURL();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates a workflow via the duplicate modal UI.
|
||||
* Verifies the form interaction completes without errors.
|
||||
|
|
@ -138,19 +104,6 @@ export class WorkflowComposer {
|
|||
await saveButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow by name via API
|
||||
* @param workflowName - Name of the workflow to find
|
||||
* @returns Workflow object with id, name, and other properties
|
||||
*/
|
||||
async getWorkflowByName(workflowName: string): Promise<IWorkflowBase> {
|
||||
const response = await this.n8n.api.request.get('/rest/workflows', {
|
||||
params: new URLSearchParams({ filter: JSON.stringify({ name: workflowName }) }),
|
||||
});
|
||||
const workflows = await response.json();
|
||||
return workflows.data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a workflow to a different project or user.
|
||||
* @param workflowName - The name of the workflow to move
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default [
|
|||
'ms-playwright-cache/**/*',
|
||||
'coverage/**/*',
|
||||
'scripts/**/*',
|
||||
'janitor.config.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,17 +45,6 @@ export class NavigationHelper {
|
|||
await this.page.goto(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to executions page
|
||||
* URLs:
|
||||
* - Home executions: /home/executions
|
||||
* - Project executions: /projects/{projectId}/executions
|
||||
*/
|
||||
async toExecutions(projectId?: string): Promise<void> {
|
||||
const url = projectId ? `/projects/${projectId}/executions` : '/home/executions';
|
||||
await this.page.goto(url);
|
||||
}
|
||||
|
||||
async toDatatables(projectId?: string): Promise<void> {
|
||||
const url = projectId ? `/projects/${projectId}/datatables` : '/home/datatables';
|
||||
await this.page.goto(url);
|
||||
|
|
@ -70,14 +59,6 @@ export class NavigationHelper {
|
|||
await this.page.goto('/variables');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to settings page (global only)
|
||||
* URL: /settings
|
||||
*/
|
||||
async toSettings(): Promise<void> {
|
||||
await this.page.goto('/settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to personal settings
|
||||
* URL: /settings/personal
|
||||
|
|
@ -86,14 +67,6 @@ export class NavigationHelper {
|
|||
await this.page.goto('/settings/personal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to projects page
|
||||
* URL: /projects
|
||||
*/
|
||||
async toProjects(): Promise<void> {
|
||||
await this.page.goto('/projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific project's dashboard
|
||||
* URL: /projects/{projectId}
|
||||
|
|
@ -204,14 +177,6 @@ export class NavigationHelper {
|
|||
await this.page.goto('/settings/log-streaming');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to worker view
|
||||
* URL: /settings/workers
|
||||
*/
|
||||
async toWorkerView(): Promise<void> {
|
||||
await this.page.goto('/settings/workers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to users management
|
||||
* URL: /settings/users
|
||||
|
|
@ -228,22 +193,6 @@ export class NavigationHelper {
|
|||
await this.page.goto('/settings/api');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to LDAP settings
|
||||
* URL: /settings/ldap
|
||||
*/
|
||||
async toLdapSettings(): Promise<void> {
|
||||
await this.page.goto('/settings/ldap');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to SSO settings
|
||||
* URL: /settings/sso
|
||||
*/
|
||||
async toSsoSettings(): Promise<void> {
|
||||
await this.page.goto('/settings/sso');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to environments settings
|
||||
* URL: /settings/environments
|
||||
|
|
@ -252,14 +201,6 @@ export class NavigationHelper {
|
|||
await this.page.goto('/settings/environments');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to external secrets settings
|
||||
* URL: /settings/external-secrets
|
||||
*/
|
||||
async toExternalSecrets(): Promise<void> {
|
||||
await this.page.goto('/settings/external-secrets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to settings page
|
||||
* URL: /settings/chat
|
||||
|
|
|
|||
85
packages/testing/playwright/janitor.config.mjs
Normal file
85
packages/testing/playwright/janitor.config.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* n8n Playwright Janitor Configuration
|
||||
*
|
||||
* This configures the janitor for the n8n Playwright test suite.
|
||||
*/
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { defineConfig } from '@n8n/playwright-janitor';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
rootDir: __dirname,
|
||||
|
||||
patterns: {
|
||||
pages: ['pages/**/*.ts'],
|
||||
components: ['pages/components/**/*.ts'],
|
||||
flows: ['composables/**/*.ts'],
|
||||
tests: ['tests/**/*.spec.ts'],
|
||||
services: ['services/**/*.ts'],
|
||||
fixtures: ['fixtures/**/*.ts'],
|
||||
helpers: [
|
||||
'helpers/**/*.ts',
|
||||
'utils/**/*.ts',
|
||||
'config/**/*.ts', // TODO: Move TestRequirements to helpers/
|
||||
'tests/**/fixtures.ts', // TODO: Consolidate colocated fixtures
|
||||
],
|
||||
factories: ['test-data/**/*.ts', 'factories/**/*.ts'],
|
||||
testData: ['workflows/**/*'],
|
||||
},
|
||||
|
||||
excludeFromPages: [
|
||||
'n8nPage.ts', // Facade
|
||||
'BasePage.ts', // Base class
|
||||
'BaseModal.ts',
|
||||
'BaseComponent.ts',
|
||||
],
|
||||
|
||||
facade: {
|
||||
file: 'pages/n8nPage.ts',
|
||||
className: 'n8nPage',
|
||||
excludeTypes: ['Page', 'ApiHelpers'],
|
||||
},
|
||||
|
||||
fixtureObjectName: 'n8n',
|
||||
|
||||
apiFixtureName: 'api',
|
||||
|
||||
rawApiPatterns: [
|
||||
/\brequest\.(get|post|put|patch|delete|head)\s*\(/i,
|
||||
/\bfetch\s*\(/,
|
||||
/\.request\(\s*['"`]/,
|
||||
],
|
||||
|
||||
flowLayerName: 'Composable',
|
||||
|
||||
architectureLayers: ['Tests', 'Composables', 'Pages', 'BasePage'],
|
||||
|
||||
rules: {
|
||||
'boundary-protection': { enabled: true, severity: 'error' },
|
||||
'scope-lockdown': { enabled: true, severity: 'error' },
|
||||
'selector-purity': { enabled: true, severity: 'error' },
|
||||
deduplication: { enabled: true, severity: 'warning' },
|
||||
'dead-code': { enabled: true, severity: 'warning' },
|
||||
'no-page-in-flow': {
|
||||
enabled: true,
|
||||
severity: 'warning',
|
||||
allowPatterns: [
|
||||
/\.page\.keyboard/,
|
||||
/\.page\.evaluate/,
|
||||
/\.page\.waitForLoadState/,
|
||||
/\.page\.waitForURL/,
|
||||
/\.page\.reload/,
|
||||
],
|
||||
},
|
||||
'api-purity': { enabled: true, severity: 'warning' },
|
||||
// Enforce facade pattern - access pages through n8n.* instead of new *Page()
|
||||
'no-direct-page-instantiation': { enabled: true, severity: 'error' },
|
||||
},
|
||||
|
||||
tcr: {
|
||||
testCommand: 'pnpm test:local',
|
||||
workerCount: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -27,11 +27,14 @@
|
|||
"prepare-test-image": "node scripts/build-test-image.mjs",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"janitor": "playwright-janitor",
|
||||
"janitor:fix": "playwright-janitor --fix --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@currents/playwright": "catalog:e2e",
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/playwright-janitor": "workspace:*",
|
||||
"@n8n/constants": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/db": "workspace:*",
|
||||
|
|
@ -48,6 +51,7 @@
|
|||
"nanoid": "catalog:",
|
||||
"nyc": "^17.1.0",
|
||||
"otplib": "^12.0.1",
|
||||
"ts-morph": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,10 +71,6 @@ export class AIAssistantPage extends BasePage {
|
|||
return this.page.getByTestId('quick-replies').locator('button');
|
||||
}
|
||||
|
||||
getQuickReplies() {
|
||||
return this.page.getByTestId('quick-replies');
|
||||
}
|
||||
|
||||
getNewAssistantSessionModal() {
|
||||
return this.page.getByTestId('new-assistant-session-modal');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { BasePage } from './BasePage';
|
||||
|
||||
export class BecomeCreatorCTAPage extends BasePage {
|
||||
getBecomeTemplateCreatorCta() {
|
||||
return this.page.getByTestId('become-template-creator-cta');
|
||||
}
|
||||
|
||||
getCloseBecomeTemplateCreatorCtaButton() {
|
||||
return this.page.getByTestId('close-become-template-creator-cta');
|
||||
}
|
||||
|
||||
async closeBecomeTemplateCreatorCta() {
|
||||
await this.getCloseBecomeTemplateCreatorCtaButton().click();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Locator } from '@playwright/test';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
import { ROUTES } from '../config/constants';
|
||||
|
|
@ -35,19 +34,11 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.getByTestId('node-creator-item-name').getByText(subItemText, { exact: true });
|
||||
}
|
||||
|
||||
getNthCreatorItem(index: number): Locator {
|
||||
return this.page.getByTestId('node-creator-item').nth(index);
|
||||
}
|
||||
|
||||
getNodeCreatorHeader(text?: string) {
|
||||
const header = this.page.getByTestId('nodes-list-header');
|
||||
return text ? header.filter({ hasText: text }) : header.first();
|
||||
}
|
||||
|
||||
async selectNodeCreatorItemByText(nodeName: string) {
|
||||
await this.page.getByText(nodeName).click();
|
||||
}
|
||||
|
||||
nodeByName(nodeName: string): Locator {
|
||||
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
|
||||
}
|
||||
|
|
@ -180,20 +171,6 @@ export class CanvasPage extends BasePage {
|
|||
await this.getExecuteWorkflowButton(triggerNodeName).click();
|
||||
}
|
||||
|
||||
async clickDebugInEditorButton(): Promise<void> {
|
||||
await this.page.getByRole('button', { name: 'Debug in editor' }).click();
|
||||
}
|
||||
|
||||
async pinNode(nodeName: string): Promise<void> {
|
||||
await this.nodeByName(nodeName).click({ button: 'right' });
|
||||
await this.page.getByTestId('context-menu').getByText('Pin').click();
|
||||
}
|
||||
|
||||
async unpinNode(nodeName: string): Promise<void> {
|
||||
await this.nodeByName(nodeName).click({ button: 'right' });
|
||||
await this.page.getByText('Unpin').click();
|
||||
}
|
||||
|
||||
async openNode(nodeName: string): Promise<void> {
|
||||
await this.nodeByName(nodeName).dblclick();
|
||||
}
|
||||
|
|
@ -272,10 +249,6 @@ export class CanvasPage extends BasePage {
|
|||
await this.clickByTestId('workflow-menu-item-import-from-url');
|
||||
}
|
||||
|
||||
async clickImportFromFile(): Promise<void> {
|
||||
await this.clickByTestId('workflow-menu-item-import-from-file');
|
||||
}
|
||||
|
||||
async fillImportURLInput(url: string): Promise<void> {
|
||||
await this.getImportURLInput().fill(url);
|
||||
}
|
||||
|
|
@ -306,10 +279,6 @@ export class CanvasPage extends BasePage {
|
|||
await responsePromise;
|
||||
}
|
||||
|
||||
async cancelPublishWorkflowModal(): Promise<void> {
|
||||
await this.page.getByTestId('workflow-publish-cancel-button').click();
|
||||
}
|
||||
|
||||
async openShareModal(): Promise<void> {
|
||||
await this.clickByTestId('workflow-menu');
|
||||
await this.clickByTestId('workflow-menu-item-share');
|
||||
|
|
@ -327,36 +296,6 @@ export class CanvasPage extends BasePage {
|
|||
return this.nodeByName(nodeName).getByTestId('node-issues');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags to the workflow
|
||||
* @param count - The number of tags to add
|
||||
* @returns An array of tag names
|
||||
*/
|
||||
async addTags(count: number = 1): Promise<string[]> {
|
||||
const tags: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tag = `tag-${nanoid(8)}-${i}`;
|
||||
tags.push(tag);
|
||||
|
||||
if (i === 0) {
|
||||
await this.clickByText('Add tag');
|
||||
} else {
|
||||
await this.page
|
||||
.getByTestId('tags-dropdown')
|
||||
.getByText(tags[i - 1])
|
||||
.click();
|
||||
}
|
||||
|
||||
await this.page.getByRole('combobox').first().fill(tag);
|
||||
await this.page.getByRole('combobox').first().press('Enter');
|
||||
}
|
||||
|
||||
await this.page.click('body');
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
async clickCreateTagButton(): Promise<void> {
|
||||
await this.page.getByTestId('new-tag-link').click();
|
||||
}
|
||||
|
|
@ -383,18 +322,14 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.getByTestId('workflow-tags').locator('.n8n-tag:not(.count-container)');
|
||||
}
|
||||
|
||||
getTagsDropdown(): Locator {
|
||||
return this.page.getByTestId('tags-dropdown');
|
||||
getWorkflowTagsElement(): Locator {
|
||||
return this.page.getByTestId('workflow-tags');
|
||||
}
|
||||
|
||||
getWorkflowTagsDropdown(): Locator {
|
||||
return this.page.getByTestId('workflow-tags-dropdown');
|
||||
}
|
||||
|
||||
getWorkflowTags(): Locator {
|
||||
return this.page.getByTestId('workflow-tags');
|
||||
}
|
||||
|
||||
getTagCloseButton(): Locator {
|
||||
return this.getWorkflowTagsDropdown().locator('.el-tag__close');
|
||||
}
|
||||
|
|
@ -437,10 +372,6 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.getByTestId('workflow-open-publish-modal-button');
|
||||
}
|
||||
|
||||
getPublishModalCallout(): Locator {
|
||||
return this.page.getByTestId('workflowPublish-modal').locator('.n8n-callout');
|
||||
}
|
||||
|
||||
getPublishButton(): Locator {
|
||||
return this.page.getByTestId('workflow-publish-button');
|
||||
}
|
||||
|
|
@ -513,10 +444,6 @@ export class CanvasPage extends BasePage {
|
|||
await this.getProductionChecklistIgnoreAllButton().click();
|
||||
}
|
||||
|
||||
async clickProductionChecklistAction(actionText: string): Promise<void> {
|
||||
await this.getProductionChecklistActionItem(actionText).click();
|
||||
}
|
||||
|
||||
async duplicateNode(nodeName: string): Promise<void> {
|
||||
await this.nodeByName(nodeName).click({ button: 'right' });
|
||||
await this.page.getByTestId('context-menu').getByText('Duplicate').click();
|
||||
|
|
@ -526,10 +453,6 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.locator('[data-test-id="edge"]');
|
||||
}
|
||||
|
||||
getConnectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
|
||||
return this.connectionBetweenNodes(sourceNodeName, targetNodeName);
|
||||
}
|
||||
|
||||
canvasNodePlusEndpointByName(nodeName: string): Locator {
|
||||
return this.page
|
||||
.locator(
|
||||
|
|
@ -550,10 +473,6 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.getByTestId('node-creator-action-item');
|
||||
}
|
||||
|
||||
nodeCreatorCategoryItem(categoryName: string): Locator {
|
||||
return this.page.getByTestId('node-creator-category-item').getByText(categoryName);
|
||||
}
|
||||
|
||||
nodeCreatorCategoryItems(): Locator {
|
||||
return this.page.getByTestId('node-creator-category-item');
|
||||
}
|
||||
|
|
@ -636,10 +555,6 @@ export class CanvasPage extends BasePage {
|
|||
await this.canvasPane().click({ button: 'right', position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
getNodeLeftPosition(nodeLocator: Locator): Promise<number> {
|
||||
return nodeLocator.evaluate((el) => el.getBoundingClientRect().left);
|
||||
}
|
||||
|
||||
// Connection helpers
|
||||
connectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
|
||||
return this.page.locator(
|
||||
|
|
@ -723,11 +638,6 @@ export class CanvasPage extends BasePage {
|
|||
await this.openNewWorkflow();
|
||||
}
|
||||
|
||||
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
|
||||
await this.addNode(searchText);
|
||||
await this.nodeCreatorSubItem(subItemText).click();
|
||||
}
|
||||
|
||||
async openNewWorkflow() {
|
||||
await this.page.goto(ROUTES.NEW_WORKFLOW_PAGE);
|
||||
}
|
||||
|
|
@ -791,12 +701,6 @@ export class CanvasPage extends BasePage {
|
|||
.last();
|
||||
}
|
||||
|
||||
getNodesWithSpinner(): Locator {
|
||||
return this.page.locator(
|
||||
'[data-test-id="canvas-node"].running, [data-test-id="canvas-node"].waiting',
|
||||
);
|
||||
}
|
||||
|
||||
getWaitingNodes(): Locator {
|
||||
return this.page.locator('[data-test-id="canvas-node"].waiting');
|
||||
}
|
||||
|
|
@ -957,14 +861,6 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.getByTestId('canvas-node-status-success');
|
||||
}
|
||||
|
||||
getCanvasHandlePlusWrapper(): Locator {
|
||||
return this.page.getByTestId('canvas-handle-plus-wrapper');
|
||||
}
|
||||
|
||||
getCanvasHandlePlus(): Locator {
|
||||
return this.page.getByTestId('canvas-handle-plus');
|
||||
}
|
||||
|
||||
getCanvasHandlePlusWrapperByName(nodeName: string): Locator {
|
||||
return this.page
|
||||
.locator(
|
||||
|
|
@ -999,10 +895,6 @@ export class CanvasPage extends BasePage {
|
|||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
async hitSaveWorkflow(): Promise<void> {
|
||||
await this.page.keyboard.press('ControlOrMeta+s');
|
||||
}
|
||||
|
||||
async hitExecuteWorkflow(): Promise<void> {
|
||||
await this.page.keyboard.press('ControlOrMeta+Enter');
|
||||
}
|
||||
|
|
@ -1083,7 +975,20 @@ export class CanvasPage extends BasePage {
|
|||
return this.page.getByTestId('workflow-name-input');
|
||||
}
|
||||
|
||||
getWorkflowNameInput(): Locator {
|
||||
return this.page.getByTestId('inline-edit-input');
|
||||
// Workflow History methods
|
||||
getWorkflowHistoryButton(): Locator {
|
||||
return this.page.getByTestId('workflow-history-button');
|
||||
}
|
||||
|
||||
getWorkflowHistoryCloseButton(): Locator {
|
||||
return this.page.getByTestId('workflow-history-close-button');
|
||||
}
|
||||
|
||||
async openWorkflowHistory(): Promise<void> {
|
||||
await this.getWorkflowHistoryButton().click();
|
||||
}
|
||||
|
||||
async closeWorkflowHistory(): Promise<void> {
|
||||
await this.getWorkflowHistoryCloseButton().click();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,6 @@ export class ChatHubChatPage extends BasePage {
|
|||
getAttachButton(): Locator {
|
||||
return this.page.getByRole('button', { name: /attach/i });
|
||||
}
|
||||
|
||||
getFileInput(): Locator {
|
||||
return this.page.locator('input[type="file"]');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ import type { Locator, Page } from '@playwright/test';
|
|||
|
||||
import { BasePage } from './BasePage';
|
||||
import { ChatHubPersonalAgentModal } from './components/ChatHubPersonalAgentModal';
|
||||
import { ChatHubSidebar } from './components/ChatHubSidebar';
|
||||
|
||||
export class ChatHubPersonalAgentsPage extends BasePage {
|
||||
readonly sidebar = new ChatHubSidebar(this.page.locator('#sidebar'));
|
||||
readonly editModal = new ChatHubPersonalAgentModal(
|
||||
this.page.getByTestId('agentEditorModal-modal'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
import { ChatHubSidebar } from './components/ChatHubSidebar';
|
||||
|
||||
export class ChatHubWorkflowAgentsPage extends BasePage {
|
||||
readonly sidebar = new ChatHubSidebar(this.page.locator('#sidebar'));
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,10 +118,6 @@ export class DataTableDetails extends BasePage {
|
|||
await this.getAddRowTableButton().click();
|
||||
}
|
||||
|
||||
getDataGrid() {
|
||||
return this.page.getByTestId('data-table-grid');
|
||||
}
|
||||
|
||||
getDataRows() {
|
||||
return this.page.locator(
|
||||
'[data-test-id="data-table-grid"] .ag-center-cols-container [row-index]',
|
||||
|
|
|
|||
|
|
@ -4,14 +4,6 @@ import { AddResource } from './components/AddResource';
|
|||
export class DataTableView extends BasePage {
|
||||
readonly addResource = new AddResource(this.page);
|
||||
|
||||
getDataTableOverviewTab() {
|
||||
return this.page.getByTestId('tab-data-tables');
|
||||
}
|
||||
|
||||
getDataTableProjectTab() {
|
||||
return this.page.getByTestId('tab-project-data-tables');
|
||||
}
|
||||
|
||||
getEmptyStateActionBox() {
|
||||
return this.page.getByTestId('empty-data-table-action-box');
|
||||
}
|
||||
|
|
@ -32,18 +24,10 @@ export class DataTableView extends BasePage {
|
|||
return this.page.getByTestId('create-from-scratch-option');
|
||||
}
|
||||
|
||||
getImportCsvOption() {
|
||||
return this.page.getByTestId('import-csv-option');
|
||||
}
|
||||
|
||||
getProceedFromSelectButton() {
|
||||
return this.page.getByTestId('proceed-from-select-button');
|
||||
}
|
||||
|
||||
getNewDataTableConfirmButton() {
|
||||
return this.page.getByTestId('confirm-add-data-table-button');
|
||||
}
|
||||
|
||||
getDataTableCards() {
|
||||
return this.page.getByTestId('data-table-card');
|
||||
}
|
||||
|
|
@ -80,10 +64,6 @@ export class DataTableView extends BasePage {
|
|||
return this.page.getByTestId('resources-list-pagination').locator('button.btn-next');
|
||||
}
|
||||
|
||||
async clickDataTableOverviewTab() {
|
||||
await this.clickByTestId('tab-data-tables');
|
||||
}
|
||||
|
||||
async clickDataTableProjectTab() {
|
||||
await this.clickByTestId('tab-project-data-tables');
|
||||
}
|
||||
|
|
@ -100,10 +80,6 @@ export class DataTableView extends BasePage {
|
|||
await this.getDataTableCardActionsButton(dataTableName).click();
|
||||
}
|
||||
|
||||
async clickDataTableCardAction(actionName: string) {
|
||||
await this.getDataTableCardAction(actionName).click();
|
||||
}
|
||||
|
||||
async clickDeleteDataTableConfirmButton() {
|
||||
await this.getDeleteDataTableConfirmButton().click();
|
||||
}
|
||||
|
|
@ -112,8 +88,4 @@ export class DataTableView extends BasePage {
|
|||
await this.getDataTablePageSizeSelect().click();
|
||||
await this.getDataTablePageOption(pageSize).click();
|
||||
}
|
||||
|
||||
async clickPaginationNextButton() {
|
||||
await this.getPaginationNextButton().click();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,10 +103,4 @@ export class ExecutionsPage extends BasePage {
|
|||
async openFilter(): Promise<void> {
|
||||
await this.getFilterButton().click();
|
||||
}
|
||||
|
||||
async selectStatus(status: string): Promise<void> {
|
||||
await this.getStatusSelect().click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
await this.page.getByRole('option', { name: status }).click();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { BasePage } from './BasePage';
|
||||
|
||||
export class IframePage extends BasePage {
|
||||
getIframe() {
|
||||
return this.page.locator('iframe');
|
||||
}
|
||||
|
||||
getIframeBySrc(src: string) {
|
||||
return this.page.locator(`iframe[src="${src}"]`);
|
||||
}
|
||||
|
||||
async waitForIframeRequest(url: string) {
|
||||
await this.page.waitForResponse(url);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,18 +18,6 @@ export class MfaLoginPage extends BasePage {
|
|||
return this.getForm().locator('input[name="mfaRecoveryCode"]');
|
||||
}
|
||||
|
||||
getEnterRecoveryCodeButton(): Locator {
|
||||
return this.page.getByTestId('mfa-enter-recovery-code-button');
|
||||
}
|
||||
|
||||
getSubmitButton(): Locator {
|
||||
return this.page.getByRole('button', { name: /^(Continue|Verify)$/ });
|
||||
}
|
||||
|
||||
async goToMfaLogin(): Promise<void> {
|
||||
await this.page.goto('/mfa');
|
||||
}
|
||||
|
||||
async fillMfaCode(code: string): Promise<void> {
|
||||
await this.getMfaCodeField().fill(code);
|
||||
}
|
||||
|
|
@ -42,10 +30,6 @@ export class MfaLoginPage extends BasePage {
|
|||
await this.clickByTestId('mfa-enter-recovery-code-button');
|
||||
}
|
||||
|
||||
async clickSubmit(): Promise<void> {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill MFA code and submit the form
|
||||
* @param code - The MFA token to submit
|
||||
|
|
|
|||
|
|
@ -15,18 +15,10 @@ export class MfaSetupModal extends BasePage {
|
|||
return this.page.getByTestId('mfa-token-input');
|
||||
}
|
||||
|
||||
getCopySecretToClipboardButton(): Locator {
|
||||
return this.page.getByTestId('mfa-secret-button');
|
||||
}
|
||||
|
||||
getDownloadRecoveryCodesButton(): Locator {
|
||||
return this.page.getByTestId('mfa-recovery-codes-button');
|
||||
}
|
||||
|
||||
getSaveButton(): Locator {
|
||||
return this.page.getByTestId('mfa-save-button');
|
||||
}
|
||||
|
||||
async fillToken(token: string): Promise<void> {
|
||||
await this.getTokenInput().fill(token);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,10 +127,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByTestId('run-data-pane-header');
|
||||
}
|
||||
|
||||
getOutputTable() {
|
||||
return this.getOutputPanel().getByTestId('ndv-data-container').locator('table');
|
||||
}
|
||||
|
||||
getOutputDataContainer() {
|
||||
return this.getOutputPanel().getByTestId('ndv-data-container');
|
||||
}
|
||||
|
|
@ -147,19 +143,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.savePinnedData();
|
||||
}
|
||||
|
||||
async pastePinnedData(data: object) {
|
||||
await this.getEditPinnedDataButton().click();
|
||||
|
||||
const editor = this.outputPanel.get().locator('[contenteditable="true"]');
|
||||
await editor.waitFor();
|
||||
await editor.click();
|
||||
await editor.fill('');
|
||||
|
||||
await this.clipboard.paste(JSON.stringify(data));
|
||||
|
||||
await this.savePinnedData();
|
||||
}
|
||||
|
||||
async savePinnedData() {
|
||||
await this.getRunDataPaneHeader().locator('button:visible').filter({ hasText: 'Save' }).click();
|
||||
}
|
||||
|
|
@ -221,10 +204,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return await this.page.request.get(path);
|
||||
}
|
||||
|
||||
getWebhookUrl() {
|
||||
return this.page.locator('.webhook-url').textContent();
|
||||
}
|
||||
|
||||
async clearExpressionEditor(parameterName?: string) {
|
||||
const editor = this.getInlineExpressionEditorInput(parameterName);
|
||||
await editor.click();
|
||||
|
|
@ -279,12 +258,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.selectFromVisibleDropdown(optionName);
|
||||
}
|
||||
|
||||
async waitForParameterDropdown(parameterName: string): Promise<void> {
|
||||
const dropdown = this.getParameterInput(parameterName);
|
||||
await dropdown.waitFor({ state: 'visible' });
|
||||
await expect(dropdown).toBeEnabled();
|
||||
}
|
||||
|
||||
async clickFloatingNode(nodeName: string) {
|
||||
await this.page.locator(`[data-test-id="floating-node"][data-node-name="${nodeName}"]`).click();
|
||||
}
|
||||
|
|
@ -383,66 +356,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
}
|
||||
}
|
||||
|
||||
async setMultipleParameters(
|
||||
parameters: Record<string, string | number | boolean>,
|
||||
): Promise<void> {
|
||||
for (const [parameterName, value] of Object.entries(parameters)) {
|
||||
if (typeof value === 'string') {
|
||||
const parameterType = await this.setupHelper.detectParameterType(parameterName);
|
||||
if (parameterType === 'dropdown') {
|
||||
await this.setParameterDropdown(parameterName, value);
|
||||
} else {
|
||||
await this.setParameterInput(parameterName, value);
|
||||
}
|
||||
} else if (typeof value === 'boolean') {
|
||||
await this.setParameterSwitch(parameterName, value);
|
||||
} else if (typeof value === 'number') {
|
||||
await this.setParameterInput(parameterName, value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getParameterValue(parameterName: string): Promise<string> {
|
||||
const parameterType = await this.setupHelper.detectParameterType(parameterName);
|
||||
|
||||
switch (parameterType) {
|
||||
case 'text':
|
||||
return await this.getTextParameterValue(parameterName);
|
||||
case 'dropdown':
|
||||
return await this.getDropdownParameterValue(parameterName);
|
||||
case 'switch':
|
||||
return await this.getSwitchParameterValue(parameterName);
|
||||
default:
|
||||
return (await this.getParameterInput(parameterName).textContent()) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
private async getTextParameterValue(parameterName: string): Promise<string> {
|
||||
const parameterContainer = this.getParameterInput(parameterName);
|
||||
const input = parameterContainer.locator('input').first();
|
||||
return await input.inputValue();
|
||||
}
|
||||
|
||||
private async getDropdownParameterValue(parameterName: string): Promise<string> {
|
||||
const selectedOption = this.getParameterInput(parameterName).locator('.el-select__tags-text');
|
||||
return (await selectedOption.textContent()) ?? '';
|
||||
}
|
||||
|
||||
private async getSwitchParameterValue(parameterName: string): Promise<string> {
|
||||
const switchElement = this.getParameterInput(parameterName).locator('.el-switch');
|
||||
const isEnabled = (await switchElement.getAttribute('aria-checked')) === 'true';
|
||||
return isEnabled ? 'true' : 'false';
|
||||
}
|
||||
|
||||
async validateParameter(parameterName: string, expectedValue: string): Promise<void> {
|
||||
const actualValue = await this.getParameterValue(parameterName);
|
||||
if (actualValue !== expectedValue) {
|
||||
throw new Error(
|
||||
`Parameter ${parameterName} has value "${actualValue}", expected "${expectedValue}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getAssignmentCollectionContainer(paramName: string) {
|
||||
return this.page.getByTestId(`assignment-collection-${paramName}`);
|
||||
}
|
||||
|
|
@ -453,14 +366,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.page.getByRole('option', { name: nodeName }).click();
|
||||
}
|
||||
|
||||
getInputTableHeader(index: number = 0) {
|
||||
return this.getInputPanel().locator('table th').nth(index);
|
||||
}
|
||||
|
||||
getInputTbodyCell(row: number, col: number) {
|
||||
return this.getInputPanel().locator('table tbody tr').nth(row).locator('td').nth(col);
|
||||
}
|
||||
|
||||
getAssignmentName(paramName: string, index = 0) {
|
||||
return this.getAssignmentCollectionContainer(paramName)
|
||||
.getByTestId('assignment')
|
||||
|
|
@ -504,10 +409,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.getNodeParameters().locator('input[placeholder*="Add Value"]');
|
||||
}
|
||||
|
||||
getCollectionAddOptionSelect() {
|
||||
return this.getNodeParameters().getByTestId('collection-add-option-select');
|
||||
}
|
||||
|
||||
getParameterSwitch(parameterName: string) {
|
||||
return this.getParameterInput(parameterName).locator('.el-switch');
|
||||
}
|
||||
|
|
@ -577,19 +478,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByTestId('expression-modal-output');
|
||||
}
|
||||
|
||||
async typeIntoParameterInput(parameterName: string, content: string): Promise<void> {
|
||||
const input = this.getParameterInput(parameterName);
|
||||
await input.type(content);
|
||||
}
|
||||
|
||||
getInputTable() {
|
||||
return this.getInputPanel().locator('table');
|
||||
}
|
||||
|
||||
getInputTableCellSpan(row: number, col: number, dataName: string) {
|
||||
return this.getInputTbodyCell(row, col).locator(`span[data-name="${dataName}"]`).first();
|
||||
}
|
||||
|
||||
getAddFieldToSortByButton() {
|
||||
return this.getNodeParameters().getByText('Add Field To Sort By');
|
||||
}
|
||||
|
|
@ -614,12 +502,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await pages.nth(pageNumber - 1).click();
|
||||
}
|
||||
|
||||
async getCurrentOutputPage(): Promise<number> {
|
||||
const activePage = this.getOutputPagination().locator('.el-pager li.is-active').first();
|
||||
const pageText = await activePage.textContent();
|
||||
return parseInt(pageText ?? '1', 10);
|
||||
}
|
||||
|
||||
async setParameterInputValue(parameterName: string, value: string): Promise<void> {
|
||||
const input = this.getParameterInput(parameterName).locator('input');
|
||||
await input.clear();
|
||||
|
|
@ -632,18 +514,10 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
async clickGetBackToCanvas(): Promise<void> {
|
||||
await this.clickBackToCanvasButton();
|
||||
}
|
||||
|
||||
getRunDataInfoCallout() {
|
||||
return this.page.getByTestId('run-data-callout');
|
||||
}
|
||||
|
||||
getOutputPanelTable() {
|
||||
return this.getOutputTable();
|
||||
}
|
||||
|
||||
async checkParameterCheckboxInputByName(name: string): Promise<void> {
|
||||
const checkbox = this.getParameterInput(name).locator('.el-switch.switch-input');
|
||||
await checkbox.click();
|
||||
|
|
@ -713,11 +587,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return await this.outputPanel.getRunSelectorInput().inputValue();
|
||||
}
|
||||
|
||||
async expandSchemaItem(itemText: string) {
|
||||
const item = this.outputPanel.getSchemaItem(itemText);
|
||||
await item.locator('.toggle').click();
|
||||
}
|
||||
|
||||
getExecuteNodeButton() {
|
||||
return this.page.getByTestId('node-execute-button');
|
||||
}
|
||||
|
|
@ -742,10 +611,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.getCodeEditorDialog().locator('.el-dialog__close').click();
|
||||
}
|
||||
|
||||
getWebhookTriggerListening() {
|
||||
return this.page.getByTestId('trigger-listening');
|
||||
}
|
||||
|
||||
getNodeRunSuccessIndicator() {
|
||||
return this.page.getByTestId('node-run-status-success');
|
||||
}
|
||||
|
|
@ -795,7 +660,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await searchInput.waitFor({ state: 'visible' });
|
||||
await searchInput.fill(searchTerm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type multiple values into the first available text parameter field
|
||||
* Useful for testing multiple parameter changes
|
||||
|
|
@ -849,14 +713,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByTestId(`add-subnode-${connectionType}-${index}`);
|
||||
}
|
||||
|
||||
getSubNodeConnectionGroup(connectionType: string, index: number = 0) {
|
||||
return this.page.getByTestId(`subnode-connection-group-${connectionType}-${index}`);
|
||||
}
|
||||
|
||||
getFloatingSubNodes(connectionType: string, index: number = 0) {
|
||||
return this.getSubNodeConnectionGroup(connectionType, index).getByTestId('floating-subnode');
|
||||
}
|
||||
|
||||
getNodesWithIssues() {
|
||||
return this.page.locator('[class*="hasIssues"]');
|
||||
}
|
||||
|
|
@ -871,10 +727,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByTestId('floating-node');
|
||||
}
|
||||
|
||||
async getNodesWithIssuesCount() {
|
||||
return await this.getNodesWithIssues().count();
|
||||
}
|
||||
|
||||
async addItemToFixedCollection(collectionName: string) {
|
||||
await this.page.getByTestId(`fixed-collection-${collectionName}`).click();
|
||||
}
|
||||
|
|
@ -890,10 +742,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.page.getByRole('option', { name: propertyName, exact: true }).click();
|
||||
}
|
||||
|
||||
async clickParameterItemAction(actionText: string) {
|
||||
await this.page.getByTestId('parameter-item').getByText(actionText).click();
|
||||
}
|
||||
|
||||
getParameterItemWithText(text: string) {
|
||||
return this.page.getByTestId('parameter-item').getByText(text);
|
||||
}
|
||||
|
|
@ -963,10 +811,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByTestId('ndv-input-select').locator('input');
|
||||
}
|
||||
|
||||
getInputTableRows() {
|
||||
return this.getInputTable().locator('tr');
|
||||
}
|
||||
|
||||
getOutputRunSelectorInput() {
|
||||
return this.getOutputPanel().locator('[data-test-id="run-selector"] input');
|
||||
}
|
||||
|
|
@ -987,18 +831,10 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.getFilterComponent(paramName).getByTestId('filter-condition');
|
||||
}
|
||||
|
||||
getFilterCondition(paramName: string, index: number = 0) {
|
||||
return this.getFilterComponent(paramName).getByTestId('filter-condition').nth(index);
|
||||
}
|
||||
|
||||
getFilterConditionLeft(paramName: string, index: number = 0) {
|
||||
return this.getFilterComponent(paramName).getByTestId('filter-condition-left').nth(index);
|
||||
}
|
||||
|
||||
getFilterConditionRight(paramName: string, index: number = 0) {
|
||||
return this.getFilterComponent(paramName).getByTestId('filter-condition-right').nth(index);
|
||||
}
|
||||
|
||||
getFilterConditionOperator(paramName: string, index: number = 0) {
|
||||
return this.getFilterComponent(paramName).getByTestId('filter-operator-select').nth(index);
|
||||
}
|
||||
|
|
@ -1027,26 +863,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByRole('combobox', { name: 'Add option' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an optional parameter from a collection dropdown
|
||||
* @param optionDisplayName - The display name of the option to add (e.g., 'Response Code')
|
||||
* @param parameterName - The parameter name to set after adding (e.g., 'responseCode')
|
||||
* @param parameterValue - The value to set for the parameter
|
||||
*/
|
||||
async setOptionalParameter(
|
||||
optionDisplayName: string,
|
||||
parameterName: string,
|
||||
parameterValue: string | boolean,
|
||||
): Promise<void> {
|
||||
await this.getAddOptionDropdown().click();
|
||||
|
||||
// Step 2: Select the option by display name
|
||||
await this.page.getByRole('option', { name: optionDisplayName }).click();
|
||||
|
||||
// Step 3: Set the parameter value
|
||||
await this.setupHelper.setParameter(parameterName, parameterValue);
|
||||
}
|
||||
|
||||
async setInvalidExpression({
|
||||
fieldName,
|
||||
invalidExpression,
|
||||
|
|
|
|||
|
|
@ -42,18 +42,6 @@ export class NotificationsPage {
|
|||
return this.page.getByRole('alert').filter({ hasText: text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the main container locator for a notification by searching in both title and content,
|
||||
* filtered to a specific node name. This is useful when multiple notifications might be present
|
||||
* and you want to ensure you're checking the right one for a specific node.
|
||||
* @param text The text or a regular expression to find within the notification's title or content.
|
||||
* @param nodeName The name of the node to filter notifications for.
|
||||
* @returns A Locator for the notification container element.
|
||||
*/
|
||||
getNotificationByTitleOrContentForNode(text: string | RegExp, nodeName: string): Locator {
|
||||
return this.page.getByRole('alert').filter({ hasText: text }).filter({ hasText: nodeName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the close button on the FIRST notification matching the text.
|
||||
* Fast execution with short timeouts for snappy notifications.
|
||||
|
|
@ -81,74 +69,6 @@ export class NotificationsPage {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes ALL currently visible notifications that match the given text.
|
||||
* Uses aggressive polling for fast cleanup.
|
||||
* @param text The text of the notifications to close.
|
||||
* @param options Optional configuration
|
||||
*/
|
||||
async closeAllNotificationsWithText(
|
||||
text: string | RegExp,
|
||||
options: { timeout?: number; maxRetries?: number } = {},
|
||||
): Promise<number> {
|
||||
const { maxRetries = 15 } = options;
|
||||
let closedCount = 0;
|
||||
let retries = 0;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const notifications = this.getNotificationByTitle(text);
|
||||
const count = await notifications.count();
|
||||
|
||||
if (count === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Close the first visible notification quickly
|
||||
const firstNotification = notifications.first();
|
||||
if (await firstNotification.isVisible({ timeout: 200 })) {
|
||||
const closeBtn = firstNotification.locator('.el-notification__closeBtn');
|
||||
await closeBtn.click({ timeout: 300 });
|
||||
|
||||
// Brief wait for disappearance, then continue
|
||||
await firstNotification.waitFor({ state: 'hidden', timeout: 500 }).catch(() => {});
|
||||
closedCount++;
|
||||
} else {
|
||||
// If not visible, likely already gone
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue quickly on any error
|
||||
break;
|
||||
}
|
||||
|
||||
retries++;
|
||||
}
|
||||
|
||||
return closedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a notification is visible based on text.
|
||||
* Fast check with short timeout.
|
||||
* @param text The text to search for in notification title.
|
||||
* @param options Optional configuration
|
||||
*/
|
||||
async isNotificationVisible(
|
||||
text: string | RegExp,
|
||||
options: { timeout?: number } = {},
|
||||
): Promise<boolean> {
|
||||
const { timeout = 500 } = options;
|
||||
|
||||
try {
|
||||
const notification = this.getNotificationByTitle(text).first();
|
||||
await notification.waitFor({ state: 'visible', timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a notification to appear with specific text.
|
||||
* Reasonable timeout for waiting, but still faster than before.
|
||||
|
|
@ -196,57 +116,6 @@ export class NotificationsPage {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all notifications to disappear.
|
||||
* Fast check with short timeout.
|
||||
* @param options Optional configuration
|
||||
*/
|
||||
async waitForAllNotificationsToDisappear(options: { timeout?: number } = {}): Promise<boolean> {
|
||||
const { timeout = 2000 } = options;
|
||||
|
||||
try {
|
||||
// Wait for no alerts to be visible
|
||||
await this.page.getByRole('alert').first().waitFor({
|
||||
state: 'detached',
|
||||
timeout,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// Check if any are still visible
|
||||
const count = await this.getNotificationCount();
|
||||
return count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of visible notifications.
|
||||
* @param text Optional text to filter notifications
|
||||
*/
|
||||
async getNotificationCount(text?: string | RegExp): Promise<number> {
|
||||
try {
|
||||
const notifications = text ? this.getNotificationByTitle(text) : this.page.getByRole('alert');
|
||||
return await notifications.count();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick utility to close any notification and continue.
|
||||
* Uses the most aggressive timeouts for maximum speed.
|
||||
* @param text The text of the notification to close.
|
||||
*/
|
||||
async quickClose(text: string | RegExp): Promise<void> {
|
||||
try {
|
||||
const notification = this.getNotificationByTitle(text).first();
|
||||
if (await notification.isVisible({ timeout: 100 })) {
|
||||
await notification.locator('.el-notification__closeBtn').click({ timeout: 200 });
|
||||
}
|
||||
} catch {
|
||||
// Silent fail for speed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuclear option: Close everything as fast as possible.
|
||||
* No waiting, no error handling, just close and move on.
|
||||
|
|
@ -279,8 +148,4 @@ export class NotificationsPage {
|
|||
getWarningNotifications(): Locator {
|
||||
return this.page.locator('.el-notification:has(.el-notification--warning)');
|
||||
}
|
||||
|
||||
getInfoNotifications(): Locator {
|
||||
return this.page.locator('.el-notification:has(.el-notification--info)');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,6 @@ export class ProjectSettingsPage extends BasePage {
|
|||
expect(actualCount).toBe(expectedCount);
|
||||
}
|
||||
|
||||
async expectSearchInputValue(expectedValue: string) {
|
||||
const searchInput = this.getMembersSearchInput();
|
||||
await expect(searchInput).toHaveValue(expectedValue);
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.page.getByTestId('project-name');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,6 @@ export class SettingsEnvironmentPage extends BasePage {
|
|||
return this.page.getByTestId('source-control-disconnect-button');
|
||||
}
|
||||
|
||||
getSSHKeyValue(): Locator {
|
||||
return this.page.getByTestId('copy-input').locator('span').first();
|
||||
}
|
||||
|
||||
getRepoUrlInput(): Locator {
|
||||
return this.page.getByPlaceholder('git@github.com:user/repository.git');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,22 +55,6 @@ export class SettingsLogStreamingPage extends BasePage {
|
|||
return this.page.getByTestId('destination-card');
|
||||
}
|
||||
|
||||
getInlineEditPreview(): Locator {
|
||||
return this.page.getByTestId('inline-edit-preview');
|
||||
}
|
||||
|
||||
getInlineEditInput(): Locator {
|
||||
return this.page.getByTestId('inline-edit-input');
|
||||
}
|
||||
|
||||
getModalOverlay(): Locator {
|
||||
return this.page.locator('.el-overlay');
|
||||
}
|
||||
|
||||
getDropdownMenu(): Locator {
|
||||
return this.page.locator('.el-dropdown-menu');
|
||||
}
|
||||
|
||||
getDropdownMenuItem(index: number): Locator {
|
||||
return this.page.locator('.el-dropdown-menu__item').nth(index);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,22 +6,10 @@ import { BasePage } from './BasePage';
|
|||
* Page object for Settings including Personal Settings where users can update their profile and manage MFA.
|
||||
*/
|
||||
export class SettingsPersonalPage extends BasePage {
|
||||
getChangePasswordLink(): Locator {
|
||||
return this.page.getByTestId('change-password-link');
|
||||
}
|
||||
|
||||
getMenuItems() {
|
||||
return this.page.getByTestId('menu-item');
|
||||
}
|
||||
|
||||
getMenuItem(id: string) {
|
||||
return this.page.getByTestId('menu-item').getByTestId(id);
|
||||
}
|
||||
|
||||
getMenuItemByText(text: string) {
|
||||
return this.page.getByTestId('menu-item').getByText(text, { exact: true });
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.page.goto('/settings');
|
||||
}
|
||||
|
|
@ -71,16 +59,6 @@ export class SettingsPersonalPage extends BasePage {
|
|||
await this.getSaveSettingsButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete workflow to update user's email address
|
||||
* @param newEmail - The new email address to set
|
||||
*/
|
||||
async updateEmail(newEmail: string): Promise<void> {
|
||||
await this.goToPersonalSettings();
|
||||
await this.fillEmail(newEmail);
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete workflow to update user's first and last name
|
||||
* @param firstName - The new first name
|
||||
|
|
@ -136,34 +114,4 @@ export class SettingsPersonalPage extends BasePage {
|
|||
getUpgradeCta(): Locator {
|
||||
return this.page.getByTestId('public-api-upgrade-cta');
|
||||
}
|
||||
|
||||
async changeTheme(theme: 'System default' | 'Light theme' | 'Dark theme') {
|
||||
await this.page.getByTestId('theme-select').click();
|
||||
await this.page.getByRole('option', { name: theme }).click();
|
||||
await this.getSaveSettingsButton().click();
|
||||
}
|
||||
|
||||
currentPassword(): Locator {
|
||||
return this.page.locator('input[name="currentPassword"]');
|
||||
}
|
||||
|
||||
newPassword(): Locator {
|
||||
return this.page.locator('input[name="password"]');
|
||||
}
|
||||
|
||||
repeatPassword(): Locator {
|
||||
return this.page.locator('input[name="password2"]');
|
||||
}
|
||||
|
||||
changePasswordModal(): Locator {
|
||||
return this.page.getByTestId('changePassword-modal');
|
||||
}
|
||||
|
||||
changePasswordButton(): Locator {
|
||||
return this.changePasswordModal().getByRole('button', { name: 'Change password' });
|
||||
}
|
||||
|
||||
emailBox(): Locator {
|
||||
return this.page.getByTestId('email');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@ export class SettingsSsoPage extends BasePage {
|
|||
await this.page.locator('.el-select-dropdown__item').filter({ hasText: 'OIDC' }).click();
|
||||
}
|
||||
|
||||
async selectSamlProtocol(): Promise<void> {
|
||||
await this.getProtocolSelect().locator('.el-select').click();
|
||||
await this.page.locator('.el-select-dropdown__item').filter({ hasText: 'SAML' }).click();
|
||||
}
|
||||
|
||||
getOidcDiscoveryEndpointInput(): Locator {
|
||||
return this.page.getByTestId('oidc-discovery-endpoint');
|
||||
}
|
||||
|
|
@ -33,10 +28,6 @@ export class SettingsSsoPage extends BasePage {
|
|||
return this.page.getByTestId('oidc-client-secret');
|
||||
}
|
||||
|
||||
getOidcPromptSelect(): Locator {
|
||||
return this.page.getByTestId('oidc-prompt');
|
||||
}
|
||||
|
||||
getOidcLoginToggle(): Locator {
|
||||
return this.page.getByTestId('sso-oidc-toggle');
|
||||
}
|
||||
|
|
@ -56,13 +47,6 @@ export class SettingsSsoPage extends BasePage {
|
|||
}
|
||||
}
|
||||
|
||||
async disableOidcLogin(): Promise<void> {
|
||||
const isEnabled = await this.isOidcLoginEnabled();
|
||||
if (isEnabled) {
|
||||
await this.getOidcLoginToggle().click();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill the OIDC form. Discovery endpoint default value is cleared first. */
|
||||
async fillOidcForm(config: {
|
||||
discoveryEndpoint: string;
|
||||
|
|
@ -95,20 +79,4 @@ export class SettingsSsoPage extends BasePage {
|
|||
throw new Error(`OIDC config save failed: ${response.status()} - ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
getSamlSaveButton(): Locator {
|
||||
return this.page.getByTestId('sso-save');
|
||||
}
|
||||
|
||||
getSamlLoginToggle(): Locator {
|
||||
return this.page.getByTestId('sso-toggle');
|
||||
}
|
||||
|
||||
getSsoContentLicensed(): Locator {
|
||||
return this.page.getByTestId('sso-content-licensed');
|
||||
}
|
||||
|
||||
getSsoContentUnlicensed(): Locator {
|
||||
return this.page.getByTestId('sso-content-unlicensed');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,6 @@ export class SettingsUsersPage extends BasePage {
|
|||
return this.page.getByRole('row', { name: email });
|
||||
}
|
||||
|
||||
getInviteButton() {
|
||||
return this.page.getByRole('button', { name: 'Invite' });
|
||||
}
|
||||
|
||||
getAccountType(email: string) {
|
||||
return this.getRow(email).getByTestId('user-role-dropdown');
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user