Skip to content

Commit dc272b9

Browse files
authored
feat(skills): add component-testing agent skill (#41738)
1 parent 8cd58ba commit dc272b9

67 files changed

Lines changed: 1215 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const ignores = [
5454
"packages/playwright-core/types/*",
5555
"packages/playwright-ct-core/src/generated/*",
5656
"packages/playwright/bundles/expect/third_party/",
57+
"packages/skills/",
5758
"packages/html-reporter/bundle.ts",
5859
"packages/html-reporter/playwright.config.ts",
5960
"packages/html-reporter/playwright/*",

examples/ct-react-vite/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
dist
3+
test-results
4+
playwright-report

examples/ct-react-vite/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Vite App</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.tsx"></script>
12+
</body>
13+
</html>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "ct-react-vite-example",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc && vite build",
9+
"test": "playwright test",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"react": "^18.2.0",
14+
"react-dom": "^18.2.0",
15+
"react-router-dom": "^6.6.1"
16+
},
17+
"devDependencies": {
18+
"@playwright/test": "^1.56.0",
19+
"@types/react": "^18.0.26",
20+
"@types/react-dom": "^18.0.10",
21+
"@vitejs/plugin-react": "^6.0.3",
22+
"typescript": "^5.2.2",
23+
"vite": "^8.1.0"
24+
}
25+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
// Component testing with Playwright, following the playwright-component-testing skill.
4+
// The gallery (playwright/gallery/index.html) is served by the app's own Vite dev server;
5+
// `baseURL` points the built-in `mount` fixture at it.
6+
export default defineConfig({
7+
// Specs live next to their components as trios: Button.tsx / Button.story.tsx / Button.spec.tsx.
8+
testDir: './src',
9+
forbidOnly: !!process.env.CI,
10+
retries: process.env.CI ? 2 : 0,
11+
reporter: process.env.CI ? 'html' : 'line',
12+
webServer: {
13+
command: 'npm run dev',
14+
url: 'http://localhost:5173/playwright/gallery/index.html',
15+
reuseExistingServer: !process.env.CI,
16+
},
17+
use: {
18+
baseURL: 'http://localhost:5173/playwright/gallery/index.html',
19+
serviceWorkers: 'block',
20+
reuseContext: true,
21+
trace: 'on-first-retry',
22+
},
23+
projects: [
24+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
25+
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
26+
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
27+
],
28+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Component gallery</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="./main.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Playwright component gallery — implements the contract in the playwright-component-testing
2+
// skill (references/gallery-spec.md): a single page exposing window.mount()/window.unmount().
3+
// The built-in `mount` fixture navigates here (baseURL) and calls window.mount via
4+
// page.evaluate(..., { exposeFunctions: true }), so props may carry real callbacks.
5+
import { flushSync } from 'react-dom';
6+
import { createRoot, type Root } from 'react-dom/client';
7+
import '../../src/assets/index.css';
8+
9+
// import.meta.glob must stay inline: Vite analyzes it statically, relative to this file.
10+
const stories = import.meta.glob('../../src/**/*.story.tsx');
11+
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
12+
13+
// Story id is '<path under src, without .story.tsx>/<ExportName>', e.g. 'components/Button/Default'.
14+
async function resolve(storyId: string) {
15+
const sep = storyId.lastIndexOf('/');
16+
const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
17+
const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
18+
const mod = (file && await stories[file]()) as Record<string, any> | undefined;
19+
return mod?.[name] ?? mod?.default;
20+
}
21+
22+
const rootEl = document.getElementById('root')!;
23+
let root: Root | undefined;
24+
25+
(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
26+
const Story = await resolve(story);
27+
if (!Story)
28+
throw new Error(`Unknown story: ${story}`);
29+
// Reuse the root so component.update() reconciles in place and preserves state.
30+
root ??= createRoot(rootEl);
31+
// flushSync so a render error rejects the promise instead of being swallowed.
32+
flushSync(() => root!.render(<Story {...props} />));
33+
};
34+
35+
(window as any).unmount = async () => {
36+
root?.unmount();
37+
root = undefined;
38+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('navigate to a page by clicking a link', async ({ mount }) => {
4+
const component = await mount('App/Routing');
5+
await expect(component.getByRole('main')).toHaveText('Login');
6+
await component.getByRole('link', { name: 'Dashboard' }).click();
7+
await expect(component.getByRole('main')).toHaveText('Dashboard');
8+
});
9+
10+
test('update does not reset the router', async ({ mount }) => {
11+
const component = await mount('App/Routing', { title: 'before' });
12+
await expect(component.getByRole('heading')).toHaveText('before');
13+
await expect(component.getByRole('main')).toHaveText('Login');
14+
15+
await component.update({ title: 'after' });
16+
await expect(component.getByRole('heading')).toHaveText('after');
17+
await expect(component.getByRole('main')).toHaveText('Login');
18+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { MemoryRouter } from 'react-router-dom';
2+
import App from './App';
3+
4+
// Providers (here a router) are wired inside the story — the skill's decorator pattern.
5+
// MemoryRouter is used so routing starts at '/' regardless of the gallery's own URL.
6+
export const Routing = (props: any) => <MemoryRouter><App {...props} /></MemoryRouter>;

examples/ct-react-vite/src/App.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Routes, Route, Link } from 'react-router-dom';
2+
import logo from './assets/logo.svg';
3+
import LoginPage from './pages/LoginPage';
4+
import DashboardPage from './pages/DashboardPage';
5+
6+
export default function App({ title }: { title?: string }) {
7+
return <>
8+
<header>
9+
<img src={logo} alt="logo" width={125} height={125} />
10+
{title && <h1>{title}</h1>}
11+
<Link to="/">Login</Link>
12+
<Link to="/dashboard">Dashboard</Link>
13+
</header>
14+
<Routes>
15+
<Route path="/">
16+
<Route index element={<LoginPage />} />
17+
<Route path="dashboard" element={<DashboardPage />} />
18+
</Route>
19+
</Routes>
20+
</>
21+
}

0 commit comments

Comments
 (0)