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 <> -
- logo - {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/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 = () => - - ; -} 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)} -
-
- {props.children?.at(2)} -
-
-} 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 <> -