참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"name": "ct-react-vite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.6.1"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^4.2.1",
"msw": "^2.3.0",
"typescript": "^5.2.2",
"vite": "^5.2.8"
}
}

View File

@@ -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 { defineConfig, devices } from '@playwright/experimental-ct-react';
import path from 'path';
import { fileURLToPath } from 'url';
export default defineConfig({
testDir: 'tests',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'html' : 'line',
use: {
trace: 'on-first-retry',
ctViteConfig: {
build: {
assetsInlineLimit: 0,
},
resolve: {
alias: {
'@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src'),
}
}
}
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
import { BrowserRouter } from 'react-router-dom';
import '../src/assets/index.css';
export type HooksConfig = {
route?: string;
routing?: boolean;
}
beforeMount<HooksConfig>(async ({ hooksConfig, App }) => {
console.log(`Before mount: ${JSON.stringify(hooksConfig)}`);
if (hooksConfig?.routing)
return <BrowserRouter><App /></BrowserRouter>;
});
afterMount<HooksConfig>(async () => {
console.log(`After mount`);
});

View File

@@ -0,0 +1,21 @@
import { Routes, Route, Link } from 'react-router-dom';
import logo from './assets/logo.svg';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
export default function App({ title }: { title?: string }) {
return <>
<header>
<img src={logo} alt="logo" width={125} height={125} />
{title && <h1>{title}</h1>}
<Link to="/">Login</Link>
<Link to="/dashboard">Dashboard</Link>
</header>
<Routes>
<Route path="/">
<Route index element={<LoginPage />} />
<Route path="dashboard" element={<DashboardPage />} />
</Route>
</Routes>
</>
}

View File

@@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,29 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
@media (prefers-color-scheme: light) {
:root {
color: #e3e3e3;
background-color: #1b1b1d;
}
}
@font-face {
font-family: 'pwtest-iconfont';
/* See tests/assets/webfont/README.md */
src: url('./iconfont.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,13 @@
import { ButtonHTMLAttributes } from "react";
type ButtonProps = {
title: string;
onClick?(props: string): void;
className?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>;
export default function Button({ onClick, title, ...attributes }: ButtonProps) {
return <button {...attributes} onClick={() => onClick?.('hello')}>
{title}
</button>
}

View File

@@ -0,0 +1,7 @@
import type { PropsWithChildren } from 'react';
type DefaultChildrenProps = PropsWithChildren<{}>;
export default function CheckChildrenProp(props: DefaultChildrenProps) {
return <>{'children' in props ? props.children : 'No Children'}</>
}

View File

@@ -0,0 +1,9 @@
import { ReactNode } from "react";
type ComponentAsProp = {
component: ReactNode[] | ReactNode;
};
export function ComponentAsProp({ component }: ComponentAsProp) {
return <div>{component}</div>
}

View File

@@ -0,0 +1,25 @@
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 <button onClick={() => props.onClick?.('hello')}>
<span data-testid="props">{ props.count }</span>
<span data-testid="remount-count">{ remountCount }</span>
{ props.children }
</button>
}

View File

@@ -0,0 +1,15 @@
type DefaultChildrenProps = {
children?: any;
}
export default function DefaultChildren(props: DefaultChildrenProps) {
return <div>
<h1>Welcome!</h1>
<main>
{props.children}
</main>
<footer>
Thanks for visiting.
</footer>
</div>
}

View File

@@ -0,0 +1,4 @@
export default function EmptyFragment(props: unknown) {
Object.assign(window, { props });
return <>{[]}</>;
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from "react"
export default function Fetcher() {
const [data, setData] = useState<{ name: string }>({ name: '<none>' });
const [fetched, setFetched] = useState(false);
useEffect(() => {
const doFetch = async () => {
try {
const response = await fetch('/data.json');
setData(await response.json());
} catch {
setData({ name: '<error>' });
}
setFetched(true);
}
if (!fetched)
doFetch();
}, [fetched, setFetched, setData]);
return <div>
<div data-testid='name'>{data.name}</div>
<button onClick={() => {
setFetched(false);
setData({ name: '<none>' });
}}>Reset</button>
<button onClick={() => {
fetch('/post', { method: 'POST', body: 'hello from the page' });
}}>Post it</button>
</div>;
}

View File

@@ -0,0 +1,6 @@
export default function MultiRoot() {
return <>
<div>root 1</div>
<div>root 2</div>
</>
}

View File

@@ -0,0 +1,18 @@
type MultipleChildrenProps = {
children?: [any, any, any];
}
export default function MultipleChildren(props: MultipleChildrenProps) {
return <div>
<header>
{props.children?.at(0)}
</header>
<main>
{props.children?.at(1)}
</main>
<footer>
{props.children?.at(2)}
</footer>
</div>
}

View File

@@ -0,0 +1,3 @@
.title-with-font {
font-family: pwtest-iconfont, sans-serif;
}

View File

@@ -0,0 +1,5 @@
import './TitleWithFont.css';
export default function TitleWithFont() {
return <div className='title-with-font'>+-</div>
}

View File

@@ -0,0 +1,13 @@
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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,3 @@
export default function DashboardPage() {
return <main>Dashboard</main>
}

View File

@@ -0,0 +1,3 @@
export default function LoginPage() {
return <main>Login</main>
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,28 @@
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '@/components/Button';
import DefaultChildren from '@/components/DefaultChildren';
test('execute callback when the button is clicked', async ({ mount }) => {
const messages: string[] = [];
const component = await mount(
<Button
title="Submit"
onClick={(data) => {
messages.push(data);
}}
></Button>
);
await component.click();
expect(messages).toEqual(['hello']);
});
test('execute callback when a child node is clicked', async ({ mount }) => {
let clickFired = false;
const component = await mount(
<DefaultChildren>
<span onClick={() => (clickFired = true)}>Main Content</span>
</DefaultChildren>
);
await component.getByText('Main Content').click();
expect(clickFired).toBeTruthy();
});

View File

@@ -0,0 +1,66 @@
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '@/components/Button';
import CheckChildrenProp from '@/components/CheckChildrenProp';
import DefaultChildren from '@/components/DefaultChildren';
import MultipleChildren from '@/components/MultipleChildren';
test('render a default child', async ({ mount }) => {
const component = await mount(
<DefaultChildren>Main Content</DefaultChildren>
);
await expect(component).toContainText('Main Content');
});
test('render a component as child', async ({ mount }) => {
const component = await mount(
<DefaultChildren>
<Button title="Submit" />
</DefaultChildren>
);
await expect(component).toContainText('Submit');
});
test('render multiple children', async ({ mount }) => {
const component = await mount(
<DefaultChildren>
<div data-testid="one">One</div>
<div data-testid="two">Two</div>
</DefaultChildren>
);
await expect(component.getByTestId('one')).toContainText('One');
await expect(component.getByTestId('two')).toContainText('Two');
});
test('render named children', async ({ mount }) => {
const component = await mount(
<MultipleChildren>
<div>Header</div>
<div>Main Content</div>
<div>Footer</div>
</MultipleChildren>
);
await expect(component).toContainText('Header');
await expect(component).toContainText('Main Content');
await expect(component).toContainText('Footer');
});
test('render string as child', async ({ mount }) => {
const component = await mount(<DefaultChildren>{'string'}</DefaultChildren>);
await expect(component).toContainText('string');
});
test('render array as child', async ({ mount }) => {
const component = await mount(<DefaultChildren>{[<h4>{[4]}</h4>,[[<p>[2,3]</p>]]]}</DefaultChildren>);
await expect(component.getByRole('heading', { level: 4 })).toHaveText('4');
await expect(component.getByRole('paragraph')).toHaveText('[2,3]');
});
test('render number as child', async ({ mount }) => {
const component = await mount(<DefaultChildren>{1337}</DefaultChildren>);
await expect(component).toContainText('1337');
});
test('absence of children when children prop is not provided', async ({ mount }) => {
const component = await mount(<CheckChildrenProp />);
await expect(component).toContainText('No Children');
});

View File

@@ -0,0 +1,26 @@
import { test, expect } from '@playwright/experimental-ct-react';
import App from '@/App';
import type { HooksConfig } from '../playwright';
test('navigate to a page by clicking a link', async ({ page, mount }) => {
const component = await mount<HooksConfig>(<App />, {
hooksConfig: { routing: true },
});
await expect(component.getByRole('main')).toHaveText('Login');
await expect(page).toHaveURL('/');
await component.getByRole('link', { name: 'Dashboard' }).click();
await expect(component.getByRole('main')).toHaveText('Dashboard');
await expect(page).toHaveURL('/dashboard');
});
test('update should not reset mount hooks', async ({ page, mount }) => {
const component = await mount<HooksConfig>(<App title='before'/>, {
hooksConfig: { routing: true },
});
await expect(component.getByRole('heading')).toHaveText('before');
await expect(component.getByRole('main')).toHaveText('Login');
await component.update(<App title='after'/>);
await expect(component.getByRole('heading')).toHaveText('after');
await expect(component.getByRole('main')).toHaveText('Login');
});

View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '@/components/Button';
import EmptyFragment from '@/components/EmptyFragment';
import { ComponentAsProp } from '@/components/ComponentAsProp';
import DefaultChildren from '@/components/DefaultChildren';
test('render props', async ({ mount }) => {
const component = await mount(<Button title="Submit" />);
await expect(component).toContainText('Submit');
});
test('render component as props', async ({ mount }) => {
const component = await mount(<ComponentAsProp component={<Button title="Submit" />} />);
await expect(component.getByRole('button', { name: 'submit' })).toBeVisible();
});
test('render jsx array as props', async ({ mount }) => {
const component = await mount(<ComponentAsProp component={[<h4>{[4]}</h4>,[[<p>[2,3]</p>]]]} />);
await expect(component.getByRole('heading', { level: 4 })).toHaveText('4');
await expect(component.getByRole('paragraph')).toHaveText('[2,3]');
});
test('render attributes', async ({ mount }) => {
const component = await mount(<Button className="primary" title="Submit" />);
await expect(component).toHaveClass('primary');
});
test('render an empty component', async ({ mount, page }) => {
const component = await mount(<EmptyFragment />);
expect(await page.evaluate(() => 'props' in window && window.props)).toEqual({});
expect(await component.allTextContents()).toEqual(['']);
expect(await component.textContent()).toBe('');
await expect(component).toHaveText('');
});
function MyInlineComponent({ value }: { value: string }) {
return <>Hello {value}</>;
}
test('render inline component with an error', async ({ mount }) => {
await expect(mount(<MyInlineComponent value="Max" />)).rejects.toThrow('Component "MyInlineComponent" cannot be mounted.');
});
test('render inline component with an error if its nested', async ({ mount }) => {
await expect(mount(<DefaultChildren>
<MyInlineComponent value="Max" />
</DefaultChildren>)).rejects.toThrow('Component "MyInlineComponent" cannot be mounted.');
});
test('render Fragment shorthand notation', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32853' } }, async ({ mount }) => {
const component = await mount(<>Learn React</>);
await expect(component).toContainText('Learn React');
});

View File

@@ -0,0 +1,182 @@
import { test, expect } from '@playwright/experimental-ct-react';
import TitleWithFont from '@/components/TitleWithFont';
import Fetcher from '@/components/Fetcher';
import { http, HttpResponse, passthrough, bypass } from 'msw';
import httpServer from 'http';
import type net from 'net';
test('should load font without routes', async ({ mount, page }) => {
const promise = page.waitForEvent('requestfinished', request => request.url().includes('iconfont'));
await mount(<TitleWithFont />);
const request = await promise;
const response = await request.response();
const body = await response!.body();
expect(body.length).toBe(348);
});
test('should load font with routes', async ({ mount, page }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27294' });
await page.route('**/*.json', r => r.continue());
const promise = page.waitForEvent('requestfinished', request => request.url().includes('iconfont'));
await mount(<TitleWithFont />);
const request = await promise;
const response = await request.response();
const body = await response!.body();
expect(body.length).toBe(348);
});
test.describe('request handlers', () => {
test('should handle requests', async ({ page, mount, router }) => {
let respond: (() => void) = () => {};
const promise = new Promise<void>(f => respond = f);
let postReceived: ((body: string) => void) = () => {};
const postBody = new Promise<string>(f => postReceived = f);
await router.use(
http.get('/data.json', async () => {
await promise;
return HttpResponse.json({ name: 'John Doe' });
}),
http.post('/post', async ({ request }) => {
postReceived(await request.text());
return HttpResponse.text('ok');
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<none>');
respond();
await expect(component.getByTestId('name')).toHaveText('John Doe');
await component.getByRole('button', { name: 'Post it' }).click();
expect(await postBody).toBe('hello from the page');
});
test('should add dynamically', async ({ page, mount, router }) => {
await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
});
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>');
await router.use(
http.get('/data.json', async () => {
return HttpResponse.json({ name: 'John Doe' });
}),
);
await component.getByRole('button', { name: 'Reset' }).click();
await expect(component.getByTestId('name')).toHaveText('John Doe');
});
test('should passthrough', async ({ page, mount, router }) => {
await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
});
await router.use(
http.get('/data.json', async () => {
return passthrough();
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<error>');
});
test('should fallback when nothing is returned', async ({ page, mount, router }) => {
await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
});
let called = false;
await router.use(
http.get('/data.json', async () => {
called = true;
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>');
expect(called).toBe(true);
});
test('should bypass(request)', async ({ page, mount, router }) => {
await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: `<original>` }) });
});
await router.use(
http.get('/data.json', async ({ request }) => {
return await fetch(bypass(request));
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<error>');
});
test('should bypass(url) and get cookies', async ({ page, mount, router, browserName }) => {
let cookie = '';
const server = new httpServer.Server();
server.on('request', (req, res) => {
cookie = req.headers['cookie']!;
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ name: '<server>' }));
});
await new Promise<void>(f => server.listen(0, f));
const port = (server.address() as net.AddressInfo).port;
await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: `<original>` }) });
});
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>');
await page.evaluate(() => document.cookie = 'foo=bar');
await router.use(
http.get('/data.json', async ({ request }) => {
if (browserName !== 'webkit') {
// WebKit does not have cookies while intercepting.
expect(request.headers.get('cookie')).toBe('foo=bar');
}
return await fetch(bypass(`http://localhost:${port}`));
}),
);
await component.getByRole('button', { name: 'Reset' }).click();
await expect(component.getByTestId('name')).toHaveText('<server>');
expect(cookie).toBe('foo=bar');
await new Promise(f => server.close(f));
});
test('should ignore navigation requests', async ({ page, mount, router }) => {
await router.route('**/newpage', async route => {
await route.fulfill({ body: `<div>original</div>`, contentType: 'text/html' });
});
await router.use(
http.get('/newpage', async ({ request }) => {
return new Response(`<div>intercepted</div>`, {
headers: new Headers({ 'Content-Type': 'text/html' }),
});
}),
);
await mount(<div />);
await page.goto('/newpage');
await expect(page.locator('div')).toHaveText('original');
});
test('should throw when calling fetch(bypass) outside of a handler', async ({ page, router, baseURL }) => {
await router.use(http.get('/data.json', async () => {}));
const error = await fetch(bypass(baseURL + '/hello')).catch(e => e);
expect(error.message).toContain(`Cannot call fetch(bypass()) outside of a request handler`);
});
});

