Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
21 KiB
AGENTS.md
Purpose
Playwright is n8n's general-purpose test orchestrator. Do not assume that every Playwright test drives the editor UI. This package also owns API tests, container topologies, process lifecycle tests, infrastructure validation, performance benchmarks, evaluation suites, and browser-backed harness contracts.
Choose the suite by the behavior under test:
| Behavior | Location | Runner |
|---|---|---|
| Product UI or API journey | tests/e2e/ |
test:local or a container project |
| Database, queue, multi-main, encryption, or process lifecycle | tests/infrastructure/ |
test:infrastructure or the named project |
| Infrastructure throughput and resource use | tests/infrastructure/benchmarks/ |
test:benchmark |
| Browser and canvas performance | tests/performance/ |
test:performance |
| Fixture or harness contract | tests/framework/ |
test:unit or test:harness |
| Evaluation scenario | Existing evaluation directory | Its named evaluation project |
Use Playwright when the test needs its worker lifecycle, fixtures, retries, artifacts, project matrix, browser context, or managed container stack. Use Vitest for browser-free unit and integration tests that need none of these.
Commands
# Run tests locally
pnpm --filter=n8n-playwright test:local <file-path>
pnpm --filter=n8n-playwright test:local tests/e2e/credentials/crud.spec.ts
# Run with container capabilities (requires pnpm build:docker first)
pnpm --filter=n8n-playwright test:container:sqlite tests/e2e/auth/password-reset.spec.ts
# Run one infrastructure benchmark
pnpm --filter=n8n-playwright test:benchmark tests/infrastructure/benchmarks/kafka/single-instance-ceiling.spec.ts
# Lint and typecheck
pnpm --filter=n8n-playwright lint
pnpm --filter=n8n-playwright typecheck
Always trim output: --reporter=list 2>&1 | tail -50
Test Layout
Product Playwright tests live under tests/. Keep product E2E specs under
tests/e2e/ and keep infrastructure, performance, evaluation, and related
suites in their existing directories under tests/.
Framework and harness tests live under tests/framework/. Use this directory
for tests of fixtures, startup lifecycle, diagnostics, telemetry, and harness
contracts. These tests are not product E2E specs and must not be placed under
tests/e2e/.
Framework unit tests use the package Vitest configuration. Browser-backed
harness contract tests use vitest.harness.config.ts so they stay separate
from browser-free unit tests.
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 --executeto safely commit. Never manually commit janitor-related fixes - TCR ensures tests pass before the commit lands.
Golden Rules
- Analysis only? Run
pnpm janitor(no TCR needed) - Making code changes? Use TCR:
pnpm janitor tcr --execute -m="chore: ..." - Never manually
git commitjanitor-related fixes - always go through TCR - Never modify
.janitor-baseline.jsonvia 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) |
no-raw-editor-navigation |
Raw page.goto() to a /workflow/ editor route in tests (use n8n.start.* so the canvas loader is awaited) |
valid-owner-annotation |
A spec with no team owner, or an owner not in the canonical list (CANONICAL_OWNERS in the rule, mirroring Notion "Ownership v2") |
Commands
# 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
# Discover test specs (for orchestration)
pnpm janitor discover
# Distribute specs across shards
pnpm janitor orchestrate --shards=14
Baseline (Incremental Cleanup)
For codebases with existing violations, create a baseline to enable incremental cleanup:
# 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.
# 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:
# 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:
- Use
--ignore-baselineto see all violations (not just new ones) - Pick small fixes from the list
- Fix violations, then TCR to safely commit
- After fixing, run
pnpm janitor baselineto update the baseline
TCR Workflow (Test && Commit || Revert)
Safe refactoring loop: make changes, run affected tests, auto-commit or auto-revert.
# 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:
pnpm janitor --file=tests/my-new-test.spec.ts --verbose
See packages/testing/janitor/README.md for full documentation.
UI Entry Points
UI journey tests should start with n8n.start.* methods. API, infrastructure,
benchmark, lifecycle, and framework tests use their own fixtures and harnesses.
See composables/TestEntryComposer.ts for UI entry points.
| Method | Use Case |
|---|---|
fromHome() |
Start from home page |
fromBlankCanvas() |
New workflow from scratch |
fromNewProjectBlankCanvas() |
Project-scoped workflow (returns projectId) |
fromNewProject() |
Project-scoped test, no canvas (returns projectId) |
fromImportedWorkflow(file) |
Test pre-built workflow JSON |
withUser(user) |
Isolated browser context per user |
withProjectFeatures() |
Enable sharing/folders/permissions |
Accessibility Checks
The a11y fixture runs axe-core against the current page, scoped to a named
bucket. It never throws - a scan that can't run (bucket not on screen, axe
failure) logs a warning and returns an empty array, so bolting a check onto an
existing journey can't turn that journey red. Callers decide what to assert.
test('canvas is accessible', async ({ n8n, a11y }) => {
await n8n.start.fromBlankCanvas();
const violations = await a11y.check('canvas');
expect(violations).toEqual([]);
});
Buckets: page (whole document), canvas, ndv, node-creator, sidebar,
modal. Defined in fixtures/a11y.ts (A11Y_BUCKETS). Scans run with WCAG 2.1
A + AA rules; override per call with a11y.check('modal', { tags, disableRules }).
When a journey acts as another user, n8n.start.withUser() returns a new
n8nPage. Point the checker at it with a11y.for(otherN8n) - the derived
checker reports into the same scan list.
Main landmark structure
utils/a11y-landmark-check.ts checks the one structural rule axe cannot see on a
composed layout: the page must have exactly one <main> element, that <main>
must not sit inside another landmark, and id="content" must be unique. It walks
the composed DOM - the document plus every open shadow tree, and slotted content
from where it renders, not from where it is written - runs no axe rules, and
leaves the default WCAG 2.1 A + AA tag selection alone.
A <section> or <form> is a landmark only once it has an accessible name, so the
check computes one: aria-label, an aria-labelledby reference resolved in the
element's own root, or title. A reference resolves through the same routine, so
the referenced element can name through its own reference, a naming attribute such
as img[alt], or its text. A reference to a missing or empty element names
nothing, and a <section> full of text stays unnamed, because region does not
take its name from content.
import { assertMainLandmarkStructure } from '../../../utils/a11y-landmark-check';
await assertMainLandmarkStructure(n8n.page);
assertMainLandmarkStructure(page) throws with each problem it found.
checkMainLandmarkStructure(page) returns { ok, problems } instead, for a
caller that wants to report rather than fail. Regression cover for both lives in
tests/e2e/a11y/a11y-landmark-structure.spec.ts, against synthetic markup.
Report and failure budget
Every scan is attached to its test, so the raw axe results are always available
on that test (including in Currents). Set PLAYWRIGHT_A11Y_REPORT to also
register reporters/a11y-reporter.ts, which aggregates a run's scans into one
axe HTML report at a11y-report/index.html plus a table in the GitHub job
summary. Under sharding each shard reports the specs it ran. CI sets the
variable for the e2e workflow, which uploads the report as its own artifact on a
failing run; locally the reporter stays off unless asked for:
PLAYWRIGHT_A11Y_REPORT=1 pnpm --filter=n8n-playwright test:local
Violations are reporting-only by default. Set
PLAYWRIGHT_A11Y_MAX_VIOLATIONS to the number of violations a single test may
report before it fails:
# Fail any test reporting more than 5 violations
PLAYWRIGHT_A11Y_MAX_VIOLATIONS=5 pnpm --filter=n8n-playwright test:local
Unset (the default), empty or malformed all mean "no budget", so the violations that already exist can't turn CI red. The budget is not applied to a test that already failed for another reason.
Per-bucket scores
The reporter also scores each bucket the run exercised and writes one line for each of them, worst first:
[a11y] score bucket=canvas scans=2 rules=3 elements=7 score=31 critical=1 serious=3 moderate=1 minor=2
elements counts the distinct violating elements. An element is counted once
for each screen it is broken on, however many rules it trips there, so a bucket
the run scanned twice on the same screen reports its elements once. score
weights those elements by impact (critical 10, serious 5, moderate 3, minor 1),
taking the worst impact reported for each element, so it goes to zero as the
bucket gets fixed. The same numbers go into the GitHub job summary as a bucket
table.
When QA_METRICS_WEBHOOK_* is set - CI sets it for the e2e workflow - the
reporter sends the scores to the QA metrics webhook the perf metrics use. Each
bucket writes three qa_performance_metrics rows (a11y-score,
a11y-violated-rules, a11y-violating-elements) under the a11y-buckets
benchmark, with the bucket in dimensions. The send is best-effort: a failure
warns and the run carries on. See .github/CI-TELEMETRY.md.
Test Isolation
Tests run in parallel. Design tests to be fully isolated so they don't interfere with each other.
Unique Identifiers
Use nanoid for unique test data:
const credentialName = `Test Credential ${nanoid()}`;
const workflow = await api.workflows.createWorkflow({
name: `Test Workflow ${nanoid()}`,
});
Dynamic User Creation
Create users dynamically via the public API:
const member = await api.publicApi.createUser({
email: `member-${nanoid()}@test.com`,
firstName: 'Test',
lastName: 'Member',
});
Isolated Browser Contexts
For UI tests requiring multiple users, create isolated browser contexts:
// 1. Create users via public API
const member1 = await api.publicApi.createUser({ role: 'global:member' });
const member2 = await api.publicApi.createUser({ role: 'global:member' });
// 2. Get isolated browser contexts
const member1Page = await n8n.start.withUser(member1);
const member2Page = await n8n.start.withUser(member2);
// 3. Each operates independently (no session bleeding)
await member1Page.navigate.toWorkflows();
await member2Page.navigate.toCredentials();
Reference: tests/e2e/building-blocks/user-service.spec.ts
| Pattern | Why | Use Instead |
|---|---|---|
test.describe.serial |
Creates test dependencies | Parallel tests with isolated setup |
| Fresh DB per file | Tests need isolated container | test.use({ capability: { env: { TEST_ISOLATION: 'name' } } }) |
| Fresh DB per test | Tests modify shared state | @db:reset tag on describe (container-only, combined with test.use()) |
n8n.api.signin() |
Session bleeding | n8n.start.withUser() |
Date.now() for IDs |
Race conditions | nanoid() |
waitForTimeout() |
Flaky | waitForResponse(), toBeVisible() |
.toHaveCount(N) |
Brittle | Named element assertions |
Raw page.goto() |
Bypasses setup | n8n.navigate.* methods |
Code Style
- Use specialized locators:
page.getByRole('button')overpage.locator('[role=button]') - Use
nanoid()for unique identifiers (parallel-safe) - API setup over UI setup when possible (faster, more reliable)
Architecture
Tests (*.spec.ts)
↓ uses
Composables (*Composer.ts) - Multi-step business workflows
↓ orchestrates
Page Objects (*Page.ts) - UI interactions
↓ extends
BasePage - Common utilities
See CONTRIBUTING.md for detailed patterns and conventions.
Debugging
See README.md#debugging for detailed instructions on:
- Keepalive mode - Keep containers running after tests with
N8N_CONTAINERS_KEEPALIVE=true - Victoria exports - Logs/metrics automatically attached on failure, importable locally via
scripts/import-victoria-data.mjs
Test Migration & Refactoring
Test Name = Contract
- Name declares intent, assertion proves it, everything else is flow
- Bad:
should open W1 as U2(describes action) - Good:
should allow sharee to edit shared workflow(declares rule)
Coverage Parity Check
- Read old test name → what was the intent?
- Find the explicit assertion that proved it
- Verify new test has equivalent proof
- No proof found? Document as intentional drop or gap
Legacy Tests (unauditable names/assertions)
- Prioritize clarity over parity - can't audit what you can't read
- Document your best interpretation of intent
- Accept short-term risk, fix regressions forward
See Quality Corner: Test Migration Guide for full rationale and examples.
Reference Files
| Purpose | File |
|---|---|
| Multi-user testing | tests/e2e/building-blocks/user-service.spec.ts |
| Entry points | composables/TestEntryComposer.ts |
| Page object example | pages/CanvasPage.ts |
| Composable example | composables/WorkflowComposer.ts |
| API helpers | services/api-helper.ts |
| Capabilities | fixtures/capabilities.ts |
| Accessibility fixture | fixtures/a11y.ts |
const member = await api.publicApi.createUser({...});
const memberN8n = await n8n.start.withUser(member);
await memberN8n.navigate.toWorkflows();
await expect(memberN8n.workflows.cards.getWorkflow(workflowName)).toBeVisible();
Isolated API Contexts
For API-only operations as another user, create isolated API contexts (no browser needed):
const member = await api.publicApi.createUser({...});
const memberApi = await api.createApiForUser(member);
const memberProject = await memberApi.projects.getMyPersonalProject();
await memberApi.credentials.createCredential({...});
Identity-Based Assertions
Assert by identity (name) rather than count for parallel-safe tests:
await expect(credentialDropdown.getByText(testCredName)).toBeVisible();
await expect(credentialDropdown.getByText(devCredName)).toBeHidden();
Worker Isolation (Fresh Database)
Use test.use() at file top-level with unique capability config:
// my-isolated-tests.spec.ts
import { test, expect } from '../fixtures/base';
// Must be top-level, not inside describe block
test.use({ capability: { env: { TEST_ISOLATION: 'my-isolated-tests' } } });
test('test with clean state', async ({ n8n }) => {
// Fresh container with reset database
});
For per-test database reset (when tests modify shared state like MFA), add @db:reset to the describe. Note: @db:reset is container-only - these tests won't run locally.
test.use({ capability: { env: { TEST_ISOLATION: 'my-stateful-tests' } } });
test.describe('My stateful tests @db:reset', () => {
// Each test gets a fresh database reset (container-only)
});
Data Setup
Use API helpers for fast, reliable test data setup. Reserve UI interactions for testing UI behavior:
// API for data setup
const credential = await api.credentials.createCredential({
name: `Test Credential ${nanoid()}`,
type: 'notionApi',
data: { apiKey: 'test' },
});
const workflow = await api.workflows.createWorkflow({
name: `Test Workflow ${nanoid()}`,
nodes: [...],
});
// UI for verification
await n8n.navigate.toCredentials();
await expect(n8n.credentials.cards.getCredential(credential.name)).toBeVisible();
Feature Enablement
The n8n fixture automatically enables project features. For API-only tests (no n8n fixture), enable features explicitly:
test('API-only test', async ({ api }) => {
await api.enableProjectFeatures();
// ...
});
Feature Flag Overrides
To test features behind feature flags (experiments), use TestRequirements with storage overrides:
import type { TestRequirements } from '../../../Types';
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:
// 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: Types.ts for the full interface definition. Import depth follows
the spec's own nesting. The example above assumes tests/e2e/<area>/; add one
../ per extra level down.
Shard Rebalancing
When refactoring, adding, or moving significant numbers of tests, consider rebalancing test shards to maintain even CI distribution. See docs/ORCHESTRATION.md for details.