Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ignores = [
"packages/playwright-core/types/*",
"packages/playwright-ct-core/src/generated/*",
"packages/playwright/bundles/expect/third_party/",
"packages/skills/",
"packages/html-reporter/bundle.ts",
"packages/html-reporter/playwright.config.ts",
"packages/html-reporter/playwright/*",
Expand Down
13 changes: 10 additions & 3 deletions packages/playwright/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

import 'playwright-core/lib/bootstrap';

import fs from 'fs';
import path from 'path';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

import { libCli, tools } from 'playwright-core/lib/coreBundle';
import { program } from 'commander';
import { setBoxedStackPrefixes } from '@utils/stackTrace';
Expand All @@ -27,7 +30,7 @@ import { runTests, clearCache, runTestServerAction } from './cli/testActions';
import { showReport, mergeReports } from './cli/reportActions';
import { TestServerBackend, testServerBackendTools } from './mcp/test/testBackend';
import { ClaudeGenerator, CodexGenerator, OpencodeGenerator, VSCodeGenerator, CopilotGenerator } from './agents/generateAgents';
import { packageRoot, packageJSON } from './package';
import { libPath, packageRoot, packageJSON } from './package';

export { program };

Expand Down Expand Up @@ -187,15 +190,19 @@ function addInitAgentsCommand(program: Command) {

function addInitSkillsCommand(program: Command) {
const command = program.command('init-skills');
command.description('Initialize the workspace and install Playwright CLI skills');
command.description('Initialize the workspace and install Playwright skills');
const option = command.createOption('--loop <loop>', 'Agentic loop provider');
option.choices(['claude', 'generic']);
option.default('generic');
command.addOption(option);
command.action(async opts => {
// Claude Code only reads skills from `.claude/skills`, every other agent reads
// them from the universal `.agents/skills` folder.
await tools.initWorkspace(opts.loop === 'claude' ? 'claude' : 'agents');
const target = opts.loop === 'claude' ? 'claude' : 'agents';
await tools.initWorkspace(target);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why u added one more line?

const skillDestDir = path.join(process.cwd(), `.${target}`, 'skills', 'playwright-component-testing');
await fs.promises.cp(libPath('skills', 'component-testing'), skillDestDir, { recursive: true });
console.log(`✅ Skills installed to \`${path.relative(process.cwd(), skillDestDir)}\`.`);
});
}

Expand Down
66 changes: 66 additions & 0 deletions packages/skills/component-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
name: playwright-component-testing
description: Set up component testing with Playwright using a story gallery — scaffold stories, a gallery dev page and a mount fixture, no dedicated component-testing runtime. Use when asked to test React or Vue components in isolation with Playwright, or to migrate off @playwright/experimental-ct-react / -vue.
---

# Component Testing with Playwright

Test components with regular Playwright e2e tests against a small **story gallery** page hosted by the app's own dev server. No extra test runner, bundler integration or npm packages are required.

## Concept

- A **story** is a tiny wrapper component that embeds the component under test in one specific scenario: hard-coded props, mock data, providers, recorded callbacks. Stories live next to the component in `*.story.tsx` (or `.ts`/`.jsx`/`.js`/`.vue`) files; each named export is one story.
- The **gallery** is a single page (`playwright/gallery/index.html` + `main.tsx`) that discovers all story files with `import.meta.glob` and renders the story named by the `?story=<id>` URL parameter. Without the parameter it renders a browsable index of all stories.
- Tests are plain Playwright tests. A **`mount` fixture** navigates to a story's URL, waits for it to render, and returns a `Locator` for the story root — so specs read just like the old component tests.

Everything the component needs must be set up *inside the story* (it runs in the browser); everything the test asserts must be observable *through the page* (DOM, URL, network). There is no marshalling of props or callbacks between test and browser.

## Setup workflow

1. **Detect the framework and bundler.** React vs Vue decides which templates to use. Then:
- **App runs on Vite** (has `vite.config.*`): the gallery is served by the existing dev server at `/playwright/gallery/index.html` — Vite serves any `.html` file under the project root, the app's plugins/aliases/CSS apply automatically, and `vite build` ignores it. No extra server needed.
- **Anything else** (Next.js, webpack, no dev server): copy `templates/common/vite.config.ts` to `playwright/vite.config.ts` — a standalone Vite server whose root is the gallery directory. Requires `vite` and the framework plugin as devDependencies.
2. **Copy the gallery**: `templates/<react|vue>/index.html` and `main.tsx` (Vue: `main.ts`) into `<project>/playwright/gallery/`. Adjust the `import.meta.glob` pattern in `main.*` if components do not live under `src/`. Import the app's global CSS into `index.html` or `main.*` the same way the app's own entry does.
3. **Copy the fixture**: `templates/common/fixtures.ts` into `<project>/playwright/fixtures.ts`. If using the standalone gallery server, change `galleryUrl` to `'/'`.
4. **Configure Playwright** — add to `playwright.config.ts`:

```ts
projects: [
{
name: 'components',
testDir: './tests/components',
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5173' },
},
],
webServer: {
command: 'npm run dev', // or: npx vite --config playwright/vite.config.ts
url: 'http://localhost:5173/playwright/gallery/index.html', // standalone server: http://localhost:3100/
reuseExistingServer: !process.env.CI,
},
```

Match the port to the dev server. If the config already has projects/webServer, merge instead of replacing.
5. **Write a first story** next to an existing component, modeled on `templates/<react|vue>/Button.story.*`.
6. **Write a first spec**, modeled on `templates/react/button.spec.ts`, importing `test`/`expect` from the fixtures file.
7. **Run**: `npx playwright test --project=components`. Open `http://localhost:5173/playwright/gallery/index.html` in a browser to eyeball all stories.

## Conventions

- Story id: path under `src/` without the `.story.*` extension, plus the export name — `src/components/Button.story.tsx` export `Primary` → `components/Button/Primary`. Any unique suffix works too: `mount('Button/Primary')`. A `.story.vue` single-file component is one story, addressable by its path alone (its `default` export).
- One export per scenario. Prefer a new story export over parameterizing an existing one — stories are greppable, reviewable documentation of component states.
- Wire callbacks inside the story and record their effect into the DOM (e.g. `<output data-testid="...">`), then assert with locators. See `references/patterns.md`.
- Each `mount()` performs a fresh navigation, so tests are fully isolated; multiple mounts in one test are fine.

## Decision points

- **Monorepos / non-`src` layouts**: change the glob in `main.*` and the `baseId` prefix stripping to match.
- **Global providers** (theme, i18n, store, router): create a shared `decorator` helper next to the gallery and wrap components in stories; see `references/react.md` / `references/vue.md`.
- **Network**: mock with `page.route()` in the test, or start MSW inside a story; see `references/patterns.md`.
- **Viewport, color scheme, locale**: standard Playwright — `test.use({ viewport, colorScheme, locale })`.

## References

- `references/react.md` — React walkthrough: providers, StrictMode, CSS.
- `references/vue.md` — Vue walkthrough: `.story.ts` and `.story.vue` stories, plugins.
- `references/patterns.md` — callbacks and events, per-test args, network mocking, visual testing.
- `references/migration.md` — migrating from `@playwright/experimental-ct-react` / `-vue`.
66 changes: 66 additions & 0 deletions packages/skills/component-testing/references/migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Migrating from @playwright/experimental-ct-react / -vue

The CT packages mounted JSX from the test over a serialization boundary. The gallery pattern moves that setup into story files that run natively in the browser. Migration is mechanical: every distinct `mount(...)` call site becomes a story export.

## Concept mapping

| `@playwright/experimental-ct-*` | Gallery pattern |
|---|---|
| `mount(<Button title="…" />)` | Story export `Primary = () => <Button title="…" />` + `mount('Button/Primary')` |
| Props from the test | Hard-coded in the story; `args` option for genuinely parametric cases |
| Callback props (`onClick={spy}`) | Wire the callback in the story, record into the DOM or `window.__events`, assert with locators (`references/patterns.md`) |
| `component.update(<Button … />)` | Separate story per state, or a story with in-page controls that switch state |
| `component.unmount()` | Story with a toggle that unmounts the subtree; assert after clicking |
| `hooksConfig` + `beforeMount`/`afterMount` in `playwright/index.ts` | Providers wrapped in the story or a shared decorator helper (`references/react.md` / `vue.md`) |
| `router` fixture / MSW handlers | `page.route()` in the test, or MSW `setupWorker` started inside the story |
| `playwright/index.html` (styles, fonts, theme) | `playwright/gallery/index.html` |
| `ctViteConfig` | The gallery runs through the app's own Vite config; for a standalone gallery server, edit `playwright/vite.config.ts` |
| `ctPort` | The dev server port in `webServer` + `baseURL` |
| `ctTemplateDir` | The `playwright/gallery/` directory location |
| `defineConfig` from `@playwright/experimental-ct-react` | Plain `defineConfig` from `@playwright/test` |

## Steps

1. Set up the gallery, fixture and config per `SKILL.md` (keep the old CT project running while migrating).
2. For each CT spec file, collect the distinct `mount(<…/>)` calls and turn each into a named story export next to the component. Test-specific data stays inline in the story; shared mock data can move to a `fixtures`/`mocks` module imported by stories.
3. Rewrite specs: import `test`/`expect` from `playwright/fixtures.ts`, replace `mount(<X …/>)` with `mount('X/StoryName')`. Assertions on the returned locator usually survive unchanged.
4. Replace callback-spy assertions (`expect(spy).toHaveBeenCalled…`) with DOM/`window.__events` assertions per `references/patterns.md`.
5. Port `beforeMount` hooks into a decorator used by the affected stories.
6. Delete the CT project from `playwright.config.ts`, remove `@playwright/experimental-ct-*` from dependencies, delete `playwright/index.html`/`index.ts*` once all specs are migrated.

## Before / after

```tsx
// Before (CT)
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from '../src/components/Button';

test('click', async ({ mount }) => {
const messages: string[] = [];
const component = await mount(<Button title="Submit" onClick={data => messages.push(data)} />);
await component.click();
expect(messages).toEqual(['hello']);
});
```

```tsx
// After: src/components/Button.story.tsx
export const RecordsClickData = () => {
const [messages, setMessages] = useState<string[]>([]);
return <>
<Button title="Submit" onClick={data => setMessages(m => [...m, data])} />
<output data-testid="messages">{messages.join(',')}</output>
</>;
};
```

```ts
// After: tests/components/button.spec.ts
import { test, expect } from '../../playwright/fixtures';

test('click', async ({ mount, page }) => {
const component = await mount('components/Button/RecordsClickData');
await component.getByRole('button').click();
await expect(page.getByTestId('messages')).toHaveText('hello');
});
```
100 changes: 100 additions & 0 deletions packages/skills/component-testing/references/patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Testing patterns

Recipes for common component-testing needs. All examples are React; the Vue equivalents differ only in story syntax.

## Callbacks and events

The test cannot receive a callback directly — record the callback's effect inside the story and assert it through the page.

**Visible output (preferred)** — renders state the test can assert with normal locators and that a human can see in the gallery:

```tsx
export const CountsClicks = () => {
const [clicks, setClicks] = useState(0);
return <>
<Button title="Submit" onClick={() => setClicks(c => c + 1)} />
<output data-testid="click-count">{clicks}</output>
</>;
};
```

**Event log on window** — for non-visual events or when payloads matter:

```tsx
declare global { interface Window { __events: any[] } }

export const RecordsSelection = () => {
window.__events = [];
return <Select options={mockOptions} onChange={value => window.__events.push({ change: value })} />;
};
```

```ts
await component.getByRole('option', { name: 'Two' }).click();
await expect.poll(() => page.evaluate(() => window.__events)).toEqual([{ change: 'two' }]);
```

## Per-test arguments

Prefer one story export per scenario. When a scenario is genuinely parametric (e.g. a boundary-value sweep), `mount` can pass JSON-serializable args; the gallery hands them to the story as an `args` prop:

```tsx
export const WithArgs = ({ args }: { args?: { title: string } }) =>
<Button title={args?.title ?? 'Default'} />;
```

```ts
const component = await mount('components/Button/WithArgs', { args: { title: 'Hello' } });
```

Args travel through a URL query parameter — keep them small and serializable.

## Network mocking

Standard Playwright routing works — set up routes before mounting:

```ts
test('shows user data', async ({ mount, page }) => {
await page.route('**/api/user/*', route => route.fulfill({ json: { name: 'Test User' } }));
const component = await mount('components/UserCard/Default');
await expect(component).toContainText('Test User');
});
```

For teams already invested in MSW, start a worker inside the story (or a decorator) instead: `setupWorker(...handlers).start()` before rendering. `page.route()` is simpler and needs no extra dependency.

## Viewport, color scheme, locale, timezone

Standard Playwright configuration applies per test file or block:

```ts
test.use({ viewport: { width: 400, height: 800 }, colorScheme: 'dark', locale: 'de-DE' });
```

Fake time works too: `await page.clock.setFixedTime(new Date('2024-02-02T10:00:00'))` before mounting.

## Visual comparison

```ts
const component = await mount('components/Card/Default');
await expect(component).toHaveScreenshot('card.png');
```

Screenshot the component locator, not the page, to avoid asserting on gallery chrome.

## Multiple states in one test

Each `mount()` navigates fresh, so mounting several stories in one test is cheap and isolated:

```ts
test('button variants', async ({ mount }) => {
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
});
```

There is no `update()` — a story that needs to transition between states should render its own controls (a button that flips a prop) and the test clicks them.

## Debugging stories

Open the gallery index in a browser (`/playwright/gallery/index.html`) to browse all stories, or a story URL directly. Render failures show the error and stack in the page and mark `#story-root` with `data-story-error`, which the `mount` fixture converts into a test failure with the same message.
52 changes: 52 additions & 0 deletions packages/skills/component-testing/references/react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# React setup

Follow the setup workflow in `SKILL.md`, using `templates/react/`. This page covers React-specific details.

## Files

- `playwright/gallery/index.html` — from `templates/react/index.html`.
- `playwright/gallery/main.tsx` — from `templates/react/main.tsx`. Requires `react` and `react-dom` 18+ (uses `createRoot`).
- Stories: `src/**/*.story.tsx` (the glob also picks up `.story.jsx`).

## StrictMode

The gallery wraps stories in `<React.StrictMode>` to match how most apps render. In development builds StrictMode intentionally double-invokes render functions and effects. This matters for stories that record events with counters set in effects — recording via state updates from event handlers (like the `CountsClicks` template story) is unaffected. If a story misbehaves under StrictMode, that is usually a real finding about the component; only remove the wrapper from `main.tsx` if the app itself does not use StrictMode.

## Global providers

If components require context (theme, store, i18n, router), create one shared decorator and use it in stories, so each story states its scenario and nothing more:

```tsx
// src/stories/decorators.tsx
import { ThemeProvider } from '../theme';
import { MemoryRouter } from 'react-router-dom';

export function AppScaffold({ children, route = '/' }: { children: React.ReactNode, route?: string }) {
return (
<ThemeProvider theme="light">
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</ThemeProvider>
);
}
```

```tsx
// src/components/ProfilePage.story.tsx
export const LoggedIn = () => (
<AppScaffold route="/profile/42">
<ProfilePage user={{ id: 42, name: 'Test User' }} />
</AppScaffold>
);
```

Do not build the decorator into `main.tsx` — keeping it in story files makes the wrapping visible and lets stories opt out.

## CSS

- Global stylesheets: import them in `playwright/gallery/main.tsx` (`import '../../src/index.css'`) or link them from `index.html`, mirroring the app's own entry point.
- Tailwind: importing the app's main CSS file is enough — the gallery runs through the app's Vite config, so the Tailwind plugin processes story files like any other source. If content scanning is path-based, make sure `*.story.tsx` files are covered.
- CSS modules and component-level imports need no extra setup.

## Data fetching

Components that fetch on mount work fine — mock the network with `page.route()` in the test (see `references/patterns.md`). For libraries with client objects (React Query, Apollo), create the client inside the story or decorator so each navigation starts fresh.
57 changes: 57 additions & 0 deletions packages/skills/component-testing/references/vue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Vue setup

Follow the setup workflow in `SKILL.md`, using `templates/vue/`. This page covers Vue-specific details.

## Files

- `playwright/gallery/index.html` — from `templates/vue/index.html`.
- `playwright/gallery/main.ts` — from `templates/vue/main.ts`. Requires Vue 3.
- Stories: `src/**/*.story.{ts,js,vue}`.

## Two ways to write stories

**Render-function stories** (`Button.story.ts`) — several scenarios per file, one named export each; see `templates/vue/Button.story.ts`. Uses `defineComponent` + `h()`, no SFC compilation involved.

**Single-file-component stories** (`Button.primary.story.vue`) — one story per file, full template syntax:

```vue
<script setup lang="ts">
import { ref } from 'vue';
import Button from './Button.vue';
const clicks = ref(0);
</script>

<template>
<Button title="Submit" @click="clicks++" />
<output data-testid="click-count">{{ clicks }}</output>
</template>
```

An SFC story is addressed by its path without the extension: `mount('components/Button.primary')`. Prefer SFC stories when the scenario needs slots or non-trivial templates.

## Global plugins

Apps that rely on plugins (Pinia, vue-router, i18n) should wrap components with a decorator story helper that creates a fresh instance per story:

```ts
// src/stories/decorators.ts
import { defineComponent, h, type Component } from 'vue';
import { createPinia } from 'pinia';

export function withStore(story: Component) {
return defineComponent(() => {
const pinia = createPinia();
return () => h(story, { pinia });
});
}
```

For plugins that must be installed on the app instance (`app.use(...)`), add them in `playwright/gallery/main.ts` right after `createApp(host)` — that is the equivalent of the app's own bootstrap.

## CSS

Import global stylesheets in `playwright/gallery/main.ts` (`import '../../src/assets/main.css'`) or link them from `index.html`, mirroring the app's entry point. Scoped styles in `.vue` components need no setup.

## Typechecking

`.story.vue` files typecheck via `vue-tsc` like any SFC. For `.story.ts` files nothing special is needed.
Loading
Loading