Merge branch 'main' into api-request-context

Signed-off-by: Shahzad <shahzad31comp@gmail.com>
This commit is contained in:
Shahzad 2025-02-27 15:00:02 +01:00 committed by GitHub
commit 8b0a70f9bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 573 additions and 1157 deletions

View file

@ -21,8 +21,12 @@ Creates new instances of [APIRequestContext].
### option: APIRequest.newContext.extraHTTPHeaders = %%-context-option-extrahttpheaders-%%
* since: v1.16
### option: APIRequest.newContext.apiRequestFailsOnErrorStatus = %%-context-option-apiRequestFailsOnErrorStatus-%%
### option: APIRequest.newContext.failOnStatusCode
* since: v1.51
- `failOnStatusCode` <[boolean]>
Whether to throw on response codes other than 2xx and 3xx. By default response object is returned
for all status codes.
### option: APIRequest.newContext.httpCredentials = %%-context-option-httpcredentials-%%
* since: v1.16
@ -67,25 +71,7 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat
- `localStorage` <[Array]<[Object]>>
- `name` <[string]>
- `value` <[string]>
- `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context
- `name` <[string]> database name
- `version` <[int]> database version
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` ?<[Array]<[unknown]>> indexedDB to set for context
Populates context with given storage state. This option can be used to initialize context with logged-in information
obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the

View file

@ -880,25 +880,7 @@ context cookies from the response. The method will automatically follow redirect
- `localStorage` <[Array]<[Object]>>
- `name` <[string]>
- `value` <[string]>
- `indexedDB` <[Array]<[Object]>>
- `name` <[string]>
- `version` <[int]>
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` <[Array]<[unknown]>>
Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor.
@ -914,7 +896,8 @@ Returns storage state for this request context, contains current cookies and loc
* since: v1.51
- `indexedDB` ?<boolean>
Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
Set to `true` to include IndexedDB in the storage state snapshot.
## event: APIRequestContext.apiRequest
* since: v1.51

View file

@ -1511,32 +1511,10 @@ Whether to emulate network being offline for the browser context.
- `localStorage` <[Array]<[Object]>>
- `name` <[string]>
- `value` <[string]>
- `indexedDB` <[Array]<[Object]>>
- `name` <[string]>
- `version` <[int]>
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` <[Array]<[unknown]>>
Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot.
:::note
IndexedDBs with typed arrays are currently not supported.
:::
## async method: BrowserContext.storageState
* since: v1.8
* langs: csharp, java
@ -1549,7 +1527,12 @@ IndexedDBs with typed arrays are currently not supported.
* since: v1.51
- `indexedDB` ?<boolean>
Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
Set to `true` to include IndexedDB in the storage state snapshot.
If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, enable this.
:::note
IndexedDBs with typed arrays are currently not supported.
:::
## property: BrowserContext.tracing
* since: v1.12

View file

