---
name: xylabs-e2e-setup
description: >
  Sets up a complete Playwright end-to-end testing package for a React/Vite monorepo app.
  Use this skill whenever the user asks to add e2e tests, set up Playwright, add browser testing,
  or create a test package for a React app — even if they just say something like "add e2e" or
  "I want to test this in Safari too". The skill creates the full packages/e2e/ directory with
  page object models, a Playwright config covering Chromium, Firefox, and WebKit, and a webkit-safe
  home page title test. It adapts to the existing repo's package naming, dev server port, and
  workspace conventions rather than using generic defaults.
---
# xylabs-e2e-setup
Scaffolds a `packages/e2e/` Playwright testing package that fits neatly into an existing
React/Vite Yarn-workspaces monorepo. The generated package uses the Page Object Model pattern,
targets all three browser engines, and includes a WebKit-safe title assertion.
---
## Step 1 — Read the repo before writing anything
Before generating any files, collect these four pieces of information from the repo root:
1. **App package name** — read `package.json` → `name` field (e.g. `web-explore.xyo.network-react`)
2. **Dev-server port** — read `vite.config.ts`; look for the parsed `port` variable or the `server.port` value. Default is `3000` if not set.
3. **Existing e2e scripts** — check whether `package.json` already has `"e2e"` scripts so you don't duplicate them.
4. **Workspace glob** — confirm `"workspaces": ["packages/*"]` is present so `packages/e2e` will be picked up automatically.
The e2e package name should be derived from the app name by stripping any leading scope/path noise
and appending `-e2e`. For example, `web-explore.xyo.network-react` → `web-explore-e2e`.
---
## Step 2 — Create `packages/e2e/`
Create every file below. Adapt the port and package name to what you found in Step 1.
### `packages/e2e/package.json`
```json
{
  "name": "<derived-e2e-name>",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "playwright test",
    "test:debug": "playwright test --debug",
    "test:headed": "playwright test --headed",
    "test:ui": "playwright test --ui"
  },
  "devDependencies": {
    "@playwright/test": "^1.49.0"
  }
}
```
### `packages/e2e/tsconfig.json`
```json
{
  "compilerOptions": {
    "esModuleInterop": true,
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "noEmit": true,
    "strict": true,
    "target": "ES2022"
  },
  "exclude": ["node_modules", "test-results", "playwright-report"],
  "include": ["playwright.config.ts", "tests/**/*.ts", "pages/**/*.ts", "fixtures/**/*.ts"]
}
```
### `packages/e2e/playwright.config.ts`
Key points:
- `baseURL` falls back to `http://localhost:<PORT>` (use the port you found)
- `webServer` restarts the app's own dev server when needed, with `reuseExistingServer: true`
- `webServer.cwd` is `'../..'` — two levels up from `packages/e2e/` to the repo root
- `webServer.command` is `'yarn start'` (the standard React/Vite dev-server script)
- CI mode: `forbidOnly`, 1 worker, 2 retries, `github` reporter
```typescript
import { defineConfig, devices } from '@playwright/test'
const baseURL = process.env['BASE_URL'] ?? 'http://localhost:<PORT>'
export default defineConfig({
  forbidOnly: !!process.env['CI'],
  fullyParallel: true,
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  reporter: process.env['CI'] ? 'github' : 'html',
  retries: process.env['CI'] ? 2 : 0,
  testDir: './tests',
  use: {
    baseURL,
    screenshot: 'only-on-failure',
    trace: 'on-first-retry',
    video: 'on-first-retry',
  },
  webServer: {
    command: 'yarn start',
    cwd: '../..',
    reuseExistingServer: true,
    url: baseURL,
  },
  workers: process.env['CI'] ? 1 : undefined,
})
```
### `packages/e2e/pages/BasePage.ts`
The `goto` base method navigates and waits for `domcontentloaded`. Subclasses override `goto()` with
no arguments and call `super.goto('/<path>')`.
```typescript
import type { Page } from '@playwright/test'
export class BasePage {
  readonly page: Page
  constructor(page: Page) {
    this.page = page
  }
  async goto(path: string): Promise<void> {
    await this.page.goto(path)
    await this.page.waitForLoadState('domcontentloaded')
  }
}
```
### `packages/e2e/pages/HomePage.ts`
```typescript
import type { Locator, Page } from '@playwright/test'
import { BasePage } from './BasePage.js'
export class HomePage extends BasePage {
  readonly nav: Locator
  constructor(page: Page) {
    super(page)
    this.nav = page.locator('nav')
  }
  override async goto(): Promise<void> {
    await super.goto('/')
  }
}
```
### `packages/e2e/tests/home.spec.ts`
> **Why the `waitForFunction` call?**
> In WebKit (Safari), `networkidle` can fire before React (or react-helmet) has written
> `document.title`. Adding `waitForFunction(() => document.title.length > 0)` polls inside
> the browser until the title is actually set, so the `toHaveTitle` assertion is always
> racing against a non-empty string rather than timing out on an empty one.
```typescript
import { expect, test } from '@playwright/test'
import { HomePage } from '../pages/HomePage.js'
test.describe('Home page', () => {
  test('loads successfully', async ({ page }) => {
    const home = new HomePage(page)
    await home.goto()
    await page.waitForFunction(() => document.title.length > 0, { timeout: 5000 })
    await expect(page).toHaveTitle(/.+/)
  })
})
```
### `packages/e2e/tests/navigation.spec.ts`
Minimal smoke test — extend the `routes` array as the app grows.
```typescript
import { expect, test } from '@playwright/test'
const routes = [
  { name: 'Home', path: '/' },
]
test.describe('Navigation', () => {
  for (const route of routes) {
    test(`${route.name} route renders without errors`, async ({ page }) => {
      await page.goto(route.path)
      await expect(page.locator('body')).toBeVisible()
    })
  }
})
```
---
## Step 3 — Wire up root `package.json`
Add convenience scripts to the root `package.json` so developers can run tests without `cd`-ing
into `packages/e2e`. Use the e2e package name you derived in Step 1.
```json
"e2e": "yarn workspace <derived-e2e-name> test",
"e2e:debug": "yarn workspace <derived-e2e-name> test:debug",
"e2e:headed": "yarn workspace <derived-e2e-name> test:headed",
"e2e:ui": "yarn workspace <derived-e2e-name> test:ui"
```
Only add scripts that don't already exist.
---
## Step 4 — Update root `.gitignore`
Playwright generates several output directories that should never be committed. Read the root
`.gitignore` and append the following block **only if the entries are not already present**:
```gitignore
# Playwright
/packages/e2e/test-results/
/packages/e2e/playwright-report/
/packages/e2e/blob-report/
/packages/e2e/.playwright/
```
Do not create a new `.gitignore` if one doesn't exist — if there is no root `.gitignore`, skip
this step and note it to the user.

---
## Step 5 — Install Playwright browsers and CLI tool
After scaffolding, run the following two commands via the Bash tool:

**5a — Install browser binaries:**
```bash
yarn workspace <derived-e2e-name> playwright install
```
This downloads the Chromium, Firefox, and WebKit browser binaries. Without this step the tests
will fail immediately with a "browser not found" error.

**5b — Install the `@playwright/cli` package and its skills:**
```bash
yarn workspace <derived-e2e-name> add @playwright/cli@latest && yarn workspace <derived-e2e-name> playwright-cli install --skills
```
`@playwright/cli` provides the `playwright-cli` binary. Running `playwright-cli install --skills`
installs the Playwright VS Code agent skills into the workspace so Copilot and other AI tooling
can generate accurate Playwright tests against the actual page structure.

Do not skip either step or ask the user to run them manually — execute both directly as part of
the setup.

---
## Conventions to preserve
- All files use ESM (`import`/`export`), never CommonJS.
- Playwright bracket notation for env vars: `process.env['CI']`, not `process.env.CI` — avoids TypeScript strict-mode complaints.
- `strict: true` in `tsconfig.json`.
- No `dist/` output — `noEmit: true` because Playwright compiles on the fly via its own ESM loader.
