Compare commits

...

7 Commits

67 changed files with 476 additions and 820 deletions

View File

@ -284,7 +284,7 @@ By default, a template containing the subset of children will be matched:
The `/children` property can be used to control how child elements are matched: The `/children` property can be used to control how child elements are matched:
- `contain` (default): Matches if all specified children are present in any order - `contain` (default): Matches if all specified children are present in order
- `equal`: Matches if the children exactly match the specified list in order - `equal`: Matches if the children exactly match the specified list in order
- `deep-equal`: Matches if the children exactly match the specified list in order, including nested children - `deep-equal`: Matches if the children exactly match the specified list in order, including nested children
@ -296,7 +296,7 @@ The `/children` property can be used to control how child elements are matched:
</ul> </ul>
``` ```
*aria snapshot will fail due Feature C not being in the template* *aria snapshot will fail due to Feature C not being in the template*
```yaml ```yaml
- list - list

View File

@ -4,6 +4,53 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.52
### Highlights
- New method [`method: LocatorAssertions.toContainClass`] to ergonomically assert individual class names on the element.
```csharp
await Expect(Page.GetByRole(AriaRole.Listitem, new() { Name = "Ship v1.52" })).ToContainClassAsync("done");
```
- [Aria Snapshots](./aria-snapshots.md) got two new properties: [`/children`](./aria-snapshots.md#strict-matching) for strict matching and `/url` for links.
```csharp
await Expect(locator).ToMatchAriaSnapshotAsync(@"
- list
- /children: equal
- listitem: Feature A
- listitem:
- link ""Feature B"":
- /url: ""https://playwright.dev""
");
```
### Miscellaneous
- New option [`option: APIRequest.newContext.maxRedirects`] in [`method: APIRequest.newContext`] to control the maximum number of redirects.
- New option [`option: Locator.ariaSnapshot.ref`] in [`method: Locator.ariaSnapshot`] to generate reference for each element in the snapshot which can later be used to locate the element.
- HTML reporter now supports *NOT filtering* via `!@my-tag` or `!my-file.spec.ts` or `!p:my-project`.
### Breaking Changes
- Base URL matching is not supported in [`method: Page.frame`] anymore. We recommend migrating to [`method: Page.frameLocator`] instead for having a more convenient API.
- Glob URL patterns in methods like [`method: Page.route`] do not support `?` and `[]` anymore. We recommend using regular expressions instead.
- Method [`method: Route.continue`] does not allow to override the `Cookie` header anymore. If a `Cookie` header is provided, it will be ignored, and the cookie will be loaded from the browser's cookie store. To set custom cookies, use [`method: BrowserContext.addCookies`].
- macOS 13 is now deprecated and will no longer receive WebKit updates. Please upgrade to a more recent macOS version to continue benefiting from the latest WebKit improvements.
### Browser Versions
- Chromium 136.0.7103.25
- Mozilla Firefox 137.0
- WebKit 18.4
This version was also tested against the following stable channels:
- Google Chrome 135
- Microsoft Edge 135
## Version 1.51 ## Version 1.51
### Highlights ### Highlights

View File

@ -4,6 +4,53 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.52
### Highlights
- New method [`method: LocatorAssertions.toContainClass`] to ergonomically assert individual class names on the element.
```java
assertThat(page.getByRole(AriaRole.LISTITEM, new Page.GetByRoleOptions().setName("Ship v1.52"))).containsClass("done");
```
- [Aria Snapshots](./aria-snapshots.md) got two new properties: [`/children`](./aria-snapshots.md#strict-matching) for strict matching and `/url` for links.
```java
assertThat(locator).toMatchAriaSnapshot("""
- list
- /children: equal
- listitem: Feature A
- listitem:
- link "Feature B":
- /url: "https://playwright.dev"
""");
```
### Miscellaneous
- New option [`option: APIRequest.newContext.maxRedirects`] in [`method: APIRequest.newContext`] to control the maximum number of redirects.
- New option [`option: Locator.ariaSnapshot.ref`] in [`method: Locator.ariaSnapshot`] to generate reference for each element in the snapshot which can later be used to locate the element.
- HTML reporter now supports *NOT filtering* via `!@my-tag` or `!my-file.spec.ts` or `!p:my-project`.
### Breaking Changes
- Base URL matching is not supported in [`method: Page.frame`] anymore. We recommend migrating to [`method: Page.frameLocator`] instead for having a more convenient API.
- Glob URL patterns in methods like [`method: Page.route`] do not support `?` and `[]` anymore. We recommend using regular expressions instead.
- Method [`method: Route.continue`] does not allow to override the `Cookie` header anymore. If a `Cookie` header is provided, it will be ignored, and the cookie will be loaded from the browser's cookie store. To set custom cookies, use [`method: BrowserContext.addCookies`].
- macOS 13 is now deprecated and will no longer receive WebKit updates. Please upgrade to a more recent macOS version to continue benefiting from the latest WebKit improvements.
### Browser Versions
- Chromium 136.0.7103.25
- Mozilla Firefox 137.0
- WebKit 18.4
This version was also tested against the following stable channels:
- Google Chrome 135
- Microsoft Edge 135
## Version 1.51 ## Version 1.51
### Highlights ### Highlights

View File

@ -6,6 +6,58 @@ toc_max_heading_level: 2
import LiteYouTube from '@site/src/components/LiteYouTube'; import LiteYouTube from '@site/src/components/LiteYouTube';
## Version 1.52
### Highlights
- New method [`method: LocatorAssertions.toContainClass`] to ergonomically assert individual class names on the element.
```ts
await expect(page.getByRole('listitem', { name: 'Ship v1.52' })).toContainClass('done');
```
- [Aria Snapshots](./aria-snapshots.md) got two new properties: [`/children`](./aria-snapshots.md#strict-matching) for strict matching and `/url` for links.
```ts
await expect(locator).toMatchAriaSnapshot(`
- list
- /children: equal
- listitem: Feature A
- listitem:
- link "Feature B":
- /url: "https://playwright.dev"
`);
```
### Test Runner
- New property [`property: TestProject.workers`] allows to specify the number of concurrent worker processes to use for a test project. The global limit of property [`property: TestConfig.workers`] still applies.
- New [`property: TestConfig.failOnFlakyTests`] option to fail the test run if any flaky tests are detected, similarly to `--fail-on-flaky-tests`. This is useful for CI/CD environments where you want to ensure that all tests are stable before deploying.
- New property [`property: TestResult.annotations`] contains annotations for each test retry.
### Miscellaneous
- New option [`option: APIRequest.newContext.maxRedirects`] in [`method: APIRequest.newContext`] to control the maximum number of redirects.
- New option [`option: Locator.ariaSnapshot.ref`] in [`method: Locator.ariaSnapshot`] to generate reference for each element in the snapshot which can later be used to locate the element.
- HTML reporter now supports *NOT filtering* via `!@my-tag` or `!my-file.spec.ts` or `!p:my-project`.
### Breaking Changes
- Glob URL patterns in methods like [`method: Page.route`] do not support `?` and `[]` anymore. We recommend using regular expressions instead.
- Method [`method: Route.continue`] does not allow to override the `Cookie` header anymore. If a `Cookie` header is provided, it will be ignored, and the cookie will be loaded from the browser's cookie store. To set custom cookies, use [`method: BrowserContext.addCookies`].
- macOS 13 is now deprecated and will no longer receive WebKit updates. Please upgrade to a more recent macOS version to continue benefiting from the latest WebKit improvements.
### Browser Versions
- Chromium 136.0.7103.25
- Mozilla Firefox 137.0
- WebKit 18.4
This version was also tested against the following stable channels:
- Google Chrome 135
- Microsoft Edge 135
## Version 1.51 ## Version 1.51
### StorageState for indexedDB ### StorageState for indexedDB

View File

@ -4,6 +4,53 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.52
### Highlights
- New method [`method: LocatorAssertions.toContainClass`] to ergonomically assert individual class names on the element.
```python
expect(page.get_by_role('listitem', name='Ship v1.52')).to_contain_class('done')
```
- [Aria Snapshots](./aria-snapshots.md) got two new properties: [`/children`](./aria-snapshots.md#strict-matching) for strict matching and `/url` for links.
```python
expect(locator).to_match_aria_snapshot("""
- list
- /children: equal
- listitem: Feature A
- listitem:
- link "Feature B":
- /url: "https://playwright.dev"
""")
```
### Miscellaneous
- New option [`option: APIRequest.newContext.maxRedirects`] in [`method: APIRequest.newContext`] to control the maximum number of redirects.
- New option [`option: Locator.ariaSnapshot.ref`] in [`method: Locator.ariaSnapshot`] to generate reference for each element in the snapshot which can later be used to locate the element.
- HTML reporter now supports *NOT filtering* via `!@my-tag` or `!my-file.spec.ts` or `!p:my-project`.
### Breaking Changes
- Base URL matching is not supported in [`method: Page.frame`] anymore. We recommend migrating to [`method: Page.frameLocator`] instead for having a more convenient API.
- Glob URL patterns in methods like [`method: Page.route`] do not support `?` and `[]` anymore. We recommend using regular expressions instead.
- Method [`method: Route.continue`] does not allow to override the `Cookie` header anymore. If a `Cookie` header is provided, it will be ignored, and the cookie will be loaded from the browser's cookie store. To set custom cookies, use [`method: BrowserContext.addCookies`].
- macOS 13 is now deprecated and will no longer receive WebKit updates. Please upgrade to a more recent macOS version to continue benefiting from the latest WebKit improvements.
### Browser Versions
- Chromium 136.0.7103.25
- Mozilla Firefox 137.0
- WebKit 18.4
This version was also tested against the following stable channels:
- Google Chrome 135
- Microsoft Edge 135
## Version 1.51 ## Version 1.51
### Highlights ### Highlights

View File

@ -19,7 +19,6 @@ test('basic test', async ({ page }, testInfo) => {
- type: <[Array]<[Object]>> - type: <[Array]<[Object]>>
- `type` <[string]> Annotation type, for example `'skip'` or `'fail'`. - `type` <[string]> Annotation type, for example `'skip'` or `'fail'`.
- `description` ?<[string]> Optional description. - `description` ?<[string]> Optional description.
- `location` ?<[Location]> Optional location in the source where the annotation is added.
The list of annotations applicable to the current test. Includes annotations from the test, annotations from all [`method: Test.describe`] groups the test belongs to and file-level annotations for the test file. The list of annotations applicable to the current test. Includes annotations from the test, annotations from all [`method: Test.describe`] groups the test belongs to and file-level annotations for the test file.

View File

@ -9,7 +9,6 @@
- type: <[Array]<[Object]>> - type: <[Array]<[Object]>>
- `type` <[string]> Annotation type, for example `'skip'` or `'fail'`. - `type` <[string]> Annotation type, for example `'skip'` or `'fail'`.
- `description` ?<[string]> Optional description. - `description` ?<[string]> Optional description.
- `location` ?<[Location]> Optional location in the source where the annotation is added.
[`property: TestResult.annotations`] of the last test run. [`property: TestResult.annotations`] of the last test run.

View File

@ -19,7 +19,6 @@ The list of files or buffers attached during the test execution through [`proper
- type: <[Array]<[Object]>> - type: <[Array]<[Object]>>
- `type` <[string]> Annotation type, for example `'skip'` or `'fail'`. - `type` <[string]> Annotation type, for example `'skip'` or `'fail'`.
- `description` ?<[string]> Optional description. - `description` ?<[string]> Optional description.
- `location` ?<[Location]> Optional location in the source where the annotation is added.
The list of annotations applicable to the current test. Includes: The list of annotations applicable to the current test. Includes:
* annotations defined on the test or suite via [`method: Test.(call)`] and [`method: Test.describe`]; * annotations defined on the test or suite via [`method: Test.(call)`] and [`method: Test.describe`];

View File

@ -55,7 +55,6 @@ List of steps inside this step.
- type: <[Array]<[Object]>> - type: <[Array]<[Object]>>
- `type` <[string]> Annotation type, for example `'skip'`. - `type` <[string]> Annotation type, for example `'skip'`.
- `description` ?<[string]> Optional description. - `description` ?<[string]> Optional description.
- `location` ?<[Location]> Optional location in the source where the annotation is added.
The list of annotations applicable to the current test step. The list of annotations applicable to the current test step.

64
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.52.0-next", "version": "1.52.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@ -7906,10 +7906,10 @@
"version": "0.0.0" "version": "0.0.0"
}, },
"packages/playwright": { "packages/playwright": {
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7923,11 +7923,11 @@
}, },
"packages/playwright-browser-chromium": { "packages/playwright-browser-chromium": {
"name": "@playwright/browser-chromium", "name": "@playwright/browser-chromium",
"version": "1.52.0-next", "version": "1.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7935,11 +7935,11 @@
}, },
"packages/playwright-browser-firefox": { "packages/playwright-browser-firefox": {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.52.0-next", "version": "1.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7947,22 +7947,22 @@
}, },
"packages/playwright-browser-webkit": { "packages/playwright-browser-webkit": {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.52.0-next", "version": "1.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-chromium": { "packages/playwright-chromium": {
"version": "1.52.0-next", "version": "1.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7976,14 +7976,14 @@
"version": "0.0.0", "version": "0.0.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-core": { "packages/playwright-core": {
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@ -7994,11 +7994,11 @@
}, },
"packages/playwright-ct-core": { "packages/playwright-ct-core": {
"name": "@playwright/experimental-ct-core", "name": "@playwright/experimental-ct-core",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.52.0-next", "playwright": "1.52.0",
"playwright-core": "1.52.0-next", "playwright-core": "1.52.0",
"vite": "^6.2.6" "vite": "^6.2.6"
}, },
"engines": { "engines": {
@ -8007,10 +8007,10 @@
}, },
"packages/playwright-ct-react": { "packages/playwright-ct-react": {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {
@ -8022,10 +8022,10 @@
}, },
"packages/playwright-ct-react17": { "packages/playwright-ct-react17": {
"name": "@playwright/experimental-ct-react17", "name": "@playwright/experimental-ct-react17",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {
@ -8037,10 +8037,10 @@
}, },
"packages/playwright-ct-svelte": { "packages/playwright-ct-svelte": {
"name": "@playwright/experimental-ct-svelte", "name": "@playwright/experimental-ct-svelte",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@sveltejs/vite-plugin-svelte": "^3.0.1" "@sveltejs/vite-plugin-svelte": "^3.0.1"
}, },
"bin": { "bin": {
@ -8598,10 +8598,10 @@
}, },
"packages/playwright-ct-vue": { "packages/playwright-ct-vue": {
"name": "@playwright/experimental-ct-vue", "name": "@playwright/experimental-ct-vue",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@vitejs/plugin-vue": "^5.2.0" "@vitejs/plugin-vue": "^5.2.0"
}, },
"bin": { "bin": {
@ -8621,18 +8621,18 @@
}, },
"devDependencies": { "devDependencies": {
"@octokit/graphql-schema": "^15.26.0", "@octokit/graphql-schema": "^15.26.0",
"@playwright/test": "1.52.0-next" "@playwright/test": "1.52.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-firefox": { "packages/playwright-firefox": {
"version": "1.52.0-next", "version": "1.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -8664,10 +8664,10 @@
}, },
"packages/playwright-test": { "packages/playwright-test": {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.52.0-next", "version": "1.52.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.52.0-next" "playwright": "1.52.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -8694,11 +8694,11 @@
} }
}, },
"packages/playwright-webkit": { "packages/playwright-webkit": {
"version": "1.52.0-next", "version": "1.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"

View File

@ -1,7 +1,7 @@
{ {
"name": "playwright-internal", "name": "playwright-internal",
"private": true, "private": true,
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -14,8 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import type { TestAnnotation } from '@playwright/test'; import type { TestCase, TestAnnotation, TestCaseSummary } from './types';
import type { TestCase, TestCaseSummary } from './types';
import * as React from 'react'; import * as React from 'react';
import { TabbedPane } from './tabbedPane'; import { TabbedPane } from './tabbedPane';
import { AutoChip } from './chip'; import { AutoChip } from './chip';

View File

@ -20,17 +20,18 @@ import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView'; import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import { TestAttachment } from './types'; import { TestAttachment } from './types';
import { fixTestInstructions } from '@web/prompts';
export const TestErrorView: React.FC<{ export const TestErrorView: React.FC<{
error: string; error: string;
testId?: string; testId?: string;
prompt?: TestAttachment; context?: TestAttachment;
}> = ({ error, testId, prompt }) => { }> = ({ error, testId, context }) => {
return ( return (
<CodeSnippet code={error} testId={testId}> <CodeSnippet code={error} testId={testId}>
{prompt && ( {context && (
<div style={{ position: 'absolute', right: 0, padding: '10px' }}> <div style={{ position: 'absolute', right: 0, padding: '10px' }}>
<PromptButton prompt={prompt} /> <PromptButton context={context} />
</div> </div>
)} )}
</CodeSnippet> </CodeSnippet>
@ -47,14 +48,14 @@ export const CodeSnippet = ({ code, children, testId }: React.PropsWithChildren<
); );
}; };
const PromptButton: React.FC<{ prompt: TestAttachment }> = ({ prompt }) => { const PromptButton: React.FC<{ context: TestAttachment }> = ({ context }) => {
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
return <button return <button
className='button' className='button'
style={{ minWidth: 100 }} style={{ minWidth: 100 }}
onClick={async () => { onClick={async () => {
const text = prompt.body ? prompt.body : await fetch(prompt.path!).then(r => r.text()); const text = context.body ? context.body : await fetch(context.path!).then(r => r.text());
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(fixTestInstructions + text);
setCopied(true); setCopied(true);
setTimeout(() => { setTimeout(() => {
setCopied(false); setCopied(false);

View File

@ -90,7 +90,7 @@ export const TestResultView: React.FC<{
{errors.map((error, index) => { {errors.map((error, index) => {
if (error.type === 'screenshot') if (error.type === 'screenshot')
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>; return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!} prompt={error.prompt}></TestErrorView>; return <TestErrorView key={'test-result-error-message-' + index} error={error.error!} context={error.context}></TestErrorView>;
})} })}
</AutoChip>} </AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'> {!!result.steps.length && <AutoChip header='Test Steps'>
@ -165,8 +165,8 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[], attachments: T
} }
} }
const prompt = attachments.find(a => a.name === `_prompt-${i}`); const context = attachments.find(a => a.name === `_error-context-${i}`);
return { type: 'regular', error, prompt }; return { type: 'regular', error, context };
}); });
} }

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { TestAnnotation, Metadata } from '@playwright/test'; import type { Metadata } from '@playwright/test';
export type Stats = { export type Stats = {
total: number; total: number;
@ -59,6 +59,8 @@ export type TestFileSummary = {
stats: Stats; stats: Stats;
}; };
export type TestAnnotation = { type: string, description?: string };
export type TestCaseSummary = { export type TestCaseSummary = {
testId: string, testId: string,
title: string; title: string;

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-chromium", "name": "@playwright/browser-chromium",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright package that automatically installs Chromium", "description": "Playwright package that automatically installs Chromium",
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,6 +27,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright package that automatically installs Firefox", "description": "Playwright package that automatically installs Firefox",
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,6 +27,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright package that automatically installs WebKit", "description": "Playwright package that automatically installs WebKit",
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,6 +27,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "playwright-chromium", "name": "playwright-chromium",
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate Chromium", "description": "A high-level API to automate Chromium",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -29,6 +29,6 @@
"watch": "esbuild ./src/index.ts --outdir=lib --format=cjs --bundle --platform=node --target=ES2019 --watch" "watch": "esbuild ./src/index.ts --outdir=lib --format=cjs --bundle --platform=node --target=ES2019 --watch"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "playwright-core", "name": "playwright-core",
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-core", "name": "@playwright/experimental-ct-core",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright Component Testing Helpers", "description": "Playwright Component Testing Helpers",
"repository": { "repository": {
"type": "git", "type": "git",
@ -26,8 +26,8 @@
} }
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next", "playwright-core": "1.52.0",
"vite": "^6.2.6", "vite": "^6.2.6",
"playwright": "1.52.0-next" "playwright": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright Component Testing for React", "description": "Playwright Component Testing for React",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-react17", "name": "@playwright/experimental-ct-react17",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright Component Testing for React", "description": "Playwright Component Testing for React",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-svelte", "name": "@playwright/experimental-ct-svelte",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright Component Testing for Svelte", "description": "Playwright Component Testing for Svelte",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@sveltejs/vite-plugin-svelte": "^3.0.1" "@sveltejs/vite-plugin-svelte": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-vue", "name": "@playwright/experimental-ct-vue",
"version": "1.52.0-next", "version": "1.52.0",
"description": "Playwright Component Testing for Vue", "description": "Playwright Component Testing for Vue",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.52.0-next", "@playwright/experimental-ct-core": "1.52.0",
"@vitejs/plugin-vue": "^5.2.0" "@vitejs/plugin-vue": "^5.2.0"
}, },
"bin": { "bin": {

View File

@ -24,6 +24,6 @@
}, },
"devDependencies": { "devDependencies": {
"@octokit/graphql-schema": "^15.26.0", "@octokit/graphql-schema": "^15.26.0",
"@playwright/test": "1.52.0-next" "@playwright/test": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "playwright-firefox", "name": "playwright-firefox",
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate Firefox", "description": "A high-level API to automate Firefox",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
}, },
"scripts": {}, "scripts": {},
"dependencies": { "dependencies": {
"playwright": "1.52.0-next" "playwright": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "playwright-webkit", "name": "playwright-webkit",
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate WebKit", "description": "A high-level API to automate WebKit",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"version": "1.52.0-next", "version": "1.52.0",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",
@ -56,7 +56,7 @@
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-next" "playwright-core": "1.52.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"

View File

@ -14,5 +14,5 @@ common/
[internalsForTest.ts] [internalsForTest.ts]
** **
[prompt.ts] [errorContext.ts]
./transform/babelBundle.ts ./transform/babelBundle.ts

View File

@ -36,6 +36,7 @@ export type FixturesWithLocation = {
fixtures: Fixtures; fixtures: Fixtures;
location: Location; location: Location;
}; };
export type Annotation = { type: string, description?: string };
export const defaultTimeout = 30000; export const defaultTimeout = 30000;

View File

@ -17,10 +17,9 @@
import { rootTestType } from './testType'; import { rootTestType } from './testType';
import { computeTestCaseOutcome } from '../isomorphic/teleReceiver'; import { computeTestCaseOutcome } from '../isomorphic/teleReceiver';
import type { FixturesWithLocation, FullProjectInternal } from './config'; import type { Annotation, FixturesWithLocation, FullProjectInternal } from './config';
import type { FixturePool } from './fixtures'; import type { FixturePool } from './fixtures';
import type { TestTypeImpl } from './testType'; import type { TestTypeImpl } from './testType';
import type { TestAnnotation } from '../../types/test';
import type * as reporterTypes from '../../types/testReporter'; import type * as reporterTypes from '../../types/testReporter';
import type { FullProject, Location } from '../../types/testReporter'; import type { FullProject, Location } from '../../types/testReporter';
@ -51,7 +50,7 @@ export class Suite extends Base {
_timeout: number | undefined; _timeout: number | undefined;
_retries: number | undefined; _retries: number | undefined;
// Annotations known statically before running the test, e.g. `test.describe.skip()` or `test.describe({ annotation }, body)`. // Annotations known statically before running the test, e.g. `test.describe.skip()` or `test.describe({ annotation }, body)`.
_staticAnnotations: TestAnnotation[] = []; _staticAnnotations: Annotation[] = [];
// Explicitly declared tags that are not a part of the title. // Explicitly declared tags that are not a part of the title.
_tags: string[] = []; _tags: string[] = [];
_modifiers: Modifier[] = []; _modifiers: Modifier[] = [];
@ -253,7 +252,7 @@ export class TestCase extends Base implements reporterTypes.TestCase {
expectedStatus: reporterTypes.TestStatus = 'passed'; expectedStatus: reporterTypes.TestStatus = 'passed';
timeout = 0; timeout = 0;
annotations: TestAnnotation[] = []; annotations: Annotation[] = [];
retries = 0; retries = 0;
repeatEachIndex = 0; repeatEachIndex = 0;

View File

@ -25,8 +25,6 @@ import { wrapFunctionWithLocation } from '../transform/transform';
import type { FixturesWithLocation } from './config'; import type { FixturesWithLocation } from './config';
import type { Fixtures, TestDetails, TestStepInfo, TestType } from '../../types/test'; import type { Fixtures, TestDetails, TestStepInfo, TestType } from '../../types/test';
import type { Location } from '../../types/testReporter'; import type { Location } from '../../types/testReporter';
import type { TestInfoImpl, TestStepInternal } from '../worker/testInfo';
const testTypeSymbol = Symbol('testType'); const testTypeSymbol = Symbol('testType');
@ -104,7 +102,7 @@ export class TestTypeImpl {
details = fnOrDetails; details = fnOrDetails;
} }
const validatedDetails = validateTestDetails(details, location); const validatedDetails = validateTestDetails(details);
const test = new TestCase(title, body, this, location); const test = new TestCase(title, body, this, location);
test._requireFile = suite._requireFile; test._requireFile = suite._requireFile;
test.annotations.push(...validatedDetails.annotations); test.annotations.push(...validatedDetails.annotations);
@ -114,9 +112,9 @@ export class TestTypeImpl {
if (type === 'only' || type === 'fail.only') if (type === 'only' || type === 'fail.only')
test._only = true; test._only = true;
if (type === 'skip' || type === 'fixme' || type === 'fail') if (type === 'skip' || type === 'fixme' || type === 'fail')
test.annotations.push({ type, location }); test.annotations.push({ type });
else if (type === 'fail.only') else if (type === 'fail.only')
test.annotations.push({ type: 'fail', location }); test.annotations.push({ type: 'fail' });
} }
private _describe(type: 'default' | 'only' | 'serial' | 'serial.only' | 'parallel' | 'parallel.only' | 'skip' | 'fixme', location: Location, titleOrFn: string | Function, fnOrDetails?: TestDetails | Function, fn?: Function) { private _describe(type: 'default' | 'only' | 'serial' | 'serial.only' | 'parallel' | 'parallel.only' | 'skip' | 'fixme', location: Location, titleOrFn: string | Function, fnOrDetails?: TestDetails | Function, fn?: Function) {
@ -143,7 +141,7 @@ export class TestTypeImpl {
body = fn!; body = fn!;
} }
const validatedDetails = validateTestDetails(details, location); const validatedDetails = validateTestDetails(details);
const child = new Suite(title, 'describe'); const child = new Suite(title, 'describe');
child._requireFile = suite._requireFile; child._requireFile = suite._requireFile;
child.location = location; child.location = location;
@ -158,7 +156,7 @@ export class TestTypeImpl {
if (type === 'parallel' || type === 'parallel.only') if (type === 'parallel' || type === 'parallel.only')
child._parallelMode = 'parallel'; child._parallelMode = 'parallel';
if (type === 'skip' || type === 'fixme') if (type === 'skip' || type === 'fixme')
child._staticAnnotations.push({ type, location }); child._staticAnnotations.push({ type });
for (let parent: Suite | undefined = suite; parent; parent = parent.parent) { for (let parent: Suite | undefined = suite; parent; parent = parent.parent) {
if (parent._parallelMode === 'serial' && child._parallelMode === 'parallel') if (parent._parallelMode === 'serial' && child._parallelMode === 'parallel')
@ -229,7 +227,7 @@ export class TestTypeImpl {
if (modifierArgs.length >= 1 && !modifierArgs[0]) if (modifierArgs.length >= 1 && !modifierArgs[0])
return; return;
const description = modifierArgs[1]; const description = modifierArgs[1];
suite._staticAnnotations.push({ type, description, location }); suite._staticAnnotations.push({ type, description });
} }
return; return;
} }
@ -239,7 +237,7 @@ export class TestTypeImpl {
throw new Error(`test.${type}() can only be called inside test, describe block or fixture`); throw new Error(`test.${type}() can only be called inside test, describe block or fixture`);
if (typeof modifierArgs[0] === 'function') if (typeof modifierArgs[0] === 'function')
throw new Error(`test.${type}() with a function can only be called inside describe block`); throw new Error(`test.${type}() with a function can only be called inside describe block`);
testInfo._modifier(type, location, modifierArgs as [any, any]); testInfo[type](...modifierArgs as [any, any]);
} }
private _setTimeout(location: Location, timeout: number) { private _setTimeout(location: Location, timeout: number) {
@ -262,21 +260,17 @@ export class TestTypeImpl {
suite._use.push({ fixtures, location }); suite._use.push({ fixtures, location });
} }
_step<T>(expectation: 'pass'|'skip', title: string, body: (step: TestStepInfo) => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> { async _step<T>(expectation: 'pass'|'skip', title: string, body: (step: TestStepInfo) => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
if (!testInfo) if (!testInfo)
throw new Error(`test.step() can only be called from a test`); throw new Error(`test.step() can only be called from a test`);
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
return testInfo._floatingPromiseScope.wrapPromiseAPIResult(this._stepInternal(expectation, testInfo, step, body, options), step.location);
}
private async _stepInternal<T>(expectation: 'pass'|'skip', testInfo: TestInfoImpl, step: TestStepInternal, body: (step: TestStepInfo) => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
return await currentZone().with('stepZone', step).run(async () => { return await currentZone().with('stepZone', step).run(async () => {
try { try {
let result: Awaited<ReturnType<typeof raceAgainstDeadline<T>>> | undefined = undefined; let result: Awaited<ReturnType<typeof raceAgainstDeadline<T>>> | undefined = undefined;
result = await raceAgainstDeadline(async () => { result = await raceAgainstDeadline(async () => {
try { try {
return await step.info._runStepBody(expectation === 'skip', body, step.location); return await step.info._runStepBody(expectation === 'skip', body);
} catch (e) { } catch (e) {
// If the step timed out, the test fixtures will tear down, which in turn // If the step timed out, the test fixtures will tear down, which in turn
// will abort unfinished actions in the step body. Record such errors here. // will abort unfinished actions in the step body. Record such errors here.
@ -315,9 +309,8 @@ function throwIfRunningInsideJest() {
} }
} }
function validateTestDetails(details: TestDetails, location: Location) { function validateTestDetails(details: TestDetails) {
const originalAnnotations = Array.isArray(details.annotation) ? details.annotation : (details.annotation ? [details.annotation] : []); const annotations = Array.isArray(details.annotation) ? details.annotation : (details.annotation ? [details.annotation] : []);
const annotations = originalAnnotations.map(annotation => ({ ...annotation, location }));
const tags = Array.isArray(details.tag) ? details.tag : (details.tag ? [details.tag] : []); const tags = Array.isArray(details.tag) ? details.tag : (details.tag ? [details.tag] : []);
for (const tag of tags) { for (const tag of tags) {
if (tag[0] !== '@') if (tag[0] !== '@')

View File

@ -22,14 +22,25 @@ import { parseErrorStack } from 'playwright-core/lib/utils';
import { stripAnsiEscapes } from './util'; import { stripAnsiEscapes } from './util';
import { codeFrameColumns } from './transform/babelBundle'; import { codeFrameColumns } from './transform/babelBundle';
import type { TestInfo } from '../types/test';
import type { MetadataWithCommitInfo } from './isomorphic/types'; import type { MetadataWithCommitInfo } from './isomorphic/types';
import type { TestInfoImpl } from './worker/testInfo'; import type { TestInfoImpl } from './worker/testInfo';
export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<string, string>, ariaSnapshot: string | undefined) { export async function attachErrorContext(testInfo: TestInfoImpl, format: 'markdown' | 'json', sourceCache: Map<string, string>, ariaSnapshot: string | undefined) {
if (process.env.PLAYWRIGHT_NO_COPY_PROMPT) if (format === 'json') {
if (!ariaSnapshot)
return; return;
testInfo._attach({
name: `_error-context`,
contentType: 'application/json',
body: Buffer.from(JSON.stringify({
pageSnapshot: ariaSnapshot,
})),
}, undefined);
return;
}
const meaningfulSingleLineErrors = new Set(testInfo.errors.filter(e => e.message && !e.message.includes('\n')).map(e => e.message!)); const meaningfulSingleLineErrors = new Set(testInfo.errors.filter(e => e.message && !e.message.includes('\n')).map(e => e.message!));
for (const error of testInfo.errors) { for (const error of testInfo.errors) {
for (const singleLineError of meaningfulSingleLineErrors.keys()) { for (const singleLineError of meaningfulSingleLineErrors.keys()) {
@ -51,16 +62,10 @@ export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<st
for (const [index, error] of errors) { for (const [index, error] of errors) {
const metadata = testInfo.config.metadata as MetadataWithCommitInfo; const metadata = testInfo.config.metadata as MetadataWithCommitInfo;
if (testInfo.attachments.find(a => a.name === `_prompt-${index}`)) if (testInfo.attachments.find(a => a.name === `_error-context-${index}`))
continue; continue;
const promptParts = [ const lines = [
`# Instructions`,
'',
`- Following Playwright test failed.`,
`- Explain why, be concise, respect Playwright best practices.`,
`- Provide a snippet of code with the fix, if possible.`,
'',
`# Test info`, `# Test info`,
'', '',
`- Name: ${testInfo.titlePath.slice(1).join(' >> ')}`, `- Name: ${testInfo.titlePath.slice(1).join(' >> ')}`,
@ -74,7 +79,7 @@ export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<st
]; ];
if (ariaSnapshot) { if (ariaSnapshot) {
promptParts.push( lines.push(
'', '',
'# Page snapshot', '# Page snapshot',
'', '',
@ -103,7 +108,7 @@ export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<st
message: inlineMessage || undefined, message: inlineMessage || undefined,
} }
); );
promptParts.push( lines.push(
'', '',
'# Test source', '# Test source',
'', '',
@ -113,7 +118,7 @@ export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<st
); );
if (metadata.gitDiff) { if (metadata.gitDiff) {
promptParts.push( lines.push(
'', '',
'# Local changes', '# Local changes',
'', '',
@ -123,30 +128,17 @@ export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<st
); );
} }
const promptPath = testInfo.outputPath(errors.length === 1 ? `prompt.md` : `prompt-${index}.md`); const filePath = testInfo.outputPath(errors.length === 1 ? `error-context.md` : `error-context-${index}.md`);
await fs.writeFile(promptPath, promptParts.join('\n'), 'utf8'); await fs.writeFile(filePath, lines.join('\n'), 'utf8');
(testInfo as TestInfoImpl)._attach({ (testInfo as TestInfoImpl)._attach({
name: `_prompt-${index}`, name: `_error-context-${index}`,
contentType: 'text/markdown', contentType: 'text/markdown',
path: promptPath, path: filePath,
}, undefined); }, undefined);
} }
} }
export async function attachErrorContext(testInfo: TestInfo, ariaSnapshot: string | undefined) {
if (!ariaSnapshot)
return;
(testInfo as TestInfoImpl)._attach({
name: `_error-context`,
contentType: 'application/json',
body: Buffer.from(JSON.stringify({
pageSnapshot: ariaSnapshot,
})),
}, undefined);
}
async function loadSource(file: string, sourceCache: Map<string, string>) { async function loadSource(file: string, sourceCache: Map<string, string>) {
let source = sourceCache.get(file); let source = sourceCache.get(file);
if (!source) { if (!source) {

View File

@ -22,7 +22,7 @@ import { setBoxedStackPrefixes, asLocator, createGuid, currentZone, debugMode, i
import { currentTestInfo } from './common/globals'; import { currentTestInfo } from './common/globals';
import { rootTestType } from './common/testType'; import { rootTestType } from './common/testType';
import { attachErrorContext, attachErrorPrompts } from './prompt'; import { attachErrorContext } from './errorContext';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
import type { ContextReuseMode } from './common/config'; import type { ContextReuseMode } from './common/config';
@ -55,13 +55,15 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>; _contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>;
}; };
type ErrorContextOption = { format: 'json' | 'markdown' } | undefined;
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
playwright: PlaywrightImpl; playwright: PlaywrightImpl;
_browserOptions: LaunchOptions; _browserOptions: LaunchOptions;
_optionContextReuseMode: ContextReuseMode, _optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean, _reuseContext: boolean,
_optionAttachErrorContext: boolean, _optionErrorContext: ErrorContextOption,
}; };
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -245,13 +247,13 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
playwright._defaultContextNavigationTimeout = undefined; playwright._defaultContextNavigationTimeout = undefined;
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any], }, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
_setupArtifacts: [async ({ playwright, screenshot, _optionAttachErrorContext }, use, testInfo) => { _setupArtifacts: [async ({ playwright, screenshot, _optionErrorContext }, use, testInfo) => {
// This fixture has a separate zero-timeout slot to ensure that artifact collection // This fixture has a separate zero-timeout slot to ensure that artifact collection
// happens even after some fixtures or hooks time out. // happens even after some fixtures or hooks time out.
// Now that default test timeout is known, we can replace zero with an actual value. // Now that default test timeout is known, we can replace zero with an actual value.
testInfo.setTimeout(testInfo.project.timeout); testInfo.setTimeout(testInfo.project.timeout);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot, _optionAttachErrorContext); const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot, _optionErrorContext);
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl); await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = []; const tracingGroupSteps: TestStepInternal[] = [];
@ -393,7 +395,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
_optionContextReuseMode: ['none', { scope: 'worker', option: true }], _optionContextReuseMode: ['none', { scope: 'worker', option: true }],
_optionConnectOptions: [undefined, { scope: 'worker', option: true }], _optionConnectOptions: [undefined, { scope: 'worker', option: true }],
_optionAttachErrorContext: [false, { scope: 'worker', option: true }], _optionErrorContext: [process.env.PLAYWRIGHT_NO_COPY_PROMPT ? undefined : { format: 'markdown' }, { scope: 'worker', option: true }],
_reuseContext: [async ({ video, _optionContextReuseMode }, use) => { _reuseContext: [async ({ video, _optionContextReuseMode }, use) => {
let mode = _optionContextReuseMode; let mode = _optionContextReuseMode;
@ -622,12 +624,12 @@ class ArtifactsRecorder {
private _screenshotRecorder: SnapshotRecorder; private _screenshotRecorder: SnapshotRecorder;
private _pageSnapshot: string | undefined; private _pageSnapshot: string | undefined;
private _sourceCache: Map<string, string> = new Map(); private _sourceCache: Map<string, string> = new Map();
private _attachErrorContext: boolean; private _errorContext: ErrorContextOption;
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption, attachErrorContext: boolean) { constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption, errorContext: ErrorContextOption) {
this._playwright = playwright; this._playwright = playwright;
this._artifactsDir = artifactsDir; this._artifactsDir = artifactsDir;
this._attachErrorContext = attachErrorContext; this._errorContext = errorContext;
const screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot; const screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts'); this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
@ -671,7 +673,7 @@ class ArtifactsRecorder {
} }
private async _takePageSnapshot(context: BrowserContext) { private async _takePageSnapshot(context: BrowserContext) {
if (process.env.PLAYWRIGHT_NO_COPY_PROMPT) if (!this._errorContext)
return; return;
if (this._testInfo.errors.length === 0) if (this._testInfo.errors.length === 0)
return; return;
@ -719,10 +721,8 @@ class ArtifactsRecorder {
if (context) if (context)
await this._takePageSnapshot(context); await this._takePageSnapshot(context);
if (this._attachErrorContext) if (this._errorContext)
await attachErrorContext(this._testInfo, this._pageSnapshot); await attachErrorContext(this._testInfo, this._errorContext.format, this._sourceCache, this._pageSnapshot);
else
await attachErrorPrompts(this._testInfo, this._sourceCache, this._pageSnapshot);
} }
private async _startTraceChunkOnContextCreation(tracing: Tracing) { private async _startTraceChunkOnContextCreation(tracing: Tracing) {

View File

@ -14,8 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Metadata, TestAnnotation } from '../../types/test'; import type { Metadata } from '../../types/test';
import type * as reporterTypes from '../../types/testReporter'; import type * as reporterTypes from '../../types/testReporter';
import type { Annotation } from '../common/config';
import type { ReporterV2 } from '../reporters/reporterV2'; import type { ReporterV2 } from '../reporters/reporterV2';
export type StringIntern = (s: string) => string; export type StringIntern = (s: string) => string;
@ -67,7 +68,7 @@ export type JsonTestCase = {
retries: number; retries: number;
tags?: string[]; tags?: string[];
repeatEachIndex: number; repeatEachIndex: number;
annotations?: TestAnnotation[]; annotations?: Annotation[];
}; };
export type JsonTestEnd = { export type JsonTestEnd = {
@ -94,7 +95,7 @@ export type JsonTestResultEnd = {
status: reporterTypes.TestStatus; status: reporterTypes.TestStatus;
errors: reporterTypes.TestError[]; errors: reporterTypes.TestError[];
attachments: JsonAttachment[]; attachments: JsonAttachment[];
annotations?: TestAnnotation[]; annotations?: Annotation[];
}; };
export type JsonTestStepStart = { export type JsonTestStepStart = {
@ -111,7 +112,7 @@ export type JsonTestStepEnd = {
duration: number; duration: number;
error?: reporterTypes.TestError; error?: reporterTypes.TestError;
attachments?: number[]; // index of JsonTestResultEnd.attachments attachments?: number[]; // index of JsonTestResultEnd.attachments
annotations?: TestAnnotation[]; annotations?: Annotation[];
}; };
export type JsonFullResult = { export type JsonFullResult = {
@ -242,7 +243,7 @@ export class TeleReporterReceiver {
result.error = result.errors?.[0]; result.error = result.errors?.[0];
result.attachments = this._parseAttachments(payload.attachments); result.attachments = this._parseAttachments(payload.attachments);
if (payload.annotations) { if (payload.annotations) {
result.annotations = this._absoluteAnnotationLocations(payload.annotations); result.annotations = payload.annotations;
test.annotations = result.annotations; test.annotations = result.annotations;
} }
this._reporter.onTestEnd?.(test, result); this._reporter.onTestEnd?.(test, result);
@ -374,18 +375,10 @@ export class TeleReporterReceiver {
test.location = this._absoluteLocation(payload.location); test.location = this._absoluteLocation(payload.location);
test.retries = payload.retries; test.retries = payload.retries;
test.tags = payload.tags ?? []; test.tags = payload.tags ?? [];
test.annotations = this._absoluteAnnotationLocations(payload.annotations ?? []); test.annotations = payload.annotations ?? [];
return test; return test;
} }
private _absoluteAnnotationLocations(annotations: TestAnnotation[]): TestAnnotation[] {
return annotations.map(annotation => {
if (annotation.location)
annotation.location = this._absoluteLocation(annotation.location);
return annotation;
});
}
private _absoluteLocation(location: reporterTypes.Location): reporterTypes.Location; private _absoluteLocation(location: reporterTypes.Location): reporterTypes.Location;
private _absoluteLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined; private _absoluteLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined;
private _absoluteLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined { private _absoluteLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined {
@ -486,7 +479,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
expectedStatus: reporterTypes.TestStatus = 'passed'; expectedStatus: reporterTypes.TestStatus = 'passed';
timeout = 0; timeout = 0;
annotations: TestAnnotation[] = []; annotations: Annotation[] = [];
retries = 0; retries = 0;
tags: string[] = []; tags: string[] = [];
repeatEachIndex = 0; repeatEachIndex = 0;

View File

@ -102,7 +102,7 @@ export interface TestServerInterface {
projects?: string[]; projects?: string[];
reuseContext?: boolean; reuseContext?: boolean;
connectWsEndpoint?: string; connectWsEndpoint?: string;
attachErrorContext?: boolean; errorContext?: { format: 'json' | 'markdown' };
}): Promise<{ }): Promise<{
status: reporterTypes.FullResult['status']; status: reporterTypes.FullResult['status'];
}>; }>;

View File

@ -385,10 +385,8 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info }); setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info });
const callback = () => matcher.call(target, ...args); const callback = () => matcher.call(target, ...args);
const result = currentZone().with('stepZone', step).run(callback); const result = currentZone().with('stepZone', step).run(callback);
if (result instanceof Promise) { if (result instanceof Promise)
const promise = result.then(finalizer).catch(reportStepError); return result.then(finalizer).catch(reportStepError);
return testInfo._floatingPromiseScope.wrapPromiseAPIResult(promise, stackFrames[0]);
}
finalizer(); finalizer();
return result; return result;
} catch (e) { } catch (e) {

View File

@ -26,7 +26,6 @@ import { getEastAsianWidth } from '../utilsBundle';
import type { ReporterV2 } from './reporterV2'; import type { ReporterV2 } from './reporterV2';
import type { FullConfig, FullResult, Location, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; import type { FullConfig, FullResult, Location, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter';
import type { Colors } from '@isomorphic/colors'; import type { Colors } from '@isomorphic/colors';
import type { TestAnnotation } from '../../types/test';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' }; export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
export const kOutputSymbol = Symbol('output'); export const kOutputSymbol = Symbol('output');
@ -320,7 +319,6 @@ export function formatFailure(screen: Screen, config: FullConfig, test: TestCase
const header = formatTestHeader(screen, config, test, { indent: ' ', index, mode: 'error' }); const header = formatTestHeader(screen, config, test, { indent: ' ', index, mode: 'error' });
lines.push(screen.colors.red(header)); lines.push(screen.colors.red(header));
for (const result of test.results) { for (const result of test.results) {
const warnings = result.annotations.filter(a => a.type === 'warning');
const resultLines: string[] = []; const resultLines: string[] = [];
const errors = formatResultFailure(screen, test, result, ' '); const errors = formatResultFailure(screen, test, result, ' ');
if (!errors.length) if (!errors.length)
@ -330,15 +328,11 @@ export function formatFailure(screen: Screen, config: FullConfig, test: TestCase
resultLines.push(screen.colors.gray(separator(screen, ` Retry #${result.retry}`))); resultLines.push(screen.colors.gray(separator(screen, ` Retry #${result.retry}`)));
} }
resultLines.push(...errors.map(error => '\n' + error.message)); resultLines.push(...errors.map(error => '\n' + error.message));
if (warnings.length) {
resultLines.push('');
resultLines.push(...formatTestWarning(screen, config, warnings));
}
for (let i = 0; i < result.attachments.length; ++i) { for (let i = 0; i < result.attachments.length; ++i) {
const attachment = result.attachments[i]; const attachment = result.attachments[i];
if (attachment.name.startsWith('_prompt') && attachment.path) { if (attachment.name.startsWith('_error-context') && attachment.path) {
resultLines.push(''); resultLines.push('');
resultLines.push(screen.colors.dim(` Error Prompt: ${relativeFilePath(screen, config, attachment.path)}`)); resultLines.push(screen.colors.dim(` Error Context: ${relativeFilePath(screen, config, attachment.path)}`));
continue; continue;
} }
if (attachment.name.startsWith('_')) if (attachment.name.startsWith('_'))
@ -376,26 +370,6 @@ export function formatFailure(screen: Screen, config: FullConfig, test: TestCase
return lines.join('\n'); return lines.join('\n');
} }
function formatTestWarning(screen: Screen, config: FullConfig, warnings: TestAnnotation[]): string[] {
warnings.sort((a, b) => {
const aLocationKey = a.location ? `${a.location.file}:${a.location.line}:${a.location.column}` : undefined;
const bLocationKey = b.location ? `${b.location.file}:${b.location.line}:${b.location.column}` : undefined;
if (!aLocationKey && !bLocationKey)
return 0;
if (!aLocationKey)
return 1;
if (!bLocationKey)
return -1;
return aLocationKey.localeCompare(bLocationKey);
});
return warnings.filter(w => !!w.description).map(w => {
const location = !!w.location ? `${relativeFilePath(screen, config, w.location.file)}:${w.location.line}:${w.location.column}: ` : '';
return `${screen.colors.yellow(` Warning: ${location}${w.description}`)}`;
});
}
export function formatRetry(screen: Screen, result: TestResult) { export function formatRetry(screen: Screen, result: TestResult) {
const retryLines = []; const retryLines = [];
if (result.retry) { if (result.retry) {

View File

@ -29,9 +29,9 @@ import { codeFrameColumns } from '../transform/babelBundle';
import { resolveReporterOutputPath, stripAnsiEscapes } from '../util'; import { resolveReporterOutputPath, stripAnsiEscapes } from '../util';
import type { ReporterV2 } from './reporterV2'; import type { ReporterV2 } from './reporterV2';
import type { Metadata, TestAnnotation } from '../../types/test'; import type { Metadata } from '../../types/test';
import type * as api from '../../types/testReporter'; import type * as api from '../../types/testReporter';
import type { HTMLReport, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep } from '@html-reporter/types'; import type { HTMLReport, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep, TestAnnotation } from '@html-reporter/types';
import type { ZipFile } from 'playwright-core/lib/zipBundle'; import type { ZipFile } from 'playwright-core/lib/zipBundle';
import type { TransformCallback } from 'stream'; import type { TransformCallback } from 'stream';
@ -503,7 +503,7 @@ class HtmlBuilder {
private _serializeAnnotations(annotations: api.TestCase['annotations']): TestAnnotation[] { private _serializeAnnotations(annotations: api.TestCase['annotations']): TestAnnotation[] {
// Annotations can be pushed directly, with a wrong type. // Annotations can be pushed directly, with a wrong type.
return annotations.map(a => ({ type: a.type, description: a.description === undefined ? undefined : String(a.description), location: a.location })); return annotations.map(a => ({ type: a.type, description: a.description === undefined ? undefined : String(a.description) }));
} }
private _createTestResult(test: api.TestCase, result: api.TestResult): TestResult { private _createTestResult(test: api.TestCase, result: api.TestResult): TestResult {

View File

@ -27,10 +27,10 @@ import { createReporters } from '../runner/reporters';
import { relativeFilePath } from '../util'; import { relativeFilePath } from '../util';
import type { BlobReportMetadata } from './blob'; import type { BlobReportMetadata } from './blob';
import type { ReporterDescription, TestAnnotation } from '../../types/test'; import type { ReporterDescription } from '../../types/test';
import type { TestError } from '../../types/testReporter'; import type { TestError } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import type { JsonConfig, JsonEvent, JsonFullResult, JsonLocation, JsonProject, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; import type { JsonConfig, JsonEvent, JsonFullResult, JsonLocation, JsonProject, JsonSuite, JsonTestCase, JsonTestResultEnd, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver';
import type * as blobV1 from './versions/blobV1'; import type * as blobV1 from './versions/blobV1';
type StatusCallback = (message: string) => void; type StatusCallback = (message: string) => void;
@ -474,10 +474,7 @@ class PathSeparatorPatcher {
return; return;
} }
if (jsonEvent.method === 'onTestEnd') { if (jsonEvent.method === 'onTestEnd') {
const test = jsonEvent.params.test as JsonTestEnd;
test.annotations?.forEach(annotation => this._updateAnnotationLocations(annotation));
const testResult = jsonEvent.params.result as JsonTestResultEnd; const testResult = jsonEvent.params.result as JsonTestResultEnd;
testResult.annotations?.forEach(annotation => this._updateAnnotationLocations(annotation));
testResult.errors.forEach(error => this._updateErrorLocations(error)); testResult.errors.forEach(error => this._updateErrorLocations(error));
testResult.attachments.forEach(attachment => { testResult.attachments.forEach(attachment => {
if (attachment.path) if (attachment.path)
@ -493,7 +490,6 @@ class PathSeparatorPatcher {
if (jsonEvent.method === 'onStepEnd') { if (jsonEvent.method === 'onStepEnd') {
const step = jsonEvent.params.step as JsonTestStepEnd; const step = jsonEvent.params.step as JsonTestStepEnd;
this._updateErrorLocations(step.error); this._updateErrorLocations(step.error);
step.annotations?.forEach(annotation => this._updateAnnotationLocations(annotation));
return; return;
} }
} }
@ -510,14 +506,12 @@ class PathSeparatorPatcher {
if (isFileSuite) if (isFileSuite)
suite.title = this._updatePath(suite.title); suite.title = this._updatePath(suite.title);
for (const entry of suite.entries) { for (const entry of suite.entries) {
if ('testId' in entry) { if ('testId' in entry)
this._updateLocation(entry.location); this._updateLocation(entry.location);
entry.annotations?.forEach(annotation => this._updateAnnotationLocations(annotation)); else
} else {
this._updateSuite(entry); this._updateSuite(entry);
} }
} }
}
private _updateErrorLocations(error: TestError | undefined) { private _updateErrorLocations(error: TestError | undefined) {
while (error) { while (error) {
@ -526,10 +520,6 @@ class PathSeparatorPatcher {
} }
} }
private _updateAnnotationLocations(annotation: TestAnnotation) {
this._updateLocation(annotation.location);
}
private _updateLocation(location?: JsonLocation) { private _updateLocation(location?: JsonLocation) {
if (location) if (location)
location.file = this._updatePath(location.file); location.file = this._updatePath(location.file);

View File

@ -22,7 +22,6 @@ import { serializeRegexPatterns } from '../isomorphic/teleReceiver';
import type { ReporterV2 } from './reporterV2'; import type { ReporterV2 } from './reporterV2';
import type * as reporterTypes from '../../types/testReporter'; import type * as reporterTypes from '../../types/testReporter';
import type { TestAnnotation } from '../../types/test';
import type * as teleReceiver from '../isomorphic/teleReceiver'; import type * as teleReceiver from '../isomorphic/teleReceiver';
export type TeleReporterEmitterOptions = { export type TeleReporterEmitterOptions = {
@ -217,7 +216,7 @@ export class TeleReporterEmitter implements ReporterV2 {
retries: test.retries, retries: test.retries,
tags: test.tags, tags: test.tags,
repeatEachIndex: test.repeatEachIndex, repeatEachIndex: test.repeatEachIndex,
annotations: this._relativeAnnotationLocations(test.annotations), annotations: test.annotations,
}; };
} }
@ -238,7 +237,7 @@ export class TeleReporterEmitter implements ReporterV2 {
status: result.status, status: result.status,
errors: result.errors, errors: result.errors,
attachments: this._serializeAttachments(result.attachments), attachments: this._serializeAttachments(result.attachments),
annotations: result.annotations?.length ? this._relativeAnnotationLocations(result.annotations) : undefined, annotations: result.annotations?.length ? result.annotations : undefined,
}; };
} }
@ -269,18 +268,10 @@ export class TeleReporterEmitter implements ReporterV2 {
duration: step.duration, duration: step.duration,
error: step.error, error: step.error,
attachments: step.attachments.length ? step.attachments.map(a => result.attachments.indexOf(a)) : undefined, attachments: step.attachments.length ? step.attachments.map(a => result.attachments.indexOf(a)) : undefined,
annotations: step.annotations.length ? this._relativeAnnotationLocations(step.annotations) : undefined, annotations: step.annotations.length ? step.annotations : undefined,
}; };
} }
private _relativeAnnotationLocations(annotations: TestAnnotation[]): TestAnnotation[] {
return annotations.map(annotation => {
if (annotation.location)
annotation.location = this._relativeLocation(annotation.location);
return annotation;
});
}
private _relativeLocation(location: reporterTypes.Location): reporterTypes.Location; private _relativeLocation(location: reporterTypes.Location): reporterTypes.Location;
private _relativeLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined; private _relativeLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined;
private _relativeLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined { private _relativeLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined {

View File

@ -314,7 +314,7 @@ export class TestServerDispatcher implements TestServerInterface {
...(params.headed !== undefined ? { headless: !params.headed } : {}), ...(params.headed !== undefined ? { headless: !params.headed } : {}),
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
_optionAttachErrorContext: params.attachErrorContext, _optionErrorContext: params.errorContext,
}, },
...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}),
...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}), ...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}),

View File

@ -1,62 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Location } from '../../types/test';
export class FloatingPromiseScope {
readonly _floatingCalls: Map<Promise<any>, Location | undefined> = new Map();
/**
* Enables a promise API call to be tracked by the test, alerting if unawaited.
*
* **NOTE:** Returning from an async function wraps the result in a promise, regardless of whether the return value is a promise. This will automatically mark the promise as awaited. Avoid this.
*/
wrapPromiseAPIResult<T>(promise: Promise<T>, location: Location | undefined): Promise<T> {
if (process.env.PW_DISABLE_FLOATING_PROMISES_WARNING)
return promise;
const promiseProxy = new Proxy(promise, {
get: (target, prop, receiver) => {
if (prop === 'then') {
return (...args: any[]) => {
this._floatingCalls.delete(promise);
const originalThen = Reflect.get(target, prop, receiver) as Promise<T>['then'];
return originalThen.call(target, ...args);
};
} else {
return Reflect.get(target, prop, receiver);
}
}
});
this._floatingCalls.set(promise, location);
return promiseProxy;
}
clear() {
this._floatingCalls.clear();
}
hasFloatingPromises(): boolean {
return this._floatingCalls.size > 0;
}
floatingPromises(): Array<{ location: Location | undefined, promise: Promise<any> }> {
return Array.from(this._floatingCalls.entries()).map(([promise, location]) => ({ location, promise }));
}
}

View File

@ -23,17 +23,16 @@ import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutMana
import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util'; import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';
import { TestTracing } from './testTracing'; import { TestTracing } from './testTracing';
import { testInfoError } from './util'; import { testInfoError } from './util';
import { FloatingPromiseScope } from './floatingPromiseScope';
import { wrapFunctionWithLocation } from '../transform/transform';
import type { RunnableDescription } from './timeoutManager'; import type { RunnableDescription } from './timeoutManager';
import type { FullProject, TestAnnotation, TestInfo, TestStatus, TestStepInfo } from '../../types/test'; import type { FullProject, TestInfo, TestStatus, TestStepInfo } from '../../types/test';
import type { FullConfig, Location } from '../../types/testReporter'; import type { FullConfig, Location } from '../../types/testReporter';
import type { FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config';
import type { AttachmentPayload, StepBeginPayload, StepEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc'; import type { AttachmentPayload, StepBeginPayload, StepEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc';
import type { TestCase } from '../common/test'; import type { TestCase } from '../common/test';
import type { StackFrame } from '@protocol/channels'; import type { StackFrame } from '@protocol/channels';
export interface TestStepInternal { export interface TestStepInternal {
complete(result: { error?: Error | unknown, suggestedRebaseline?: string }): void; complete(result: { error?: Error | unknown, suggestedRebaseline?: string }): void;
info: TestStepInfoImpl info: TestStepInfoImpl
@ -60,7 +59,6 @@ export class TestInfoImpl implements TestInfo {
readonly _startTime: number; readonly _startTime: number;
readonly _startWallTime: number; readonly _startWallTime: number;
readonly _tracing: TestTracing; readonly _tracing: TestTracing;
readonly _floatingPromiseScope: FloatingPromiseScope = new FloatingPromiseScope();
readonly _uniqueSymbol; readonly _uniqueSymbol;
_wasInterrupted = false; _wasInterrupted = false;
@ -75,12 +73,6 @@ export class TestInfoImpl implements TestInfo {
_hasUnhandledError = false; _hasUnhandledError = false;
_allowSkips = false; _allowSkips = false;
// ------------ Main methods ------------
skip: (arg?: any, description?: string) => void;
fixme: (arg?: any, description?: string) => void;
fail: (arg?: any, description?: string) => void;
slow: (arg?: any, description?: string) => void;
// ------------ TestInfo fields ------------ // ------------ TestInfo fields ------------
readonly testId: string; readonly testId: string;
readonly repeatEachIndex: number; readonly repeatEachIndex: number;
@ -98,7 +90,7 @@ export class TestInfoImpl implements TestInfo {
readonly fn: Function; readonly fn: Function;
expectedStatus: TestStatus; expectedStatus: TestStatus;
duration: number = 0; duration: number = 0;
readonly annotations: TestAnnotation[] = []; readonly annotations: Annotation[] = [];
readonly attachments: TestInfo['attachments'] = []; readonly attachments: TestInfo['attachments'] = [];
status: TestStatus = 'passed'; status: TestStatus = 'passed';
snapshotSuffix: string = ''; snapshotSuffix: string = '';
@ -206,14 +198,9 @@ export class TestInfoImpl implements TestInfo {
}; };
this._tracing = new TestTracing(this, workerParams.artifactsDir); this._tracing = new TestTracing(this, workerParams.artifactsDir);
this.skip = wrapFunctionWithLocation((location, ...args) => this._modifier('skip', location, args));
this.fixme = wrapFunctionWithLocation((location, ...args) => this._modifier('fixme', location, args));
this.fail = wrapFunctionWithLocation((location, ...args) => this._modifier('fail', location, args));
this.slow = wrapFunctionWithLocation((location, ...args) => this._modifier('slow', location, args));
} }
_modifier(type: 'skip' | 'fail' | 'fixme' | 'slow', location: Location, modifierArgs: [arg?: any, description?: string]) { private _modifier(type: 'skip' | 'fail' | 'fixme' | 'slow', modifierArgs: [arg?: any, description?: string]) {
if (typeof modifierArgs[1] === 'function') { if (typeof modifierArgs[1] === 'function') {
throw new Error([ throw new Error([
'It looks like you are calling test.skip() inside the test and pass a callback.', 'It looks like you are calling test.skip() inside the test and pass a callback.',
@ -228,7 +215,7 @@ export class TestInfoImpl implements TestInfo {
return; return;
const description = modifierArgs[1]; const description = modifierArgs[1];
this.annotations.push({ type, description, location }); this.annotations.push({ type, description });
if (type === 'slow') { if (type === 'slow') {
this._timeoutManager.slow(); this._timeoutManager.slow();
} else if (type === 'skip' || type === 'fixme') { } else if (type === 'skip' || type === 'fixme') {
@ -492,13 +479,29 @@ export class TestInfoImpl implements TestInfo {
return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments); return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments);
} }
skip(...args: [arg?: any, description?: string]) {
this._modifier('skip', args);
}
fixme(...args: [arg?: any, description?: string]) {
this._modifier('fixme', args);
}
fail(...args: [arg?: any, description?: string]) {
this._modifier('fail', args);
}
slow(...args: [arg?: any, description?: string]) {
this._modifier('slow', args);
}
setTimeout(timeout: number) { setTimeout(timeout: number) {
this._timeoutManager.setTimeout(timeout); this._timeoutManager.setTimeout(timeout);
} }
} }
export class TestStepInfoImpl implements TestStepInfo { export class TestStepInfoImpl implements TestStepInfo {
annotations: TestAnnotation[] = []; annotations: Annotation[] = [];
private _testInfo: TestInfoImpl; private _testInfo: TestInfoImpl;
private _stepId: string; private _stepId: string;
@ -508,9 +511,9 @@ export class TestStepInfoImpl implements TestStepInfo {
this._stepId = stepId; this._stepId = stepId;
} }
async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>, location?: Location) { async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) {
if (skip) { if (skip) {
this.annotations.push({ type: 'skip', location }); this.annotations.push({ type: 'skip' });
return undefined as T; return undefined as T;
} }
try { try {
@ -530,15 +533,15 @@ export class TestStepInfoImpl implements TestStepInfo {
this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options)); this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options));
} }
skip = wrapFunctionWithLocation((location: Location, ...args: unknown[]) => { skip(...args: unknown[]) {
// skip(); // skip();
// skip(condition: boolean, description: string); // skip(condition: boolean, description: string);
if (args.length > 0 && !args[0]) if (args.length > 0 && !args[0])
return; return;
const description = args[1] as (string|undefined); const description = args[1] as (string|undefined);
this.annotations.push({ type: 'skip', description, location }); this.annotations.push({ type: 'skip', description });
throw new StepSkipError(description); throw new StepSkipError(description);
}); }
} }
export class TestSkipError extends Error { export class TestSkipError extends Error {

View File

@ -32,10 +32,9 @@ import { loadTestFile } from '../common/testLoader';
import type { TimeSlot } from './timeoutManager'; import type { TimeSlot } from './timeoutManager';
import type { Location } from '../../types/testReporter'; import type { Location } from '../../types/testReporter';
import type { FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config';
import type { DonePayload, RunPayload, TeardownErrorsPayload, TestBeginPayload, TestEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc'; import type { DonePayload, RunPayload, TeardownErrorsPayload, TestBeginPayload, TestEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc';
import type { Suite, TestCase } from '../common/test'; import type { Suite, TestCase } from '../common/test';
import type { TestAnnotation } from '../../types/test';
export class WorkerMain extends ProcessRunner { export class WorkerMain extends ProcessRunner {
private _params: WorkerInitParams; private _params: WorkerInitParams;
@ -61,7 +60,7 @@ export class WorkerMain extends ProcessRunner {
// Suites that had their beforeAll hooks, but not afterAll hooks executed. // Suites that had their beforeAll hooks, but not afterAll hooks executed.
// These suites still need afterAll hooks to be executed for the proper cleanup. // These suites still need afterAll hooks to be executed for the proper cleanup.
// Contains dynamic annotations originated by modifiers with a callback, e.g. `test.skip(() => true)`. // Contains dynamic annotations originated by modifiers with a callback, e.g. `test.skip(() => true)`.
private _activeSuites = new Map<Suite, TestAnnotation[]>(); private _activeSuites = new Map<Suite, Annotation[]>();
constructor(params: WorkerInitParams) { constructor(params: WorkerInitParams) {
super(); super();
@ -265,7 +264,7 @@ export class WorkerMain extends ProcessRunner {
stepEndPayload => this.dispatchEvent('stepEnd', stepEndPayload), stepEndPayload => this.dispatchEvent('stepEnd', stepEndPayload),
attachment => this.dispatchEvent('attach', attachment)); attachment => this.dispatchEvent('attach', attachment));
const processAnnotation = (annotation: TestAnnotation) => { const processAnnotation = (annotation: Annotation) => {
testInfo.annotations.push(annotation); testInfo.annotations.push(annotation);
switch (annotation.type) { switch (annotation.type) {
case 'fixme': case 'fixme':
@ -428,25 +427,9 @@ export class WorkerMain extends ProcessRunner {
throw firstAfterHooksError; throw firstAfterHooksError;
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
if (testInfo._isFailure()) { if (testInfo._isFailure())
this._isStopped = true; this._isStopped = true;
// Only if failed, create warning if any of the async calls were not awaited in various stages.
if (!process.env.PW_DISABLE_FLOATING_PROMISES_WARNING && testInfo._floatingPromiseScope.hasFloatingPromises()) {
// Dedupe by location
const annotationLocations = new Map<string | undefined, Location | undefined>(testInfo._floatingPromiseScope.floatingPromises().map(
({ location }) => {
const locationKey = location ? `${location.file}:${location.line}:${location.column}` : undefined;
return [locationKey, location];
}));
testInfo.annotations.push(...[...annotationLocations.values()].map(location => ({
type: 'warning', description: `This async call was not awaited by the end of the test. This can cause flakiness. It is recommended to run ESLint with "@typescript-eslint/no-floating-promises" to verify.`, location
})));
testInfo._floatingPromiseScope.clear();
}
}
if (this._isStopped) { if (this._isStopped) {
// Run all remaining "afterAll" hooks and teardown all fixtures when worker is shutting down. // Run all remaining "afterAll" hooks and teardown all fixtures when worker is shutting down.
// Mark as "cleaned up" early to avoid running cleanup twice. // Mark as "cleaned up" early to avoid running cleanup twice.
@ -510,7 +493,7 @@ export class WorkerMain extends ProcessRunner {
continue; continue;
const fn = async (fixtures: any) => { const fn = async (fixtures: any) => {
const result = await modifier.fn(fixtures); const result = await modifier.fn(fixtures);
testInfo._modifier(modifier.type, modifier.location, [!!result, modifier.description]); testInfo[modifier.type](!!result, modifier.description);
}; };
inheritFixtureNames(modifier.fn, fn); inheritFixtureNames(modifier.fn, fn);
runnables.push({ runnables.push({
@ -528,12 +511,12 @@ export class WorkerMain extends ProcessRunner {
private async _runBeforeAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl) { private async _runBeforeAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl) {
if (this._activeSuites.has(suite)) if (this._activeSuites.has(suite))
return; return;
const extraAnnotations: TestAnnotation[] = []; const extraAnnotations: Annotation[] = [];
this._activeSuites.set(suite, extraAnnotations); this._activeSuites.set(suite, extraAnnotations);
await this._runAllHooksForSuite(suite, testInfo, 'beforeAll', extraAnnotations); await this._runAllHooksForSuite(suite, testInfo, 'beforeAll', extraAnnotations);
} }
private async _runAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl, type: 'beforeAll' | 'afterAll', extraAnnotations?: TestAnnotation[]) { private async _runAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl, type: 'beforeAll' | 'afterAll', extraAnnotations?: Annotation[]) {
// Always run all the hooks, and capture the first error. // Always run all the hooks, and capture the first error.
let firstError: Error | undefined; let firstError: Error | undefined;
for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) {

View File

@ -2049,10 +2049,6 @@ export type TestDetailsAnnotation = {
description?: string; description?: string;
}; };
export type TestAnnotation = TestDetailsAnnotation & {
location?: Location;
};
export type TestDetails = { export type TestDetails = {
tag?: string | string[]; tag?: string | string[];
annotation?: TestDetailsAnnotation | TestDetailsAnnotation[]; annotation?: TestDetailsAnnotation | TestDetailsAnnotation[];
@ -9442,11 +9438,6 @@ export interface TestInfo {
* Optional description. * Optional description.
*/ */
description?: string; description?: string;
/**
* Optional location in the source where the annotation is added.
*/
location?: Location;
}>; }>;
/** /**

View File

@ -451,11 +451,6 @@ export interface TestCase {
* Optional description. * Optional description.
*/ */
description?: string; description?: string;
/**
* Optional location in the source where the annotation is added.
*/
location?: Location;
}>; }>;
/** /**
@ -612,11 +607,6 @@ export interface TestResult {
* Optional description. * Optional description.
*/ */
description?: string; description?: string;
/**
* Optional location in the source where the annotation is added.
*/
location?: Location;
}>; }>;
/** /**
@ -732,11 +722,6 @@ export interface TestStep {
* Optional description. * Optional description.
*/ */
description?: string; description?: string;
/**
* Optional location in the source where the annotation is added.
*/
location?: Location;
}>; }>;
/** /**

View File

@ -18,10 +18,11 @@ import * as React from 'react';
import './annotationsTab.css'; import './annotationsTab.css';
import { PlaceholderPanel } from './placeholderPanel'; import { PlaceholderPanel } from './placeholderPanel';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import type { TestAnnotation } from '@playwright/test';
type Annotation = { type: string; description?: string; };
export const AnnotationsTab: React.FunctionComponent<{ export const AnnotationsTab: React.FunctionComponent<{
annotations: TestAnnotation[], annotations: Annotation[],
}> = ({ annotations }) => { }> = ({ annotations }) => {
if (!annotations.length) if (!annotations.length)

View File

@ -26,6 +26,7 @@ import { ToolbarButton } from '@web/components/toolbarButton';
import { useIsLLMAvailable, useLLMChat } from './llm'; import { useIsLLMAvailable, useLLMChat } from './llm';
import { useAsyncMemo } from '@web/uiUtils'; import { useAsyncMemo } from '@web/uiUtils';
import { attachmentURL } from './attachmentsTab'; import { attachmentURL } from './attachmentsTab';
import { fixTestInstructions } from '@web/prompts';
const CopyPromptButton: React.FC<{ prompt: string }> = ({ prompt }) => { const CopyPromptButton: React.FC<{ prompt: string }> = ({ prompt }) => {
return ( return (
@ -67,10 +68,10 @@ function Error({ message, error, errorId, sdkLanguage, revealInSource }: { messa
} }
const prompt = useAsyncMemo(async () => { const prompt = useAsyncMemo(async () => {
if (!error.prompt) if (!error.context)
return; return;
const response = await fetch(attachmentURL(error.prompt)); const response = await fetch(attachmentURL(error.context));
return await response.text(); return fixTestInstructions + await response.text();
}, [error], undefined); }, [error], undefined);
return <div style={{ display: 'flex', flexDirection: 'column', overflowX: 'clip' }}> return <div style={{ display: 'flex', flexDirection: 'column', overflowX: 'clip' }}>

View File

@ -56,7 +56,7 @@ export type ErrorDescription = {
action?: ActionTraceEventInContext; action?: ActionTraceEventInContext;
stack?: StackFrame[]; stack?: StackFrame[];
message: string; message: string;
prompt?: trace.AfterActionTraceEventAttachment & { traceUrl: string }; context?: trace.AfterActionTraceEventAttachment & { traceUrl: string };
}; };
export type Attachment = trace.AfterActionTraceEventAttachment & { traceUrl: string }; export type Attachment = trace.AfterActionTraceEventAttachment & { traceUrl: string };
@ -141,7 +141,7 @@ export class MultiTraceModel {
return this.errors.filter(e => !!e.message).map((error, i) => ({ return this.errors.filter(e => !!e.message).map((error, i) => ({
stack: error.stack, stack: error.stack,
message: error.message, message: error.message,
prompt: this.attachments.find(a => a.name === `_prompt-${i}`), context: this.attachments.find(a => a.name === `_error-context-${i}`),
})); }));
} }
} }

View File

@ -42,7 +42,6 @@ import { testStatusIcon, testStatusText } from './testUtils';
import type { UITestStatus } from './testUtils'; import type { UITestStatus } from './testUtils';
import type { AfterActionTraceEventAttachment } from '@trace/trace'; import type { AfterActionTraceEventAttachment } from '@trace/trace';
import type { HighlightedElement } from './snapshotTab'; import type { HighlightedElement } from './snapshotTab';
import type { TestAnnotation } from '@playwright/test';
export const Workbench: React.FunctionComponent<{ export const Workbench: React.FunctionComponent<{
model?: modelUtil.MultiTraceModel, model?: modelUtil.MultiTraceModel,
@ -52,7 +51,7 @@ export const Workbench: React.FunctionComponent<{
isLive?: boolean, isLive?: boolean,
hideTimeline?: boolean, hideTimeline?: boolean,
status?: UITestStatus, status?: UITestStatus,
annotations?: TestAnnotation[]; annotations?: { type: string; description?: string; }[];
inert?: boolean, inert?: boolean,
onOpenExternally?: (location: modelUtil.SourceLocation) => void, onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,

View File

@ -0,0 +1,23 @@
/**
* 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.
*/
export const fixTestInstructions = `
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
`.trimStart();

View File

@ -61,7 +61,7 @@ test('should access annotations in fixture', async ({ runInlineTest }) => {
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
const test = report.suites[0].specs[0].tests[0]; const test = report.suites[0].specs[0].tests[0];
expect(test.annotations).toEqual([ expect(test.annotations).toEqual([
{ type: 'slow', description: 'just slow', location: { file: expect.any(String), line: 10, column: 14 } }, { type: 'slow', description: 'just slow' },
{ type: 'myname', description: 'hello' } { type: 'myname', description: 'hello' }
]); ]);
expect(test.results[0].stdout).toEqual([{ text: 'console.log\n' }]); expect(test.results[0].stdout).toEqual([{ text: 'console.log\n' }]);

View File

@ -167,7 +167,7 @@ test('should print debug log when failed to connect', async ({ runInlineTest })
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.output).toContain('b-debug-log-string'); expect(result.output).toContain('b-debug-log-string');
expect(result.results[0].attachments).toEqual([expect.objectContaining({ name: '_prompt-0' })]); expect(result.results[0].attachments).toEqual([expect.objectContaining({ name: '_error-context-0' })]);
}); });
test('should record trace', async ({ runInlineTest }) => { test('should record trace', async ({ runInlineTest }) => {
@ -223,7 +223,7 @@ test('should record trace', async ({ runInlineTest }) => {
'After Hooks', 'After Hooks',
'fixture: page', 'fixture: page',
'fixture: context', 'fixture: context',
'_attach "_prompt-0"', '_attach "_error-context-0"',
'Worker Cleanup', 'Worker Cleanup',
'fixture: browser', 'fixture: browser',
]); ]);

View File

@ -510,13 +510,13 @@ test('should work with video: on-first-retry', async ({ runInlineTest }) => {
expect(fs.existsSync(dirPass)).toBeFalsy(); expect(fs.existsSync(dirPass)).toBeFalsy();
const dirFail = test.info().outputPath('test-results', 'a-fail-chromium'); const dirFail = test.info().outputPath('test-results', 'a-fail-chromium');
expect(fs.readdirSync(dirFail)).toEqual(['prompt.md']); expect(fs.readdirSync(dirFail)).toEqual(['error-context.md']);
const dirRetry = test.info().outputPath('test-results', 'a-fail-chromium-retry1'); const dirRetry = test.info().outputPath('test-results', 'a-fail-chromium-retry1');
const videoFailRetry = fs.readdirSync(dirRetry).find(file => file.endsWith('webm')); const videoFailRetry = fs.readdirSync(dirRetry).find(file => file.endsWith('webm'));
expect(videoFailRetry).toBeTruthy(); expect(videoFailRetry).toBeTruthy();
const errorPrompt = expect.objectContaining({ name: '_prompt-0' }); const errorPrompt = expect.objectContaining({ name: '_error-context-0' });
expect(result.report.suites[0].specs[1].tests[0].results[0].attachments).toEqual([errorPrompt]); expect(result.report.suites[0].specs[1].tests[0].results[0].attachments).toEqual([errorPrompt]);
expect(result.report.suites[0].specs[1].tests[0].results[1].attachments).toEqual([{ expect(result.report.suites[0].specs[1].tests[0].results[1].attachments).toEqual([{
name: 'video', name: 'video',

View File

@ -486,72 +486,5 @@ for (const useIntermediateMergeReport of [false, true] as const) {
expect(text).toContain(' passes @bar1 @bar2 ('); expect(text).toContain(' passes @bar1 @bar2 (');
expect(text).toContain(' passes @baz1 @baz2 ('); expect(text).toContain(' passes @baz1 @baz2 (');
}); });
test('should show warnings on failing tests', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('fail', async ({ page }, testInfo) => {
testInfo.annotations.push({ type: 'warning', description: 'foo' });
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
throw new Error();
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain('Warning: a.spec.ts:5:41: This async call was not awaited by the end of the test.');
expect(result.output).toContain('Warning: foo');
});
test('should not show warnings on passing tests', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('success', async ({ page }, testInfo) => {
testInfo.annotations.push({ type: 'warning', description: 'foo' });
});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.output).not.toContain('Warning: foo');
});
test('should properly sort warnings', async ({ runInlineTest }) => {
const result = await runInlineTest({
'external.js': `
import { expect } from '@playwright/test';
export const externalAsyncCall = (page) => {
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
};
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
import { externalAsyncCall } from './external.js';
test('fail a', async ({ page }, testInfo) => {
testInfo.annotations.push({ type: 'warning', description: 'foo' });
externalAsyncCall(page);
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
testInfo.annotations.push({ type: 'warning', description: 'bar' });
throw new Error();
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain('Warning: a.spec.ts:7:41: This async call was not awaited by the end of the test.');
expect(result.output).toContain('Warning: external.js:4:41: This async call was not awaited by the end of the test.');
expect(result.output).toContain('Warning: foo');
expect(result.output).toContain('Warning: bar');
const manualIndexFoo = result.output.indexOf('Warning: foo');
const manualIndexBar = result.output.indexOf('Warning: bar');
const externalIndex = result.output.indexOf('Warning: external.js:4:41');
const specIndex = result.output.indexOf('Warning: a.spec.ts:7:41');
expect(specIndex).toBeLessThan(externalIndex);
expect(externalIndex).toBeLessThan(manualIndexFoo);
expect(manualIndexFoo).toBeLessThan(manualIndexBar);
});
}); });
} }

View File

@ -1721,9 +1721,6 @@ test('merge reports with different rootDirs and path separators', async ({ runIn
console.log('test:', test.location.file); console.log('test:', test.location.file);
console.log('test title:', test.titlePath()[2]); console.log('test title:', test.titlePath()[2]);
} }
onTestEnd(test) {
console.log('annotations:', test.annotations.map(a => 'type: ' + a.type + ', description: ' + a.description + ', file: ' + a.location.file).join(','));
}
}; };
`, `,
'merge.config.ts': `module.exports = { 'merge.config.ts': `module.exports = {
@ -1735,7 +1732,7 @@ test('merge reports with different rootDirs and path separators', async ({ runIn
};`, };`,
'dir1/tests1/a.test.js': ` 'dir1/tests1/a.test.js': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('math 1', { annotation: { type: 'warning', description: 'Some warning' } }, async ({}) => { }); test('math 1', async ({}) => { });
`, `,
}; };
await runInlineTest(files1, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('dir1/playwright.config.ts')] }); await runInlineTest(files1, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('dir1/playwright.config.ts')] });
@ -1746,7 +1743,7 @@ test('merge reports with different rootDirs and path separators', async ({ runIn
};`, };`,
'dir2/tests2/b.test.js': ` 'dir2/tests2/b.test.js': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('math 2', { annotation: { type: 'issue' } }, async ({}) => { }); test('math 2', async ({}) => { });
`, `,
}; };
await runInlineTest(files2, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('dir2/playwright.config.ts')] }); await runInlineTest(files2, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('dir2/playwright.config.ts')] });
@ -1768,16 +1765,12 @@ test('merge reports with different rootDirs and path separators', async ({ runIn
{ {
const { exitCode, output } = await mergeReports(allReportsDir, undefined, { additionalArgs: ['--config', 'merge.config.ts'] }); const { exitCode, output } = await mergeReports(allReportsDir, undefined, { additionalArgs: ['--config', 'merge.config.ts'] });
const testPath1 = test.info().outputPath('mergeRoot', 'tests1', 'a.test.js');
const testPath2 = test.info().outputPath('mergeRoot', 'tests2', 'b.test.js');
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(output).toContain(`rootDir: ${test.info().outputPath('mergeRoot')}`); expect(output).toContain(`rootDir: ${test.info().outputPath('mergeRoot')}`);
expect(output).toContain(`test: ${testPath1}`); expect(output).toContain(`test: ${test.info().outputPath('mergeRoot', 'tests1', 'a.test.js')}`);
expect(output).toContain(`test title: ${'tests1' + path.sep + 'a.test.js'}`); expect(output).toContain(`test title: ${'tests1' + path.sep + 'a.test.js'}`);
expect(output).toContain(`annotations: type: warning, description: Some warning, file: ${testPath1}`); expect(output).toContain(`test: ${test.info().outputPath('mergeRoot', 'tests2', 'b.test.js')}`);
expect(output).toContain(`test: ${testPath2}`);
expect(output).toContain(`test title: ${'tests2' + path.sep + 'b.test.js'}`); expect(output).toContain(`test title: ${'tests2' + path.sep + 'b.test.js'}`);
expect(output).toContain(`annotations: type: issue, description: undefined, file: ${testPath2}`);
} }
}); });
@ -1793,9 +1786,6 @@ test('merge reports without --config preserves path separators', async ({ runInl
console.log('test:', test.location.file); console.log('test:', test.location.file);
console.log('test title:', test.titlePath()[2]); console.log('test title:', test.titlePath()[2]);
} }
onTestEnd(test) {
console.log('annotations:', test.annotations.map(a => 'type: ' + a.type + ', description: ' + a.description + ', file: ' + a.location.file).join(','));
}
}; };
`, `,
'dir1/playwright.config.ts': `module.exports = { 'dir1/playwright.config.ts': `module.exports = {
@ -1803,11 +1793,11 @@ test('merge reports without --config preserves path separators', async ({ runInl
};`, };`,
'dir1/tests1/a.test.js': ` 'dir1/tests1/a.test.js': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('math 1', { annotation: { type: 'warning', description: 'Some warning' } }, async ({}) => { }); test('math 1', async ({}) => { });
`, `,
'dir1/tests2/b.test.js': ` 'dir1/tests2/b.test.js': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('math 2', { annotation: { type: 'issue' } }, async ({}) => { }); test('math 2', async ({}) => { });
`, `,
}; };
await runInlineTest(files1, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('dir1/playwright.config.ts')] }); await runInlineTest(files1, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('dir1/playwright.config.ts')] });
@ -1827,15 +1817,11 @@ test('merge reports without --config preserves path separators', async ({ runInl
const { exitCode, output } = await mergeReports(allReportsDir, undefined, { additionalArgs: ['--reporter', './echo-reporter.js'] }); const { exitCode, output } = await mergeReports(allReportsDir, undefined, { additionalArgs: ['--reporter', './echo-reporter.js'] });
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
const otherSeparator = path.sep === '/' ? '\\' : '/'; const otherSeparator = path.sep === '/' ? '\\' : '/';
const testPath1 = test.info().outputPath('dir1', 'tests1', 'a.test.js').replaceAll(path.sep, otherSeparator);
const testPath2 = test.info().outputPath('dir1', 'tests2', 'b.test.js').replaceAll(path.sep, otherSeparator);
expect(output).toContain(`rootDir: ${test.info().outputPath('dir1').replaceAll(path.sep, otherSeparator)}`); expect(output).toContain(`rootDir: ${test.info().outputPath('dir1').replaceAll(path.sep, otherSeparator)}`);
expect(output).toContain(`test: ${testPath1}`); expect(output).toContain(`test: ${test.info().outputPath('dir1', 'tests1', 'a.test.js').replaceAll(path.sep, otherSeparator)}`);
expect(output).toContain(`test title: ${'tests1' + otherSeparator + 'a.test.js'}`); expect(output).toContain(`test title: ${'tests1' + otherSeparator + 'a.test.js'}`);
expect(output).toContain(`annotations: type: warning, description: Some warning, file: ${testPath1}`); expect(output).toContain(`test: ${test.info().outputPath('dir1', 'tests2', 'b.test.js').replaceAll(path.sep, otherSeparator)}`);
expect(output).toContain(`test: ${testPath2}`);
expect(output).toContain(`test title: ${'tests2' + otherSeparator + 'b.test.js'}`); expect(output).toContain(`test title: ${'tests2' + otherSeparator + 'b.test.js'}`);
expect(output).toContain(`annotations: type: issue, description: undefined, file: ${testPath2}`);
}); });
test('merge reports must not change test ids when there is no need to', async ({ runInlineTest, mergeReports }) => { test('merge reports must not change test ids when there is no need to', async ({ runInlineTest, mergeReports }) => {

View File

@ -359,7 +359,7 @@ test('should report parallelIndex', async ({ runInlineTest }, testInfo) => {
test('attaches error context', async ({ runInlineTest }) => { test('attaches error context', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `
export default { use: { _optionAttachErrorContext: true } }; export default { use: { _optionErrorContext: { format: 'json' } } };
`, `,
'a.test.js': ` 'a.test.js': `
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');

View File

@ -190,7 +190,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
}); });
test('should show error prompt with relative path', async ({ runInlineTest, useIntermediateMergeReport }) => { test('should show error context with relative path', async ({ runInlineTest, useIntermediateMergeReport }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.test.js': ` 'a.test.js': `
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
@ -201,9 +201,9 @@ for (const useIntermediateMergeReport of [false, true] as const) {
}, { reporter: 'line' }); }, { reporter: 'line' });
const text = result.output; const text = result.output;
if (useIntermediateMergeReport) if (useIntermediateMergeReport)
expect(text).toContain(`Error Prompt: ${path.join('blob-report', 'resources')}`); expect(text).toContain(`Error Context: ${path.join('blob-report', 'resources')}`);
else else
expect(text).toContain(`Error Prompt: ${path.join('test-results', 'a-one', 'prompt.md')}`); expect(text).toContain(`Error Context: ${path.join('test-results', 'a-one', 'error-context.md')}`);
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
}); });
}); });

View File

@ -584,9 +584,7 @@ test('should report annotations from test declaration', async ({ runInlineTest }
const visit = suite => { const visit = suite => {
for (const test of suite.tests || []) { for (const test of suite.tests || []) {
const annotations = test.annotations.map(a => { const annotations = test.annotations.map(a => {
const description = a.description ? a.type + '=' + a.description : a.type; return a.description ? a.type + '=' + a.description : a.type;
const location = a.location ? '(' + a.location.line + ':' + a.location.column + ')' : '';
return description + location;
}); });
console.log('\\n%%title=' + test.title + ', annotations=' + annotations.join(',')); console.log('\\n%%title=' + test.title + ', annotations=' + annotations.join(','));
} }
@ -611,7 +609,7 @@ test('should report annotations from test declaration', async ({ runInlineTest }
expect(test.info().annotations).toEqual([]); expect(test.info().annotations).toEqual([]);
}); });
test('foo', { annotation: { type: 'foo' } }, () => { test('foo', { annotation: { type: 'foo' } }, () => {
expect(test.info().annotations).toEqual([{ type: 'foo', location: { file: expect.any(String), line: 6, column: 11 } }]); expect(test.info().annotations).toEqual([{ type: 'foo' }]);
}); });
test('foo-bar', { test('foo-bar', {
annotation: [ annotation: [
@ -620,8 +618,8 @@ test('should report annotations from test declaration', async ({ runInlineTest }
], ],
}, () => { }, () => {
expect(test.info().annotations).toEqual([ expect(test.info().annotations).toEqual([
{ type: 'foo', description: 'desc', location: { file: expect.any(String), line: 9, column: 11 } }, { type: 'foo', description: 'desc' },
{ type: 'bar', location: { file: expect.any(String), line: 9, column: 11 } }, { type: 'bar' },
]); ]);
}); });
test.skip('skip-foo', { annotation: { type: 'foo' } }, () => { test.skip('skip-foo', { annotation: { type: 'foo' } }, () => {
@ -638,14 +636,11 @@ test('should report annotations from test declaration', async ({ runInlineTest }
}); });
test.describe('suite', { annotation: { type: 'foo' } }, () => { test.describe('suite', { annotation: { type: 'foo' } }, () => {
test('foo-suite', () => { test('foo-suite', () => {
expect(test.info().annotations).toEqual([{ type: 'foo', location: { file: expect.any(String), line: 32, column: 12 } }]); expect(test.info().annotations).toEqual([{ type: 'foo' }]);
}); });
test.describe('inner', { annotation: { type: 'bar' } }, () => { test.describe('inner', { annotation: { type: 'bar' } }, () => {
test('foo-bar-suite', () => { test('foo-bar-suite', () => {
expect(test.info().annotations).toEqual([ expect(test.info().annotations).toEqual([{ type: 'foo' }, { type: 'bar' }]);
{ type: 'foo', location: { file: expect.any(String), line: 32, column: 12 } },
{ type: 'bar', location: { file: expect.any(String), line: 36, column: 14 } }
]);
}); });
}); });
}); });
@ -662,15 +657,15 @@ test('should report annotations from test declaration', async ({ runInlineTest }
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.outputLines).toEqual([ expect(result.outputLines).toEqual([
`title=none, annotations=`, `title=none, annotations=`,
`title=foo, annotations=foo(6:11)`, `title=foo, annotations=foo`,
`title=foo-bar, annotations=foo=desc(9:11),bar(9:11)`, `title=foo-bar, annotations=foo=desc,bar`,
`title=skip-foo, annotations=foo(20:12),skip(20:12)`, `title=skip-foo, annotations=foo,skip`,
`title=fixme-bar, annotations=bar(22:12),fixme(22:12)`, `title=fixme-bar, annotations=bar,fixme`,
`title=fail-foo-bar, annotations=foo(24:12),bar=desc(24:12),fail(24:12)`, `title=fail-foo-bar, annotations=foo,bar=desc,fail`,
`title=foo-suite, annotations=foo(32:12)`, `title=foo-suite, annotations=foo`,
`title=foo-bar-suite, annotations=foo(32:12),bar(36:14)`, `title=foo-bar-suite, annotations=foo,bar`,
`title=skip-foo-suite, annotations=foo(45:21),skip(45:21)`, `title=skip-foo-suite, annotations=foo,skip`,
`title=fixme-bar-suite, annotations=bar(49:21),fixme(49:21)`, `title=fixme-bar-suite, annotations=bar,fixme`,
]); ]);
}); });

View File

@ -260,5 +260,5 @@ test('failed and skipped on retry should be marked as flaky', async ({ runInline
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.flaky).toBe(1); expect(result.flaky).toBe(1);
expect(result.output).toContain('Failed on first run'); expect(result.output).toContain('Failed on first run');
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'Skipped on first retry', location: expect.anything() }]); expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'Skipped on first retry' }]);
}); });

View File

@ -113,19 +113,19 @@ test('test modifiers should work', async ({ runInlineTest }) => {
expectTest('passed3', 'passed', 'passed', []); expectTest('passed3', 'passed', 'passed', []);
expectTest('passed4', 'passed', 'passed', []); expectTest('passed4', 'passed', 'passed', []);
expectTest('passed5', 'passed', 'passed', []); expectTest('passed5', 'passed', 'passed', []);
expectTest('skipped1', 'skipped', 'skipped', [{ type: 'skip', location: { file: expect.any(String), line: 20, column: 14 } }]); expectTest('skipped1', 'skipped', 'skipped', [{ type: 'skip' }]);
expectTest('skipped2', 'skipped', 'skipped', [{ type: 'skip', location: { file: expect.any(String), line: 23, column: 14 } }]); expectTest('skipped2', 'skipped', 'skipped', [{ type: 'skip' }]);
expectTest('skipped3', 'skipped', 'skipped', [{ type: 'skip', location: { file: expect.any(String), line: 26, column: 14 } }]); expectTest('skipped3', 'skipped', 'skipped', [{ type: 'skip' }]);
expectTest('skipped4', 'skipped', 'skipped', [{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 29, column: 14 } }]); expectTest('skipped4', 'skipped', 'skipped', [{ type: 'skip', description: 'reason' }]);
expectTest('skipped5', 'skipped', 'skipped', [{ type: 'fixme', location: { file: expect.any(String), line: 32, column: 14 } }]); expectTest('skipped5', 'skipped', 'skipped', [{ type: 'fixme' }]);
expectTest('skipped6', 'skipped', 'skipped', [{ type: 'fixme', description: 'reason', location: { file: expect.any(String), line: 35, column: 14 } }]); expectTest('skipped6', 'skipped', 'skipped', [{ type: 'fixme', description: 'reason' }]);
expectTest('failed1', 'failed', 'failed', [{ type: 'fail', location: { file: expect.any(String), line: 39, column: 14 } }]); expectTest('failed1', 'failed', 'failed', [{ type: 'fail' }]);
expectTest('failed2', 'failed', 'failed', [{ type: 'fail', location: { file: expect.any(String), line: 43, column: 14 } }]); expectTest('failed2', 'failed', 'failed', [{ type: 'fail' }]);
expectTest('failed3', 'failed', 'failed', [{ type: 'fail', location: { file: expect.any(String), line: 47, column: 14 } }]); expectTest('failed3', 'failed', 'failed', [{ type: 'fail' }]);
expectTest('failed4', 'failed', 'failed', [{ type: 'fail', description: 'reason', location: { file: expect.any(String), line: 51, column: 14 } }]); expectTest('failed4', 'failed', 'failed', [{ type: 'fail', description: 'reason' }]);
expectTest('suite1', 'skipped', 'skipped', [{ type: 'skip', location: { file: expect.any(String), line: 56, column: 14 } }]); expectTest('suite1', 'skipped', 'skipped', [{ type: 'skip' }]);
expectTest('suite2', 'skipped', 'skipped', [{ type: 'skip', location: { file: expect.any(String), line: 61, column: 14 } }]); expectTest('suite2', 'skipped', 'skipped', [{ type: 'skip' }]);
expectTest('suite3', 'skipped', 'skipped', [{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 66, column: 14 } }]); expectTest('suite3', 'skipped', 'skipped', [{ type: 'skip', description: 'reason' }]);
expectTest('suite4', 'passed', 'passed', []); expectTest('suite4', 'passed', 'passed', []);
expect(result.passed).toBe(10); expect(result.passed).toBe(10);
expect(result.skipped).toBe(9); expect(result.skipped).toBe(9);
@ -407,7 +407,7 @@ test('should skip inside fixture', async ({ runInlineTest }) => {
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.skipped).toBe(1); expect(result.skipped).toBe(1);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 5, column: 20 } }]); expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
}); });
test('modifier with a function should throw in the test', async ({ runInlineTest }) => { test('modifier with a function should throw in the test', async ({ runInlineTest }) => {
@ -460,8 +460,8 @@ test('test.skip with worker fixtures only should skip before hooks and tests', a
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(result.skipped).toBe(2); expect(result.skipped).toBe(2);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([]); expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([]);
expect(result.report.suites[0].suites![0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 14, column: 14 } }]); expect(result.report.suites[0].suites![0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
expect(result.report.suites[0].suites![0].suites![0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 14, column: 14 } }]); expect(result.report.suites[0].suites![0].suites![0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
expect(result.outputLines).toEqual([ expect(result.outputLines).toEqual([
'beforeEach', 'beforeEach',
'passed', 'passed',
@ -493,8 +493,8 @@ test('test.skip without a callback in describe block should skip hooks', async (
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.skipped).toBe(2); expect(result.skipped).toBe(2);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 10, column: 12 } }]); expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
expect(result.report.suites[0].suites![0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 10, column: 12 } }]); expect(result.report.suites[0].suites![0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
expect(result.output).not.toContain('%%'); expect(result.output).not.toContain('%%');
}); });
@ -598,8 +598,8 @@ test('should skip all tests from beforeAll', async ({ runInlineTest }) => {
'beforeAll', 'beforeAll',
'afterAll', 'afterAll',
]); ]);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 5, column: 14 } }]); expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
expect(result.report.suites[0].specs[1].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason', location: { file: expect.any(String), line: 5, column: 14 } }]); expect(result.report.suites[0].specs[1].tests[0].annotations).toEqual([{ type: 'skip', description: 'reason' }]);
}); });
test('should report skipped tests in-order with correct properties', async ({ runInlineTest }) => { test('should report skipped tests in-order with correct properties', async ({ runInlineTest }) => {
@ -695,9 +695,9 @@ test('static modifiers should be added in serial mode', async ({ runInlineTest }
expect(result.passed).toBe(0); expect(result.passed).toBe(0);
expect(result.skipped).toBe(2); expect(result.skipped).toBe(2);
expect(result.didNotRun).toBe(1); expect(result.didNotRun).toBe(1);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'slow', location: { file: expect.any(String), line: 6, column: 14 } }]); expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'slow' }]);
expect(result.report.suites[0].specs[1].tests[0].annotations).toEqual([{ type: 'fixme', location: { file: expect.any(String), line: 9, column: 12 } }]); expect(result.report.suites[0].specs[1].tests[0].annotations).toEqual([{ type: 'fixme' }]);
expect(result.report.suites[0].specs[2].tests[0].annotations).toEqual([{ type: 'skip', location: { file: expect.any(String), line: 11, column: 12 } }]); expect(result.report.suites[0].specs[2].tests[0].annotations).toEqual([{ type: 'skip' }]);
expect(result.report.suites[0].specs[3].tests[0].annotations).toEqual([]); expect(result.report.suites[0].specs[3].tests[0].annotations).toEqual([]);
}); });
@ -721,18 +721,9 @@ test('should contain only one slow modifier', async ({ runInlineTest }) => {
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([ expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'fixme' }, { type: 'issue', description: 'my-value' }]);
{ type: 'fixme', location: { file: expect.any(String), line: 3, column: 12 } }, expect(result.report.suites[1].specs[0].tests[0].annotations).toEqual([{ type: 'skip' }, { type: 'issue', description: 'my-value' }]);
{ type: 'issue', description: 'my-value', location: { file: expect.any(String), line: 4, column: 11 } } expect(result.report.suites[2].specs[0].tests[0].annotations).toEqual([{ type: 'slow' }, { type: 'issue', description: 'my-value' }]);
]);
expect(result.report.suites[1].specs[0].tests[0].annotations).toEqual([
{ type: 'skip', location: { file: expect.any(String), line: 3, column: 12 } },
{ type: 'issue', description: 'my-value', location: { file: expect.any(String), line: 4, column: 11 } }
]);
expect(result.report.suites[2].specs[0].tests[0].annotations).toEqual([
{ type: 'slow', location: { file: expect.any(String), line: 3, column: 12 } },
{ type: 'issue', description: 'my-value', location: { file: expect.any(String), line: 4, column: 11 } }
]);
}); });
test('should skip beforeEach hooks upon modifiers', async ({ runInlineTest }) => { test('should skip beforeEach hooks upon modifiers', async ({ runInlineTest }) => {

View File

@ -1,291 +0,0 @@
/**
* 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 { test, expect } from './playwright-test-fixtures';
const description = 'This async call was not awaited by the end of the test. This can cause flakiness. It is recommended to run ESLint with "@typescript-eslint/no-floating-promises" to verify.';
test.describe.configure({ mode: 'parallel' });
test.describe('await', () => {
test('should not care about non-API promises', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test } from '@playwright/test';
test('test', async () => {
new Promise(() => {});
await expect(page.locator('div')).toHaveText('A', { timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([]);
});
test('should warn on failure', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('custom test name', async ({ page }) => {
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
// Timeout to make sure the expect actually gets processed
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 39 }) }]);
});
test('should not warn on success', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('custom test name', async ({ page }) => {
await page.setContent('<div>A</div>');
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(0);
expect(results[0].annotations).toEqual([]);
});
test('should warn about missing await on expects', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('custom test name', async ({ page }) => {
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 39 }) }]);
});
test('should not warn when not missing await on expects', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await expect(page.locator('div')).toHaveText('A', { timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([]);
});
test('should not warn when using then() on expects', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
expect(page.locator('div')).toHaveText('A').then(() => {});
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([]);
});
test('should warn about missing await on resolve', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
expect(Promise.reject(new Error('foo'))).resolves.toBe('foo');
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 61 }) }]);
});
test('should warn about missing await on reject.not', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
expect(Promise.reject(new Error('foo'))).rejects.not.toThrow('foo');
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 64 }) }]);
});
test('should warn about missing await on test.step', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
test.step('step', () => {});
await expect(page.locator('div')).toHaveText('A', { timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 16 }) }]);
});
test('should not warn when not missing await on test.step', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await test.step('step', () => {});
await expect(page.locator('div')).toHaveText('A', { timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([]);
});
test('should warn about missing await on test.step.skip', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
test.step.skip('step', () => {});
await expect(page.locator('div')).toHaveText('A');
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 21 }) }]);
});
test('traced promise should be instanceof Promise', async ({ runInlineTest }) => {
const { exitCode } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
const expectPromise = expect(page.locator('div')).toHaveText('A');
expect(expectPromise instanceof Promise).toBeTruthy();
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(0);
});
test('should warn about missing await in before hooks', async ({ runInlineTest }) => {
const group = ['beforeAll', 'beforeEach'];
for (const hook of group) {
await test.step(hook, async () => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
let page;
test.${hook}(async ({ browser }) => {
page = await browser.newPage();
await page.setContent('<div>A</div>');
expect(page.locator('div')).toHaveText('A');
await new Promise(f => setTimeout(f, 1000));
});
test('test ${hook}', async () => {
await expect(page.locator('button')).toBeVisible({ timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 7, column: 43 }) }]);
});
}
});
test.describe('should warn about missing await in after hooks', () => {
const group = ['afterAll', 'afterEach'];
for (const hook of group) {
test(hook, async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
let page;
test('test ${hook}', async ({ browser }) => {
await expect(Promise.resolve()).resolves.toBe('A');
});
test.${hook}(async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 8, column: 50 }) }]);
});
}
});
test('should warn about missing await across hooks and test', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test.beforeAll(async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
await new Promise(f => setTimeout(f, 1000));
});
test('test', async () => {
expect(Promise.resolve()).resolves.toBe('A');
await new Promise(f => setTimeout(f, 1000));
});
test.afterEach(async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([
{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 4, column: 46 }) },
{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 8, column: 46 }) },
{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 12, column: 46 }) },
]);
});
test('should dedupe warnings that occur at the same location', async ({ runInlineTest }) => {
const { exitCode, results } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
for (let i = 0; i < 3; i++) {
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
}
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
await new Promise(f => setTimeout(f, 1000));
});
`
});
expect(exitCode).toBe(1);
expect(results[0].annotations).toEqual([
{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 5, column: 41 }) },
{ type: 'warning', description, location: expect.objectContaining({ file: expect.stringMatching(/a\.test\.ts$/), line: 7, column: 39 }) },
]);
});
});

View File

@ -70,10 +70,6 @@ export type TestDetailsAnnotation = {
description?: string; description?: string;
}; };
export type TestAnnotation = TestDetailsAnnotation & {
location?: Location;
};
export type TestDetails = { export type TestDetails = {
tag?: string | string[]; tag?: string | string[];
annotation?: TestDetailsAnnotation | TestDetailsAnnotation[]; annotation?: TestDetailsAnnotation | TestDetailsAnnotation[];