| name | e2e-bootstrap |
| description | For E2E environment setup. Use for new setup, Playwright installation, or converting an existing project to the 4-layer architecture. Includes Definition of Done, minimal skeleton, Fixture/constants templates, coding conventions, and conversion steps from the Playwright default layout. |
E2E Bootstrap Skill
If your goal is adding tests, use /e2e-test-create instead.
§1. Definition of Done
npm test runs (one smoke test passes)
npx tsc --noEmit passes (zero type errors and zero unused imports)
npm run gate exits 0 (machine gate — canonical source is scripts/gate.sh)
- Playwright and browser dependencies are installed
- The minimal 4-layer architecture directories exist
- Credentials are managed via
.env / CI environment variables
- The fixture file (app.fixture.ts) exists and exports test/expect
§2. Minimal 4-Layer Skeleton
src/
├── tests/ # Layer 3: scenarios
├── actions/ # Layer 2: user operation flows
├── pages/ # Layer 1: screen elements and operations (Locators live here)
├── fixtures/ # Fixture definitions
│ └── app.fixture.ts
├── utils/ # uniqueId.ts / formatDate.ts etc. (see §4)
└── config/ # Layer 4: environment differences / configuration
├── env.ts
└── constants.ts
§3. Minimal Fixture
Canonical source = fixture-template.md (same directory) — always read it when creating a new Fixture, converting to the 4-layer architecture, or registering a new Action. Contains: the full base.extend structure (complete form including the worker-scoped stepCounter).
§4. Minimal constants.ts
export const TIMEOUTS = {
SHORT: 3000,
MEDIUM: 10000,
LONG: 30000,
DEFAULT: 10000,
AUTH_STABILIZATION: 2000,
MODAL_ANIMATION: 1000,
SPA_RENDERING: 2000,
REDIRECT: 3000,
} as const;
export const SELECTORS = {
MODAL: '[role="dialog"]',
SUBMIT_BUTTON: 'button[type="submit"]',
} as const;
export const URL_PATTERNS = {
LOGIN: ,
: ,
: ,
} ;
Extension examples (add as project-specific elements grow):
ELEMENT_VISIBLE: 5000,
AGREEMENT_CHECKBOX: 'input[type="checkbox"]:near(:text("I agree"))',
AUTH_EMAIL_INPUT: 'input[name="username"]',
AUTH_PASSWORD_INPUT: 'input[name="password"]',
Create alongside it: src/utils/uniqueId.ts (unique test data names)
Required to guarantee uniqueness of test data names (Date.now() alone collides across parallel workers — the criteria are in prohibited-patterns.md, "Generate unique test data names with uniqueId()").
export function uniqueId(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}`;
}
§5. Required playwright.config.ts Settings
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/tests',
globalSetup: './src/global-setup.ts',
timeout: 60000,
expect: {
timeout: 10000,
},
reporter: [
['json', { outputFile: 'test-results/report.json' }],
['html', { open: 'never' }],
['list'],
],
use: {
trace: 'retain-on-failure',
screenshot: 'on',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
: {
: [],
},
},
},
],
});
Why these settings are required:
timeout + expect.timeout: never wait forever. Prevents false Passes
json reporter: report.json retains the step structure. Essential for tracing failures
trace/video retain-on-failure: saved only on failure. Always-on trace causes browser crashes in headed runs of long tests. Pass-time evidence is covered by report.json + screenshots instead
screenshot on: screenshots saved for all tests. Serves as "evidence of correct behavior" on Pass
html reporter: lets humans review results in a browser
projects: specify browsers explicitly
--disable-crash-reporter: defensive setting against leftover crash reporters on macOS arm64 + Chromium. Not sufficient alone, so combine with globalSetup
globalSetup: cleans up any leftover chrome_crashpad_handler from the previous run before tests start (symptomatic treatment)
globalSetup (crashpad_handler cleanup)
Symptomatic treatment for the issue where, in headed mode with macOS arm64 + Chromium for Testing, chrome_crashpad_handler lingers after tests finish and the process hangs. Cleans up leftover processes from the previous run before tests start.
import { execSync } from 'child_process';
export default function globalSetup() {
try {
execSync("pkill -f 'ms-playwright.*chrome_crashpad_handler' 2>/dev/null", { stdio: 'ignore' });
} catch {
}
}
Add globalSetup: './src/global-setup.ts' to playwright.config.ts.
Since the path is narrowed by ms-playwright, regular Chrome / VS Code / other apps are unaffected.
§6. BaseAction / StepCounter Templates
All Actions inherit from BaseAction. The step() helper outputs to both the console (user-story granularity) and test.step() (hierarchical display in the HTML report). The prefix ([Suite / Phase]) is derived automatically from test.info().titlePath.
BaseAction.ts
import { Page, test } from '@playwright/test';
import { StepCounter } from './StepCounter';
export class BaseAction {
protected readonly page: Page;
protected readonly actionName: string;
protected readonly stepCounter?: StepCounter;
constructor(page: Page, actionName: string, stepCounter?: StepCounter) {
this.page = page;
this.actionName = actionName;
this.stepCounter = stepCounter;
}
protected beginAction(): void {
this.stepCounter?.nextMain();
}
(: , : <>): <> {
{ prefix, hasTestContext } = .();
mainNo = .?. ?? ;
(. && mainNo === ) {
(
);
}
stepLabel = mainNo > ? : ;
.();
(hasTestContext) {
test.(name, fn);
} {
();
}
}
(): { : ; : } {
{
info = test.();
parts = info..();
(parts. === ) { : , : };
labels = parts.( .(p));
{ : , : };
} {
{ : , : };
}
}
(: ): {
candidates = [title.(), title.()].( i !== -);
(candidates. === ) title;
colonIdx = .(...candidates);
title.(, colonIdx).();
}
}
StepCounter.ts
import { test } from '@playwright/test';
export class StepCounter {
private mainNumber = 0;
private lastDescribeKey: string | null = null;
nextMain(): number {
const currentKey = this.getDescribeKey();
if (currentKey !== this.lastDescribeKey) {
this.mainNumber = 0;
this.lastDescribeKey = currentKey;
}
this.mainNumber++;
return this.mainNumber;
}
get currentMain(): number {
return this.mainNumber;
}
private getDescribeKey(): string | {
{
info = test.();
parts = info.;
keyParts = parts.(, -);
keyParts. > ? keyParts.() : ;
} {
;
}
}
}
Prefix composition:
- Derived automatically from
test.info().titlePath (in [file, describe, test] order)
- The part of the describe / test name before
: is used as the label (e.g., 'Suite-A: flow name...' → 'Suite-A')
- If there is no
:, the full name is used
- Supports both ASCII
: and full-width : (splits on whichever appears first)
Output example:
Console:
[Suite-A / Phase 1] Step 1: LoginAction - Navigate to login page
[Suite-A / Phase 1] Step 1: LoginAction - Enter credentials
[Suite-A / Phase 1] Step 2: NavigationAction - Open main menu
...
[Suite-A / Phase 2] Step 21: LoginAction - Navigate to login page ← numbering continues within the describe
HTML report (test.step() nesting — Action internals are shown hierarchically here):
Navigate to login page
Enter credentials
Click submit button
...
Meaning of the numbers: the number is the "Action invocation order within the same describe". With parallel workers, each worker has its own independent StepCounter, so numbers from different describes cannot be compared with each other (global ordering cannot be inferred).
§7. .env.example
TEST_BASE_URL=
TEST_USER_EMAIL=
TEST_USER_PASSWORD=
§8. Coding Conventions
Required package.json devDependencies / scripts
{
"scripts": {
"gate": "bash scripts/gate.sh"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"dotenv": "^16.4.0",
"typescript": "^5.9.0",
"@types/node": "^22.0.0"
}
}
Keep typescript on the 5.x line (the fixed-wait check inside the gate's verify uses the TypeScript 5.x JS compiler API; the 7.x line does not expose that API, so the check errors out).
Without typescript and @types/node, type checking via npx tsc --noEmit cannot run.
Required to satisfy the type-check requirement of the §1 Definition of Done.
The gate script is required (machine gate). If you run the gate in CI, add the project's directory to .github/workflows/gate.yml or equivalent.
TypeScript configuration (tsconfig.json)
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Code formatting (Prettier)
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
Naming conventions
| Kind | Convention | Example |
|---|
| Class | PascalCase | LoginPage, LoginAction |
| Method | camelCase | fillEmail(), clickButton() |
| Variable | camelCase | emailInput, userName |
| Constant | UPPER_SNAKE_CASE | MAX_RETRY, DEFAULT_TIMEOUT |
| Interface | PascalCase | TestEnvironment |
JSDoc comment conventions
async execute(email: string, password: string): Promise<boolean> {
}
§9. Converting from the Playwright Default Layout to the 4 Layers
When converting from the Playwright default (spec files directly under tests/):
- Create the 4-layer directories under
src/ (see §2)
- Create
config/constants.ts and config/env.ts (see §4, §5)
- Move Locators in spec files → Page Objects in
pages/
- Move flow operations in spec files → Actions in
actions/
- Create the Fixture definition (see §3) and switch the import source of test
- Leave only intent and expected results in spec files (Locators and logic prohibited)
- Move hard-coded values →
constants.ts
- Move credentials →
env.ts + .env
- Confirm all tests pass with
npx playwright test
Conversion cautions:
- Do not convert everything at once. Migrate one test at a time and verify
- Always keep existing tests in a passing state
- Do not forget to register new Actions in the Fixture
§10. Project-Specific Configuration Checklist
When introducing this to a new project, confirm the following and record them in CLAUDE.md:
§11. Troubleshooting
Terminal hangs after a test failure in macOS + Chromium headed mode
Symptom: after a failure with npx playwright test --headed, the terminal stops accepting commands. Ctrl+C may not work either.
Cause: a chrome_crashpad_handler process lingers and the parent process is not released. globalSetup (§5) is a preventive measure that cleans up leftovers before the next test run — it does not resolve the current hang.
Fix: from another terminal tab, kill only Playwright's Chromium processes with the following command.
pkill -f 'ms-playwright'
Since the path is narrowed by ms-playwright, regular Chrome / VS Code / other apps are unaffected. After the kill, the hung terminal is released.