@ -1090,6 +1090,9 @@ await rowLocator
### option: Locator.filter.hasNotText = %%-locator-option-has-not-text-%%
* since: v1.33
### option: Locator.filter.visible = %%-locator-option-visible-%%
* since: v1.51
## method: Locator.first
* since: v1.14
- returns: <[Locator]>
@ -2478,18 +2481,6 @@ When all steps combined have not finished during the specified [`option: timeout
### option: Locator.uncheck.trial = %%-input-trial-%%
* since: v1.14
## method: Locator.visible
* since: v1.51
- returns: <[Locator]>
Returns a locator that only matches [visible](../actionability.md#visible) elements.
### option: Locator.visible.visible
* since: v1.51
- `visible` <[boolean]>
Whether to match visible or invisible elements.
## async method: Locator.waitFor
* since: v1.16

View file

@ -2263,13 +2263,13 @@ assertThat(page.locator("body")).matchesAriaSnapshot("""
Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md).
Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
Snapshot is stored in a separate `.snapshot.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
**Usage**
```js
await expect(page.locator('body')).toMatchAriaSnapshot();
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' });
```
### option: LocatorAssertions.toMatchAriaSnapshot#2.name

View file

@ -264,25 +264,7 @@ Specify environment variables that will be visible to the browser. Defaults to `
- `localStorage` <[Array]<[Object]>> localStorage to set for context
- `name` <[string]>
- `value` <[string]>
- `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context
- `name` <[string]> database name
- `version` <[int]> database version
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` ?<[Array]<[unknown]>> indexedDB to set for context
Learn more about [storage state and auth](../auth.md).
@ -639,11 +621,6 @@ A list of permissions to grant to all pages in this context. See
An object containing additional HTTP headers to be sent with every request. Defaults to none.
## context-option-apiRequestFailsOnErrorStatus
- `apiRequestFailsOnErrorStatus` <[boolean]>
An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By default, response object is returned for all status codes.
## context-option-offline
- `offline` <[boolean]>
@ -1001,7 +978,6 @@ between the same pixel in compared images, between zero (strict) and one (lax),
- %%-context-option-locale-%%
- %%-context-option-permissions-%%
- %%-context-option-extrahttpheaders-%%
- %%-context-option-apiRequestFailsOnErrorStatus-%%
- %%-context-option-offline-%%
- %%-context-option-httpcredentials-%%
- %%-context-option-colorscheme-%%
@ -1179,6 +1155,11 @@ Note that outer and inner locators must belong to the same frame. Inner locator
Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring.
## locator-option-visible
- `visible` <[boolean]>
Only matches visible or invisible elements.
## locator-options-list-v1.14
- %%-locator-option-has-text-%%
- %%-locator-option-has-%%

View file

@ -339,10 +339,10 @@ npx playwright test --update-snapshots --update-source-mode=3way
#### Snapshots as separate files
To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.yml` file extension.
To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.snapshot.yml` file extension.
```js
await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main-snapshot.yml' });
await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main.snapshot.yml' });
```
By default, snapshots from a test file `example.spec.ts` are placed in the `example.spec.ts-snapshots` directory. As snapshots should be the same across browsers, only one snapshot is saved even if testing with multiple browsers. Should you wish, you can customize the [snapshot path template](./api/class-testconfig#test-config-snapshot-path-template) using the following configuration:

View file

@ -751,10 +751,10 @@ page.locator("x-details", new Page.LocatorOptions().setHasText("Details"))
.click();
```
```python async
await page.locator("x-details", has_text="Details" ).click()
await page.locator("x-details", has_text="Details").click()
```
```python sync
page.locator("x-details", has_text="Details" ).click()
page.locator("x-details", has_text="Details").click()
```
```csharp
await page
@ -1310,19 +1310,19 @@ Consider a page with two buttons, the first invisible and the second [visible](.
* This will only find a second button, because it is visible, and then click it.
```js
await page.locator('button').visible().click();
await page.locator('button').filter({ visible: true }).click();
```
```java
page.locator("button").visible().click();
page.locator("button").filter(new Locator.FilterOptions.setVisible(true)).click();
```
```python async
await page.locator("button").visible().click()
await page.locator("button").filter(visible=True).click()
```
```python sync
page.locator("button").visible().click()
page.locator("button").filter(visible=True).click()
```
```csharp
await page.Locator("button").Visible().ClickAsync();
await page.Locator("button").Filter(new() { Visible = true }).ClickAsync();
```
## Lists

View file

@ -239,7 +239,10 @@ export default defineConfig({
Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json.
Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD environments.
* Providing `gitCommit: 'generate'` property will populate it with the git commit details.
* Providing `gitDiff: 'generate'` property will populate it with the git diff details.
On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic generation.
**Usage**

View file

@ -20,32 +20,10 @@ import './common.css';
import './theme.css';
import './metadataView.css';
import type { Metadata } from '@playwright/test';
import type { GitCommitInfo } from '@testIsomorphic/types';
import type { CIInfo, GitCommitInfo, MetadataWithCommitInfo } from '@testIsomorphic/types';
import { CopyToClipboardContainer } from './copyToClipboard';
import { linkifyText } from '@web/renderUtils';
type MetadataEntries = [string, unknown][];
export const MetadataContext = React.createContext<MetadataEntries>([]);
export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) {
const entries = React.useMemo(() => {
// TODO: do not plumb actualWorkers through metadata.
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
}, [metadata]);
return <MetadataContext.Provider value={entries}>{children}</MetadataContext.Provider>;
}
export function useMetadata() {
return React.useContext(MetadataContext);
}
export function useGitCommitInfo() {
const metadataEntries = useMetadata();
return metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
}
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
error: null,
@ -72,23 +50,22 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
}
}
export const MetadataView = () => {
return <ErrorBoundary><InnerMetadataView/></ErrorBoundary>;
export const MetadataView: React.FC<{ metadata: Metadata }> = params => {
return <ErrorBoundary><InnerMetadataView metadata={params.metadata}/></ErrorBoundary>;
};
const InnerMetadataView = () => {
const metadataEntries = useMetadata();
const gitCommitInfo = useGitCommitInfo();
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
if (!gitCommitInfo && !entries.length)
return null;
const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => {
const commitInfo = params.metadata as MetadataWithCommitInfo;
const otherEntries = Object.entries(params.metadata).filter(([key]) => !ignoreKeys.has(key));
const hasMetadata = commitInfo.ci || commitInfo.gitCommit || otherEntries.length > 0;
if (!hasMetadata)
return;
return <div className='metadata-view'>
{gitCommitInfo && <>
<GitCommitInfoView info={gitCommitInfo}/>
{entries.length > 0 && <div className='metadata-separator' />}
</>}
{commitInfo.ci && !commitInfo.gitCommit && <CiInfoView info={commitInfo.ci}/>}
{commitInfo.gitCommit && <GitCommitInfoView ci={commitInfo.ci} commit={commitInfo.gitCommit}/>}
{otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) && <div className='metadata-separator' />}
<div className='metadata-section metadata-properties' role='list'>
{entries.map(([propertyName, value]) => {
{otherEntries.map(([propertyName, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return (
@ -104,39 +81,39 @@ const InnerMetadataView = () => {
</div>;
};
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
const email = info.revision?.email ? ` <${info.revision?.email}>` : '';
const author = `${info.revision?.author || ''}${email}`;
let subject = info.revision?.subject || '';
let link = info.revision?.link;
if (info.pull_request?.link && info.pull_request?.title) {
subject = info.pull_request?.title;
link = info.pull_request?.link;
}
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.revision?.timestamp);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.revision?.timestamp);
const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => {
const title = info.prTitle || `Commit ${info.commitHash}`;
const link = info.prHref || info.commitHref;
return <div className='metadata-section' role='list'>
<div role='listitem'>
{link ? (
<a href={link} target='_blank' rel='noopener noreferrer' title={subject}>
{subject}
</a>
) : <span title={subject}>
{subject}
</span>}
<a href={link} target='_blank' rel='noopener noreferrer' title={title}>{title}</a>
</div>
</div>;
};
const GitCommitInfoView: React.FC<{ ci?: CIInfo, commit: GitCommitInfo }> = ({ ci, commit }) => {
const title = ci?.prTitle || commit.subject;
const link = ci?.prHref || ci?.commitHref;
const email = ` <${commit.author.email}>`;
const author = `${commit.author.name}${email}`;
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(commit.committer.time);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(commit.committer.time);
return <div className='metadata-section' role='list'>
<div role='listitem'>
{link && <a href={link} target='_blank' rel='noopener noreferrer' title={title}>{title}</a>}
{!link && <span title={title}>{title}</span>}
</div>
<div role='listitem' className='hbox'>
<span className='mr-1'>{author}</span>
<span title={longTimestamp}> on {shortTimestamp}</span>
{info.ci?.link && (
<>
<span className='mx-2'>·</span>
<a href={info.ci?.link} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>Logs</a>
</>
)}
</div>
</div>;
};
const ignoreKeys = new Set(['ci', 'gitCommit', 'gitDiff', 'actualWorkers']);
export const isMetadataEmpty = (metadata: MetadataWithCommitInfo): boolean => {
const otherEntries = Object.entries(metadata).filter(([key]) => !ignoreKeys.has(key));
return !metadata.ci && !metadata.gitCommit && !otherEntries.length;
};

View file

@ -0,0 +1,29 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import type { HTMLReport } from './types';
const HTMLReportContext = React.createContext<HTMLReport | undefined>(undefined);
export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) {
return <HTMLReportContext.Provider value={report}>{children}</HTMLReportContext.Provider>;
}
export function useHTMLReport() {
return React.useContext(HTMLReportContext);
}

View file

@ -26,7 +26,7 @@ import './reportView.css';
import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css';
import { MetadataProvider } from './metadataView';
import { HTMLReportContextProvider } from './reportContext';
declare global {
interface Window {
@ -73,7 +73,7 @@ export const ReportView: React.FC<{
return result;
}, [report, filter]);
return <MetadataProvider metadata={report?.json().metadata ?? {}}><div className='htmlreport vbox px-4 pb-4'>
return <HTMLReportContextProvider report={report?.json()}><div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
<Route predicate={testFilesRoutePredicate}>
@ -89,7 +89,7 @@ export const ReportView: React.FC<{
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route>
</main>
</div></MetadataProvider>;
</div></HTMLReportContextProvider>;
};
const TestCaseViewLoader: React.FC<{

View file

@ -21,9 +21,14 @@ import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import type { TestResult } from './types';
import { fixTestPrompt } from '@web/components/prompts';
import { useGitCommitInfo } from './metadataView';
import { useHTMLReport } from './reportContext';
import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types';
export const TestErrorView: React.FC<{ error: string; testId?: string; result?: TestResult }> = ({ error, testId, result }) => {
export const TestErrorView: React.FC<{
error: string;
testId?: string;
result?: TestResult
}> = ({ error, testId, result }) => {
return (
<CodeSnippet code={error} testId={testId}>
<div style={{ float: 'right', margin: 10 }}>
@ -47,12 +52,13 @@ const PromptButton: React.FC<{
error: string;
result?: TestResult;
}> = ({ error, result }) => {
const gitCommitInfo = useGitCommitInfo();
const report = useHTMLReport();
const commitInfo = report?.metadata as MetadataWithCommitInfo | undefined;
const prompt = React.useMemo(() => fixTestPrompt(
error,
gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff,
commitInfo?.gitDiff,
result?.attachments.find(a => a.name === 'pageSnapshot')?.body
), [gitCommitInfo, result, error]);
), [commitInfo, result, error]);
const [copied, setCopied] = React.useState(false);

View file

@ -22,7 +22,7 @@ import { msToString } from './utils';
import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { MetadataView, useMetadata } from './metadataView';
import { isMetadataEmpty, MetadataView } from './metadataView';
export const TestFilesView: React.FC<{
tests: TestFileSummary[],
@ -67,13 +67,12 @@ export const TestFilesHeader: React.FC<{
metadataVisible: boolean,
toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
const metadataEntries = useMetadata();
if (!report)
return null;
return <>
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
<div className='test-file-header-info'>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{!isMetadataEmpty(report.metadata) && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name'>Project: {report.projectNames[0]}</div>}
@ -83,7 +82,7 @@ export const TestFilesHeader: React.FC<{
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div>
{metadataVisible && <MetadataView/>}
{metadataVisible && <MetadataView metadata={report.metadata}/>}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}

View file

@ -9268,14 +9268,15 @@ export interface BrowserContext {
/**
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
* @param options
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot. If your application uses IndexedDB to store
* authentication tokens, like Firebase Authentication, enable this.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
*/
indexedDB?: boolean;
@ -9317,49 +9318,7 @@ export interface BrowserContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -9742,12 +9701,6 @@ export interface Browser {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -10136,55 +10089,7 @@ export interface Browser {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -13225,6 +13130,11 @@ export interface Locator {
* `<article><div>Playwright</div></article>`.
*/
hasText?: string|RegExp;
/**
* Only matches visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
@ -14616,17 +14526,6 @@ export interface Locator {
trial?: boolean;
}): Promise<void>;
/**
* Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements.
* @param options
*/
visible(options?: {
/**
* Whether to match visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
* Returns when element specified by locator satisfies the
* [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option.
@ -14825,12 +14724,6 @@ export interface BrowserType<Unused = {}> {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -16721,12 +16614,6 @@ export interface AndroidDevice {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -17573,12 +17460,6 @@ export interface APIRequest {
* @param options
*/
newContext(options?: {
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* Methods like
* [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get)
@ -17654,6 +17535,12 @@ export interface APIRequest {
*/
extraHTTPHeaders?: { [key: string]: string; };
/**
* Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status
* codes.
*/
failOnStatusCode?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
@ -17755,55 +17642,7 @@ export interface APIRequest {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -19711,7 +19550,7 @@ export interface APIRequestContext {
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot.
*/
indexedDB?: boolean;
@ -19753,49 +19592,7 @@ export interface APIRequestContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -23292,12 +23089,6 @@ export interface BrowserContextOptions {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -23653,55 +23444,7 @@ export interface BrowserContextOptions {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};

View file

@ -510,7 +510,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise<channels.BrowserNewContextParams['storageState']> {
if (typeof options.storageState !== 'string')
return options.storageState;
return options.storageState as any;
try {
return JSON.parse(await platform.fs().promises.readFile(options.storageState, 'utf8'));
} catch (e) {

View file

@ -35,6 +35,7 @@ export type LocatorOptions = {
hasNotText?: string | RegExp;
has?: Locator;
hasNot?: Locator;
visible?: boolean;
};
export class Locator implements api.Locator {
@ -65,6 +66,9 @@ export class Locator implements api.Locator {
this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector);
}
if (options?.visible !== undefined)
this._selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
if (this._frame._platform.inspectCustom)
(this as any)[this._frame._platform.inspectCustom] = () => this._inspect();
}
@ -150,7 +154,7 @@ export class Locator implements api.Locator {
return await this._frame._highlight(this._selector);
}
locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator {
locator(selectorOrLocator: string | Locator, options?: Omit<LocatorOptions, 'visible'>): Locator {
if (isString(selectorOrLocator))
return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options);
if (selectorOrLocator._frame !== this._frame)
@ -218,11 +222,6 @@ export class Locator implements api.Locator {
return new Locator(this._frame, this._selector + ` >> nth=${index}`);
}
visible(options: { visible?: boolean } = {}): Locator {
const { visible = true } = options;
return new Locator(this._frame, this._selector + ` >> visible=${visible ? 'true' : 'false'}`);
}
and(locator: Locator): Locator {
if (locator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`);

