Merge branch 'main' into tar-download-3rd-party-lib
This commit is contained in:
commit
b048d2afeb
|
|
@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t
|
||||||
browser server.
|
browser server.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`].
|
This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`].
|
||||||
:::
|
:::
|
||||||
|
|
||||||
The [Browser] object itself is considered to be disposed and cannot be used anymore.
|
The [Browser] object itself is considered to be disposed and cannot be used anymore.
|
||||||
|
|
|
||||||
|
|
@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v
|
||||||
|
|
||||||
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
||||||
|
|
||||||
|
## async method: Test.step.fail
|
||||||
|
* since: v1.50
|
||||||
|
- returns: <[void]>
|
||||||
|
|
||||||
|
Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**Usage**
|
||||||
|
|
||||||
|
You can declare a test step as failing, so that Playwright ensures it actually fails.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('my test', async ({ page }) => {
|
||||||
|
// ...
|
||||||
|
await test.step.fail('currently failing', async () => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### param: Test.step.fail.title
|
||||||
|
* since: v1.50
|
||||||
|
- `title` <[string]>
|
||||||
|
|
||||||
|
Step name.
|
||||||
|
|
||||||
|
### param: Test.step.fail.body
|
||||||
|
* since: v1.50
|
||||||
|
- `body` <[function]\(\):[Promise]<[any]>>
|
||||||
|
|
||||||
|
Step body.
|
||||||
|
|
||||||
|
### option: Test.step.fail.box
|
||||||
|
* since: v1.50
|
||||||
|
- `box` <boolean>
|
||||||
|
|
||||||
|
Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details.
|
||||||
|
|
||||||
|
### option: Test.step.fail.location
|
||||||
|
* since: v1.50
|
||||||
|
- `location` <[Location]>
|
||||||
|
|
||||||
|
Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown.
|
||||||
|
|
||||||
|
### option: Test.step.fail.timeout
|
||||||
|
* since: v1.50
|
||||||
|
- `timeout` <[float]>
|
||||||
|
|
||||||
|
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
||||||
|
|
||||||
|
## async method: Test.step.fixme
|
||||||
|
* since: v1.50
|
||||||
|
- returns: <[void]>
|
||||||
|
|
||||||
|
Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step.
|
||||||
|
|
||||||
|
**Usage**
|
||||||
|
|
||||||
|
You can declare a test step as failing, so that Playwright ensures it actually fails.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('my test', async ({ page }) => {
|
||||||
|
// ...
|
||||||
|
await test.step.fixme('not yet ready', async () => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### param: Test.step.fixme.title
|
||||||
|
* since: v1.50
|
||||||
|
- `title` <[string]>
|
||||||
|
|
||||||
|
Step name.
|
||||||
|
|
||||||
|
### param: Test.step.fixme.body
|
||||||
|
* since: v1.50
|
||||||
|
- `body` <[function]\(\):[Promise]<[any]>>
|
||||||
|
|
||||||
|
Step body.
|
||||||
|
|
||||||
|
### option: Test.step.fixme.box
|
||||||
|
* since: v1.50
|
||||||
|
- `box` <boolean>
|
||||||
|
|
||||||
|
Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details.
|
||||||
|
|
||||||
|
### option: Test.step.fixme.location
|
||||||
|
* since: v1.50
|
||||||
|
- `location` <[Location]>
|
||||||
|
|
||||||
|
Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown.
|
||||||
|
|
||||||
|
### option: Test.step.fixme.timeout
|
||||||
|
* since: v1.50
|
||||||
|
- `timeout` <[float]>
|
||||||
|
|
||||||
|
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
||||||
|
|
||||||
## method: Test.use
|
## method: Test.use
|
||||||
* since: v1.10
|
* since: v1.10
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import './colors.css';
|
||||||
import './common.css';
|
import './common.css';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
import { clsx } from '@web/uiUtils';
|
import { clsx } from '@web/uiUtils';
|
||||||
import { useAnchor } from './links';
|
import { type AnchorID, useAnchor } from './links';
|
||||||
|
|
||||||
export const Chip: React.FC<{
|
export const Chip: React.FC<{
|
||||||
header: JSX.Element | string,
|
header: JSX.Element | string,
|
||||||
|
|
@ -53,7 +53,7 @@ export const AutoChip: React.FC<{
|
||||||
noInsets?: boolean,
|
noInsets?: boolean,
|
||||||
children?: any,
|
children?: any,
|
||||||
dataTestId?: string,
|
dataTestId?: string,
|
||||||
revealOnAnchorId?: string,
|
revealOnAnchorId?: AnchorID,
|
||||||
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
|
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
|
||||||
const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
|
const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
|
||||||
const onReveal = React.useCallback(() => setExpanded(true), []);
|
const onReveal = React.useCallback(() => setExpanded(true), []);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TestAttachment } from './types';
|
import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
import { TreeItem } from './treeItem';
|
import { TreeItem } from './treeItem';
|
||||||
|
|
@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
||||||
linkName?: string,
|
linkName?: string,
|
||||||
openInNewTab?: boolean,
|
openInNewTab?: boolean,
|
||||||
}> = ({ attachment, href, linkName, openInNewTab }) => {
|
}> = ({ attachment, href, linkName, openInNewTab }) => {
|
||||||
|
const isAnchored = useIsAnchored('attachment-' + attachment.name);
|
||||||
return <TreeItem title={<span>
|
return <TreeItem title={<span>
|
||||||
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
||||||
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
||||||
|
|
@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
||||||
)}
|
)}
|
||||||
</span>} loadChildren={attachment.body ? () => {
|
</span>} loadChildren={attachment.body ? () => {
|
||||||
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
|
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
|
||||||
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
|
} : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}></TreeItem>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
|
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
|
||||||
|
|
@ -114,31 +115,48 @@ export function generateTraceUrl(traces: TestAttachment[]) {
|
||||||
|
|
||||||
const kMissingContentType = 'x-playwright/missing';
|
const kMissingContentType = 'x-playwright/missing';
|
||||||
|
|
||||||
type AnchorID = string | ((id: string | null) => boolean) | undefined;
|
export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;
|
||||||
|
|
||||||
export function useAnchor(id: AnchorID, onReveal: () => void) {
|
export function useAnchor(id: AnchorID, onReveal: () => void) {
|
||||||
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
|
const isAnchored = useIsAnchored(id);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof id === 'undefined')
|
if (isAnchored)
|
||||||
return;
|
onReveal();
|
||||||
|
}, [isAnchored, onReveal, searchParams]);
|
||||||
|
}
|
||||||
|
|
||||||
const listener = () => {
|
export function useIsAnchored(id: AnchorID) {
|
||||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
const anchor = params.get('anchor');
|
const anchor = searchParams.get('anchor');
|
||||||
const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id;
|
if (anchor === null)
|
||||||
if (isRevealed)
|
return false;
|
||||||
onReveal();
|
if (typeof id === 'undefined')
|
||||||
};
|
return false;
|
||||||
window.addEventListener('popstate', listener);
|
if (typeof id === 'string')
|
||||||
return () => window.removeEventListener('popstate', listener);
|
return id === anchor;
|
||||||
}, [id, onReveal]);
|
if (Array.isArray(id))
|
||||||
|
return id.includes(anchor);
|
||||||
|
return id(anchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) {
|
export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) {
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
const onAnchorReveal = React.useCallback(() => {
|
const onAnchorReveal = React.useCallback(() => {
|
||||||
requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', inline: 'start' }));
|
ref.current?.scrollIntoView({ block: 'start', inline: 'start' });
|
||||||
}, []);
|
}, []);
|
||||||
useAnchor(id, onAnchorReveal);
|
useAnchor(id, onAnchorReveal);
|
||||||
|
|
||||||
return <div ref={ref}>{children}</div>;
|
return <div ref={ref}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (test)
|
||||||
|
params.set('testId', test.testId);
|
||||||
|
if (test && result)
|
||||||
|
params.set('run', '' + test.results.indexOf(result as any));
|
||||||
|
if (anchor)
|
||||||
|
params.set('anchor', anchor);
|
||||||
|
return `#?` + params;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import * as React from 'react';
|
||||||
import { TabbedPane } from './tabbedPane';
|
import { TabbedPane } from './tabbedPane';
|
||||||
import { AutoChip } from './chip';
|
import { AutoChip } from './chip';
|
||||||
import './common.css';
|
import './common.css';
|
||||||
import { Link, ProjectLink, SearchParamsContext } from './links';
|
import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testCaseView.css';
|
import './testCaseView.css';
|
||||||
import { TestResultView } from './testResultView';
|
import { TestResultView } from './testResultView';
|
||||||
|
|
@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{
|
||||||
{test && <div className='hbox'>
|
{test && <div className='hbox'>
|
||||||
<div className='test-case-path'>{test.path.join(' › ')}</div>
|
<div className='test-case-path'>{test.path.join(' › ')}</div>
|
||||||
<div style={{ flex: 'auto' }}></div>
|
<div style={{ flex: 'auto' }}></div>
|
||||||
<div className={clsx(!prev && 'hidden')}><Link href={`#?testId=${prev?.testId}${filterParam}`}>« previous</Link></div>
|
<div className={clsx(!prev && 'hidden')}><Link href={testResultHref({ test: prev }) + filterParam}>« previous</Link></div>
|
||||||
<div style={{ width: 10 }}></div>
|
<div style={{ width: 10 }}></div>
|
||||||
<div className={clsx(!next && 'hidden')}><Link href={`#?testId=${next?.testId}${filterParam}`}>next »</Link></div>
|
<div className={clsx(!next && 'hidden')}><Link href={testResultHref({ test: next }) + filterParam}>next »</Link></div>
|
||||||
</div>}
|
</div>}
|
||||||
{test && <div className='test-case-title'>{test?.title}</div>}
|
{test && <div className='test-case-title'>{test?.title}</div>}
|
||||||
{test && <div className='hbox'>
|
{test && <div className='hbox'>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import * as React from 'react';
|
||||||
import { hashStringToInt, msToString } from './utils';
|
import { hashStringToInt, msToString } from './utils';
|
||||||
import { Chip } from './chip';
|
import { Chip } from './chip';
|
||||||
import { filterWithToken } from './filter';
|
import { filterWithToken } from './filter';
|
||||||
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links';
|
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testFileView.css';
|
import './testFileView.css';
|
||||||
import { video, image, trace } from './icons';
|
import { video, image, trace } from './icons';
|
||||||
|
|
@ -48,7 +48,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
{statusIcon(test.outcome)}
|
{statusIcon(test.outcome)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<Link href={`#?testId=${test.testId}${filterParam}`} title={[...test.path, test.title].join(' › ')}>
|
<Link href={testResultHref({ test }) + filterParam} title={[...test.path, test.title].join(' › ')}>
|
||||||
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{projectNames.length > 1 && !!test.projectName &&
|
{projectNames.length > 1 && !!test.projectName &&
|
||||||
|
|
@ -59,7 +59,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='test-file-details-row'>
|
<div className='test-file-details-row'>
|
||||||
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
|
<Link href={testResultHref({ test })} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
|
||||||
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{imageDiffBadge(test)}
|
{imageDiffBadge(test)}
|
||||||
|
|
@ -72,15 +72,17 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
};
|
};
|
||||||
|
|
||||||
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => {
|
for (const result of test.results) {
|
||||||
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/);
|
for (const attachment of result.attachments) {
|
||||||
}));
|
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
|
||||||
return resultWithImageDiff ? <Link href={`#?testId=${test.testId}&anchor=diff-0&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : undefined;
|
return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
|
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
|
||||||
return resultWithVideo ? <Link href={`#?testId=${test.testId}&anchor=videos&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
return resultWithVideo ? <Link href={testResultHref({ test, result: resultWithVideo, anchor: 'attachment-video' })} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,20 @@ import { TreeItem } from './treeItem';
|
||||||
import { msToString } from './utils';
|
import { msToString } from './utils';
|
||||||
import { AutoChip } from './chip';
|
import { AutoChip } from './chip';
|
||||||
import { traceImage } from './images';
|
import { traceImage } from './images';
|
||||||
import { Anchor, AttachmentLink, generateTraceUrl } from './links';
|
import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import type { ImageDiff } from '@web/shared/imageDiffView';
|
import type { ImageDiff } from '@web/shared/imageDiffView';
|
||||||
import { ImageDiffView } from '@web/shared/imageDiffView';
|
import { ImageDiffView } from '@web/shared/imageDiffView';
|
||||||
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
|
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
|
||||||
|
import * as icons from './icons';
|
||||||
import './testResultView.css';
|
import './testResultView.css';
|
||||||
|
|
||||||
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
interface ImageDiffWithAnchors extends ImageDiff {
|
||||||
const snapshotNameToImageDiff = new Map<string, ImageDiff>();
|
anchors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] {
|
||||||
|
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
|
||||||
for (const attachment of screenshots) {
|
for (const attachment of screenshots) {
|
||||||
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
|
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
|
|
@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
||||||
const snapshotName = name + extension;
|
const snapshotName = name + extension;
|
||||||
let imageDiff = snapshotNameToImageDiff.get(snapshotName);
|
let imageDiff = snapshotNameToImageDiff.get(snapshotName);
|
||||||
if (!imageDiff) {
|
if (!imageDiff) {
|
||||||
imageDiff = { name: snapshotName };
|
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
|
||||||
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
||||||
}
|
}
|
||||||
|
imageDiff.anchors.push(`attachment-${attachment.name}`);
|
||||||
if (category === 'actual')
|
if (category === 'actual')
|
||||||
imageDiff.actual = { attachment };
|
imageDiff.actual = { attachment };
|
||||||
if (category === 'expected')
|
if (category === 'expected')
|
||||||
|
|
@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
||||||
export const TestResultView: React.FC<{
|
export const TestResultView: React.FC<{
|
||||||
test: TestCase,
|
test: TestCase,
|
||||||
result: TestResult,
|
result: TestResult,
|
||||||
}> = ({ result }) => {
|
}> = ({ test, result }) => {
|
||||||
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => {
|
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
|
||||||
const attachments = result?.attachments || [];
|
const attachments = result?.attachments || [];
|
||||||
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
||||||
|
const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`);
|
||||||
const videos = attachments.filter(a => a.contentType.startsWith('video/'));
|
const videos = attachments.filter(a => a.contentType.startsWith('video/'));
|
||||||
const traces = attachments.filter(a => a.name === 'trace');
|
const traces = attachments.filter(a => a.name === 'trace');
|
||||||
const htmls = attachments.filter(a => a.contentType.startsWith('text/html'));
|
|
||||||
const otherAttachments = new Set<TestAttachment>(attachments);
|
const otherAttachments = new Set<TestAttachment>(attachments);
|
||||||
[...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a));
|
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
|
||||||
|
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`);
|
||||||
const diffs = groupImageDiffs(screenshots);
|
const diffs = groupImageDiffs(screenshots);
|
||||||
const errors = classifyErrors(result.errors, diffs);
|
const errors = classifyErrors(result.errors, diffs);
|
||||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls };
|
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
|
||||||
}, [result]);
|
}, [result]);
|
||||||
|
|
||||||
return <div className='test-result'>
|
return <div className='test-result'>
|
||||||
|
|
@ -87,29 +94,29 @@ export const TestResultView: React.FC<{
|
||||||
})}
|
})}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
{!!result.steps.length && <AutoChip header='Test Steps'>
|
{!!result.steps.length && <AutoChip header='Test Steps'>
|
||||||
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
|
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} result={result} test={test} depth={0}/>)}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
|
|
||||||
{diffs.map((diff, index) =>
|
{diffs.map((diff, index) =>
|
||||||
<Anchor key={`diff-${index}`} id={`diff-${index}`}>
|
<Anchor key={`diff-${index}`} id={diff.anchors}>
|
||||||
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={`diff-${index}`}>
|
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={diff.anchors}>
|
||||||
<ImageDiffView diff={diff}/>
|
<ImageDiffView diff={diff}/>
|
||||||
</AutoChip>
|
</AutoChip>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!screenshots.length && <AutoChip header='Screenshots'>
|
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
|
||||||
{screenshots.map((a, i) => {
|
{screenshots.map((a, i) => {
|
||||||
return <div key={`screenshot-${i}`}>
|
return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}>
|
||||||
<a href={a.path}>
|
<a href={a.path}>
|
||||||
<img className='screenshot' src={a.path} />
|
<img className='screenshot' src={a.path} />
|
||||||
</a>
|
</a>
|
||||||
<AttachmentLink attachment={a}></AttachmentLink>
|
<AttachmentLink attachment={a}></AttachmentLink>
|
||||||
</div>;
|
</Anchor>;
|
||||||
})}
|
})}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
|
|
||||||
{!!traces.length && <Anchor id='traces'><AutoChip header='Traces' revealOnAnchorId='traces'>
|
{!!traces.length && <Anchor id='attachment-trace'><AutoChip header='Traces' revealOnAnchorId='attachment-trace'>
|
||||||
{<div>
|
{<div>
|
||||||
<a href={generateTraceUrl(traces)}>
|
<a href={generateTraceUrl(traces)}>
|
||||||
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||||
|
|
@ -118,7 +125,7 @@ export const TestResultView: React.FC<{
|
||||||
</div>}
|
</div>}
|
||||||
</AutoChip></Anchor>}
|
</AutoChip></Anchor>}
|
||||||
|
|
||||||
{!!videos.length && <Anchor id='videos'><AutoChip header='Videos' revealOnAnchorId='videos'>
|
{!!videos.length && <Anchor id='attachment-video'><AutoChip header='Videos' revealOnAnchorId='attachment-video'>
|
||||||
{videos.map((a, i) => <div key={`video-${i}`}>
|
{videos.map((a, i) => <div key={`video-${i}`}>
|
||||||
<video controls>
|
<video controls>
|
||||||
<source src={a.path} type={a.contentType}/>
|
<source src={a.path} type={a.contentType}/>
|
||||||
|
|
@ -127,11 +134,12 @@ export const TestResultView: React.FC<{
|
||||||
</div>)}
|
</div>)}
|
||||||
</AutoChip></Anchor>}
|
</AutoChip></Anchor>}
|
||||||
|
|
||||||
{!!(otherAttachments.size + htmls.length) && <AutoChip header='Attachments'>
|
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
|
||||||
{[...htmls].map((a, i) => (
|
{[...otherAttachments].map((a, i) =>
|
||||||
<AttachmentLink key={`html-link-${i}`} attachment={a} openInNewTab />)
|
<Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}>
|
||||||
|
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} />
|
||||||
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
{[...otherAttachments].map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
|
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const StepTreeItem: React.FC<{
|
const StepTreeItem: React.FC<{
|
||||||
|
test: TestCase;
|
||||||
|
result: TestResult;
|
||||||
step: TestStep;
|
step: TestStep;
|
||||||
depth: number,
|
depth: number,
|
||||||
}> = ({ step, depth }) => {
|
}> = ({ test, step, result, depth }) => {
|
||||||
return <TreeItem title={<span>
|
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
|
||||||
|
return <TreeItem title={<span aria-label={step.title}>
|
||||||
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
||||||
|
{attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
|
||||||
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
|
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
|
||||||
<span>{step.title}</span>
|
<span>{step.title}</span>
|
||||||
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</span></>}
|
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</span></>}
|
||||||
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
|
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
|
||||||
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
|
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
|
||||||
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
|
||||||
if (step.snippet)
|
if (step.snippet)
|
||||||
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}></TestErrorView>);
|
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
|
||||||
return children;
|
return children;
|
||||||
} : undefined} depth={depth}></TreeItem>;
|
} : undefined} depth={depth}/>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-item-title.selected {
|
||||||
|
text-decoration: underline var(--color-underlinenav-icon);
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-item-body {
|
.tree-item-body {
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './treeItem.css';
|
import './treeItem.css';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
|
import { clsx } from '@web/uiUtils';
|
||||||
|
|
||||||
export const TreeItem: React.FunctionComponent<{
|
export const TreeItem: React.FunctionComponent<{
|
||||||
title: JSX.Element,
|
title: JSX.Element,
|
||||||
|
|
@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{
|
||||||
style?: React.CSSProperties,
|
style?: React.CSSProperties,
|
||||||
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
|
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
|
||||||
const [expanded, setExpanded] = React.useState(expandByDefault || false);
|
const [expanded, setExpanded] = React.useState(expandByDefault || false);
|
||||||
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
|
|
||||||
return <div className={'tree-item'} style={style}>
|
return <div className={'tree-item'} style={style}>
|
||||||
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
||||||
{loadChildren && !!expanded && icons.downArrow()}
|
{loadChildren && !!expanded && icons.downArrow()}
|
||||||
{loadChildren && !expanded && icons.rightArrow()}
|
{loadChildren && !expanded && icons.rightArrow()}
|
||||||
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
|
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "chromium-tip-of-tree",
|
"name": "chromium-tip-of-tree",
|
||||||
"revision": "1286",
|
"revision": "1287",
|
||||||
"installByDefault": false,
|
"installByDefault": false,
|
||||||
"browserVersion": "133.0.6891.0"
|
"browserVersion": "133.0.6901.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "firefox",
|
"name": "firefox",
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,8 @@ export class InjectedScript {
|
||||||
const queryAll = (root: SelectorRoot, body: string) => {
|
const queryAll = (root: SelectorRoot, body: string) => {
|
||||||
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
||||||
return [];
|
return [];
|
||||||
return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : [];
|
const visible = body === 'true';
|
||||||
|
return isElementVisible(root as Element) === visible ? [root as Element] : [];
|
||||||
};
|
};
|
||||||
return { queryAll };
|
return { queryAll };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi
|
||||||
}, frameElement);
|
}, frameElement);
|
||||||
return selector;
|
return selector;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e.toString();
|
|
||||||
}
|
}
|
||||||
}, monotonicTime() + 2000);
|
}, monotonicTime() + 2000);
|
||||||
if (!result.timedOut && result.result)
|
if (!result.timedOut && result.result)
|
||||||
|
|
|
||||||
7
packages/playwright-core/types/types.d.ts
vendored
7
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -9589,10 +9589,11 @@ export interface Browser {
|
||||||
* In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from
|
* In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from
|
||||||
* the browser server.
|
* the browser server.
|
||||||
*
|
*
|
||||||
* **NOTE** This is similar to force quitting the browser. Therefore, you should call
|
* **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close
|
||||||
|
* events, call
|
||||||
* [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on
|
* [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on
|
||||||
* any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with
|
* any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier
|
||||||
* [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before**
|
* using [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before**
|
||||||
* calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close).
|
* calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close).
|
||||||
*
|
*
|
||||||
* The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot
|
* The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@ export class TestTypeImpl {
|
||||||
test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only'));
|
test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only'));
|
||||||
test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow'));
|
test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow'));
|
||||||
test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this));
|
test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this));
|
||||||
test.step = this._step.bind(this);
|
test.step = this._step.bind(this, 'pass');
|
||||||
|
test.step.fail = this._step.bind(this, 'fail');
|
||||||
|
test.step.fixme = this._step.bind(this, 'fixme');
|
||||||
test.use = wrapFunctionWithLocation(this._use.bind(this));
|
test.use = wrapFunctionWithLocation(this._use.bind(this));
|
||||||
test.extend = wrapFunctionWithLocation(this._extend.bind(this));
|
test.extend = wrapFunctionWithLocation(this._extend.bind(this));
|
||||||
test.info = () => {
|
test.info = () => {
|
||||||
|
|
@ -257,22 +259,40 @@ export class TestTypeImpl {
|
||||||
suite._use.push({ fixtures, location });
|
suite._use.push({ fixtures, location });
|
||||||
}
|
}
|
||||||
|
|
||||||
async _step<T>(title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
|
async _step<T>(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
|
||||||
const testInfo = currentTestInfo();
|
const testInfo = currentTestInfo();
|
||||||
if (!testInfo)
|
if (!testInfo)
|
||||||
throw new Error(`test.step() can only be called from a test`);
|
throw new Error(`test.step() can only be called from a test`);
|
||||||
|
if (expectation === 'fixme')
|
||||||
|
return undefined as T;
|
||||||
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
|
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
|
||||||
return await zones.run('stepZone', step, async () => {
|
return await zones.run('stepZone', step, async () => {
|
||||||
|
let result;
|
||||||
|
let error;
|
||||||
try {
|
try {
|
||||||
const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0);
|
result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0);
|
||||||
if (result.timedOut)
|
} catch (e) {
|
||||||
throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`);
|
error = e;
|
||||||
step.complete({});
|
}
|
||||||
return result.result;
|
if (result?.timedOut) {
|
||||||
} catch (error) {
|
const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`);
|
||||||
step.complete({ error });
|
step.complete({ error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const expectedToFail = expectation === 'fail';
|
||||||
|
if (error) {
|
||||||
|
step.complete({ error });
|
||||||
|
if (expectedToFail)
|
||||||
|
return undefined as T;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (expectedToFail) {
|
||||||
|
error = new Error(`Step is expected to fail, but passed`);
|
||||||
|
step.complete({ error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
step.complete({});
|
||||||
|
return result!.result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 {
|
||||||
console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`));
|
console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`));
|
||||||
});
|
});
|
||||||
if (slowTests.length)
|
if (slowTests.length)
|
||||||
console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution'));
|
console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _printSummary(summary: string) {
|
private _printSummary(summary: string) {
|
||||||
|
|
|
||||||
212
packages/playwright/types/test.d.ts
vendored
212
packages/playwright/types/test.d.ts
vendored
|
|
@ -5551,7 +5551,217 @@ export interface TestType<TestArgs extends {}, WorkerArgs extends {}> {
|
||||||
* @param body Step body.
|
* @param body Step body.
|
||||||
* @param options
|
* @param options
|
||||||
*/
|
*/
|
||||||
step<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
|
step: {
|
||||||
|
/**
|
||||||
|
* Declares a test step that is shown in the report.
|
||||||
|
*
|
||||||
|
* **Usage**
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { test, expect } from '@playwright/test';
|
||||||
|
*
|
||||||
|
* test('test', async ({ page }) => {
|
||||||
|
* await test.step('Log in', async () => {
|
||||||
|
* // ...
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* await test.step('Outer step', async () => {
|
||||||
|
* // ...
|
||||||
|
* // You can nest steps inside each other.
|
||||||
|
* await test.step('Inner step', async () => {
|
||||||
|
* // ...
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Details**
|
||||||
|
*
|
||||||
|
* The method returns the value returned by the step callback.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { test, expect } from '@playwright/test';
|
||||||
|
*
|
||||||
|
* test('test', async ({ page }) => {
|
||||||
|
* const user = await test.step('Log in', async () => {
|
||||||
|
* // ...
|
||||||
|
* return 'john';
|
||||||
|
* });
|
||||||
|
* expect(user).toBe('john');
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Decorator**
|
||||||
|
*
|
||||||
|
* You can use TypeScript method decorators to turn a method into a step. Each call to the decorated method will show
|
||||||
|
* up as a step in the report.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* function step(target: Function, context: ClassMethodDecoratorContext) {
|
||||||
|
* return function replacementMethod(...args: any) {
|
||||||
|
* const name = this.constructor.name + '.' + (context.name as string);
|
||||||
|
* return test.step(name, async () => {
|
||||||
|
* return await target.call(this, ...args);
|
||||||
|
* });
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* class LoginPage {
|
||||||
|
* constructor(readonly page: Page) {}
|
||||||
|
*
|
||||||
|
* @step
|
||||||
|
* async login() {
|
||||||
|
* const account = { username: 'Alice', password: 's3cr3t' };
|
||||||
|
* await this.page.getByLabel('Username or email address').fill(account.username);
|
||||||
|
* await this.page.getByLabel('Password').fill(account.password);
|
||||||
|
* await this.page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
* await expect(this.page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* test('example', async ({ page }) => {
|
||||||
|
* const loginPage = new LoginPage(page);
|
||||||
|
* await loginPage.login();
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Boxing**
|
||||||
|
*
|
||||||
|
* When something inside a step fails, you would usually see the error pointing to the exact action that failed. For
|
||||||
|
* example, consider the following login step:
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* async function login(page) {
|
||||||
|
* await test.step('login', async () => {
|
||||||
|
* const account = { username: 'Alice', password: 's3cr3t' };
|
||||||
|
* await page.getByLabel('Username or email address').fill(account.username);
|
||||||
|
* await page.getByLabel('Password').fill(account.password);
|
||||||
|
* await page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
* await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* test('example', async ({ page }) => {
|
||||||
|
* await page.goto('https://github.com/login');
|
||||||
|
* await login(page);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ```txt
|
||||||
|
* Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
|
||||||
|
* ... error details omitted ...
|
||||||
|
*
|
||||||
|
* 8 | await page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
* > 9 | await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
|
||||||
|
* | ^
|
||||||
|
* 10 | });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* As we see above, the test may fail with an error pointing inside the step. If you would like the error to highlight
|
||||||
|
* the "login" step instead of its internals, use the `box` option. An error inside a boxed step points to the step
|
||||||
|
* call site.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* async function login(page) {
|
||||||
|
* await test.step('login', async () => {
|
||||||
|
* // ...
|
||||||
|
* }, { box: true }); // Note the "box" option here.
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ```txt
|
||||||
|
* Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
|
||||||
|
* ... error details omitted ...
|
||||||
|
*
|
||||||
|
* 14 | await page.goto('https://github.com/login');
|
||||||
|
* > 15 | await login(page);
|
||||||
|
* | ^
|
||||||
|
* 16 | });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* You can also create a TypeScript decorator for a boxed step, similar to a regular step decorator above:
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* function boxedStep(target: Function, context: ClassMethodDecoratorContext) {
|
||||||
|
* return function replacementMethod(...args: any) {
|
||||||
|
* const name = this.constructor.name + '.' + (context.name as string);
|
||||||
|
* return test.step(name, async () => {
|
||||||
|
* return await target.call(this, ...args);
|
||||||
|
* }, { box: true }); // Note the "box" option here.
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* class LoginPage {
|
||||||
|
* constructor(readonly page: Page) {}
|
||||||
|
*
|
||||||
|
* @boxedStep
|
||||||
|
* async login() {
|
||||||
|
* // ....
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* test('example', async ({ page }) => {
|
||||||
|
* const loginPage = new LoginPage(page);
|
||||||
|
* await loginPage.login(); // <-- Error will be reported on this line.
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param title Step name.
|
||||||
|
* @param body Step body.
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
|
||||||
|
/**
|
||||||
|
* Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step.
|
||||||
|
*
|
||||||
|
* **Usage**
|
||||||
|
*
|
||||||
|
* You can declare a test step as failing, so that Playwright ensures it actually fails.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { test, expect } from '@playwright/test';
|
||||||
|
*
|
||||||
|
* test('my test', async ({ page }) => {
|
||||||
|
* // ...
|
||||||
|
* await test.step.fixme('not yet ready', async () => {
|
||||||
|
* // ...
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param title Step name.
|
||||||
|
* @param body Step body.
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
fixme(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is
|
||||||
|
* useful for documentation purposes to acknowledge that some functionality is broken until it is fixed.
|
||||||
|
*
|
||||||
|
* **NOTE** If the step exceeds the timeout, a [TimeoutError](https://playwright.dev/docs/api/class-timeouterror) is
|
||||||
|
* thrown. This indicates the step did not fail as expected.
|
||||||
|
*
|
||||||
|
* **Usage**
|
||||||
|
*
|
||||||
|
* You can declare a test step as failing, so that Playwright ensures it actually fails.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { test, expect } from '@playwright/test';
|
||||||
|
*
|
||||||
|
* test('my test', async ({ page }) => {
|
||||||
|
* // ...
|
||||||
|
* await test.step.fail('currently failing', async () => {
|
||||||
|
* // ...
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param title Step name.
|
||||||
|
* @param body Step body.
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
fail(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions).
|
* `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ansi2html } from '@web/ansi2html';
|
import { ansi2html } from '../ansi2html';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './errorMessage.css';
|
import './errorMessage.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import * as React from 'react';
|
||||||
import { ListView } from './listView';
|
import { ListView } from './listView';
|
||||||
import type { ListViewProps } from './listView';
|
import type { ListViewProps } from './listView';
|
||||||
import './gridView.css';
|
import './gridView.css';
|
||||||
import { ResizeView } from '@web/shared/resizeView';
|
import { ResizeView } from '../shared/resizeView';
|
||||||
|
|
||||||
export type Sorting<T> = { by: keyof T, negate: boolean };
|
export type Sorting<T> = { by: keyof T, negate: boolean };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './listView.css';
|
import './listView.css';
|
||||||
import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils';
|
import { clsx, scrollIntoViewIfNeeded } from '../uiUtils';
|
||||||
|
|
||||||
export type ListViewProps<T> = {
|
export type ListViewProps<T> = {
|
||||||
name: string,
|
name: string,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { clsx } from '@web/uiUtils';
|
import { clsx } from '../uiUtils';
|
||||||
import './tabbedPane.css';
|
import './tabbedPane.css';
|
||||||
import { Toolbar } from './toolbar';
|
import { Toolbar } from './toolbar';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { clsx } from '@web/uiUtils';
|
import { clsx } from '../uiUtils';
|
||||||
import './toolbar.css';
|
import './toolbar.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import './toolbarButton.css';
|
import './toolbarButton.css';
|
||||||
import '../third_party/vscode/codicon.css';
|
import '../third_party/vscode/codicon.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { clsx } from '@web/uiUtils';
|
import { clsx } from '../uiUtils';
|
||||||
|
|
||||||
export interface ToolbarButtonProps {
|
export interface ToolbarButtonProps {
|
||||||
title: string,
|
title: string,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils';
|
import { clsx, scrollIntoViewIfNeeded } from '../uiUtils';
|
||||||
import './treeView.css';
|
import './treeView.css';
|
||||||
|
|
||||||
export type TreeItem = {
|
export type TreeItem = {
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ import './xtermWrapper.css';
|
||||||
import type { ITheme, Terminal } from 'xterm';
|
import type { ITheme, Terminal } from 'xterm';
|
||||||
import type { FitAddon } from 'xterm-addon-fit';
|
import type { FitAddon } from 'xterm-addon-fit';
|
||||||
import type { XtermModule } from './xtermModule';
|
import type { XtermModule } from './xtermModule';
|
||||||
import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme';
|
import { currentTheme, addThemeListener, removeThemeListener } from '../theme';
|
||||||
import { useMeasure } from '@web/uiUtils';
|
import { useMeasure } from '../uiUtils';
|
||||||
|
|
||||||
export type XtermDataSource = {
|
export type XtermDataSource = {
|
||||||
pending: (string | Uint8Array)[];
|
pending: (string | Uint8Array)[];
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ export function useMeasure<T extends Element>() {
|
||||||
const target = ref.current;
|
const target = ref.current;
|
||||||
if (!target)
|
if (!target)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const bounds = target.getBoundingClientRect();
|
||||||
|
|
||||||
|
setMeasure(new DOMRect(0, 0, bounds.width, bounds.height));
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver((entries: any) => {
|
const resizeObserver = new ResizeObserver((entries: any) => {
|
||||||
const entry = entries[entries.length - 1];
|
const entry = entries[entries.length - 1];
|
||||||
if (entry && entry.contentRect)
|
if (entry && entry.contentRect)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function createSkipTestPredicate(projectName: string): Promise<Shou
|
||||||
return (info: TestInfo) => {
|
return (info: TestInfo) => {
|
||||||
const key = info.titlePath.join(' › ');
|
const key = info.titlePath.join(' › ');
|
||||||
const expectation = expectationsMap.get(key);
|
const expectation = expectationsMap.get(key);
|
||||||
return expectation === 'fail' || expectation === 'timeout';
|
return expectation === 'timeout';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export class TestProxy {
|
||||||
|
|
||||||
connectHosts: string[] = [];
|
connectHosts: string[] = [];
|
||||||
requestUrls: string[] = [];
|
requestUrls: string[] = [];
|
||||||
|
wsUrls: string[] = [];
|
||||||
|
|
||||||
private readonly _server: ProxyServer;
|
private readonly _server: ProxyServer;
|
||||||
private readonly _sockets = new Set<net.Socket>();
|
private readonly _sockets = new Set<net.Socket>();
|
||||||
|
|
@ -58,11 +59,16 @@ export class TestProxy {
|
||||||
await new Promise(x => this._server.close(x));
|
await new Promise(x => this._server.close(x));
|
||||||
}
|
}
|
||||||
|
|
||||||
forwardTo(port: number, options?: { allowConnectRequests: boolean }) {
|
forwardTo(port: number, options?: { allowConnectRequests?: boolean, prefix?: string, preserveHostname?: boolean }) {
|
||||||
this._prependHandler('request', (req: IncomingMessage) => {
|
this._prependHandler('request', (req: IncomingMessage) => {
|
||||||
this.requestUrls.push(req.url);
|
this.requestUrls.push(req.url);
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
url.host = `127.0.0.1:${port}`;
|
if (options?.preserveHostname)
|
||||||
|
url.port = '' + port;
|
||||||
|
else
|
||||||
|
url.host = `127.0.0.1:${port}`;
|
||||||
|
if (options?.prefix)
|
||||||
|
url.pathname = url.pathname.replace(options.prefix, '');
|
||||||
req.url = url.toString();
|
req.url = url.toString();
|
||||||
});
|
});
|
||||||
this._prependHandler('connect', (req: IncomingMessage) => {
|
this._prependHandler('connect', (req: IncomingMessage) => {
|
||||||
|
|
@ -73,6 +79,17 @@ export class TestProxy {
|
||||||
this.connectHosts.push(req.url);
|
this.connectHosts.push(req.url);
|
||||||
req.url = `127.0.0.1:${port}`;
|
req.url = `127.0.0.1:${port}`;
|
||||||
});
|
});
|
||||||
|
this._prependHandler('upgrade', (req: IncomingMessage) => {
|
||||||
|
this.wsUrls.push(req.url);
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
if (options?.preserveHostname)
|
||||||
|
url.port = '' + port;
|
||||||
|
else
|
||||||
|
url.host = `127.0.0.1:${port}`;
|
||||||
|
if (options?.prefix)
|
||||||
|
url.pathname = url.pathname.replace(options.prefix, '');
|
||||||
|
req.url = url.toString();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthHandler(handler: (req: IncomingMessage) => boolean) {
|
setAuthHandler(handler: (req: IncomingMessage) => boolean) {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,18 @@ it('should work with >> visible=', async ({ page }) => {
|
||||||
expect(await page.$eval('div >> visible=true', div => div.id)).toBe('target2');
|
expect(await page.$eval('div >> visible=true', div => div.id)).toBe('target2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should work with >> visible=false', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<section>
|
||||||
|
<div id=target1></div>
|
||||||
|
<div id=target2></div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
await expect(page.locator('div >> visible=false')).toHaveCount(2);
|
||||||
|
await page.locator('#target2').evaluate(div => div.textContent = 'Now visible');
|
||||||
|
await expect(page.locator('div >> visible=false')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should work with :nth-match', async ({ page }) => {
|
it('should work with :nth-match', async ({ page }) => {
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
|
||||||
expect(result.output).toContain(`Slow test file: [bar] › dir${path.sep}a.test.js (`);
|
expect(result.output).toContain(`Slow test file: [bar] › dir${path.sep}a.test.js (`);
|
||||||
expect(result.output).toContain(`Slow test file: [baz] › dir${path.sep}a.test.js (`);
|
expect(result.output).toContain(`Slow test file: [baz] › dir${path.sep}a.test.js (`);
|
||||||
expect(result.output).toContain(`Slow test file: [qux] › dir${path.sep}a.test.js (`);
|
expect(result.output).toContain(`Slow test file: [qux] › dir${path.sep}a.test.js (`);
|
||||||
expect(result.output).toContain(`Consider splitting slow test files to speed up parallel execution`);
|
expect(result.output).toContain(`Consider running tests from slow files in parallel`);
|
||||||
expect(result.output).not.toContain(`Slow test file: [foo] › dir${path.sep}b.test.js (`);
|
expect(result.output).not.toContain(`Slow test file: [foo] › dir${path.sep}b.test.js (`);
|
||||||
expect(result.output).not.toContain(`Slow test file: [bar] › dir${path.sep}b.test.js (`);
|
expect(result.output).not.toContain(`Slow test file: [bar] › dir${path.sep}b.test.js (`);
|
||||||
expect(result.output).not.toContain(`Slow test file: [baz] › dir${path.sep}b.test.js (`);
|
expect(result.output).not.toContain(`Slow test file: [baz] › dir${path.sep}b.test.js (`);
|
||||||
|
|
|
||||||
|
|
@ -847,7 +847,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
||||||
'a.test.js': `
|
'a.test.js': `
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
test('passing', async ({ page }, testInfo) => {
|
test('passing', async ({ page }, testInfo) => {
|
||||||
testInfo.attach('axe-report.html', {
|
await testInfo.attach('axe-report.html', {
|
||||||
contentType: 'text/html',
|
contentType: 'text/html',
|
||||||
body: '<h1>Axe Report</h1>',
|
body: '<h1>Axe Report</h1>',
|
||||||
});
|
});
|
||||||
|
|
@ -916,6 +916,31 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should link from attach step to attachment view', async ({ runInlineTest, page, showReport }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.js': `
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
test('passing', async ({ page }, testInfo) => {
|
||||||
|
for (let i = 0; i < 100; i++)
|
||||||
|
await testInfo.attach('foo-1', { body: 'bar' });
|
||||||
|
await testInfo.attach('foo-2', { body: 'bar' });
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
|
||||||
|
await showReport();
|
||||||
|
await page.getByRole('link', { name: 'passing' }).click();
|
||||||
|
|
||||||
|
const attachment = page.getByText('foo-2', { exact: true });
|
||||||
|
await expect(attachment).not.toBeInViewport();
|
||||||
|
await page.getByLabel('attach "foo-2"').getByTitle('link to attachment').click();
|
||||||
|
await expect(attachment).toBeInViewport();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(attachment).toBeInViewport();
|
||||||
|
});
|
||||||
|
|
||||||
test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => {
|
test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'helper.ts': `
|
'helper.ts': `
|
||||||
|
|
|
||||||
|
|
@ -1494,3 +1494,103 @@ fixture | fixture: context
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test.step.fail and test.step.fixme should work', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'reporter.ts': stepIndentReporter,
|
||||||
|
'playwright.config.ts': `module.exports = { reporter: './reporter' };`,
|
||||||
|
'a.test.ts': `
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
test('test', async ({ }) => {
|
||||||
|
await test.step('outer step 1', async () => {
|
||||||
|
await test.step.fail('inner step 1.1', async () => {
|
||||||
|
throw new Error('inner step 1.1 failed');
|
||||||
|
});
|
||||||
|
await test.step.fixme('inner step 1.2', async () => {});
|
||||||
|
await test.step('inner step 1.3', async () => {});
|
||||||
|
});
|
||||||
|
await test.step('outer step 2', async () => {
|
||||||
|
await test.step.fixme('inner step 2.1', async () => {});
|
||||||
|
await test.step('inner step 2.2', async () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await test.step.fail('outer step 3', async () => {
|
||||||
|
throw new Error('outer step 3 failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}, { reporter: '' });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.report.stats.expected).toBe(1);
|
||||||
|
expect(result.report.stats.unexpected).toBe(0);
|
||||||
|
expect(stripAnsi(result.output)).toBe(`
|
||||||
|
hook |Before Hooks
|
||||||
|
test.step |outer step 1 @ a.test.ts:4
|
||||||
|
test.step | inner step 1.1 @ a.test.ts:5
|
||||||
|
test.step | ↪ error: Error: inner step 1.1 failed
|
||||||
|
test.step | inner step 1.3 @ a.test.ts:9
|
||||||
|
test.step |outer step 2 @ a.test.ts:11
|
||||||
|
test.step | inner step 2.2 @ a.test.ts:13
|
||||||
|
expect | expect.toBe @ a.test.ts:14
|
||||||
|
test.step |outer step 3 @ a.test.ts:17
|
||||||
|
test.step |↪ error: Error: outer step 3 failed
|
||||||
|
hook |After Hooks
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeout inside test.step.fail is an error', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'reporter.ts': stepIndentReporter,
|
||||||
|
'playwright.config.ts': `module.exports = { reporter: './reporter' };`,
|
||||||
|
'a.test.ts': `
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
test('test 2', async ({ }) => {
|
||||||
|
await test.step('outer step 2', async () => {
|
||||||
|
await test.step.fail('inner step 2', async () => {
|
||||||
|
await new Promise(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}, { reporter: '', timeout: 2500 });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.report.stats.unexpected).toBe(1);
|
||||||
|
expect(stripAnsi(result.output)).toBe(`
|
||||||
|
hook |Before Hooks
|
||||||
|
test.step |outer step 2 @ a.test.ts:4
|
||||||
|
test.step | inner step 2 @ a.test.ts:5
|
||||||
|
hook |After Hooks
|
||||||
|
hook |Worker Cleanup
|
||||||
|
|Test timeout of 2500ms exceeded.
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip test.step.fixme body', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'reporter.ts': stepIndentReporter,
|
||||||
|
'playwright.config.ts': `module.exports = { reporter: './reporter' };`,
|
||||||
|
'a.test.ts': `
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
test('test', async ({ }) => {
|
||||||
|
let didRun = false;
|
||||||
|
await test.step('outer step 2', async () => {
|
||||||
|
await test.step.fixme('inner step 2', async () => {
|
||||||
|
didRun = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(didRun).toBe(false);
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}, { reporter: '' });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.report.stats.expected).toBe(1);
|
||||||
|
expect(stripAnsi(result.output)).toBe(`
|
||||||
|
hook |Before Hooks
|
||||||
|
test.step |outer step 2 @ a.test.ts:5
|
||||||
|
expect |expect.toBe @ a.test.ts:10
|
||||||
|
hook |After Hooks
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -204,3 +204,26 @@ test('step should inherit return type from its callback ', async ({ runTSC }) =>
|
||||||
});
|
});
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('step.fail and step.fixme return void ', async ({ runTSC }) => {
|
||||||
|
const result = await runTSC({
|
||||||
|
'a.spec.ts': `
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
test('test step.fail', async ({ }) => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const bad1: string = await test.step.fail('my step', () => { });
|
||||||
|
const good: void = await test.step.fail('my step', async () => {
|
||||||
|
return 2024;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('test step.fixme', async ({ }) => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const bad1: string = await test.step.fixme('my step', () => { });
|
||||||
|
const good: void = await test.step.fixme('my step', async () => {
|
||||||
|
return 2024;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,38 @@ test('should show request source context id', async ({ runUITest, server }) => {
|
||||||
await expect(page.getByText('api#1')).toBeVisible();
|
await expect(page.getByText('api#1')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should work behind reverse proxy', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33705' } }, async ({ runUITest, proxyServer: reverseProxy }) => {
|
||||||
|
const { page } = await runUITest({
|
||||||
|
'a.test.ts': `
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
test('trace test', async ({ page }) => {
|
||||||
|
await page.setContent('<button>Submit</button>');
|
||||||
|
await page.getByRole('button').click();
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiModeUrl = new URL(page.url());
|
||||||
|
reverseProxy.forwardTo(+uiModeUrl.port, { prefix: '/subdir', preserveHostname: true });
|
||||||
|
await page.goto(`${reverseProxy.URL}/subdir${uiModeUrl.pathname}?${uiModeUrl.searchParams}`);
|
||||||
|
|
||||||
|
await page.getByText('trace test').dblclick();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('actions-tree')).toMatchAriaSnapshot(`
|
||||||
|
- tree:
|
||||||
|
- treeitem /Before Hooks \\d+[hmsp]+/
|
||||||
|
- treeitem /page\\.setContent \\d+[hmsp]+/
|
||||||
|
- treeitem /locator\\.clickgetByRole\\('button'\\) \\d+[hmsp]+/
|
||||||
|
- treeitem /expect\\.toBe \\d+[hmsp]+/ [selected]
|
||||||
|
- treeitem /After Hooks \\d+[hmsp]+/
|
||||||
|
`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('button'),
|
||||||
|
).toHaveText('Submit');
|
||||||
|
});
|
||||||
|
|
||||||
test('should filter actions tab on double-click', async ({ runUITest, server }) => {
|
test('should filter actions tab on double-click', async ({ runUITest, server }) => {
|
||||||
const { page } = await runUITest({
|
const { page } = await runUITest({
|
||||||
'a.spec.ts': `
|
'a.spec.ts': `
|
||||||
|
|
|
||||||
27
tests/third_party/proxy/index.ts
vendored
27
tests/third_party/proxy/index.ts
vendored
|
|
@ -3,6 +3,7 @@ import * as net from 'net';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
|
||||||
const pkg = { version: '1.0.0' }
|
const pkg = { version: '1.0.0' }
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ export function createProxy(server?: http.Server): ProxyServer {
|
||||||
if (!server) server = http.createServer();
|
if (!server) server = http.createServer();
|
||||||
server.on('request', onrequest);
|
server.on('request', onrequest);
|
||||||
server.on('connect', onconnect);
|
server.on('connect', onconnect);
|
||||||
|
server.on('upgrade', onupgrade);
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -465,4 +467,29 @@ function requestAuthorization(
|
||||||
};
|
};
|
||||||
res.writeHead(407, headers);
|
res.writeHead(407, headers);
|
||||||
res.end('Proxy authorization required');
|
res.end('Proxy authorization required');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onupgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
||||||
|
const proxyReq = http.request(req.url, {
|
||||||
|
method: req.method,
|
||||||
|
headers: req.headers,
|
||||||
|
localAddress: this.localAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('upgrade', async function (proxyRes, proxySocket, proxyHead) {
|
||||||
|
const header = ['HTTP/1.1 101 Switching Protocols'];
|
||||||
|
for (const [key, value] of Object.entries(proxyRes.headersDistinct))
|
||||||
|
header.push(`${key}: ${value}`);
|
||||||
|
socket.write(header.join('\r\n') + '\r\n\r\n');
|
||||||
|
if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pipeline(proxySocket, socket, proxySocket);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ECONNRESET")
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.end(head);
|
||||||
}
|
}
|
||||||
6
utils/generate_types/overrides-test.d.ts
vendored
6
utils/generate_types/overrides-test.d.ts
vendored
|
|
@ -162,7 +162,11 @@ export interface TestType<TestArgs extends {}, WorkerArgs extends {}> {
|
||||||
afterAll(inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise<any> | any): void;
|
afterAll(inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise<any> | any): void;
|
||||||
afterAll(title: string, inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise<any> | any): void;
|
afterAll(title: string, inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise<any> | any): void;
|
||||||
use(fixtures: Fixtures<{}, {}, TestArgs, WorkerArgs>): void;
|
use(fixtures: Fixtures<{}, {}, TestArgs, WorkerArgs>): void;
|
||||||
step<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
|
step: {
|
||||||
|
<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
|
||||||
|
fixme(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
|
||||||
|
fail(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
|
||||||
|
}
|
||||||
expect: Expect<{}>;
|
expect: Expect<{}>;
|
||||||
extend<T extends {}, W extends {} = {}>(fixtures: Fixtures<T, W, TestArgs, WorkerArgs>): TestType<TestArgs & T, WorkerArgs & W>;
|
extend<T extends {}, W extends {} = {}>(fixtures: Fixtures<T, W, TestArgs, WorkerArgs>): TestType<TestArgs & T, WorkerArgs & W>;
|
||||||
info(): TestInfo;
|
info(): TestInfo;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue