diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 37bb2cb999..686be4303c 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -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 diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index ff51ae02ab..1ac2b37695 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -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` ? -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 diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index f831bd4cae..91e43e22dd 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -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` ? -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 diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index c7680319d4..42c09c79f1 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -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 diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index d7b4463265..26b487d0ff 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -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 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9a87db7f6a..a31ea6047c 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -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-%% diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index 25b05164ad..0467cf3878 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -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: diff --git a/docs/src/locators.md b/docs/src/locators.md index ed15a82762..b0a1c0e8f5 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -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 diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 33419387c7..213928e2e7 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -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** diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index a7db657b4d..dcc27acb25 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -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([]); - -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 {children}; -} - -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, { 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, { error } } -export const MetadataView = () => { - return ; +export const MetadataView: React.FC<{ metadata: Metadata }> = params => { + return ; }; -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
- {gitCommitInfo && <> - - {entries.length > 0 &&
} - } + {commitInfo.ci && !commitInfo.gitCommit && } + {commitInfo.gitCommit && } + {otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) &&
}
- {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 = () => {
; }; -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
- {link ? ( - - {subject} - - ) : - {subject} - } + {title} +
+
; +}; + +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
+
+ {link && {title}} + {!link && {title}}
{author} on {shortTimestamp} - {info.ci?.link && ( - <> - ยท - Logs - - )}
; }; + +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; +}; diff --git a/packages/html-reporter/src/reportContext.tsx b/packages/html-reporter/src/reportContext.tsx new file mode 100644 index 0000000000..0ea8ab1e50 --- /dev/null +++ b/packages/html-reporter/src/reportContext.tsx @@ -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(undefined); + +export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) { + return {children}; +} + +export function useHTMLReport() { + return React.useContext(HTMLReportContext); +} diff --git a/packages/html-reporter/src/reportView.tsx b/packages/html-reporter/src/reportView.tsx index baf85f32a8..12b97584f3 100644 --- a/packages/html-reporter/src/reportView.tsx +++ b/packages/html-reporter/src/reportView.tsx @@ -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
+ return
{report?.json() && } @@ -89,7 +89,7 @@ export const ReportView: React.FC<{ {!!report && }
-
; +
; }; const TestCaseViewLoader: React.FC<{ diff --git a/packages/html-reporter/src/testErrorView.tsx b/packages/html-reporter/src/testErrorView.tsx index 9134f082d0..3d253459ba 100644 --- a/packages/html-reporter/src/testErrorView.tsx +++ b/packages/html-reporter/src/testErrorView.tsx @@ -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 (
@@ -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); diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index 49e2233669..4b2c48ae1d 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -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 <>
- {metadataEntries.length > 0 &&
+ {!isMetadataEmpty(report.metadata) &&
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
} {report.projectNames.length === 1 && !!report.projectNames[0] &&
Project: {report.projectNames[0]}
} @@ -83,7 +82,7 @@ export const TestFilesHeader: React.FC<{
{report ? new Date(report.startTime).toLocaleString() : ''}
Total time: {msToString(report.duration ?? 0)}
- {metadataVisible && } + {metadataVisible && } {!!report.errors.length && {report.errors.map((error, index) => )} } diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index e516ee2b3c..0e5e4e4284 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -13225,6 +13130,11 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Only matches visible or invisible elements. + */ + visible?: boolean; }): Locator; /** @@ -14616,17 +14526,6 @@ export interface Locator { trial?: boolean; }): Promise; - /** - * 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 { */ 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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 445379083e..accee95288 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -510,7 +510,7 @@ export class BrowserContext extends ChannelOwner async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise { 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) { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 1ed86b847d..a915c0a8ef 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -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): 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.`); diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 29dcd5112c..53c805a496 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -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 & { indexedDB: unknown[] })[], }; export type SetStorageState = { cookies?: channels.SetNetworkCookie[], - origins?: channels.SetOriginStorage[] + origins?: (Omit & { indexedDB?: unknown[] })[] }; export type LifecycleEvent = channels.LifecycleEvent; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 0ef6abd4cc..37ff466e42 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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, diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index e40b13bb2e..e67c07ba8f 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -79,9 +79,6 @@ export class RawMouseImpl implements input.RawMouse { } async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - // Bidi throws when x/y are not integers. - x = Math.floor(x); - y = Math.floor(y); await this._performActions([{ type: 'pointerMove', x, y }]); } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index a57af3bf12..b2bf4bf202 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -511,7 +511,7 @@ export abstract class BrowserContext extends SdkObject { this._origins.add(origin); } - async storageState(indexedDB = true): Promise { + async storageState(indexedDB = false): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(), origins: [] diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 3a3279bc72..50f9be8c6a 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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 { + override async storageState(indexedDB = false): Promise { return { cookies: this._cookieStore.allCookies(), origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })), diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index c79ee6610c..5bcd94a5bf 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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}`); diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 283c4e492a..5be1f36b96 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -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)) { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index e3481b0973..6dcbc1cbcc 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -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') diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index e516ee2b3c..0e5e4e4284 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -13225,6 +13130,11 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Only matches visible or invisible elements. + */ + visible?: boolean; }): Locator; /** @@ -14616,17 +14526,6 @@ export interface Locator { trial?: boolean; }): Promise; - /** - * 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 { */ 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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index e8afe5a74a..68b29bf3d6 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -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); }); diff --git a/packages/playwright/src/isomorphic/types.d.ts b/packages/playwright/src/isomorphic/types.d.ts index 2d54911c6e..cbdb01cc52 100644 --- a/packages/playwright/src/isomorphic/types.d.ts +++ b/packages/playwright/src/isomorphic/types.d.ts @@ -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; +}; diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index ad1855a88e..0c76a97c68 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -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; diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index 20945f6332..bba2acfb73 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -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 { - 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 { + 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 { + 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 { + 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); } diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 2f7995cb2f..7734145468 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = { }; export { webServer } from './webServerPlugin'; -export { gitCommitInfo } from './gitCommitInfoPlugin'; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 53760970bd..c992313e06 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -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(); } diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 555f86ab57..51bd517dde 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -35,7 +35,7 @@ class Fixture { private _selfTeardownComplete: Promise | 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(); _usages = new Set(); @@ -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 { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 5bac56776d..72bb9fb025 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -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('stepZone') - ?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent. + return currentZone().data('stepZone') ?? this._findLastPredefinedStep(this._steps); } _addStep(data: Omit, 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) { - 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) { + 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) { + 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)); } diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index 65e97c9f36..c003342d15 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -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(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { - if (!runnable) - return await cb(); + async withRunnable(runnable: RunnableDescription, cb: () => Promise): Promise { 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}`); } } diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 8ff6d31fd0..91abde8a2e 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -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) { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 76d2b31ffd..1e9fef6b6c 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1284,9 +1284,11 @@ interface TestConfig { /** * 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 diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 879c2391e8..98cd94e569 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -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, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 9ac9973adf..3025e9af54 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -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: diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index 5cb28299df..a5fd9b071a 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -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(undefined); +const CommitInfoContext = React.createContext(undefined); -export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { - return {children}; +export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) { + return {children}; } -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
} {llmAvailable - ? - : } + ? + : }
diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 441163c5d9..4762b6057e 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -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<{}> = ({
- + = ({ revealSource={revealSource} onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} /> - +
} sidebar={
diff --git a/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts b/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts deleted file mode 100644 index e922218d68..0000000000 --- a/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index d1431e88fd..e22c66f23c 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -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: [] }); }); diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index f2f522a619..e34cf1561b 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -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(); }); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 9a402ed152..53db88e325 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -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.'); diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index 50f4c5f063..2b40b8943a 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -92,13 +92,14 @@ it('should support locator.or()', async ({ page }) => { }); it('should support playwright.getBy*', async ({ page }) => { - await page.setContent('HelloWorld'); + await page.setContent('HelloWorld
one
two
'); 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 }) => { diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 0a2ccb333a..cc54a143e8 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -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")`, }); }); diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index 478d86c30c..f09749eca5 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -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(`
visible data1
@@ -160,11 +160,10 @@ it('should support .visible()', async ({ page }) => {
visible data3
`); - 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 }) => { diff --git a/tests/page/page-check.spec.ts b/tests/page/page-check.spec.ts index 01b00ddc55..4ea0f9e50c 100644 --- a/tests/page/page-check.spec.ts +++ b/tests/page/page-check.spec.ts @@ -21,6 +21,7 @@ it('should check the box @smoke', async ({ page }) => { await page.setContent(``); 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 }) => { diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index c121d623d1..f02ae3b26b 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -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(\`

hello world

\`); - 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(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

\`); - 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(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

\`); - 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(\`

hello world

\`); - 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(\`

hello new world

\`); + 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(\`

hello world

\`); - 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(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index 5a8a6fcbef..d54fe5265c 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -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', ]); }); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 7e9c550619..3d3a927b04 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -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 / - - link "Logs" + - listitem: /William / - 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', diff --git a/tests/playwright-test/ui-mode-metadata.spec.ts b/tests/playwright-test/ui-mode-metadata.spec.ts index c6127cb7cb..bfbbba08a5 100644 --- a/tests/playwright-test/ui-mode-metadata.spec.ts +++ b/tests/playwright-test/ui-mode-metadata.spec.ts @@ -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', });