Skip to content
Merged
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 @@ -54,6 +54,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
4 changes: 4 additions & 0 deletions examples/ct-react-vite/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
test-results
playwright-report
13 changes: 13 additions & 0 deletions examples/ct-react-vite/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions examples/ct-react-vite/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "ct-react-vite-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "playwright test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.6.1"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^6.0.3",
"typescript": "^5.2.2",
"vite": "^8.1.0"
}
}
28 changes: 28 additions & 0 deletions examples/ct-react-vite/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test';

// Component testing with Playwright, following the playwright-component-testing skill.
// The gallery (playwright/gallery/index.html) is served by the app's own Vite dev server;
// `baseURL` points the built-in `mount` fixture at it.
export default defineConfig({
// Specs live next to their components as trios: Button.tsx / Button.story.tsx / Button.spec.tsx.
testDir: './src',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'html' : 'line',
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173/playwright/gallery/index.html',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:5173/playwright/gallery/index.html',
serviceWorkers: 'block',
reuseContext: true,
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
12 changes: 12 additions & 0 deletions examples/ct-react-vite/playwright/gallery/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Component gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions examples/ct-react-vite/playwright/gallery/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Playwright component gallery — implements the contract in the playwright-component-testing
// skill (references/gallery-spec.md): a single page exposing window.mount()/window.unmount().
// The built-in `mount` fixture navigates here (baseURL) and calls window.mount via
// page.evaluate(..., { exposeFunctions: true }), so props may carry real callbacks.
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import '../../src/assets/index.css';

// import.meta.glob must stay inline: Vite analyzes it statically, relative to this file.
const stories = import.meta.glob('../../src/**/*.story.tsx');
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');

// Story id is '<path under src, without .story.tsx>/<ExportName>', e.g. 'components/Button/Default'.
async function resolve(storyId: string) {
const sep = storyId.lastIndexOf('/');
const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
const mod = (file && await stories[file]()) as Record<string, any> | undefined;
return mod?.[name] ?? mod?.default;
}

const rootEl = document.getElementById('root')!;
let root: Root | undefined;

(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
const Story = await resolve(story);
if (!Story)
throw new Error(`Unknown story: ${story}`);
// Reuse the root so component.update() reconciles in place and preserves state.
root ??= createRoot(rootEl);
// flushSync so a render error rejects the promise instead of being swallowed.
flushSync(() => root!.render(<Story {...props} />));
};

(window as any).unmount = async () => {
root?.unmount();
root = undefined;
};
18 changes: 18 additions & 0 deletions examples/ct-react-vite/src/App.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';

test('navigate to a page by clicking a link', async ({ mount }) => {
const component = await mount('App/Routing');
await expect(component.getByRole('main')).toHaveText('Login');
await component.getByRole('link', { name: 'Dashboard' }).click();
await expect(component.getByRole('main')).toHaveText('Dashboard');
});

test('update does not reset the router', async ({ mount }) => {
const component = await mount('App/Routing', { title: 'before' });
await expect(component.getByRole('heading')).toHaveText('before');
await expect(component.getByRole('main')).toHaveText('Login');

await component.update({ title: 'after' });
await expect(component.getByRole('heading')).toHaveText('after');
await expect(component.getByRole('main')).toHaveText('Login');
});
6 changes: 6 additions & 0 deletions examples/ct-react-vite/src/App.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { MemoryRouter } from 'react-router-dom';
import App from './App';

// Providers (here a router) are wired inside the story — the skill's decorator pattern.
// MemoryRouter is used so routing starts at '/' regardless of the gallery's own URL.
export const Routing = (props: any) => <MemoryRouter><App {...props} /></MemoryRouter>;
21 changes: 21 additions & 0 deletions examples/ct-react-vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Routes, Route, Link } from 'react-router-dom';
import logo from './assets/logo.svg';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';

export default function App({ title }: { title?: string }) {
return <>
<header>
<img src={logo} alt="logo" width={125} height={125} />
{title && <h1>{title}</h1>}
<Link to="/">Login</Link>
<Link to="/dashboard">Dashboard</Link>
</header>
<Routes>
<Route path="/">
<Route index element={<LoginPage />} />
<Route path="dashboard" element={<DashboardPage />} />
</Route>
</Routes>
</>
}
15 changes: 15 additions & 0 deletions examples/ct-react-vite/src/assets/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/ct-react-vite/src/assets/iconfont.woff2
Binary file not shown.
29 changes: 29 additions & 0 deletions examples/ct-react-vite/src/assets/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

@media (prefers-color-scheme: light) {
:root {
color: #e3e3e3;
background-color: #1b1b1d;
}
}

@font-face {
font-family: 'pwtest-iconfont';
/* See tests/assets/webfont/README.md */
src: url('./iconfont.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
7 changes: 7 additions & 0 deletions examples/ct-react-vite/src/assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions examples/ct-react-vite/src/components/Button.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';

test('render props', async ({ mount }) => {
const component = await mount('components/Button/Default');
await expect(component).toContainText('Submit');
});

test('render attributes', async ({ mount }) => {
const component = await mount('components/Button/Default', { className: 'primary' });
await expect(component).toHaveClass('primary');
});

test('execute callback when the button is clicked', async ({ mount }) => {
const messages: string[] = [];
const component = await mount('components/Button/Default', {
onClick: (data: string) => messages.push(data),
});
await component.click();
expect(messages).toEqual(['hello']);
});

test('unmount', async ({ mount, page }) => {
const component = await mount('components/Button/Default');
await expect(page.locator('#root')).toContainText('Submit');
await component.unmount();
await expect(page.locator('#root')).not.toContainText('Submit');
});

test('mount, unmount, then mount again', async ({ mount }) => {
let component = await mount('components/Button/Default');
await component.unmount();
component = await mount('components/Button/Default', { title: 'Save' });
await expect(component).toContainText('Save');
});
4 changes: 4 additions & 0 deletions examples/ct-react-vite/src/components/Button.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Button from './Button';

// Spreads props so a test can pass `title`, `className`, or a real `onClick` callback.
export const Default = (props: any) => <Button title="Submit" {...props} />;
13 changes: 13 additions & 0 deletions examples/ct-react-vite/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ButtonHTMLAttributes } from "react";

type ButtonProps = {
title: string;
onClick?(props: string): void;
className?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>;

export default function Button({ onClick, title, ...attributes }: ButtonProps) {
return <button {...attributes} onClick={() => onClick?.('hello')}>
{title}
</button>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test, expect } from '@playwright/test';

test('absence of children', async ({ mount }) => {
const component = await mount('components/CheckChildrenProp/NoChildren');
await expect(component).toContainText('No Children');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import CheckChildrenProp from './CheckChildrenProp';

export const NoChildren = () => <CheckChildrenProp />;
7 changes: 7 additions & 0 deletions examples/ct-react-vite/src/components/CheckChildrenProp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { PropsWithChildren } from 'react';

type DefaultChildrenProps = PropsWithChildren<{}>;

export default function CheckChildrenProp(props: DefaultChildrenProps) {
return <>{'children' in props ? props.children : 'No Children'}</>
}
12 changes: 12 additions & 0 deletions examples/ct-react-vite/src/components/ComponentAsProp.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';

test('render component as a prop', async ({ mount }) => {
const component = await mount('components/ComponentAsProp/WithButton');
await expect(component.getByRole('button', { name: 'Submit' })).toBeVisible();
});

test('render a jsx array as a prop', async ({ mount }) => {
const component = await mount('components/ComponentAsProp/WithArray');
await expect(component.getByRole('heading', { level: 4 })).toHaveText('4');
await expect(component.getByRole('paragraph')).toHaveText('[2,3]');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ComponentAsProp } from './ComponentAsProp';
import Button from './Button';

// The rendered node passed as a prop is baked into the story (JSX can't travel from the test).
export const WithButton = () => <ComponentAsProp component={<Button title="Submit" />} />;

export const WithArray = () => <ComponentAsProp component={[<h4>{[4]}</h4>, [[<p>[2,3]</p>]]]} />;
9 changes: 9 additions & 0 deletions examples/ct-react-vite/src/components/ComponentAsProp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ReactNode } from "react";

type ComponentAsProp = {
component: ReactNode[] | ReactNode;
};

export function ComponentAsProp({ component }: ComponentAsProp) {
return <div>{component}</div>
}
36 changes: 36 additions & 0 deletions examples/ct-react-vite/src/components/Counter.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';

// component.update(props) re-renders the same story on the reused root, so state survives —
// remount-count stays 1 throughout.
test('update props without remounting', async ({ mount }) => {
const component = await mount('components/Counter/Default', { count: 9001 });
await expect(component.getByTestId('props')).toContainText('9001');

await component.update({ count: 1337 });
await expect(component).not.toContainText('9001');
await expect(component.getByTestId('props')).toContainText('1337');

await expect(component.getByTestId('remount-count')).toContainText('1');
});

test('update callbacks without remounting', async ({ mount }) => {
const component = await mount('components/Counter/Default');

const messages: string[] = [];
await component.update({ onClick: (message: string) => messages.push(message) });
await component.click();
expect(messages).toEqual(['hello']);

await expect(component.getByTestId('remount-count')).toContainText('1');
});

test('update children without remounting', async ({ mount }) => {
const component = await mount('components/Counter/Default', { children: 'Default Slot' });
await expect(component).toContainText('Default Slot');

await component.update({ children: 'Test Slot' });
await expect(component).not.toContainText('Default Slot');
await expect(component).toContainText('Test Slot');

await expect(component.getByTestId('remount-count')).toContainText('1');
});
5 changes: 5 additions & 0 deletions examples/ct-react-vite/src/components/Counter.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Counter from './Counter';

// Spreads props (count, onClick, children) so tests can drive it and, crucially, call
// component.update(newProps) to re-render in place — remount-count stays 1.
export const Default = (props: any) => <Counter {...props} />;
Loading