diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml
index 5e4be9bea7a20..a4da6a0e7f061 100644
--- a/.github/workflows/tests_primary.yml
+++ b/.github/workflows/tests_primary.yml
@@ -140,6 +140,29 @@ jobs:
env:
PWTEST_SHARD_WEIGHTS: ${{ matrix.shardWeights }}
+ test_web_components:
+ name: Web Components - ${{ matrix.package }}
+ environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}
+ permissions:
+ id-token: write # This is required for OIDC login (azure/login) to succeed
+ contents: read # This is required for actions/checkout to succeed
+ strategy:
+ fail-fast: false
+ matrix:
+ package: [html-reporter, web]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/run-test
+ with:
+ node-version: 20
+ browsers-to-install: chromium
+ command: npm run test-${{ matrix.package }}
+ bot-name: "web-components-${{ matrix.package }}"
+ flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }}
+ flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }}
+ flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }}
+
test_vscode_extension:
name: VSCode Extension
runs-on: ubuntu-latest
diff --git a/examples/ct-react-vite/.gitignore b/examples/ct-react-vite/.gitignore
deleted file mode 100644
index 5bb1c7684c2fe..0000000000000
--- a/examples/ct-react-vite/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-node_modules
-dist
-test-results
-playwright-report
diff --git a/examples/ct-react-vite/index.html b/examples/ct-react-vite/index.html
deleted file mode 100644
index cf3a13303c03d..0000000000000
--- a/examples/ct-react-vite/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Vite App
-
-
-
-
-
-
diff --git a/examples/ct-react-vite/package.json b/examples/ct-react-vite/package.json
deleted file mode 100644
index 0479b93f1b15e..0000000000000
--- a/examples/ct-react-vite/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "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"
- }
-}
diff --git a/examples/ct-react-vite/playwright.config.ts b/examples/ct-react-vite/playwright.config.ts
deleted file mode 100644
index 8be33427cbda6..0000000000000
--- a/examples/ct-react-vite/playwright.config.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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'] } },
- ],
-});
diff --git a/examples/ct-react-vite/playwright/gallery/index.html b/examples/ct-react-vite/playwright/gallery/index.html
deleted file mode 100644
index 2dfb0a5b573ca..0000000000000
--- a/examples/ct-react-vite/playwright/gallery/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- Component gallery
-
-
-
-
-
-
diff --git a/examples/ct-react-vite/playwright/gallery/main.tsx b/examples/ct-react-vite/playwright/gallery/main.tsx
deleted file mode 100644
index 5822ceca54ab3..0000000000000
--- a/examples/ct-react-vite/playwright/gallery/main.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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 '/', 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 | 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 }) => {
- 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());
-};
-
-(window as any).unmount = async () => {
- root?.unmount();
- root = undefined;
-};
diff --git a/examples/ct-react-vite/src/App.spec.tsx b/examples/ct-react-vite/src/App.spec.tsx
deleted file mode 100644
index 3e01da7173372..0000000000000
--- a/examples/ct-react-vite/src/App.spec.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-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');
-});
diff --git a/examples/ct-react-vite/src/App.story.tsx b/examples/ct-react-vite/src/App.story.tsx
deleted file mode 100644
index 66a9b8d2296d9..0000000000000
--- a/examples/ct-react-vite/src/App.story.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-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) => ;
diff --git a/examples/ct-react-vite/src/App.tsx b/examples/ct-react-vite/src/App.tsx
deleted file mode 100644
index fd004390f3b1f..0000000000000
--- a/examples/ct-react-vite/src/App.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-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 <>
-
-
- {title && {title}
}
- Login
- Dashboard
-
-
-
- } />
- } />
-
-
- >
-}
diff --git a/examples/ct-react-vite/src/assets/favicon.svg b/examples/ct-react-vite/src/assets/favicon.svg
deleted file mode 100644
index de4aeddc12bdf..0000000000000
--- a/examples/ct-react-vite/src/assets/favicon.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
diff --git a/examples/ct-react-vite/src/assets/iconfont.woff2 b/examples/ct-react-vite/src/assets/iconfont.woff2
deleted file mode 100644
index ed1f4f34a41d6..0000000000000
Binary files a/examples/ct-react-vite/src/assets/iconfont.woff2 and /dev/null differ
diff --git a/examples/ct-react-vite/src/assets/index.css b/examples/ct-react-vite/src/assets/index.css
deleted file mode 100644
index dd2a6fa726c4a..0000000000000
--- a/examples/ct-react-vite/src/assets/index.css
+++ /dev/null
@@ -1,29 +0,0 @@
-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;
-}
diff --git a/examples/ct-react-vite/src/assets/logo.svg b/examples/ct-react-vite/src/assets/logo.svg
deleted file mode 100644
index 6b60c1042f58d..0000000000000
--- a/examples/ct-react-vite/src/assets/logo.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/examples/ct-react-vite/src/components/Button.spec.tsx b/examples/ct-react-vite/src/components/Button.spec.tsx
deleted file mode 100644
index fd2d401151832..0000000000000
--- a/examples/ct-react-vite/src/components/Button.spec.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-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');
-});
diff --git a/examples/ct-react-vite/src/components/Button.story.tsx b/examples/ct-react-vite/src/components/Button.story.tsx
deleted file mode 100644
index 34d0dd117c01f..0000000000000
--- a/examples/ct-react-vite/src/components/Button.story.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import Button from './Button';
-
-// Spreads props so a test can pass `title`, `className`, or a real `onClick` callback.
-export const Default = (props: any) => ;
diff --git a/examples/ct-react-vite/src/components/Button.tsx b/examples/ct-react-vite/src/components/Button.tsx
deleted file mode 100644
index 8785b878d0754..0000000000000
--- a/examples/ct-react-vite/src/components/Button.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { ButtonHTMLAttributes } from "react";
-
-type ButtonProps = {
- title: string;
- onClick?(props: string): void;
- className?: string;
-} & Omit, 'onClick'>;
-
-export default function Button({ onClick, title, ...attributes }: ButtonProps) {
- return
-}
diff --git a/examples/ct-react-vite/src/components/CheckChildrenProp.spec.tsx b/examples/ct-react-vite/src/components/CheckChildrenProp.spec.tsx
deleted file mode 100644
index 755e1b5d062f9..0000000000000
--- a/examples/ct-react-vite/src/components/CheckChildrenProp.spec.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-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');
-});
diff --git a/examples/ct-react-vite/src/components/CheckChildrenProp.story.tsx b/examples/ct-react-vite/src/components/CheckChildrenProp.story.tsx
deleted file mode 100644
index 7c3f8410aed02..0000000000000
--- a/examples/ct-react-vite/src/components/CheckChildrenProp.story.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import CheckChildrenProp from './CheckChildrenProp';
-
-export const NoChildren = () => ;
diff --git a/examples/ct-react-vite/src/components/CheckChildrenProp.tsx b/examples/ct-react-vite/src/components/CheckChildrenProp.tsx
deleted file mode 100644
index 3e8f405a5bc45..0000000000000
--- a/examples/ct-react-vite/src/components/CheckChildrenProp.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { PropsWithChildren } from 'react';
-
-type DefaultChildrenProps = PropsWithChildren<{}>;
-
-export default function CheckChildrenProp(props: DefaultChildrenProps) {
- return <>{'children' in props ? props.children : 'No Children'}>
-}
diff --git a/examples/ct-react-vite/src/components/ComponentAsProp.spec.tsx b/examples/ct-react-vite/src/components/ComponentAsProp.spec.tsx
deleted file mode 100644
index 1e8c2156f62fa..0000000000000
--- a/examples/ct-react-vite/src/components/ComponentAsProp.spec.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-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]');
-});
diff --git a/examples/ct-react-vite/src/components/ComponentAsProp.story.tsx b/examples/ct-react-vite/src/components/ComponentAsProp.story.tsx
deleted file mode 100644
index 9c3db66e3c181..0000000000000
--- a/examples/ct-react-vite/src/components/ComponentAsProp.story.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-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 = () => } />;
-
-export const WithArray = () => {[4]}, [[[2,3]
]]]} />;
diff --git a/examples/ct-react-vite/src/components/ComponentAsProp.tsx b/examples/ct-react-vite/src/components/ComponentAsProp.tsx
deleted file mode 100644
index 67b88132076c0..0000000000000
--- a/examples/ct-react-vite/src/components/ComponentAsProp.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { ReactNode } from "react";
-
-type ComponentAsProp = {
- component: ReactNode[] | ReactNode;
-};
-
-export function ComponentAsProp({ component }: ComponentAsProp) {
- return {component}
-}
diff --git a/examples/ct-react-vite/src/components/Counter.spec.tsx b/examples/ct-react-vite/src/components/Counter.spec.tsx
deleted file mode 100644
index 5e7640bb777dc..0000000000000
--- a/examples/ct-react-vite/src/components/Counter.spec.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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');
-});
diff --git a/examples/ct-react-vite/src/components/Counter.story.tsx b/examples/ct-react-vite/src/components/Counter.story.tsx
deleted file mode 100644
index fc5e31281e904..0000000000000
--- a/examples/ct-react-vite/src/components/Counter.story.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-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) => ;
diff --git a/examples/ct-react-vite/src/components/Counter.tsx b/examples/ct-react-vite/src/components/Counter.tsx
deleted file mode 100644
index 18e999de73e23..0000000000000
--- a/examples/ct-react-vite/src/components/Counter.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useLayoutEffect, useRef, useState } from "react"
-
- type CounterProps = {
- count?: number;
- onClick?(props: string): void;
- children?: any;
- }
-
- let _remountCount = 1;
-
- export default function Counter(props: CounterProps) {
- const [remountCount] = useState(_remountCount);
- const didMountRef = useRef(false)
- useLayoutEffect(() => {
- if (!didMountRef.current) {
- didMountRef.current = true;
- _remountCount++;
- }
- }, [])
- return
- }
diff --git a/examples/ct-react-vite/src/components/DefaultChildren.spec.tsx b/examples/ct-react-vite/src/components/DefaultChildren.spec.tsx
deleted file mode 100644
index bd41ae3221096..0000000000000
--- a/examples/ct-react-vite/src/components/DefaultChildren.spec.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-test('render a default child', async ({ mount }) => {
- const component = await mount('components/DefaultChildren/Text');
- await expect(component).toContainText('Main Content');
-});
-
-test('render a component as a child', async ({ mount }) => {
- const component = await mount('components/DefaultChildren/WithButton');
- await expect(component).toContainText('Submit');
-});
-
-test('render multiple children', async ({ mount }) => {
- const component = await mount('components/DefaultChildren/Multiple');
- await expect(component.getByTestId('one')).toContainText('One');
- await expect(component.getByTestId('two')).toContainText('Two');
-});
-
-test('render a number as a child', async ({ mount }) => {
- const component = await mount('components/DefaultChildren/Number');
- await expect(component).toContainText('1337');
-});
-
-test('execute callback when a child node is clicked', async ({ mount }) => {
- let clickFired = false;
- const component = await mount('components/DefaultChildren/ClickableChild', {
- onChildClick: () => (clickFired = true),
- });
- await component.getByText('Main Content').click();
- expect(clickFired).toBeTruthy();
-});
diff --git a/examples/ct-react-vite/src/components/DefaultChildren.story.tsx b/examples/ct-react-vite/src/components/DefaultChildren.story.tsx
deleted file mode 100644
index 59254c743ee24..0000000000000
--- a/examples/ct-react-vite/src/components/DefaultChildren.story.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import DefaultChildren from './DefaultChildren';
-import Button from './Button';
-
-// Children cannot cross the test/browser boundary as JSX, so each composition is its own story.
-export const Text = () => Main Content;
-
-export const WithButton = () => ;
-
-export const Multiple = () =>
- One
- Two
-;
-
-export const Number = () => {1337};
-
-// A clickable child whose handler is supplied by the test as a callback prop.
-export const ClickableChild = (props: { onChildClick?: () => void }) =>
- Main Content;
diff --git a/examples/ct-react-vite/src/components/DefaultChildren.tsx b/examples/ct-react-vite/src/components/DefaultChildren.tsx
deleted file mode 100644
index 691b6a080615c..0000000000000
--- a/examples/ct-react-vite/src/components/DefaultChildren.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-type DefaultChildrenProps = {
- children?: any;
-}
-
-export default function DefaultChildren(props: DefaultChildrenProps) {
- return
-
Welcome!
-
- {props.children}
-
-
-
-}
diff --git a/examples/ct-react-vite/src/components/EmptyFragment.spec.tsx b/examples/ct-react-vite/src/components/EmptyFragment.spec.tsx
deleted file mode 100644
index cf656897c2563..0000000000000
--- a/examples/ct-react-vite/src/components/EmptyFragment.spec.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-test('render an empty component', async ({ mount, page }) => {
- const component = await mount('components/EmptyFragment/Default');
- expect(await page.evaluate(() => 'props' in window && (window as any).props)).toEqual({});
- await expect(component).toHaveText('');
-});
diff --git a/examples/ct-react-vite/src/components/EmptyFragment.story.tsx b/examples/ct-react-vite/src/components/EmptyFragment.story.tsx
deleted file mode 100644
index 95ba94b540852..0000000000000
--- a/examples/ct-react-vite/src/components/EmptyFragment.story.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import EmptyFragment from './EmptyFragment';
-
-export const Default = () => ;
diff --git a/examples/ct-react-vite/src/components/EmptyFragment.tsx b/examples/ct-react-vite/src/components/EmptyFragment.tsx
deleted file mode 100644
index b424841462281..0000000000000
--- a/examples/ct-react-vite/src/components/EmptyFragment.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-export default function EmptyFragment(props: unknown) {
- Object.assign(window, { props });
- return <>{[]}>;
-}
diff --git a/examples/ct-react-vite/src/components/Fetcher.spec.tsx b/examples/ct-react-vite/src/components/Fetcher.spec.tsx
deleted file mode 100644
index e6a9fb3302d6f..0000000000000
--- a/examples/ct-react-vite/src/components/Fetcher.spec.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-// Network mocking is standard Playwright routing — set the route before mounting.
-test('mock a fetch with page.route', async ({ mount, page }) => {
- await page.route('**/data.json', route => route.fulfill({ json: { name: 'John Doe' } }));
- const component = await mount('components/Fetcher/Default');
- await expect(component.getByTestId('name')).toHaveText('John Doe');
-});
diff --git a/examples/ct-react-vite/src/components/Fetcher.story.tsx b/examples/ct-react-vite/src/components/Fetcher.story.tsx
deleted file mode 100644
index 8613c48ed35ee..0000000000000
--- a/examples/ct-react-vite/src/components/Fetcher.story.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import Fetcher from './Fetcher';
-
-export const Default = () => ;
diff --git a/examples/ct-react-vite/src/components/Fetcher.tsx b/examples/ct-react-vite/src/components/Fetcher.tsx
deleted file mode 100644
index 2f1322d148c3d..0000000000000
--- a/examples/ct-react-vite/src/components/Fetcher.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { useEffect, useState } from "react"
-
-export default function Fetcher() {
- const [data, setData] = useState<{ name: string }>({ name: '' });
- const [fetched, setFetched] = useState(false);
-
- useEffect(() => {
- const doFetch = async () => {
- try {
- const response = await fetch('/data.json');
- setData(await response.json());
- } catch {
- setData({ name: '' });
- }
- setFetched(true);
- }
-
- if (!fetched)
- doFetch();
- }, [fetched, setFetched, setData]);
-
- return
-
{data.name}
-
-
-
;
-}
diff --git a/examples/ct-react-vite/src/components/MultiRoot.spec.tsx b/examples/ct-react-vite/src/components/MultiRoot.spec.tsx
deleted file mode 100644
index 61705e0fa282a..0000000000000
--- a/examples/ct-react-vite/src/components/MultiRoot.spec.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-test('render a multi-root component', async ({ mount, page }) => {
- await mount('components/MultiRoot/Default');
- await expect(page.locator('#root')).toContainText('root 1');
- await expect(page.locator('#root')).toContainText('root 2');
-});
-
-test('unmount a multi-root component', async ({ mount, page }) => {
- const component = await mount('components/MultiRoot/Default');
- await expect(page.locator('#root')).toContainText('root 1');
- await expect(page.locator('#root')).toContainText('root 2');
- await component.unmount();
- await expect(page.locator('#root')).not.toContainText('root 1');
-});
diff --git a/examples/ct-react-vite/src/components/MultiRoot.story.tsx b/examples/ct-react-vite/src/components/MultiRoot.story.tsx
deleted file mode 100644
index 8defa2057977f..0000000000000
--- a/examples/ct-react-vite/src/components/MultiRoot.story.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import MultiRoot from './MultiRoot';
-
-export const Default = () => ;
diff --git a/examples/ct-react-vite/src/components/MultiRoot.tsx b/examples/ct-react-vite/src/components/MultiRoot.tsx
deleted file mode 100644
index f29e397c0fe54..0000000000000
--- a/examples/ct-react-vite/src/components/MultiRoot.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-export default function MultiRoot() {
- return <>
- root 1
- root 2
- >
-}
diff --git a/examples/ct-react-vite/src/components/MultipleChildren.spec.tsx b/examples/ct-react-vite/src/components/MultipleChildren.spec.tsx
deleted file mode 100644
index e6ca55f961310..0000000000000
--- a/examples/ct-react-vite/src/components/MultipleChildren.spec.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-test('render named children', async ({ mount }) => {
- const component = await mount('components/MultipleChildren/Default');
- await expect(component).toContainText('Header');
- await expect(component).toContainText('Main Content');
- await expect(component).toContainText('Footer');
-});
diff --git a/examples/ct-react-vite/src/components/MultipleChildren.story.tsx b/examples/ct-react-vite/src/components/MultipleChildren.story.tsx
deleted file mode 100644
index 4ea9ac411a3f1..0000000000000
--- a/examples/ct-react-vite/src/components/MultipleChildren.story.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import MultipleChildren from './MultipleChildren';
-
-export const Default = () =>
- Header
- Main Content
- Footer
-;
diff --git a/examples/ct-react-vite/src/components/MultipleChildren.tsx b/examples/ct-react-vite/src/components/MultipleChildren.tsx
deleted file mode 100644
index 63bd0104c62f5..0000000000000
--- a/examples/ct-react-vite/src/components/MultipleChildren.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-
-type MultipleChildrenProps = {
- children?: [any, any, any];
-}
-
-export default function MultipleChildren(props: MultipleChildrenProps) {
- return
-
- {props.children?.at(0)}
-
-
- {props.children?.at(1)}
-
-
-
-}
diff --git a/examples/ct-react-vite/src/components/TitleWithFont.css b/examples/ct-react-vite/src/components/TitleWithFont.css
deleted file mode 100644
index 9fc7e0a7c056e..0000000000000
--- a/examples/ct-react-vite/src/components/TitleWithFont.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.title-with-font {
- font-family: pwtest-iconfont, sans-serif;
-}
diff --git a/examples/ct-react-vite/src/components/TitleWithFont.spec.tsx b/examples/ct-react-vite/src/components/TitleWithFont.spec.tsx
deleted file mode 100644
index a5ea2761ffe58..0000000000000
--- a/examples/ct-react-vite/src/components/TitleWithFont.spec.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-// The gallery imports the app's global CSS, so the story's @font-face triggers a real font load.
-test('load a web font', async ({ mount, page }) => {
- const promise = page.waitForEvent('requestfinished', r => r.url().includes('iconfont'));
- await mount('components/TitleWithFont/Default');
- const request = await promise;
- const response = await request.response();
- const body = await response!.body();
- expect(body.length).toBe(348);
-});
diff --git a/examples/ct-react-vite/src/components/TitleWithFont.story.tsx b/examples/ct-react-vite/src/components/TitleWithFont.story.tsx
deleted file mode 100644
index b684d31300996..0000000000000
--- a/examples/ct-react-vite/src/components/TitleWithFont.story.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import TitleWithFont from './TitleWithFont';
-
-export const Default = () => ;
diff --git a/examples/ct-react-vite/src/components/TitleWithFont.tsx b/examples/ct-react-vite/src/components/TitleWithFont.tsx
deleted file mode 100644
index ad783205c3c19..0000000000000
--- a/examples/ct-react-vite/src/components/TitleWithFont.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import './TitleWithFont.css';
-
-export default function TitleWithFont() {
- return +-
-}
diff --git a/examples/ct-react-vite/src/main.tsx b/examples/ct-react-vite/src/main.tsx
deleted file mode 100644
index f3fe4a896d131..0000000000000
--- a/examples/ct-react-vite/src/main.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as React from 'react';
-import { createRoot } from 'react-dom/client';
-import { BrowserRouter } from 'react-router-dom';
-import App from './App';
-import './assets/index.css';
-
-createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-);
diff --git a/examples/ct-react-vite/src/pages/DashboardPage.tsx b/examples/ct-react-vite/src/pages/DashboardPage.tsx
deleted file mode 100644
index 64307e87f34d3..0000000000000
--- a/examples/ct-react-vite/src/pages/DashboardPage.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DashboardPage() {
- return Dashboard
-}
diff --git a/examples/ct-react-vite/src/pages/LoginPage.tsx b/examples/ct-react-vite/src/pages/LoginPage.tsx
deleted file mode 100644
index 96adc431edb51..0000000000000
--- a/examples/ct-react-vite/src/pages/LoginPage.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function LoginPage() {
- return Login
-}
diff --git a/examples/ct-react-vite/src/vite-env.d.ts b/examples/ct-react-vite/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe2a0061..0000000000000
--- a/examples/ct-react-vite/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/examples/ct-react-vite/tsconfig.json b/examples/ct-react-vite/tsconfig.json
deleted file mode 100644
index c84ccd8aca32b..0000000000000
--- a/examples/ct-react-vite/tsconfig.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "compilerOptions": {
- "target": "ESNext",
- "useDefineForClassFields": true,
- "lib": ["DOM", "DOM.Iterable", "ESNext"],
- "skipLibCheck": true,
- "allowSyntheticDefaultImports": true,
- "strict": true,
- "module": "ESNext",
- "moduleResolution": "Bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "types": ["vite/client"]
- },
- "include": ["src", "playwright"]
-}
diff --git a/examples/ct-react-vite/vite.config.ts b/examples/ct-react-vite/vite.config.ts
deleted file mode 100644
index b1b5f91e5ffd7..0000000000000
--- a/examples/ct-react-vite/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-
-// https://vitejs.dev/config/
-export default defineConfig({
- plugins: [react()]
-})
diff --git a/package.json b/package.json
index cd2015d4faa7d..ac8a8bfebb4d3 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,8 @@
"itest": "playwright test --config=tests/installation/playwright.config.ts",
"stest": "playwright test --config=tests/stress/playwright.config.ts",
"biditest": "playwright test --config=tests/bidi/playwright.config.ts",
+ "test-html-reporter": "playwright test --config=packages/html-reporter",
+ "test-web": "playwright test --config=packages/web",
"ttest": "node ./tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli test --config=tests/playwright-test/playwright.config.ts",
"ct": "playwright test tests/components/test-all.spec.js --reporter=list",
"test": "playwright test --config=tests/library/playwright.config.ts",
diff --git a/packages/html-reporter/playwright.config.ts b/packages/html-reporter/playwright.config.ts
new file mode 100644
index 0000000000000..d15ded9a00656
--- /dev/null
+++ b/packages/html-reporter/playwright.config.ts
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import path from 'path';
+import url from 'url';
+import { devices, defineConfig } from '@playwright/test';
+
+const dirname = path.dirname(url.fileURLToPath(import.meta.url));
+const outputDir = path.join(dirname, '..', '..', 'test-results');
+
+export default defineConfig({
+ testDir: 'src',
+ outputDir,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? [
+ ['dot'],
+ ['json', { outputFile: path.join(outputDir, 'report.json') }],
+ ['blob', { outputDir: path.join(dirname, '..', '..', 'blob-report') }],
+ ['../../tests/config/parquetReporter.ts'],
+ ] : [
+ ['html', { open: 'on-failure' }]
+ ],
+ tag: process.env.PW_TAG,
+ use: {
+ baseURL: 'http://localhost:3101/playwright/gallery/index.html',
+ serviceWorkers: 'block',
+ reuseContext: true,
+ trace: 'on-first-retry',
+ },
+ projects: [{
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ }],
+ webServer: {
+ command: 'npx vite --port 3101 --strictPort',
+ url: 'http://localhost:3101/playwright/gallery/index.html',
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/packages/html-reporter/playwright/gallery/index.html b/packages/html-reporter/playwright/gallery/index.html
new file mode 100644
index 0000000000000..0842528ca8989
--- /dev/null
+++ b/packages/html-reporter/playwright/gallery/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ Component Gallery
+
+
+
+
+
+
+
diff --git a/packages/html-reporter/playwright/gallery/main.tsx b/packages/html-reporter/playwright/gallery/main.tsx
new file mode 100644
index 0000000000000..38120efe0cc83
--- /dev/null
+++ b/packages/html-reporter/playwright/gallery/main.tsx
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { flushSync } from 'react-dom';
+import { createRoot, type Root } from 'react-dom/client';
+import '../../src/theme.css';
+
+const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
+const storyId = (file: string) => file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
+
+async function resolveStory(id: string): Promise | undefined> {
+ const sep = id.lastIndexOf('/');
+ const [path, name] = [id.slice(0, sep), id.slice(sep + 1)];
+ const file = Object.keys(stories).find(f => storyId(f) === path || storyId(f).endsWith('/' + path));
+ const mod = (file && await stories[file]()) as Record | undefined;
+ return mod?.[name] ?? mod?.default;
+}
+
+const rootElement = document.getElementById('root')!;
+let root: Root | undefined;
+
+(window as any).mount = async ({ story, props }: { story: string, props?: Record }) => {
+ const Story = await resolveStory(story);
+ if (!Story)
+ throw new Error(`Unknown story: ${story}`);
+ // Reuse the root so that update() reconciles and preserves state.
+ root ??= createRoot(rootElement);
+ // flushSync so that a render error rejects the promise instead of being swallowed.
+ flushSync(() => root!.render());
+};
+
+(window as any).unmount = async () => {
+ root?.unmount();
+ root = undefined;
+};
diff --git a/packages/html-reporter/src/DEPS.list b/packages/html-reporter/src/DEPS.list
index 058dd4d82b631..87831ba08b5a2 100644
--- a/packages/html-reporter/src/DEPS.list
+++ b/packages/html-reporter/src/DEPS.list
@@ -2,14 +2,11 @@
@web/**
@isomorphic/**
-[chip.spec.tsx]
+[chip.spec.ts]
***
-[headerView.spec.tsx]
+[headerView.spec.ts]
***
-[imageDiffView.spec.tsx]
-***
-
-[testCaseView.spec.tsx]
+[testCaseView.spec.ts]
***
diff --git a/packages/html-reporter/src/chip.spec.ts b/packages/html-reporter/src/chip.spec.ts
new file mode 100644
index 0000000000000..770ce94722157
--- /dev/null
+++ b/packages/html-reporter/src/chip.spec.ts
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, test } from '@playwright/test';
+
+test.use({ viewport: { width: 500, height: 500 } });
+
+test('expand collapse', async ({ mount }) => {
+ const component = await mount('chip/Auto');
+ await expect(component.getByText('Chip body')).toBeVisible();
+ await component.getByText('Title').click();
+ await expect(component.getByText('Chip body')).not.toBeVisible();
+ await component.getByText('Title').click();
+ await expect(component.getByText('Chip body')).toBeVisible();
+});
+
+test('render long title', async ({ mount }) => {
+ const title = 'Extremely long title. '.repeat(10);
+ const component = await mount('chip/Auto', { header: title });
+ await expect(component).toContainText('Extremely long title.');
+ await expect(component.getByText('Extremely long title.')).toHaveAttribute('title', title);
+});
+
+test('setExpanded is called', async ({ mount }) => {
+ const component = await mount('chip/Stateful');
+ await component.getByText('Title').click();
+ await expect(component.getByTestId('expanded')).toHaveValue('true');
+ await component.getByText('Title').click();
+ await expect(component.getByTestId('expanded')).toHaveValue('false');
+});
+
+test('body render prop is rendered', async ({ mount }) => {
+ const component = await mount('chip/WithBody');
+ await expect(component.getByText('Body from render prop')).toBeVisible();
+ await expect(component.getByText('Chip children')).toBeVisible();
+});
+
+test('setExpanded should work', async ({ mount }) => {
+ const component = await mount('chip/AutoCollapsed');
+ await component.getByText('Title').click();
+ await expect(component).toMatchAriaSnapshot(`
+ - button "Title" [expanded]
+ - region: Body
+ `);
+});
diff --git a/packages/html-reporter/src/chip.story.tsx b/packages/html-reporter/src/chip.story.tsx
new file mode 100644
index 0000000000000..5945cfdb459ca
--- /dev/null
+++ b/packages/html-reporter/src/chip.story.tsx
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as React from 'react';
+import { AutoChip, Chip } from './chip';
+
+export const Auto = ({ header }: { header?: string }) =>
+ Chip body;
+
+export const AutoCollapsed = () =>
+ Body;
+
+export const Stateful = () => {
+ const [expanded, setExpanded] = React.useState(false);
+ return <>
+
+
+ >;
+};
+
+export const WithBody = () =>
+ 'Body from render prop'}>Chip children;
diff --git a/packages/html-reporter/src/headerView.spec.ts b/packages/html-reporter/src/headerView.spec.ts
new file mode 100644
index 0000000000000..67262ab994461
--- /dev/null
+++ b/packages/html-reporter/src/headerView.spec.ts
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, test } from '@playwright/test';
+
+test.use({ viewport: { width: 720, height: 200 } });
+
+test('should render counters', async ({ mount }) => {
+ const component = await mount('headerView/Default');
+ await expect(component.locator('a', { hasText: 'All' }).locator('.counter')).toHaveText('90');
+ await expect(component.locator('a', { hasText: 'Passed' }).locator('.counter')).toHaveText('42');
+ await expect(component.locator('a', { hasText: 'Failed' }).locator('.counter')).toHaveText('31');
+ await expect(component.locator('a', { hasText: 'Flaky' }).locator('.counter')).toHaveText('17');
+ await expect(component.locator('a', { hasText: 'Skipped' }).locator('.counter')).toHaveText('10');
+ await expect(component).toMatchAriaSnapshot(`
+ - navigation:
+ - link "All90"
+ - link "Passed42"
+ - link "Failed31"
+ - link "Flaky17"
+ - link "Skipped10"
+ `);
+});
+
+test('should toggle filters', async ({ page, mount }) => {
+ const component = await mount('headerView/Default');
+ const filterText = component.getByTestId('filter-text');
+ await component.locator('a', { hasText: 'All' }).click();
+ await expect(filterText).toHaveValue('');
+ await component.locator('a', { hasText: 'Passed' }).click();
+ await expect(page).toHaveURL(/#\?q=s(:|%3A)passed/);
+ await expect(filterText).toHaveValue('s:passed ');
+ await component.locator('a', { hasText: 'Failed' }).click();
+ await expect(page).toHaveURL(/#\?q=s(:|%3A)failed/);
+ await expect(filterText).toHaveValue('s:failed ');
+ await component.locator('a', { hasText: 'Flaky' }).click();
+ await expect(page).toHaveURL(/#\?q=s(:|%3A)flaky/);
+ await expect(filterText).toHaveValue('s:flaky ');
+ await component.locator('a', { hasText: 'Skipped' }).click();
+ await expect(page).toHaveURL(/#\?q=s(:|%3A)skipped/);
+ await expect(filterText).toHaveValue('s:skipped ');
+ await component.getByRole('textbox').fill('annot:annotation type=annotation description');
+ await expect(filterText).toHaveValue('annot:annotation type=annotation description');
+});
diff --git a/packages/html-reporter/src/headerView.story.tsx b/packages/html-reporter/src/headerView.story.tsx
new file mode 100644
index 0000000000000..d4c61c327b7a4
--- /dev/null
+++ b/packages/html-reporter/src/headerView.story.tsx
@@ -0,0 +1,36 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as React from 'react';
+import { GlobalFilterView } from './headerView';
+import { SearchParamsProvider } from './links';
+
+const stats = {
+ total: 100,
+ expected: 42,
+ unexpected: 31,
+ flaky: 17,
+ skipped: 10,
+ ok: false,
+};
+
+export const Default = () => {
+ const [filterText, setFilterText] = React.useState('');
+ return
+
+
+ ;
+};
diff --git a/packages/html-reporter/src/testCaseView.spec.ts b/packages/html-reporter/src/testCaseView.spec.ts
new file mode 100644
index 0000000000000..c9656d7c1dcda
--- /dev/null
+++ b/packages/html-reporter/src/testCaseView.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, test } from '@playwright/test';
+
+test.use({ viewport: { width: 800, height: 600 } });
+
+test('should render test case', async ({ mount }) => {
+ const component = await mount('testCaseView/Default');
+ await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
+ await expect(component.getByText('Hidden annotation')).toBeHidden();
+ await component.getByText('Annotations').click();
+ await expect(component.getByText('Annotation text')).not.toBeVisible();
+ await expect(component.getByText('Outer step')).toBeVisible();
+ await expect(component.getByText('Inner step')).not.toBeVisible();
+ await component.getByText('Outer step').click();
+ await expect(component.getByText('Inner step')).toBeVisible();
+ await expect(component.getByText('test.spec.ts:42')).toBeVisible();
+ await expect(component.getByText('My test')).toBeVisible();
+});
+
+test('should render copy buttons for annotations', async ({ mount, page, context }) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+
+ const component = await mount('testCaseView/Default');
+ await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
+ await component.getByText('Annotation text', { exact: false }).first().hover();
+ await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible();
+ await component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first().click();
+ const handle = await page.evaluateHandle(() => navigator.clipboard.readText());
+ const clipboardContent = await handle.jsonValue();
+ expect(clipboardContent).toBe('Annotation text');
+});
+
+test('should correctly render links in annotations', async ({ mount }) => {
+ const component = await mount('testCaseView/AnnotationLinks');
+
+ const firstLink = component.getByText('https://playwright.dev/docs/intro').first();
+ await expect(firstLink).toBeVisible();
+ await expect(firstLink).toHaveAttribute('href', 'https://playwright.dev/docs/intro');
+
+ const secondLink = component.getByText('https://playwright.dev/docs/api/class-playwright').first();
+ await expect(secondLink).toBeVisible();
+ await expect(secondLink).toHaveAttribute('href', 'https://playwright.dev/docs/api/class-playwright');
+
+ const thirdLink = component.getByText('https://github.com/microsoft/playwright/issues/23180').first();
+ await expect(thirdLink).toBeVisible();
+ await expect(thirdLink).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/23180');
+
+ const fourthLink = component.getByText('https://github.com/microsoft/playwright/issues/23181').first();
+ await expect(fourthLink).toBeVisible();
+ await expect(fourthLink).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/23181');
+});
+
+test('should correctly render links in attachments', async ({ mount }) => {
+ const component = await mount('testCaseView/AttachmentLinks');
+ await component.getByText('first attachment').click();
+ const body = component.getByText('The body with https://playwright.dev/docs/intro link');
+ await expect(body).toBeVisible();
+ await expect(body.locator('a').filter({ hasText: 'playwright.dev' })).toHaveAttribute('href', 'https://playwright.dev/docs/intro');
+ await expect(body.locator('a').filter({ hasText: 'github.com' })).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
+ await expect(component).toMatchAriaSnapshot(`
+ - link "https://playwright.dev/docs/intro"
+ - link "https://github.com/microsoft/playwright/issues/31284"
+ `);
+});
+
+test('should correctly render links in attachment name', async ({ mount }) => {
+ const component = await mount('testCaseView/AttachmentLinks');
+ const link = component.getByText('attachment with inline link').locator('a');
+ await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
+ await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284');
+ await expect(component).toMatchAriaSnapshot(`
+ - link /https:\\/\\/github\\.com\\/microsoft\\/playwright\\/issues\\/\\d+/
+ `);
+});
+
+test('should correctly render prev and next', async ({ mount }) => {
+ const component = await mount('testCaseView/PrevNext');
+ await expect(component).toMatchAriaSnapshot(`
+ - text: group
+ - link "« previous"
+ - link "next »"
+ - text: "My test test.spec.ts:42 10ms chromium"
+ `);
+});
+
+test('total duration is selected run duration', async ({ mount, page }) => {
+ const component = await mount('testCaseView/TwoAttempts');
+ await expect(component).toMatchAriaSnapshot(`
+ - text: "My test test.spec.ts:42 200ms chromium"
+ - tablist:
+ - tab "Run 50ms"
+ - 'tab "Retry #1 150ms"'
+ `);
+ await page.getByRole('tab', { name: 'Run' }).click();
+ await expect(component).toMatchAriaSnapshot(`
+ - text: "My test test.spec.ts:42 200ms chromium"
+ `);
+ await page.getByRole('tab', { name: 'Retry' }).click();
+ await expect(component).toMatchAriaSnapshot(`
+ - text: "My test test.spec.ts:42 200ms chromium"
+ `);
+});
diff --git a/packages/html-reporter/src/testCaseView.story.tsx b/packages/html-reporter/src/testCaseView.story.tsx
new file mode 100644
index 0000000000000..ba2f9066c5d9a
--- /dev/null
+++ b/packages/html-reporter/src/testCaseView.story.tsx
@@ -0,0 +1,152 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { LoadedReport } from './loadedReport';
+import { TestCaseView } from './testCaseView';
+import type { HTMLReport, TestCase, TestCaseSummary, TestResult } from './types';
+
+const report: LoadedReport = {
+ json: () => ({ projectNames: ['chromium', 'webkit'] } as HTMLReport),
+ entry: async () => undefined,
+};
+
+const result: TestResult = {
+ retry: 0,
+ workerIndex: 0,
+ startTime: new Date(0).toUTCString(),
+ duration: 100,
+ errors: [],
+ steps: [{
+ title: 'Outer step',
+ startTime: new Date(100).toUTCString(),
+ duration: 10,
+ location: { file: 'test.spec.ts', line: 62, column: 0 },
+ count: 1,
+ steps: [{
+ title: 'Inner step',
+ startTime: new Date(200).toUTCString(),
+ duration: 10,
+ location: { file: 'test.spec.ts', line: 82, column: 0 },
+ steps: [],
+ attachments: [],
+ count: 1,
+ }],
+ attachments: [],
+ }],
+ annotations: [
+ { type: 'annotation', description: 'Annotation text' },
+ { type: 'annotation', description: 'Another annotation text' },
+ { type: '_annotation', description: 'Hidden annotation' },
+ ],
+ attachments: [],
+ status: 'passed',
+};
+
+const testCase: TestCase = {
+ testId: 'testid',
+ title: 'My test',
+ path: [],
+ projectName: 'chromium',
+ location: { file: 'test.spec.ts', line: 42, column: 0 },
+ annotations: result.annotations,
+ tags: [],
+ outcome: 'expected',
+ duration: 200,
+ ok: true,
+ results: [result]
+};
+
+const annotationLinkRenderingTestCase: TestCase = {
+ ...testCase,
+ duration: 10,
+ annotations: [],
+ results: [{
+ ...result,
+ annotations: [
+ { type: 'more info', description: 'read https://playwright.dev/docs/intro and https://playwright.dev/docs/api/class-playwright' },
+ { type: 'related issues', description: 'https://github.com/microsoft/playwright/issues/23180, https://github.com/microsoft/playwright/issues/23181' },
+ ]
+ }]
+};
+
+const resultWithAttachment: TestResult = {
+ ...result,
+ steps: [{
+ title: 'Outer step',
+ startTime: new Date(100).toUTCString(),
+ duration: 10,
+ location: { file: 'test.spec.ts', line: 62, column: 0 },
+ count: 1,
+ steps: [],
+ attachments: [1],
+ }],
+ attachments: [{
+ name: 'first attachment',
+ body: 'The body with https://playwright.dev/docs/intro link and https://github.com/microsoft/playwright/issues/31284.',
+ contentType: 'text/plain'
+ }, {
+ name: 'attachment with inline link https://github.com/microsoft/playwright/issues/31284',
+ contentType: 'text/plain'
+ }],
+ annotations: [],
+};
+
+const attachmentLinkRenderingTestCase: TestCase = {
+ ...testCase,
+ path: ['group'],
+ duration: 10,
+ annotations: [],
+ results: [resultWithAttachment]
+};
+
+const testCaseSummary: TestCaseSummary = {
+ ...attachmentLinkRenderingTestCase,
+ testId: 'nextTestId',
+ title: 'next test',
+ path: [],
+};
+
+const testCaseWithTwoAttempts: TestCase = {
+ ...testCase,
+ results: [
+ {
+ ...result,
+ errors: [{ message: 'Error message' }],
+ status: 'failed',
+ duration: 50,
+ },
+ {
+ ...result,
+ duration: 150,
+ status: 'passed',
+ },
+ ],
+};
+
+export const Default = () =>
+ ;
+
+export const AnnotationLinks = () =>
+ ;
+
+export const AttachmentLinks = () =>
+ ;
+
+export const PrevNext = () =>
+ ;
+
+export const TwoAttempts = () =>
+ ;
diff --git a/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md
index 54e8bc218efb3..a475aefbf2f4e 100644
--- a/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md
+++ b/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md
@@ -11,9 +11,9 @@ Test components with regular Playwright e2e tests against a small **story galler
- 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 you implement to `references/gallery-spec.md`: it exposes `window.mount(params)` / `window.unmount()` that render a story — resolved from your story files (e.g. with `import.meta.glob`) — into `#root`. It is framework-specific and yours to own — there is no template to copy for it.
-- Tests are plain Playwright tests. The built-in **`mount(storyId, props?)` fixture** (from `@playwright/test`) drives the gallery's `window.mount` and returns a `Locator` for the mounted component (the single element rendered into `#root`, or `#root` itself when the story renders a fragment) — so specs read just like the old component tests. Nothing to scaffold for it.
+- Tests are plain Playwright tests. The built-in **`mount(storyId, props?)` fixture** (from `@playwright/test`) drives the gallery's `window.mount` and returns a `Locator` for the gallery root (`#root`). Scope the queries from there — `component.getByRole('button').click()`, not `component.click()`. Nothing to scaffold for it.
-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). `mount(id, props)` passes `props` to the component, and props may include **callbacks** — the component calls them in the browser and your test-side function runs in Node.
+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). Where the component takes callbacks, the story creates the state, provides the callbacks and records the state into a hidden form for the test to assert on. `mount(id, props)` passes plain serializable `props` to the story.
## Setup workflow
@@ -54,38 +54,31 @@ Examples are React; the Vue equivalents differ only in story syntax.
### Callbacks and events
-**Pass the callback straight to `mount`** — it runs in Node, so you assert directly on what the component called it with:
-
-```ts
-test('fires onSubmit with the form data', async ({ mount }) => {
- const calls: any[] = [];
- const component = await mount('components/Form/Default', { onSubmit: (data: any) => calls.push(data) });
- await component.getByRole('button', { name: 'Save' }).click();
- expect(calls).toEqual([{ name: 'Ada' }]);
-});
-```
-
-Callbacks may be async and return a value the component `await`s — the return travels back from Node:
-
-```ts
-await mount('components/Field/Default', { validate: async (v: string) => v.length > 0 });
-```
-
-**Or record the effect in the DOM** inside the story — handy when you also want the state visible when eyeballing the gallery:
+**The story owns the state and provides the callbacks.** Where the component takes callbacks, create the state inside the story, wire the callbacks to it, and record the state into a hidden form next to the component. Tests perform operations and assert on the recorded values:
```tsx
-export const CountsClicks = () => {
- const [clicks, setClicks] = useState(0);
+export const Stateful = () => {
+ const [expanded, setExpanded] = useState(false);
return <>
-