View file

@ -37,11 +37,11 @@ export type SelectOptionOptions = { force?: boolean, timeout?: number };
export type FilePayload = { name: string, mimeType: string, buffer: Buffer };
export type StorageState = {
cookies: channels.NetworkCookie[],
origins: channels.OriginStorage[],
origins: (Omit<channels.OriginStorage, 'indexedDB'> & { indexedDB: unknown[] })[],
};
export type SetStorageState = {
cookies?: channels.SetNetworkCookie[],
origins?: channels.SetOriginStorage[]
origins?: (Omit<channels.SetOriginStorage, 'indexedDB'> & { indexedDB?: unknown[] })[]
};
export type LifecycleEvent = channels.LifecycleEvent;

View file

@ -407,7 +407,7 @@ scheme.PlaywrightNewRequestParams = tObject({
userAgent: tOptional(tString),
ignoreHTTPSErrors: tOptional(tBoolean),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
failOnStatusCode: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({
origin: tString,
cert: tOptional(tBinary),
@ -637,7 +637,6 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,
@ -725,7 +724,6 @@ scheme.BrowserNewContextParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,
@ -796,7 +794,6 @@ scheme.BrowserNewContextForReuseParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,
@ -2704,7 +2701,6 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,

View file

@ -79,9 +79,6 @@ export class RawMouseImpl implements input.RawMouse {
}
async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
// Bidi throws when x/y are not integers.
x = Math.floor(x);
y = Math.floor(y);
await this._performActions([{ type: 'pointerMove', x, y }]);
}

View file

@ -511,7 +511,7 @@ export abstract class BrowserContext extends SdkObject {
this._origins.add(origin);
}
async storageState(indexedDB = true): Promise<channels.BrowserContextStorageStateResult> {
async storageState(indexedDB = false): Promise<channels.BrowserContextStorageStateResult> {
const result: channels.BrowserContextStorageStateResult = {
cookies: await this.cookies(),
origins: []

View file

@ -51,7 +51,7 @@ import type { Readable, TransformCallback } from 'stream';
type FetchRequestOptions = {
userAgent: string;
extraHTTPHeaders?: HeadersArray;
apiRequestFailsOnErrorStatus?: boolean;
failOnStatusCode?: boolean;
httpCredentials?: HTTPCredentials;
proxy?: ProxySettings;
timeoutSettings: TimeoutSettings;
@ -213,7 +213,7 @@ export abstract class APIRequestContext extends SdkObject {
});
const fetchUid = this._storeResponseBody(fetchResponse.body);
this.fetchLog.set(fetchUid, controller.metadata.log);
const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.apiRequestFailsOnErrorStatus;
const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode;
if (failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) {
let responseText = '';
if (fetchResponse.body.byteLength) {
@ -610,7 +610,7 @@ export class BrowserContextAPIRequestContext extends APIRequestContext {
return {
userAgent: this._context._options.userAgent || this._context._browser.userAgent(),
extraHTTPHeaders: this._context._options.extraHTTPHeaders,
apiRequestFailsOnErrorStatus: this._context._options.apiRequestFailsOnErrorStatus,
failOnStatusCode: undefined,
httpCredentials: this._context._options.httpCredentials,
proxy: this._context._options.proxy || this._context._browser.options.proxy,
timeoutSettings: this._context._timeoutSettings,
@ -662,7 +662,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
baseURL: options.baseURL,
userAgent: options.userAgent || getUserAgent(),
extraHTTPHeaders: options.extraHTTPHeaders,
apiRequestFailsOnErrorStatus: !!options.apiRequestFailsOnErrorStatus,
failOnStatusCode: !!options.failOnStatusCode,
ignoreHTTPSErrors: !!options.ignoreHTTPSErrors,
httpCredentials: options.httpCredentials,
clientCertificates: options.clientCertificates,
@ -695,7 +695,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
return this._cookieStore.cookies(url);
}
override async storageState(indexedDB = true): Promise<channels.APIRequestContextStorageStateResult> {
override async storageState(indexedDB = false): Promise<channels.APIRequestContextStorageStateResult> {
return {
cookies: this._cookieStore.allCookies(),
origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })),

View file

@ -29,7 +29,7 @@ class Locator {
element: Element | undefined;
elements: Element[] | undefined;
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }) {
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }) {
if (options?.hasText)
selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
if (options?.hasNotText)
@ -38,6 +38,8 @@ class Locator {
selector += ` >> internal:has=` + JSON.stringify(options.has[selectorSymbol]);
if (options?.hasNot)
selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot[selectorSymbol]);
if (options?.visible !== undefined)
selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
this[selectorSymbol] = selector;
if (selector) {
const parsed = injectedScript.parseSelector(selector);
@ -46,7 +48,7 @@ class Locator {
}
const selectorBase = selector;
const self = this as any;
self.locator = (selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator => {
self.locator = (selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }): Locator => {
return new Locator(injectedScript, selectorBase ? selectorBase + ' >> ' + selector : selector, options);
};
self.getByTestId = (testId: string): Locator => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), testId));
@ -56,7 +58,7 @@ class Locator {
self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options));
self.getByTitle = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTitleSelector(text, options));
self.getByRole = (role: string, options: ByRoleOptions = {}): Locator => self.locator(getByRoleSelector(role, options));
self.filter = (options?: { hasText?: string | RegExp, has?: Locator }): Locator => new Locator(injectedScript, selector, options);
self.filter = (options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }): Locator => new Locator(injectedScript, selector, options);
self.first = (): Locator => self.locator('nth=0');
self.last = (): Locator => self.locator('nth=-1');
self.nth = (index: number): Locator => self.locator(`nth=${index}`);

View file

@ -280,7 +280,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
case 'last':
return `last()`;
case 'visible':
return `visible(${body === 'true' ? '' : '{ visible: false }'})`;
return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -376,7 +376,7 @@ export class PythonLocatorFactory implements LocatorFactory {
case 'last':
return `last`;
case 'visible':
return `visible(${body === 'true' ? '' : 'visible=False'})`;
return `filter(visible=${body === 'true' ? 'True' : 'False'})`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -485,7 +485,7 @@ export class JavaLocatorFactory implements LocatorFactory {
case 'last':
return `last()`;
case 'visible':
return `visible(${body === 'true' ? '' : `new ${clazz}.VisibleOptions().setVisible(false)`})`;
return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -584,7 +584,7 @@ export class CSharpLocatorFactory implements LocatorFactory {
case 'last':
return `Last`;
case 'visible':
return `Visible(${body === 'true' ? '' : 'new() { Visible = false }'})`;
return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {

View file

@ -170,9 +170,8 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
.replace(/first(\(\))?/g, 'nth=0')
.replace(/last(\(\))?/g, 'nth=-1')
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
.replace(/visible\(,?visible=true\)/g, 'visible=true')
.replace(/visible\(,?visible=false\)/g, 'visible=false')
.replace(/visible\(\)/g, 'visible=true')
.replace(/filter\(,?visible=true\)/g, 'visible=true')
.replace(/filter\(,?visible=false\)/g, 'visible=false')
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
.replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1')
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')

View file

@ -9268,14 +9268,15 @@ export interface BrowserContext {
/**
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
* @param options
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot. If your application uses IndexedDB to store
* authentication tokens, like Firebase Authentication, enable this.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
*/
indexedDB?: boolean;
@ -9317,49 +9318,7 @@ export interface BrowserContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -9742,12 +9701,6 @@ export interface Browser {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -10136,55 +10089,7 @@ export interface Browser {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -13225,6 +13130,11 @@ export interface Locator {
* `<article><div>Playwright</div></article>`.
*/
hasText?: string|RegExp;
/**
* Only matches visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
@ -14616,17 +14526,6 @@ export interface Locator {
trial?: boolean;
}): Promise<void>;
/**
* Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements.
* @param options
*/
visible(options?: {
/**
* Whether to match visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
* Returns when element specified by locator satisfies the
* [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option.
@ -14825,12 +14724,6 @@ export interface BrowserType<Unused = {}> {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -16721,12 +16614,6 @@ export interface AndroidDevice {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -17573,12 +17460,6 @@ export interface APIRequest {
* @param options
*/
newContext(options?: {
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* Methods like
* [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get)
@ -17654,6 +17535,12 @@ export interface APIRequest {
*/
extraHTTPHeaders?: { [key: string]: string; };
/**
* Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status
* codes.
*/
failOnStatusCode?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
@ -17755,55 +17642,7 @@ export interface APIRequest {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -19711,7 +19550,7 @@ export interface APIRequestContext {
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot.
*/
indexedDB?: boolean;
@ -19753,49 +19592,7 @@ export interface APIRequestContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -23292,12 +23089,6 @@ export interface BrowserContextOptions {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -23653,55 +23444,7 @@ export interface BrowserContextOptions {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};

View file

@ -628,7 +628,7 @@ class ArtifactsRecorder {
await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' });
});
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.ariasnapshot', async (page, path) => {
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.snapshot.yml', async (page, path) => {
const ariaSnapshot = await page.locator('body').ariaSnapshot({ timeout: 5000 });
await fs.promises.writeFile(path, ariaSnapshot);
});

View file

@ -14,23 +14,42 @@
* limitations under the License.
*/
export interface GitCommitInfo {
revision?: {
id?: string;
author?: string;
email?: string;
subject?: string;
timestamp?: number;
link?: string;
diff?: string;
},
pull_request?: {
link?: string;
diff?: string;
base?: string;
title?: string;
},
ci?: {
link?: string;
}
}
export type GitCommitInfo = {
shortHash: string;
hash: string;
subject: string;
body: string;
author: {
name: string;
email: string;
time: number;
};
committer: {
name: string;
email: string
time: number;
};
branch: string;
};
export type CIInfo = {
commitHref: string;
prHref?: string;
prTitle?: string;
buildHref?: string;
commitHash?: string;
baseHash?: string;
branch?: string;
};
export type UserMetadataWithCommitInfo = {
ci?: CIInfo;
gitCommit?: GitCommitInfo | 'generate';
gitDiff?: string | 'generate';
};
export type MetadataWithCommitInfo = {
ci?: CIInfo;
gitCommit?: GitCommitInfo;
gitDiff?: string;
};

View file

@ -22,7 +22,7 @@ import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-
import { kNoElementsFoundError, matcherHint } from './matcherHint';
import { EXPECTED_COLOR } from '../common/expectBundle';
import { callLogText, sanitizeFilePathBeforeExtension, trimLongString } from '../util';
import { callLogText, fileExistsAsync, sanitizeFilePathBeforeExtension, trimLongString } from '../util';
import { printReceivedStringContainExpectedSubstring } from './expect';
import { currentTestInfo } from '../common/globals';
@ -70,7 +70,8 @@ export async function toMatchAriaSnapshot(
timeout = options.timeout ?? this.timeout;
} else {
if (expectedParam?.name) {
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name)]);
const ext = expectedParam.name!.endsWith('.snapshot.yml') ? '.snapshot.yml' : undefined;
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name, ext)]);
} else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
if (!snapshotNames) {
@ -78,7 +79,14 @@ export async function toMatchAriaSnapshot(
(testInfo as any)[snapshotNamesSymbol] = snapshotNames;
}
const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' ');
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml']);
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.snapshot.yml');
// in 1.51, we changed the default template to use .snapshot.yml extension
// for backwards compatibility, we check for the legacy .yml extension
if (!(await fileExistsAsync(expectedPath))) {
const legacyPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.yml');
if (await fileExistsAsync(legacyPath))
expectedPath = legacyPath;
}
}
expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => '');
timeout = expectedParam?.timeout ?? this.timeout;

View file

@ -14,119 +14,151 @@
* limitations under the License.
*/
import fs from 'fs';
import * as fs from 'fs';
import { createGuid, spawnAsync } from 'playwright-core/lib/utils';
import { spawnAsync } from 'playwright-core/lib/utils';
import type { TestRunnerPlugin } from './';
import type { FullConfig } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config';
import type { GitCommitInfo } from '../isomorphic/types';
import type { GitCommitInfo, CIInfo, UserMetadataWithCommitInfo } from '../isomorphic/types';
const GIT_OPERATIONS_TIMEOUT_MS = 1500;
const GIT_OPERATIONS_TIMEOUT_MS = 3000;
export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => {
const commitProperty = fullConfig.config.metadata['git.commit.info'];
if (commitProperty && typeof commitProperty === 'object' && Object.keys(commitProperty).length === 0)
fullConfig.plugins.push({ factory: gitCommitInfo });
fullConfig.plugins.push({ factory: gitCommitInfoPlugin });
};
export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
type GitCommitInfoPluginOptions = {
directory?: string;
};
export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
return {
name: 'playwright:git-commit-info',
setup: async (config: FullConfig, configDir: string) => {
const commitInfo = await linksFromEnv();
await enrichStatusFromCLI(options?.directory || configDir, commitInfo);
config.metadata = config.metadata || {};
config.metadata['git.commit.info'] = commitInfo;
const metadata = config.metadata as UserMetadataWithCommitInfo;
const ci = await ciInfo();
if (!metadata.ci && ci)
metadata.ci = ci;
if ((ci && !metadata.gitCommit) || metadata.gitCommit === 'generate') {
const git = await gitCommitInfo(options?.directory || configDir).catch(e => {
// eslint-disable-next-line no-console
console.error('Failed to get git commit info', e);
});
if (git)
metadata.gitCommit = git;
}
if ((ci && !metadata.gitDiff) || metadata.gitDiff === 'generate') {
const diffResult = await gitDiff(options?.directory || configDir, ci).catch(e => {
// eslint-disable-next-line no-console
console.error('Failed to get git diff', e);
});
if (diffResult)
metadata.gitDiff = diffResult;
}
},
};
};
interface GitCommitInfoPluginOptions {
directory?: string;
}
async function linksFromEnv(): Promise<GitCommitInfo> {
const out: GitCommitInfo = {};
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
if (process.env.BUILD_URL) {
out.ci = out.ci || {};
out.ci.link = process.env.BUILD_URL;
}
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA) {
out.revision = out.revision || {};
out.revision.link = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`;
}
if (process.env.CI_JOB_URL) {
out.ci = out.ci || {};
out.ci.link = process.env.CI_JOB_URL;
}
// GitHub: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA) {
out.revision = out.revision || {};
out.revision.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`;
}
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) {
out.ci = out.ci || {};
out.ci.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
}
if (process.env.GITHUB_EVENT_PATH) {
async function ciInfo(): Promise<CIInfo | undefined> {
if (process.env.GITHUB_ACTIONS) {
let pr: { title: string, number: number } | undefined;
try {
const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH, 'utf8'));
if (json.pull_request) {
out.pull_request = out.pull_request || {};
out.pull_request.title = json.pull_request.title;
out.pull_request.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${json.pull_request.number}`;
out.pull_request.base = json.pull_request.base.ref;
}
const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH!, 'utf8'));
pr = { title: json.pull_request.title, number: json.pull_request.number };
} catch {
}
return {
commitHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`,
prHref: pr ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${pr.number}` : undefined,
prTitle: pr ? pr.title : undefined,
buildHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
commitHash: process.env.GITHUB_SHA,
baseHash: process.env.GITHUB_BASE_REF,
branch: process.env.GITHUB_REF_NAME,
};
}
return out;
if (process.env.GITLAB_CI) {
return {
commitHref: `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`,
prHref: process.env.CI_MERGE_REQUEST_IID ? `${process.env.CI_PROJECT_URL}/-/merge_requests/${process.env.CI_MERGE_REQUEST_IID}` : undefined,
buildHref: process.env.CI_JOB_URL,
commitHash: process.env.CI_COMMIT_SHA,
baseHash: process.env.CI_COMMIT_BEFORE_SHA,
branch: process.env.CI_COMMIT_REF_NAME,
};
}
if (process.env.JENKINS_URL && process.env.BUILD_URL) {
return {
commitHref: process.env.BUILD_URL,
commitHash: process.env.GIT_COMMIT,
baseHash: process.env.GIT_PREVIOUS_COMMIT,
branch: process.env.GIT_BRANCH,
};
}
// Open to PRs.
}
async function enrichStatusFromCLI(gitDir: string, commitInfo: GitCommitInfo) {
const separator = `:${createGuid().slice(0, 4)}:`;
async function gitCommitInfo(gitDir: string): Promise<GitCommitInfo | undefined> {
const separator = `---786eec917292---`;
const tokens = [
'%H', // commit hash
'%h', // abbreviated commit hash
'%s', // subject
'%B', // raw body (unwrapped subject and body)
'%an', // author name
'%ae', // author email
'%at', // author date, UNIX timestamp
'%cn', // committer name
'%ce', // committer email
'%ct', // committer date, UNIX timestamp
'', // branch
];
const commitInfoResult = await spawnAsync(
'git',
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
`git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, [],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true }
);
if (commitInfoResult.code)
return;
return undefined;
const showOutput = commitInfoResult.stdout.trim();
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = showOutput.split(separator);
commitInfo.revision = {
...commitInfo.revision,
id,
author,
email,
return {
shortHash,
hash,
subject,
timestamp,
body,
author: {
name: authorName,
email: authorEmail,
time: +authorTime * 1000,
},
committer: {
name: committerName,
email: committerEmail,
time: +committerTime * 1000,
},
branch: branch.trim(),
};
const diffLimit = 1_000_000; // 1MB
if (commitInfo.pull_request?.base) {
const pullDiffResult = await spawnAsync(
'git',
['diff', commitInfo.pull_request?.base],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!pullDiffResult.code)
commitInfo.pull_request!.diff = pullDiffResult.stdout.substring(0, diffLimit);
} else {
const diffResult = await spawnAsync(
'git',
['diff', 'HEAD~1'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!diffResult.code)
commitInfo.revision!.diff = diffResult.stdout.substring(0, diffLimit);
}
}
async function gitDiff(gitDir: string, ci?: CIInfo): Promise<string | undefined> {
const diffLimit = 100_000;
const baseHash = ci?.baseHash ?? 'HEAD~1';
const pullDiffResult = await spawnAsync(
'git',
['diff', baseHash],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!pullDiffResult.code)
return pullDiffResult.stdout.substring(0, diffLimit);
}

View file

@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = {
};
export { webServer } from './webServerPlugin';
export { gitCommitInfo } from './gitCommitInfoPlugin';

View file

@ -206,8 +206,8 @@ export function addSuffixToFilePath(filePath: string, suffix: string): string {
return base + suffix + ext;
}
export function sanitizeFilePathBeforeExtension(filePath: string): string {
const ext = path.extname(filePath);
export function sanitizeFilePathBeforeExtension(filePath: string, ext?: string): string {
ext ??= path.extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
return sanitizeForFilePath(base) + ext;
}
@ -391,6 +391,15 @@ function fileExists(resolved: string) {
return fs.statSync(resolved, { throwIfNoEntry: false })?.isFile();
}
export async function fileExistsAsync(resolved: string) {
try {
const stat = await fs.promises.stat(resolved);
return stat.isFile();
} catch {
return false;
}
}
function dirExists(resolved: string) {
return fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory();
}

View file

@ -35,7 +35,7 @@ class Fixture {
private _selfTeardownComplete: Promise<void> | undefined;
private _setupDescription: FixtureDescription;
private _teardownDescription: FixtureDescription;
private _stepInfo: { category: 'fixture', location?: Location } | undefined;
private _stepInfo: { title: string, category: 'fixture', location?: Location } | undefined;
_deps = new Set<Fixture>();
_usages = new Set<Fixture>();
@ -47,7 +47,7 @@ class Fixture {
const isUserFixture = this.registration.location && filterStackFile(this.registration.location.file);
const title = this.registration.customTitle || this.registration.name;
const location = isUserFixture ? this.registration.location : undefined;
this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined;
this._stepInfo = shouldGenerateStep ? { title: `fixture: ${title}`, category: 'fixture', location } : undefined;
this._setupDescription = {
title,
phase: 'setup',
@ -68,13 +68,11 @@ class Fixture {
return;
}
await testInfo._runAsStage({
title: `fixture: ${this.registration.customTitle ?? this.registration.name}`,
runnable: { ...runnable, fixture: this._setupDescription },
stepInfo: this._stepInfo,
}, async () => {
await this._setupInternal(testInfo);
});
const run = () => testInfo._runWithTimeout({ ...runnable, fixture: this._setupDescription }, () => this._setupInternal(testInfo));
if (this._stepInfo)
await testInfo._runAsStep(this._stepInfo, run);
else
await run();
}
private async _setupInternal(testInfo: TestInfoImpl) {
@ -133,13 +131,11 @@ class Fixture {
// Do not even start the teardown for a fixture that does not have any
// time remaining in the time slot. This avoids cascading timeouts.
if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) {
await testInfo._runAsStage({
title: `fixture: ${this.registration.customTitle ?? this.registration.name}`,
runnable: fixtureRunnable,
stepInfo: this._stepInfo,
}, async () => {
await this._teardownInternal();
});
const run = () => testInfo._runWithTimeout(fixtureRunnable, () => this._teardownInternal());
if (this._stepInfo)
await testInfo._runAsStep(this._stepInfo, run);
else
await run();
}
} finally {
// To preserve fixtures integrity, forcefully cleanup fixtures
@ -268,9 +264,7 @@ export class FixtureRunner {
// Do not run the function when fixture setup has already failed.
return null;
}
await testInfo._runAsStage({ title: 'run function', runnable }, async () => {
await fn(params, testInfo);
});
await testInfo._runWithTimeout(runnable, () => fn(params, testInfo));
}
private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl, runnable: RunnableDescription): Promise<Fixture> {

View file

@ -20,7 +20,7 @@ import path from 'path';
import { captureRawStack, monotonicTime, sanitizeForFilePath, stringifyStackFrames, currentZone } from 'playwright-core/lib/utils';
import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager';
import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';
import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';
import { TestTracing } from './testTracing';
import { testInfoError } from './util';
import { FloatingPromiseScope } from './floatingPromiseScope';
@ -50,16 +50,8 @@ export interface TestStepInternal {
error?: TestInfoErrorImpl;
infectParentStepsWithError?: boolean;
box?: boolean;
isStage?: boolean;
}
export type TestStage = {
title: string;
stepInfo?: { category: 'hook' | 'fixture', location?: Location };
runnable?: RunnableDescription;
step?: TestStepInternal;
};
export class TestInfoImpl implements TestInfo {
private _onStepBegin: (payload: StepBeginPayload) => void;
private _onStepEnd: (payload: StepEndPayload) => void;
@ -235,28 +227,27 @@ export class TestInfoImpl implements TestInfo {
}
}
private _findLastStageStep(steps: TestStepInternal[]): TestStepInternal | undefined {
// Find the deepest step that is marked as isStage and has not finished yet.
private _findLastPredefinedStep(steps: TestStepInternal[]): TestStepInternal | undefined {
// Find the deepest predefined step that has not finished yet.
for (let i = steps.length - 1; i >= 0; i--) {
const child = this._findLastStageStep(steps[i].steps);
const child = this._findLastPredefinedStep(steps[i].steps);
if (child)
return child;
if (steps[i].isStage && !steps[i].endWallTime)
if ((steps[i].category === 'hook' || steps[i].category === 'fixture') && !steps[i].endWallTime)
return steps[i];
}
}
private _parentStep() {
return currentZone().data<TestStepInternal>('stepZone')
?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent.
return currentZone().data<TestStepInternal>('stepZone') ?? this._findLastPredefinedStep(this._steps);
}
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps' | 'attachmentIndices' | 'info'>, parentStep?: TestStepInternal): TestStepInternal {
const stepId = `${data.category}@${++this._lastStepId}`;
if (data.isStage) {
// Predefined stages form a fixed hierarchy - use the current one as parent.
parentStep = this._findLastStageStep(this._steps);
if (data.category === 'hook' || data.category === 'fixture') {
// Predefined steps form a fixed hierarchy - use the current one as parent.
parentStep = this._findLastPredefinedStep(this._steps);
} else {
if (!parentStep)
parentStep = this._parentStep();
@ -355,21 +346,23 @@ export class TestInfoImpl implements TestInfo {
this._tracing.appendForError(serialized);
}
async _runAsStage(stage: TestStage, cb: () => Promise<any>) {
if (debugTest.enabled) {
const location = stage.runnable?.location ? ` at "${formatLocation(stage.runnable.location)}"` : ``;
debugTest(`started stage "${stage.title}"${location}`);
}
stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, isStage: true }) : undefined;
async _runAsStep(stepInfo: { title: string, category: 'hook' | 'fixture', location?: Location }, cb: () => Promise<any>) {
const step = this._addStep(stepInfo);
try {
await this._timeoutManager.withRunnable(stage.runnable, async () => {
await cb();
step.complete({});
} catch (error) {
step.complete({ error });
throw error;
}
}
async _runWithTimeout(runnable: RunnableDescription, cb: () => Promise<any>) {
try {
await this._timeoutManager.withRunnable(runnable, async () => {
try {
await cb();
} catch (e) {
// Only handle errors directly thrown by the user code.
if (!stage.runnable)
throw e;
if (this._allowSkips && (e instanceof SkipError)) {
if (this.status === 'passed')
this.status = 'skipped';
@ -377,7 +370,7 @@ export class TestInfoImpl implements TestInfo {
// Unfortunately, we have to handle user errors and timeout errors differently.
// Consider the following scenario:
// - locator.click times out
// - all stages containing the test function finish with TimeoutManagerError
// - all steps containing the test function finish with TimeoutManagerError
// - test finishes, the page is closed and this triggers locator.click error
// - we would like to present the locator.click error to the user
// - therefore, we need a try/catch inside the "run with timeout" block and capture the error
@ -386,16 +379,12 @@ export class TestInfoImpl implements TestInfo {
throw e;
}
});
stage.step?.complete({});
} catch (error) {
// When interrupting, we arrive here with a TimeoutManagerError, but we should not
// consider it a timeout.
if (!this._wasInterrupted && (error instanceof TimeoutManagerError) && stage.runnable)
if (!this._wasInterrupted && (error instanceof TimeoutManagerError))
this._failWithError(error);
stage.step?.complete({ error });
throw error;
} finally {
debugTest(`finished stage "${stage.title}"`);
}
}
@ -430,7 +419,7 @@ export class TestInfoImpl implements TestInfo {
} else {
// trace viewer has no means of representing attachments outside of a step, so we create an artificial action
const callId = `attach@${++this._lastStepId}`;
this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []);
this._tracing.appendBeforeActionForStep(callId, this._findLastPredefinedStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []);
this._tracing.appendAfterActionForStep(callId, undefined, [attachment]);
}
@ -463,9 +452,11 @@ export class TestInfoImpl implements TestInfo {
return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
}
_resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[]) {
_resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], extension?: string) {
const subPath = path.join(...pathSegments);
const parsedSubPath = path.parse(subPath);
const dir = path.dirname(subPath);
const ext = extension ?? path.extname(subPath);
const name = path.basename(subPath, ext);
const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile);
const parsedRelativeTestFilePath = path.parse(relativeTestFilePath);
const projectNamePathSegment = sanitizeForFilePath(this.project.name);
@ -481,8 +472,8 @@ export class TestInfoImpl implements TestInfo {
.replace(/\{(.)?testName\}/g, '$1' + this._fsSanitizedTestName())
.replace(/\{(.)?testFileName\}/g, '$1' + parsedRelativeTestFilePath.base)
.replace(/\{(.)?testFilePath\}/g, '$1' + relativeTestFilePath)
.replace(/\{(.)?arg\}/g, '$1' + path.join(parsedSubPath.dir, parsedSubPath.name))
.replace(/\{(.)?ext\}/g, parsedSubPath.ext ? '$1' + parsedSubPath.ext : '');
.replace(/\{(.)?arg\}/g, '$1' + path.join(dir, name))
.replace(/\{(.)?ext\}/g, ext ? '$1' + ext : '');
return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath));
}

View file

@ -17,6 +17,8 @@
import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utils';
import { debugTest, formatLocation } from '../util';
import type { Location } from '../../types/testReporter';
export type TimeSlot = {
@ -76,9 +78,7 @@ export class TimeoutManager {
return slot.timeout > 0 && (slot.elapsed >= slot.timeout - 1);
}
async withRunnable<T>(runnable: RunnableDescription | undefined, cb: () => Promise<T>): Promise<T> {
if (!runnable)
return await cb();
async withRunnable<T>(runnable: RunnableDescription, cb: () => Promise<T>): Promise<T> {
if (this._running)
throw new Error(`Internal error: duplicate runnable`);
const running = this._running = {
@ -89,7 +89,13 @@ export class TimeoutManager {
timer: undefined,
timeoutPromise: new ManualPromise(),
};
let debugTitle = '';
try {
if (debugTest.enabled) {
debugTitle = runnable.fixture ? `${runnable.fixture.phase} "${runnable.fixture.title}"` : runnable.type;
const location = runnable.location ? ` at "${formatLocation(runnable.location)}"` : ``;
debugTest(`started ${debugTitle}${location}`);
}
this._updateTimeout(running);
return await Promise.race([
cb(),
@ -101,6 +107,8 @@ export class TimeoutManager {
running.timer = undefined;
running.slot.elapsed += monotonicTime() - running.start;
this._running = undefined;
if (debugTest.enabled)
debugTest(`finished ${debugTitle}`);
}
}

View file

@ -115,12 +115,12 @@ export class WorkerMain extends ProcessRunner {
const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {});
const runnable = { type: 'teardown' } as const;
// We have to load the project to get the right deadline below.
await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => this._loadIfNeeded()).catch(() => {});
await fakeTestInfo._runWithTimeout(runnable, () => this._loadIfNeeded()).catch(() => {});
await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {});
await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {});
// Close any other browsers launched in this process. This includes anything launched
// manually in the test/hooks and internal browsers like Playwright Inspector.
await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {});
await fakeTestInfo._runWithTimeout(runnable, () => gracefullyCloseAll()).catch(() => {});
this._fatalErrors.push(...fakeTestInfo.errors);
} catch (e) {
this._fatalErrors.push(testInfoError(e));
@ -330,8 +330,8 @@ export class WorkerMain extends ProcessRunner {
testInfo._floatingPromiseScope.clear();
};
await testInfo._runAsStage({ title: 'setup and test' }, async () => {
await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => {
await (async () => {
await testInfo._runWithTimeout({ type: 'test' }, async () => {
// Ideally, "trace" would be an config-level option belonging to the
// test runner instead of a fixture belonging to Playwright.
// However, for backwards compatibility, we have to read it from a fixture today.
@ -356,7 +356,7 @@ export class WorkerMain extends ProcessRunner {
await removeFolders([testInfo.outputDir]);
let testFunctionParams: object | null = null;
await testInfo._runAsStage({ title: 'Before Hooks', stepInfo: { category: 'hook' } }, async () => {
await testInfo._runAsStep({ title: 'Before Hooks', category: 'hook' }, async () => {
// Run "beforeAll" hooks, unless already run during previous tests.
for (const suite of suites)
await this._runBeforeAllHooksForSuite(suite, testInfo);
@ -376,13 +376,13 @@ export class WorkerMain extends ProcessRunner {
return;
}
await testInfo._runAsStage({ title: 'test function', runnable: { type: 'test' } }, async () => {
await testInfo._runWithTimeout({ type: 'test' }, async () => {
// Now run the test itself.
const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]").
await fn(testFunctionParams, testInfo);
checkForFloatingPromises('the test');
});
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
})().catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
// Update duration, so it is available in fixture teardown and afterEach hooks.
testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0;
@ -393,12 +393,12 @@ export class WorkerMain extends ProcessRunner {
// After hooks get an additional timeout.
const afterHooksTimeout = calculateMaxTimeout(this._project.project.timeout, testInfo.timeout);
const afterHooksSlot = { timeout: afterHooksTimeout, elapsed: 0 };
await testInfo._runAsStage({ title: 'After Hooks', stepInfo: { category: 'hook' } }, async () => {
await testInfo._runAsStep({ title: 'After Hooks', category: 'hook' }, async () => {
let firstAfterHooksError: Error | undefined;
try {
// Run "immediately upon test function finish" callback.
await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.());
await testInfo._runWithTimeout({ type: 'test', slot: afterHooksSlot }, async () => testInfo._onDidFinishTestFunction?.());
} catch (error) {
firstAfterHooksError = firstAfterHooksError ?? error;
}
@ -448,7 +448,7 @@ export class WorkerMain extends ProcessRunner {
// Mark as "cleaned up" early to avoid running cleanup twice.
this._didRunFullCleanup = true;
await testInfo._runAsStage({ title: 'Worker Cleanup', stepInfo: { category: 'hook' } }, async () => {
await testInfo._runAsStep({ title: 'Worker Cleanup', category: 'hook' }, async () => {
let firstWorkerCleanupError: Error | undefined;
// Give it more time for the full cleanup.
@ -481,7 +481,7 @@ export class WorkerMain extends ProcessRunner {
}
const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 };
await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => {
await testInfo._runWithTimeout({ type: 'test', slot: tracingSlot }, async () => {
await testInfo._tracing.stopIfNeeded();
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
@ -534,7 +534,7 @@ export class WorkerMain extends ProcessRunner {
let firstError: Error | undefined;
for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) {
try {
await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => {
await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => {
// Separate time slot for each beforeAll/afterAll hook.
const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 };
const runnable = { type: hook.type, slot: timeSlot, location: hook.location };
@ -587,7 +587,7 @@ export class WorkerMain extends ProcessRunner {
continue;
}
try {
await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => {
await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => {
await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test', runnable);
});
} catch (error) {

View file

@ -1284,9 +1284,11 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
/**
* Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as
* key-value pairs, and JSON report will include metadata serialized as json.
* - Providing `gitCommit: 'generate'` property will populate it with the git commit details.
* - Providing `gitDiff: 'generate'` property will populate it with the git diff details.
*
* Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD
* environments.
* On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic
* generation.
*
* **Usage**
*
@ -8791,14 +8793,14 @@ interface LocatorAssertions {
/**
* Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots).
*
* Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate`
* and/or `snapshotPathTemplate` properties in the configuration file.
* Snapshot is stored in a separate `.snapshot.yml` file in a location configured by
* `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
*
* **Usage**
*
* ```js
* await expect(page.locator('body')).toMatchAriaSnapshot();
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' });
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' });
* ```
*
* @param options

View file

@ -664,7 +664,7 @@ export type PlaywrightNewRequestParams = {
userAgent?: string,
ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
failOnStatusCode?: boolean,
clientCertificates?: {
origin: string,
cert?: Binary,
@ -696,7 +696,7 @@ export type PlaywrightNewRequestOptions = {
userAgent?: string,
ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
failOnStatusCode?: boolean,
clientCertificates?: {
origin: string,
cert?: Binary,
@ -1070,7 +1070,6 @@ export type BrowserTypeLaunchPersistentContextParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1152,7 +1151,6 @@ export type BrowserTypeLaunchPersistentContextOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1269,7 +1267,6 @@ export type BrowserNewContextParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1337,7 +1334,6 @@ export type BrowserNewContextOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1408,7 +1404,6 @@ export type BrowserNewContextForReuseParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1476,7 +1471,6 @@ export type BrowserNewContextForReuseOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -4843,7 +4837,6 @@ export type AndroidDeviceLaunchBrowserParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -4909,7 +4902,6 @@ export type AndroidDeviceLaunchBrowserOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,

View file

@ -567,7 +567,6 @@ ContextOptions:
extraHTTPHeaders:
type: array?
items: NameValue
apiRequestFailsOnErrorStatus: boolean?
offline: boolean?
httpCredentials:
type: object?
@ -799,7 +798,7 @@ Playwright:
extraHTTPHeaders:
type: array?
items: NameValue
apiRequestFailsOnErrorStatus: boolean?
failOnStatusCode: boolean?
clientCertificates:
type: array?
items:

View file

@ -24,20 +24,20 @@ import type { StackFrame } from '@protocol/channels';
import { CopyToClipboardTextButton } from './copyToClipboard';
import { attachmentURL } from './attachmentsTab';
import { fixTestPrompt } from '@web/components/prompts';
import type { GitCommitInfo } from '@testIsomorphic/types';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
import { AIConversation } from './aiConversation';
import { ToolbarButton } from '@web/components/toolbarButton';
import { useIsLLMAvailable, useLLMChat } from './llm';
import { useAsyncMemo } from '@web/uiUtils';
const GitCommitInfoContext = React.createContext<GitCommitInfo | undefined>(undefined);
const CommitInfoContext = React.createContext<MetadataWithCommitInfo | undefined>(undefined);
export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) {
return <GitCommitInfoContext.Provider value={gitCommitInfo}>{children}</GitCommitInfoContext.Provider>;
export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) {
return <CommitInfoContext.Provider value={commitInfo}>{children}</CommitInfoContext.Provider>;
}
export function useGitCommitInfo() {
return React.useContext(GitCommitInfoContext);
export function useCommitInfo() {
return React.useContext(CommitInfoContext);
}
function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) {
@ -100,8 +100,7 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined):
function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) {
const [showLLM, setShowLLM] = React.useState(false);
const llmAvailable = useIsLLMAvailable();
const gitCommitInfo = useGitCommitInfo();
const diff = gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff;
const metadata = useCommitInfo();
let location: string | undefined;
let longLocation: string | undefined;
@ -127,8 +126,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
</div>}
<span style={{ position: 'absolute', right: '5px' }}>
{llmAvailable
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={diff} pageSnapshot={pageSnapshot} />
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={diff} />}
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={metadata?.gitDiff} pageSnapshot={pageSnapshot} />
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={metadata?.gitDiff} />}
</span>
</div>

View file

@ -37,8 +37,9 @@ import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
import { GitCommitInfoProvider } from './errorsTab';
import { CommitInfoProvider } from './errorsTab';
import { LLMProvider } from './llm';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -432,7 +433,7 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={clsx('vbox', isShowingOutput && 'hidden')}>
<GitCommitInfoProvider gitCommitInfo={testModel?.config.metadata['git.commit.info']}>
<CommitInfoProvider commitInfo={testModel?.config.metadata as MetadataWithCommitInfo}>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
@ -440,7 +441,7 @@ export const UIModeView: React.FC<{}> = ({
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</GitCommitInfoProvider>
</CommitInfoProvider>
</div>
</div>}
sidebar={<div className='vbox ui-mode-sidebar'>

View file

@ -1,67 +0,0 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* 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 { browserTest as it, expect } from '../config/browserTest';
it('should throw when apiRequestFailsOnErrorStatus is set to true inside BrowserContext options', async ({ browser, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const context = await browser.newContext({ apiRequestFailsOnErrorStatus: true });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toContain('404 Not Found');
await context.close();
});
it('should not throw when failOnStatusCode is set to false inside BrowserContext options', async ({ browser, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const context = await browser.newContext({ apiRequestFailsOnErrorStatus: false });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toBeUndefined();
await context.close();
});
it('should throw when apiRequestFailsOnErrorStatus is set to true inside browserType.launchPersistentContext options', async ({ browserType, server, createUserDataDir }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const userDataDir = await createUserDataDir();
const context = await browserType.launchPersistentContext(userDataDir, { apiRequestFailsOnErrorStatus: true });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toContain('404 Not Found');
await context.close();
});
it('should not throw when apiRequestFailsOnErrorStatus is set to false inside browserType.launchPersistentContext options', async ({ browserType, server, createUserDataDir }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const userDataDir = await createUserDataDir();
const context = await browserType.launchPersistentContext(userDataDir, { apiRequestFailsOnErrorStatus: false });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toBeUndefined();
await context.close();
});

View file

@ -110,7 +110,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
});
const path = testInfo.outputPath('storage-state.json');
const state = await context.storageState({ path });
const state = await context.storageState({ path, indexedDB: true });
const written = await fs.promises.readFile(path, 'utf8');
expect(JSON.stringify(state, undefined, 2)).toBe(written);
@ -365,7 +365,7 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
await page.getByLabel('Mins').fill('1');
await page.getByText('Add Task').click();
const storageState = await page.context().storageState();
const storageState = await page.context().storageState({ indexedDB: true });
expect(storageState.origins).toEqual([
{
origin: server.PREFIX,
@ -438,7 +438,7 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
]);
const context = await contextFactory({ storageState });
expect(await context.storageState()).toEqual(storageState);
expect(await context.storageState({ indexedDB: true })).toEqual(storageState);
const recreatedPage = await context.newPage();
await recreatedPage.goto(server.PREFIX + '/to-do-notifications/index.html');
@ -448,5 +448,5 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
- text: /Pet the cat/
`);
expect(await context.storageState({ indexedDB: false })).toEqual({ cookies: [], origins: [] });
expect(await context.storageState()).toEqual({ cookies: [], origins: [] });
});

View file

@ -376,7 +376,7 @@ it('should preserve local storage on import/export of storage state', async ({ p
};
const request = await playwright.request.newContext({ storageState });
await request.get(server.EMPTY_PAGE);
const exportedState = await request.storageState();
const exportedState = await request.storageState({ indexedDB: true });
expect(exportedState).toEqual(storageState);
await request.dispose();
});

View file

@ -537,9 +537,9 @@ it('should retry ECONNRESET', {
await request.dispose();
});
it('should throw when apiRequestFailsOnErrorStatus is set to true inside APIRequest context options', async ({ playwright, server }) => {
it('should throw when failOnStatusCode is set to true inside APIRequest context options', async ({ playwright, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const request = await playwright.request.newContext({ apiRequestFailsOnErrorStatus: true });
const request = await playwright.request.newContext({ failOnStatusCode: true });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
@ -549,9 +549,9 @@ it('should throw when apiRequestFailsOnErrorStatus is set to true inside APIRequ
await request.dispose();
});
it('should not throw when apiRequestFailsOnErrorStatus is set to false inside APIRequest context options', async ({ playwright, server }) => {
it('should not throw when failOnStatusCode is set to false inside APIRequest context options', async ({ playwright, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const request = await playwright.request.newContext({ apiRequestFailsOnErrorStatus: false });
const request = await playwright.request.newContext({ failOnStatusCode: false });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');

View file

@ -92,13 +92,14 @@ it('should support locator.or()', async ({ page }) => {
});
it('should support playwright.getBy*', async ({ page }) => {
await page.setContent('<span>Hello</span><span title="world">World</span>');
await page.setContent('<span>Hello</span><span title="world">World</span><div>one</div><div style="display:none">two</div>');
expect(await page.evaluate(`playwright.getByText('hello').element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.getByTitle('world').element.innerHTML`)).toContain('World');
expect(await page.evaluate(`playwright.locator('span').filter({ hasText: 'hello' }).element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.locator('span').first().element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.locator('span').last().element.innerHTML`)).toContain('World');
expect(await page.evaluate(`playwright.locator('span').nth(1).element.innerHTML`)).toContain('World');
expect(await page.evaluate(`playwright.locator('div').filter({ visible: false }).element.innerHTML`)).toContain('two');
});
it('expected properties on playwright object', async ({ page }) => {

View file

@ -321,23 +321,17 @@ it('reverse engineer hasNotText', async ({ page }) => {
});
it('reverse engineer visible', async ({ page }) => {
expect.soft(generate(page.getByText('Hello').visible().locator('div'))).toEqual({
csharp: `GetByText("Hello").Visible().Locator("div")`,
java: `getByText("Hello").visible().locator("div")`,
javascript: `getByText('Hello').visible().locator('div')`,
python: `get_by_text("Hello").visible().locator("div")`,
expect.soft(generate(page.getByText('Hello').filter({ visible: true }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Filter(new() { Visible = true }).Locator("div")`,
java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(true)).locator("div")`,
javascript: `getByText('Hello').filter({ visible: true }).locator('div')`,
python: `get_by_text("Hello").filter(visible=True).locator("div")`,
});
expect.soft(generate(page.getByText('Hello').visible({ visible: true }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Visible().Locator("div")`,
java: `getByText("Hello").visible().locator("div")`,
javascript: `getByText('Hello').visible().locator('div')`,
python: `get_by_text("Hello").visible().locator("div")`,
});
expect.soft(generate(page.getByText('Hello').visible({ visible: false }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Visible(new() { Visible = false }).Locator("div")`,
java: `getByText("Hello").visible(new Locator.VisibleOptions().setVisible(false)).locator("div")`,
javascript: `getByText('Hello').visible({ visible: false }).locator('div')`,
python: `get_by_text("Hello").visible(visible=False).locator("div")`,
expect.soft(generate(page.getByText('Hello').filter({ visible: false }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Filter(new() { Visible = false }).Locator("div")`,
java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(false)).locator("div")`,
javascript: `getByText('Hello').filter({ visible: false }).locator('div')`,
python: `get_by_text("Hello").filter(visible=False).locator("div")`,
});
});

View file

@ -150,7 +150,7 @@ it('should combine visible with other selectors', async ({ page }) => {
await expect(page.locator('.item >> visible=true >> text=data3')).toHaveText('visible data3');
});
it('should support .visible()', async ({ page }) => {
it('should support filter(visible)', async ({ page }) => {
await page.setContent(`<div>
<div class="item" style="display: none">Hidden data0</div>
<div class="item">visible data1</div>
@ -160,11 +160,10 @@ it('should support .visible()', async ({ page }) => {
<div class="item">visible data3</div>
</div>
`);
const locator = page.locator('.item').visible().nth(1);
const locator = page.locator('.item').filter({ visible: true }).nth(1);
await expect(locator).toHaveText('visible data2');
await expect(page.locator('.item').visible().getByText('data3')).toHaveText('visible data3');
await expect(page.locator('.item').visible({ visible: true }).getByText('data2')).toHaveText('visible data2');
await expect(page.locator('.item').visible({ visible: false }).getByText('data1')).toHaveText('Hidden data1');
await expect(page.locator('.item').filter({ visible: true }).getByText('data3')).toHaveText('visible data3');
await expect(page.locator('.item').filter({ visible: false }).getByText('data1')).toHaveText('Hidden data1');
});
it('locator.count should work with deleted Map in main world', async ({ page }) => {

View file

@ -21,6 +21,7 @@ it('should check the box @smoke', async ({ page }) => {
await page.setContent(`<input id='checkbox' type='checkbox'></input>`);
await page.check('input');
expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true);
await expect(page.locator('input[type="checkbox"]')).toBeChecked({ timeout: 1000 });
});
it('should not check the checked box', async ({ page }) => {

View file

@ -22,14 +22,14 @@ test.describe.configure({ mode: 'parallel' });
test('should match snapshot with name', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test.yml': `
'a.spec.ts-snapshots/test.snapshot.yml': `
- heading "hello world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
});
@ -43,66 +43,66 @@ test('should generate multiple missing', async ({ runInlineTest }, testInfo) =>
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' });
await page.setContent(\`<h1>hello world 2</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' });
});
`
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8');
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
});
test('should rebaseline all', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test-1.yml': `
'a.spec.ts-snapshots/test-1.snapshot.yml': `
- heading "foo"
`,
'a.spec.ts-snapshots/test-2.yml': `
'a.spec.ts-snapshots/test-2.snapshot.yml': `
- heading "bar"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' });
await page.setContent(\`<h1>hello world 2</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' });
});
`
}, { 'update-snapshots': 'all' });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml`);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.yml`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8');
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml`);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
});
test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test.yml': `
'a.spec.ts-snapshots/test.snapshot.yml': `
- heading "hello world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
}, { 'update-snapshots': 'changed' });
expect(result.exitCode).toBe(0);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.yml'), 'utf8');
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.snapshot.yml'), 'utf8');
expect(snapshot1.trim()).toBe('- heading "hello world"');
});
@ -120,14 +120,32 @@ test('should generate snapshot name', async ({ runInlineTest }, testInfo) => {
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.yml'), 'utf8');
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.snapshot.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.snapshot.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.snapshot.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.yml'), 'utf8');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.snapshot.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
});
test('backwards compat with .yml extension', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test-1.yml': `
- heading "hello old world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello new world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot();
});
`
}, { 'update-snapshots': 'changed' });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml.`);
});
for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
test(`should update snapshot with the update-snapshots=${updateSnapshots} (config)`, async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
@ -143,13 +161,13 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 });
});
`,
'a.spec.ts-snapshots/test-1.yml': '- heading "Old content" [level=1]',
'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "Old content" [level=1]',
});
const rebase = updateSnapshots === 'all' || updateSnapshots === 'changed';
expect(result.exitCode).toBe(rebase ? 0 : 1);
if (rebase) {
const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.yml');
const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml');
expect(result.output).toContain(`A snapshot is generated at`);
const data = fs.readFileSync(snapshotOutputPath);
expect(data.toString()).toBe('- heading "New content" [level=1]');
@ -169,7 +187,7 @@ test('should respect timeout', async ({ runInlineTest }, testInfo) => {
await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 });
});
`,
'a.spec.ts-snapshots/test-1.yml': '- heading "new world" [level=1]',
'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "new world" [level=1]',
});
expect(result.exitCode).toBe(1);
@ -183,14 +201,14 @@ test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, tes
snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}',
};
`,
'my-snapshots/dir/a.spec.ts/test.yml': `
'my-snapshots/dir/a.spec.ts/test.snapshot.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
});
@ -210,17 +228,17 @@ test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ r
},
};
`,
'my-snapshots/dir/a.spec.ts/test.yml': `
'my-snapshots/dir/a.spec.ts/test.snapshot.yml': `
- heading "wrong one"
`,
'actual-snapshots/dir/a.spec.ts/test.yml': `
'actual-snapshots/dir/a.spec.ts/test.snapshot.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
});

View file

@ -435,29 +435,29 @@ test('should work with pageSnapshot: on', async ({ runInlineTest }, testInfo) =>
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-own-context-passing',
' test-finished-1.ariasnapshot',
' test-finished-1.snapshot.yml',
'artifacts-passing',
' test-finished-1.ariasnapshot',
' test-finished-1.snapshot.yml',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-persistent-passing',
' test-finished-1.ariasnapshot',
' test-finished-1.snapshot.yml',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
'artifacts-shared-shared-passing',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
' test-finished-1.snapshot.yml',
' test-finished-2.snapshot.yml',
'artifacts-two-contexts',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
' test-finished-1.snapshot.yml',
' test-finished-2.snapshot.yml',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
]);
});
@ -475,16 +475,16 @@ test('should work with pageSnapshot: only-on-failure', async ({ runInlineTest },
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
]);
});

View file

@ -1187,12 +1187,12 @@ for (const useIntermediateMergeReport of [true, false] as const) {
]);
});
test('should include metadata with git.commit.info', async ({ runInlineTest, writeFiles, showReport, page }) => {
test('should include metadata with gitCommit', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {
metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
};
`,
'example.spec.ts': `
@ -1219,6 +1219,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_SERVER_URL: 'https://playwright.dev',
GITHUB_SHA: 'example-sha',
@ -1240,12 +1241,12 @@ for (const useIntermediateMergeReport of [true, false] as const) {
`);
});
test('should include metadata with git.commit.info on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => {
test('should include metadata on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {
metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
};
`,
'example.spec.ts': `
@ -1281,6 +1282,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev',
@ -1296,9 +1298,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
- list:
- listitem:
- link "My PR"
- listitem:
- text: /William <shakespeare@example\\.local>/
- link "Logs"
- listitem: /William <shakespeare@example.local>/
- list:
- listitem: "foo : value1"
- listitem: "bar : {\\"prop\\":\\"value2\\"}"
@ -1306,7 +1306,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
`);
});
test('should not include git metadata w/o git.commit.info', async ({ runInlineTest, showReport, page }) => {
test('should not include git metadata w/o gitCommit', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default {};
@ -1330,7 +1330,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
'playwright.config.ts': `
export default {
metadata: {
'git.commit.info': { revision: { timestamp: 'hi' } }
gitCommit: { author: { date: 'hi' } }
},
};
`,
@ -2765,7 +2765,6 @@ for (const useIntermediateMergeReport of [true, false] as const) {
'playwright.config.ts': `
export default {
metadata: {
'git.commit.info': {},
foo: 'value1',
bar: { prop: 'value2' },
baz: ['value3', 123]
@ -2799,6 +2798,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev',

View file

@ -21,14 +21,13 @@ test('should render html report git info metadata', async ({ runUITest }) => {
'reporter.ts': `
module.exports = class Reporter {
onBegin(config, suite) {
console.log('ci.link:', config.metadata['git.commit.info'].ci.link);
console.log('ci.link:', config.metadata['ci'].commitHref);
}
}
`,
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({
metadata: { 'git.commit.info': {} },
reporter: './reporter.ts',
});
`,
@ -37,6 +36,7 @@ test('should render html report git info metadata', async ({ runUITest }) => {
test('should work', async ({}) => {});
`
}, {
JENKINS_URL: '1',
BUILD_URL: 'https://playwright.dev',
});