View File

@@ -0,0 +1,32 @@
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '@/components/Button';
import MultiRoot from '@/components/MultiRoot';
test('unmount', async ({ page, mount }) => {
const component = await mount(<Button title="Submit" />);
await expect(page.locator('#root')).toContainText('Submit');
await component.unmount();
await expect(page.locator('#root')).not.toContainText('Submit');
});
test('unmount a multi root component', async ({ mount, page }) => {
const component = await mount(<MultiRoot />);
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');
await expect(page.locator('#root')).not.toContainText('root 2');
});
test('unmount twice throws an error', async ({ mount }) => {
const component = await mount(<Button title="Submit" />);
await component.unmount();
await expect(component.unmount()).rejects.toThrowError('Component was not mounted');
});
test('mount then unmount then mount', async ({ mount }) => {
let component = await mount(<Button title="Submit" />);
await component.unmount();
component = await mount(<Button title="Save" />);
await expect(component).toContainText('Save');
});

View File

@@ -0,0 +1,91 @@
import { test, expect } from '@playwright/experimental-ct-react';
import Counter from '@/components/Counter';
import DefaultChildren from '@/components/DefaultChildren';
test('update props without remounting', async ({ mount }) => {
const component = await mount(<Counter count={9001} />);
await expect(component.getByTestId('props')).toContainText('9001');
await component.update(<Counter 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 child props without remounting', async ({ mount }) => {
const component = await mount(<DefaultChildren><Counter count={9001} /></DefaultChildren>);
await expect(component.getByTestId('props')).toContainText('9001');
await component.update(<DefaultChildren><Counter count={1337} /></DefaultChildren>);
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(<Counter />);
const messages: string[] = [];
await component.update(
<Counter
onClick={(message) => {
messages.push(message);
}}
/>
);
await component.click();
expect(messages).toEqual(['hello']);
await expect(component.getByTestId('remount-count')).toContainText('1');
});
test('update child callbacks without remounting', async ({ mount }) => {
const component = await mount(<DefaultChildren><Counter /></DefaultChildren>);
const messages: string[] = [];
await component.update(
<DefaultChildren>
<Counter
onClick={(message) => {
messages.push(message);
}}
/>
</DefaultChildren>
);
await component.getByRole('button').click();
expect(messages).toEqual(['hello']);
await expect(component.getByTestId('remount-count')).toContainText('1');
});
test('update children without remounting', async ({ mount }) => {
const component = await mount(<Counter>Default Slot</Counter>);
await expect(component).toContainText('Default Slot');
await component.update(<Counter>Test Slot</Counter>);
await expect(component).not.toContainText('Default Slot');
await expect(component).toContainText('Test Slot');
await expect(component.getByTestId('remount-count')).toContainText('1');
});
test('update grandchild without remounting', async ({ mount }) => {
const component = await mount(
<DefaultChildren>
<Counter>Default Slot</Counter>
</DefaultChildren>
);
await expect(component.getByRole('button')).toContainText('Default Slot');
await component.update(
<DefaultChildren>
<Counter>Test Slot</Counter>
</DefaultChildren>
);
await expect(component.getByRole('button')).not.toContainText('Default Slot');
await expect(component.getByRole('button')).toContainText('Test Slot');
await expect(component.getByTestId('remount-count')).toContainText('1');
});

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"*": ["_"],
}
},
"include": ["src", "tests"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()]
})