Compare commits

...

11 commits

Author SHA1 Message Date
Adam Gastineau 9d22178533
chore: mark v1.50.0 (#34447) 2025-01-23 05:38:26 -08:00
Dmitry Gozman 5d6ac9622d
cherry-pick(#34442): fix(test runner): respect updateSourceMethod from the config (#34445) 2025-01-23 13:04:09 +00:00
Max Schmitt 97b76b46af cherry-pick(#34440): chore(driver): roll driver to recent Node.js LTS version 2025-01-23 11:56:20 +01:00
Adam Gastineau 7bbcc3c624
cherry-pick(#34353): chore: move attachment link back to tree item, make it flash yellow (#34432)
Co-authored-by: Simon Knott <info@simonknott.de>
2025-01-22 10:06:07 -08:00
Adam Gastineau 2811a1d4f5
cherry-pick(#34430): docs: switch gracefulShutdown to primarily mention SIGTERM and add Docker comment (#34431) 2025-01-22 09:56:09 -08:00
Adam Gastineau 2b8d1ce260
cherry-pick(#34380): docs: release notes for v1.50 js (#34425) 2025-01-22 06:54:50 -08:00
Dmitry Gozman 715eb250e7
cherry-pick(#34409): fix(aria snapshot): make rebase work when options are specified (#34417) 2025-01-22 09:27:07 +00:00
Dmitry Gozman 6106ef020f
cherry-pick(#34410): fix(list reporter): do not break after output without trailing eol (#34412) 2025-01-22 07:53:36 +00:00
Dmitry Gozman 09cd74f7b0
cherry-pick(#34407): fix(step.skip): show a skipped indicator in UI mode (#34411) 2025-01-22 07:52:13 +00:00
Yury Semikhatsky cc6eb090ec
cherry-pick(#34386): chore: step timeout improvements (#34387) 2025-01-17 21:54:17 -08:00
Pavel Feldman cd02be37ae cherry-pick(#34379) chore: remove toMatchAriaSnapshot.path 2025-01-17 14:36:41 -08:00
49 changed files with 452 additions and 188 deletions

View file

@ -2274,13 +2274,8 @@ assertThat(page.locator("body")).matchesAriaSnapshot(new LocatorAssertions.Match
* langs: js * langs: js
- `name` <[string]> - `name` <[string]>
Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not specified. Name of the snapshot to store in the snapshot (screenshot) folder corresponding to this test.
Generates sequential names if not specified.
### option: LocatorAssertions.toMatchAriaSnapshot#2.path
* since: v1.50
- `path` <[string]>
Path to the YAML snapshot file.
### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%% ### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%%
* since: v1.50 * since: v1.50

View file

@ -6,6 +6,74 @@ toc_max_heading_level: 2
import LiteYouTube from '@site/src/components/LiteYouTube'; import LiteYouTube from '@site/src/components/LiteYouTube';
## Version 1.50
### Test runner
* New option [`option: Test.step.timeout`] allows specifying a maximum run time for an individual test step. A timed-out step will fail the execution of the test.
```js
test('some test', async ({ page }) => {
await test.step('a step', async () => {
// This step can time out separately from the test
}, { timeout: 1000 });
});
```
* New method [`method: Test.step.skip`] to disable execution of a test step.
```js
test('some test', async ({ page }) => {
await test.step('before running step', async () => {
// Normal step
});
await test.step.skip('not yet ready', async () => {
// This step is skipped
});
await test.step('after running step', async () => {
// This step still runs even though the previous one was skipped
});
});
```
* Expanded [`method: LocatorAssertions.toMatchAriaSnapshot#2`] to allow storing of aria snapshots in separate YAML files.
* Added method [`method: LocatorAssertions.toHaveAccessibleErrorMessage`] to assert the Locator points to an element with a given [aria errormessage](https://w3c.github.io/aria/#aria-errormessage).
* Option [`property: TestConfig.updateSnapshots`] added the configuration enum `changed`. `changed` updates only the snapshots that have changed, whereas `all` now updates all snapshots, regardless of whether there are any differences.
* New option [`property: TestConfig.updateSourceMethod`] defines the way source code is updated when [`property: TestConfig.updateSnapshots`] is configured. Added `overwrite` and `3-way` modes that write the changes into source code, on top of existing `patch` mode that creates a patch file.
```bash
npx playwright test --update-snapshots=changed --update-source-method=3way
```
* Option [`property: TestConfig.webServer`] added a `gracefulShutdown` field for specifying a process kill signal other than the default `SIGKILL`.
* Exposed [`property: TestStep.attachments`] from the reporter API to allow retrieval of all attachments created by that step.
### UI updates
* Updated default HTML reporter to improve display of attachments.
* New button for picking elements to produce aria snapshots.
* Additional details (such as keys pressed) are now displayed alongside action API calls in traces.
* Display of `canvas` content in traces is error-prone. Display is now disabled by default, and can be enabled via the `Display canvas content` UI setting.
* `Call` and `Network` panels now display additional time information.
### Breaking
* [`method: LocatorAssertions.toBeEditable`] and [`method: Locator.isEditable`] now throw if the target element is not `<input>`, `<select>`, or a number of other editable elements.
* Option [`property: TestConfig.updateSnapshots`] now updates all snapshots when set to `all`, rather than only the failed/changed snapshots. Use the new enum `changed` to keep the old functionality of only updating the changed snapshots.
### Browser Versions
* Chromium 133.0.6943.16
* Mozilla Firefox 134.0
* WebKit 18.2
This version was also tested against the following stable channels:
* Google Chrome 132
* Microsoft Edge 132
## Version 1.49 ## Version 1.49
<LiteYouTube <LiteYouTube

View file

@ -1822,7 +1822,7 @@ Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout
* since: v1.50 * since: v1.50
- `timeout` <[float]> - `timeout` <[float]>
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). The maximum time, in milliseconds, allowed for the step to complete. If the step does not complete within the specified timeout, the [`method: Test.step`] method will throw a [TimeoutError]. Defaults to `0` (no timeout).
## method: Test.use ## method: Test.use
* since: v1.10 * since: v1.10

View file

@ -629,7 +629,7 @@ export default defineConfig({
- `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`.
- `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`.
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
- `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`.
- `signal` <["SIGINT"|"SIGTERM"]> - `signal` <["SIGINT"|"SIGTERM"]>
- `timeout` <[int]> - `timeout` <[int]>
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified.

View file

@ -37,7 +37,7 @@ export default defineConfig({
| `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. | | `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. |
| `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. | | `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. |
| `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | | `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. |
| `gracefulShutdown` | How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. | | `gracefulShutdown` | How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`. |
## Adding a server timeout ## Adding a server timeout

60
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.50.0-next", "version": "1.50.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@ -7751,10 +7751,10 @@
"version": "0.0.0" "version": "0.0.0"
}, },
"packages/playwright": { "packages/playwright": {
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7768,11 +7768,11 @@
}, },
"packages/playwright-browser-chromium": { "packages/playwright-browser-chromium": {
"name": "@playwright/browser-chromium", "name": "@playwright/browser-chromium",
"version": "1.50.0-next", "version": "1.50.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7780,11 +7780,11 @@
}, },
"packages/playwright-browser-firefox": { "packages/playwright-browser-firefox": {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.50.0-next", "version": "1.50.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7792,22 +7792,22 @@
}, },
"packages/playwright-browser-webkit": { "packages/playwright-browser-webkit": {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.50.0-next", "version": "1.50.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-chromium": { "packages/playwright-chromium": {
"version": "1.50.0-next", "version": "1.50.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7817,7 +7817,7 @@
} }
}, },
"packages/playwright-core": { "packages/playwright-core": {
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@ -7828,11 +7828,11 @@
}, },
"packages/playwright-ct-core": { "packages/playwright-ct-core": {
"name": "@playwright/experimental-ct-core", "name": "@playwright/experimental-ct-core",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.50.0-next", "playwright": "1.50.0",
"playwright-core": "1.50.0-next", "playwright-core": "1.50.0",
"vite": "^5.2.8" "vite": "^5.2.8"
}, },
"engines": { "engines": {
@ -7841,10 +7841,10 @@
}, },
"packages/playwright-ct-react": { "packages/playwright-ct-react": {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.50.0-next", "@playwright/experimental-ct-core": "1.50.0",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {
@ -7856,10 +7856,10 @@
}, },
"packages/playwright-ct-react17": { "packages/playwright-ct-react17": {
"name": "@playwright/experimental-ct-react17", "name": "@playwright/experimental-ct-react17",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.50.0-next", "@playwright/experimental-ct-core": "1.50.0",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {
@ -7871,10 +7871,10 @@
}, },
"packages/playwright-ct-svelte": { "packages/playwright-ct-svelte": {
"name": "@playwright/experimental-ct-svelte", "name": "@playwright/experimental-ct-svelte",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.50.0-next", "@playwright/experimental-ct-core": "1.50.0",
"@sveltejs/vite-plugin-svelte": "^3.0.1" "@sveltejs/vite-plugin-svelte": "^3.0.1"
}, },
"bin": { "bin": {
@ -7889,10 +7889,10 @@
}, },
"packages/playwright-ct-vue": { "packages/playwright-ct-vue": {
"name": "@playwright/experimental-ct-vue", "name": "@playwright/experimental-ct-vue",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.50.0-next", "@playwright/experimental-ct-core": "1.50.0",
"@vitejs/plugin-vue": "^5.2.0" "@vitejs/plugin-vue": "^5.2.0"
}, },
"bin": { "bin": {
@ -7903,11 +7903,11 @@
} }
}, },
"packages/playwright-firefox": { "packages/playwright-firefox": {
"version": "1.50.0-next", "version": "1.50.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7918,10 +7918,10 @@
}, },
"packages/playwright-test": { "packages/playwright-test": {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.50.0-next", "version": "1.50.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.50.0-next" "playwright": "1.50.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7931,11 +7931,11 @@
} }
}, },
"packages/playwright-webkit": { "packages/playwright-webkit": {
"version": "1.50.0-next", "version": "1.50.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.50.0-next" "playwright-core": "1.50.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.50.0-next", "version": "1.50.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

@ -60,11 +60,6 @@
color: var(--color-scale-orange-6); color: var(--color-scale-orange-6);
border: 1px solid var(--color-scale-orange-4); border: 1px solid var(--color-scale-orange-4);
} }
.label-color-gray {
background-color: var(--color-scale-gray-0);
color: var(--color-scale-gray-6);
border: 1px solid var(--color-scale-gray-4);
}
} }
@media(prefers-color-scheme: dark) { @media(prefers-color-scheme: dark) {
@ -98,11 +93,6 @@
color: var(--color-scale-orange-2); color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4); border: 1px solid var(--color-scale-orange-4);
} }
.label-color-gray {
background-color: var(--color-scale-gray-9);
color: var(--color-scale-gray-2);
border: 1px solid var(--color-scale-gray-4);
}
} }
.attachment-body { .attachment-body {

View file

@ -21,7 +21,7 @@ import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard'; import { CopyToClipboard } from './copyToClipboard';
import './links.css'; import './links.css';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils'; import { clsx, useFlash } from '@web/uiUtils';
export function navigate(href: string | URL) { export function navigate(href: string | URL) {
window.history.pushState({}, '', href); window.history.pushState({}, '', href);
@ -73,7 +73,8 @@ export const AttachmentLink: React.FunctionComponent<{
linkName?: string, linkName?: string,
openInNewTab?: boolean, openInNewTab?: boolean,
}> = ({ attachment, result, href, linkName, openInNewTab }) => { }> = ({ attachment, result, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); const [flash, triggerFlash] = useFlash();
useAnchor('attachment-' + result.attachments.indexOf(attachment), triggerFlash);
return <TreeItem title={<span> return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>} {attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
@ -84,7 +85,7 @@ export const AttachmentLink: React.FunctionComponent<{
)} )}
</span>} loadChildren={attachment.body ? () => { </span>} loadChildren={attachment.body ? () => {
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>]; return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
} : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}></TreeItem>; } : undefined} depth={0} style={{ lineHeight: '32px' }} flash={flash}></TreeItem>;
}; };
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1))); export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
@ -118,12 +119,12 @@ const kMissingContentType = 'x-playwright/missing';
export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;
export function useAnchor(id: AnchorID, onReveal: () => void) { export function useAnchor(id: AnchorID, onReveal: React.EffectCallback) {
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const isAnchored = useIsAnchored(id); const isAnchored = useIsAnchored(id);
React.useEffect(() => { React.useEffect(() => {
if (isAnchored) if (isAnchored)
onReveal(); return onReveal();
}, [isAnchored, onReveal, searchParams]); }, [isAnchored, onReveal, searchParams]);
} }

View file

@ -176,6 +176,7 @@ const StepTreeItem: React.FC<{
}> = ({ test, step, result, depth }) => { }> = ({ test, step, result, depth }) => {
return <TreeItem title={<span aria-label={step.title}> return <TreeItem title={<span aria-label={step.title}>
<span style={{ float: 'right' }}>{msToString(step.duration)}</span> <span style={{ float: 'right' }}>{msToString(step.duration)}</span>
{step.attachments.length > 0 && <a style={{ float: 'right' }} title={`reveal attachment`} href={testResultHref({ test, result, anchor: `attachment-${step.attachments[0]}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
{statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))} {statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))}
<span>{step.title}</span> <span>{step.title}</span>
{step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>} {step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>}
@ -183,20 +184,6 @@ const StepTreeItem: React.FC<{
</span>} loadChildren={step.steps.length || step.snippet ? () => { </span>} loadChildren={step.steps.length || step.snippet ? () => {
const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : []; const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : [];
const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />); const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
const attachments = step.attachments.map(attachmentIndex => ( return snippet.concat(steps);
<a key={'' + attachmentIndex}
href={testResultHref({ test, result, anchor: `attachment-${attachmentIndex}` })}
style={{ paddingLeft: depth * 22 + 4, textDecoration: 'none' }}
>
<span
style={{ margin: '8px 0 0 8px', padding: '2px 10px', cursor: 'pointer' }}
className='label label-color-gray'
title={`see "${result.attachments[attachmentIndex].name}"`}
>
{icons.attachment()}{result.attachments[attachmentIndex].name}
</span>
</a>
));
return snippet.concat(steps, attachments);
} : undefined} depth={depth}/>; } : undefined} depth={depth}/>;
}; };

View file

@ -25,11 +25,14 @@
cursor: pointer; cursor: pointer;
} }
.tree-item-title.selected {
text-decoration: underline var(--color-underlinenav-icon);
text-decoration-thickness: 1.5px;
}
.tree-item-body { .tree-item-body {
min-height: 18px; min-height: 18px;
} }
.yellow-flash {
animation: yellowflash-bg 2s;
}
@keyframes yellowflash-bg {
from { background: var(--color-attention-subtle); }
to { background: transparent; }
}

View file

@ -25,12 +25,12 @@ export const TreeItem: React.FunctionComponent<{
onClick?: () => void, onClick?: () => void,
expandByDefault?: boolean, expandByDefault?: boolean,
depth: number, depth: number,
selected?: boolean,
style?: React.CSSProperties, style?: React.CSSProperties,
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { flash?: boolean
}> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => {
const [expanded, setExpanded] = React.useState(expandByDefault || false); const [expanded, setExpanded] = React.useState(expandByDefault || false);
return <div className={'tree-item'} style={style}> return <div className={clsx('tree-item', flash && 'yellow-flash')} style={style}>
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} > <span className='tree-item-title' style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && icons.downArrow()} {loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()} {loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>} {!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-chromium", "name": "@playwright/browser-chromium",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-chromium", "name": "playwright-chromium",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-core", "name": "playwright-core",
"version": "1.50.0-next", "version": "1.50.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.50.0-next", "version": "1.50.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.50.0-next", "playwright-core": "1.50.0",
"vite": "^5.2.8", "vite": "^5.2.8",
"playwright": "1.50.0-next" "playwright": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.50.0-next", "version": "1.50.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.50.0-next", "@playwright/experimental-ct-core": "1.50.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.50.0-next", "version": "1.50.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.50.0-next", "@playwright/experimental-ct-core": "1.50.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.50.0-next", "version": "1.50.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.50.0-next", "@playwright/experimental-ct-core": "1.50.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.50.0-next", "version": "1.50.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.50.0-next", "@playwright/experimental-ct-core": "1.50.0",
"@vitejs/plugin-vue": "^5.2.0" "@vitejs/plugin-vue": "^5.2.0"
}, },
"bin": { "bin": {

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-firefox", "name": "playwright-firefox",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-webkit", "name": "playwright-webkit",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"version": "1.50.0-next", "version": "1.50.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.50.0-next" "playwright-core": "1.50.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"

View file

@ -270,9 +270,20 @@ export class TestTypeImpl {
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
return await zones.run('stepZone', step, async () => { return await zones.run('stepZone', step, async () => {
try { try {
const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); let result: Awaited<ReturnType<typeof raceAgainstDeadline<T>>> | undefined = undefined;
result = await raceAgainstDeadline(async () => {
try {
return await body();
} catch (e) {
// 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.
if (result?.timedOut)
testInfo._failWithError(e);
throw e;
}
}, options.timeout ? monotonicTime() + options.timeout : 0);
if (result.timedOut) if (result.timedOut)
throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); throw new errors.TimeoutError(`Step timeout of ${options.timeout}ms exceeded.`);
step.complete({}); step.complete({});
return result.result; return result.result;
} catch (error) { } catch (error) {

View file

@ -59,9 +59,7 @@ export async function toMatchAriaSnapshot(
if (isString(expectedParam)) { if (isString(expectedParam)) {
expected = expectedParam; expected = expectedParam;
} else { } else {
if (expectedParam?.path) { if (expectedParam?.name) {
expectedPath = expectedParam.path;
} else if (expectedParam?.name) {
expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name)); expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name));
} else { } else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
@ -136,7 +134,7 @@ export async function toMatchAriaSnapshot(
} }
return { pass: true, message: () => '', name: 'toMatchAriaSnapshot' }; return { pass: true, message: () => '', name: 'toMatchAriaSnapshot' };
} else { } else {
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`; const suggestedRebaseline = `\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\``;
return { pass: false, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline }; return { pass: false, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
} }
} }

View file

@ -282,7 +282,7 @@ async function mergeReports(reportDir: string | undefined, opts: { [key: string]
function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides { function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides {
const shardPair = options.shard ? options.shard.split('/').map((t: string) => parseInt(t, 10)) : undefined; const shardPair = options.shard ? options.shard.split('/').map((t: string) => parseInt(t, 10)) : undefined;
let updateSnapshots: 'all' | 'changed' | 'missing' | 'none'; let updateSnapshots: 'all' | 'changed' | 'missing' | 'none' | undefined;
if (['all', 'changed', 'missing', 'none'].includes(options.updateSnapshots)) if (['all', 'changed', 'missing', 'none'].includes(options.updateSnapshots))
updateSnapshots = options.updateSnapshots; updateSnapshots = options.updateSnapshots;
else else
@ -303,7 +303,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid
tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined, tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined,
ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined, ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined,
updateSnapshots, updateSnapshots,
updateSourceMethod: options.updateSourceMethod || 'patch', updateSourceMethod: options.updateSourceMethod,
workers: options.workers, workers: options.workers,
}; };

View file

@ -129,6 +129,8 @@ class ListReporter extends TerminalReporter {
if (this._needNewLine) { if (this._needNewLine) {
this._needNewLine = false; this._needNewLine = false;
process.stdout.write('\n'); process.stdout.write('\n');
++this._lastRow;
this._lastColumn = 0;
} }
} }
@ -210,6 +212,7 @@ class ListReporter extends TerminalReporter {
process.stdout.write('\n'); process.stdout.write('\n');
} }
++this._lastRow; ++this._lastRow;
this._lastColumn = 0;
} }
private _updateLine(row: number, text: string, prefix: string) { private _updateLine(row: number, text: string, prefix: string) {

View file

@ -68,24 +68,27 @@ export async function applySuggestedRebaselines(config: FullConfigInternal, repo
traverse(fileNode, { traverse(fileNode, {
CallExpression: path => { CallExpression: path => {
const node = path.node; const node = path.node;
if (node.arguments.length !== 1) if (node.arguments.length < 1)
return; return;
if (!t.isMemberExpression(node.callee)) if (!t.isMemberExpression(node.callee))
return; return;
const argument = node.arguments[0]; const argument = node.arguments[0];
if (!t.isStringLiteral(argument) && !t.isTemplateLiteral(argument)) if (!t.isStringLiteral(argument) && !t.isTemplateLiteral(argument))
return; return;
const prop = node.callee.property;
const matcher = node.callee.property; if (!prop.loc || !argument.start || !argument.end)
return;
// Replacements are anchored by the location of the call expression.
// However, replacement text is meant to only replace the first argument.
for (const replacement of replacements) { for (const replacement of replacements) {
// In Babel, rows are 1-based, columns are 0-based. // In Babel, rows are 1-based, columns are 0-based.
if (matcher.loc!.start.line !== replacement.location.line) if (prop.loc.start.line !== replacement.location.line)
continue; continue;
if (matcher.loc!.start.column + 1 !== replacement.location.column) if (prop.loc.start.column + 1 !== replacement.location.column)
continue; continue;
const indent = lines[matcher.loc!.start.line - 1].match(/^\s*/)![0]; const indent = lines[prop.loc.start.line - 1].match(/^\s*/)![0];
const newText = replacement.code.replace(/\{indent\}/g, indent); const newText = replacement.code.replace(/\{indent\}/g, indent);
ranges.push({ start: matcher.start!, end: node.end!, oldText: source.substring(matcher.start!, node.end!), newText }); ranges.push({ start: argument.start, end: argument.end, oldText: source.substring(argument.start, argument.end), newText });
// We can have multiple, hopefully equal, replacements for the same location, // We can have multiple, hopefully equal, replacements for the same location,
// for example when a single test runs multiple times because of projects or retries. // for example when a single test runs multiple times because of projects or retries.
// Do not apply multiple replacements for the same assertion. // Do not apply multiple replacements for the same assertion.

View file

@ -322,7 +322,7 @@ export class TestInfoImpl implements TestInfo {
location: data.location, location: data.location,
}; };
this._onStepBegin(payload); this._onStepBegin(payload);
this._tracing.appendBeforeActionForStep(stepId, parentStep?.stepId, data.apiName || data.title, data.params, data.location ? [data.location] : []); this._tracing.appendBeforeActionForStep(stepId, parentStep?.stepId, data.category, data.apiName || data.title, data.params, data.location ? [data.location] : []);
return step; return step;
} }
@ -421,7 +421,7 @@ export class TestInfoImpl implements TestInfo {
} else { } else {
// trace viewer has no means of representing attachments outside of a step, so we create an artificial action // trace viewer has no means of representing attachments outside of a step, so we create an artificial action
const callId = `attach@${++this._lastStepId}`; const callId = `attach@${++this._lastStepId}`;
this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, `attach "${attachment.name}"`, undefined, []); this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []);
this._tracing.appendAfterActionForStep(callId, undefined, [attachment]); this._tracing.appendAfterActionForStep(callId, undefined, [attachment]);
} }

View file

@ -245,14 +245,14 @@ export class TestTracing {
}); });
} }
appendBeforeActionForStep(callId: string, parentId: string | undefined, apiName: string, params: Record<string, any> | undefined, stack: StackFrame[]) { appendBeforeActionForStep(callId: string, parentId: string | undefined, category: string, apiName: string, params: Record<string, any> | undefined, stack: StackFrame[]) {
this._appendTraceEvent({ this._appendTraceEvent({
type: 'before', type: 'before',
callId, callId,
parentId, parentId,
startTime: monotonicTime(), startTime: monotonicTime(),
class: 'Test', class: 'Test',
method: 'step', method: category,
apiName, apiName,
params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])), params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])),
stack, stack,

View file

@ -75,16 +75,20 @@ export class WorkerMain extends ProcessRunner {
process.on('unhandledRejection', reason => this.unhandledError(reason)); process.on('unhandledRejection', reason => this.unhandledError(reason));
process.on('uncaughtException', error => this.unhandledError(error)); process.on('uncaughtException', error => this.unhandledError(error));
process.stdout.write = (chunk: string | Buffer) => { process.stdout.write = (chunk: string | Buffer, cb?: any) => {
this.dispatchEvent('stdOut', stdioChunkToParams(chunk)); this.dispatchEvent('stdOut', stdioChunkToParams(chunk));
this._currentTest?._tracing.appendStdioToTrace('stdout', chunk); this._currentTest?._tracing.appendStdioToTrace('stdout', chunk);
if (typeof cb === 'function')
process.nextTick(cb);
return true; return true;
}; };
if (!process.env.PW_RUNNER_DEBUG) { if (!process.env.PW_RUNNER_DEBUG) {
process.stderr.write = (chunk: string | Buffer) => { process.stderr.write = (chunk: string | Buffer, cb?: any) => {
this.dispatchEvent('stdErr', stdioChunkToParams(chunk)); this.dispatchEvent('stdErr', stdioChunkToParams(chunk));
this._currentTest?._tracing.appendStdioToTrace('stderr', chunk); this._currentTest?._tracing.appendStdioToTrace('stderr', chunk);
if (typeof cb === 'function')
process.nextTick(cb);
return true; return true;
}; };
} }

View file

@ -8697,16 +8697,11 @@ interface LocatorAssertions {
*/ */
toMatchAriaSnapshot(options?: { toMatchAriaSnapshot(options?: {
/** /**
* Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not * Name of the snapshot to store in the snapshot (screenshot) folder corresponding to this test. Generates sequential
* specified. * names if not specified.
*/ */
name?: string; name?: string;
/**
* Path to the YAML snapshot file.
*/
path?: string;
/** /**
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
*/ */
@ -9650,9 +9645,10 @@ interface TestConfigWebServer {
/** /**
* How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: * How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal:
* 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit * 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit
* within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't * within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent.
* support `SIGINT` and `SIGTERM` signals, so this option is ignored. * Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting
* down a Docker container requires `SIGTERM`.
*/ */
gracefulShutdown?: { gracefulShutdown?: {
signal: "SIGINT"|"SIGTERM"; signal: "SIGINT"|"SIGTERM";

View file

@ -41,6 +41,10 @@
color: var(--vscode-editorCodeLens-foreground); color: var(--vscode-editorCodeLens-foreground);
} }
.action-skipped {
margin-right: 4px;
}
.action-icon { .action-icon {
flex: none; flex: none;
display: flex; display: flex;

View file

@ -15,7 +15,7 @@
*/ */
import type { ActionTraceEvent, AfterActionTraceEventAttachment } from '@trace/trace'; import type { ActionTraceEvent, AfterActionTraceEventAttachment } from '@trace/trace';
import { msToString } from '@web/uiUtils'; import { clsx, msToString } from '@web/uiUtils';
import * as React from 'react'; import * as React from 'react';
import './actionList.css'; import './actionList.css';
import * as modelUtil from './modelUtil'; import * as modelUtil from './modelUtil';
@ -25,6 +25,7 @@ import { TreeView } from '@web/components/treeView';
import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil'; import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil';
import type { Boundaries } from './geometry'; import type { Boundaries } from './geometry';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
import { testStatusIcon } from './testUtils';
export interface ActionListProps { export interface ActionListProps {
actions: ActionTraceEventInContext[], actions: ActionTraceEventInContext[],
@ -119,6 +120,7 @@ export const renderAction = (
const parameterString = actionParameterDisplayString(action, sdkLanguage || 'javascript'); const parameterString = actionParameterDisplayString(action, sdkLanguage || 'javascript');
const isSkipped = action.class === 'Test' && action.method === 'test.step.skip';
let time: string = ''; let time: string = '';
if (action.endTime) if (action.endTime)
time = msToString(action.endTime - action.startTime); time = msToString(action.endTime - action.startTime);
@ -149,9 +151,10 @@ export const renderAction = (
{action.method === 'goto' && action.params.url && <div className='action-url' title={action.params.url}>{action.params.url}</div>} {action.method === 'goto' && action.params.url && <div className='action-url' title={action.params.url}>{action.params.url}</div>}
{action.class === 'APIRequestContext' && action.params.url && <div className='action-url' title={action.params.url}>{excludeOrigin(action.params.url)}</div>} {action.class === 'APIRequestContext' && action.params.url && <div className='action-url' title={action.params.url}>{excludeOrigin(action.params.url)}</div>}
</div> </div>
{(showDuration || showBadges || showAttachments) && <div className='spacer'></div>} {(showDuration || showBadges || showAttachments || isSkipped) && <div className='spacer'></div>}
{showAttachments && <ToolbarButton icon='attach' title='Open Attachment' onClick={() => revealAttachment(action.attachments![0])} />} {showAttachments && <ToolbarButton icon='attach' title='Open Attachment' onClick={() => revealAttachment(action.attachments![0])} />}
{showDuration && <div className='action-duration'>{time || <span className='codicon codicon-loading'></span>}</div>} {showDuration && !isSkipped && <div className='action-duration'>{time || <span className='codicon codicon-loading'></span>}</div>}
{isSkipped && <span className={clsx('action-skipped', 'codicon', testStatusIcon('skipped'))} title='skipped'></span>}
{showBadges && <div className='action-icons' onClick={() => revealConsole?.()}> {showBadges && <div className='action-icons' onClick={() => revealConsole?.()}>
{!!errors && <div className='action-icon'><span className='codicon codicon-error'></span><span className='action-icon-value'>{errors}</span></div>} {!!errors && <div className='action-icon'><span className='codicon codicon-error'></span><span className='action-icon-value'>{errors}</span></div>}
{!!warnings && <div className='action-icon'><span className='codicon codicon-warning'></span><span className='action-icon-value'>{warnings}</span></div>} {!!warnings && <div className='action-icon'><span className='codicon codicon-warning'></span><span className='action-icon-value'>{warnings}</span></div>}

View file

@ -55,3 +55,11 @@
a.codicon-cloud-download:hover{ a.codicon-cloud-download:hover{
background-color: var(--vscode-list-inactiveSelectionBackground) background-color: var(--vscode-list-inactiveSelectionBackground)
} }
.yellow-flash {
animation: yellowflash-bg 2s;
}
@keyframes yellowflash-bg {
from { background: var(--vscode-peekViewEditor-matchHighlightBackground); }
to { background: transparent; }
}

View file

@ -17,36 +17,38 @@
import * as React from 'react'; import * as React from 'react';
import './attachmentsTab.css'; import './attachmentsTab.css';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import type { MultiTraceModel } from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel'; import { PlaceholderPanel } from './placeholderPanel';
import type { AfterActionTraceEventAttachment } from '@trace/trace'; import type { AfterActionTraceEventAttachment } from '@trace/trace';
import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper';
import { isTextualMimeType } from '@isomorphic/mimeType'; import { isTextualMimeType } from '@isomorphic/mimeType';
import { Expandable } from '@web/components/expandable'; import { Expandable } from '@web/components/expandable';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils'; import { clsx, useFlash } from '@web/uiUtils';
type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string };
type ExpandableAttachmentProps = { type ExpandableAttachmentProps = {
attachment: Attachment; attachment: Attachment;
reveal: boolean; reveal?: any;
highlight: boolean;
}; };
const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment, reveal, highlight }) => { const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment, reveal }) => {
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [attachmentText, setAttachmentText] = React.useState<string | null>(null); const [attachmentText, setAttachmentText] = React.useState<string | null>(null);
const [placeholder, setPlaceholder] = React.useState<string | null>(null); const [placeholder, setPlaceholder] = React.useState<string | null>(null);
const [flash, triggerFlash] = useFlash();
const ref = React.useRef<HTMLSpanElement>(null); const ref = React.useRef<HTMLSpanElement>(null);
const isTextAttachment = isTextualMimeType(attachment.contentType); const isTextAttachment = isTextualMimeType(attachment.contentType);
const hasContent = !!attachment.sha1 || !!attachment.path; const hasContent = !!attachment.sha1 || !!attachment.path;
React.useEffect(() => { React.useEffect(() => {
if (reveal) if (reveal) {
ref.current?.scrollIntoView({ behavior: 'smooth' }); ref.current?.scrollIntoView({ behavior: 'smooth' });
}, [reveal]); return triggerFlash();
}
}, [reveal, triggerFlash]);
React.useEffect(() => { React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) { if (expanded && attachmentText === null && placeholder === null) {
@ -66,14 +68,14 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
}, [attachmentText]); }, [attachmentText]);
const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}> const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}>
<span className={clsx(highlight && 'attachment-title-highlight')}>{linkifyText(attachment.name)}</span> <span>{linkifyText(attachment.name)}</span>
{hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</span>; </span>;
if (!isTextAttachment || !hasContent) if (!isTextAttachment || !hasContent)
return <div style={{ marginLeft: 20 }}>{title}</div>; return <div style={{ marginLeft: 20 }}>{title}</div>;
return <> return <div className={clsx(flash && 'yellow-flash')}>
<Expandable title={title} expanded={expanded} setExpanded={setExpanded} expandOnTitleClick={true}> <Expandable title={title} expanded={expanded} setExpanded={setExpanded} expandOnTitleClick={true}>
{placeholder && <i>{placeholder}</i>} {placeholder && <i>{placeholder}</i>}
</Expandable> </Expandable>
@ -87,14 +89,13 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
wrapLines={false}> wrapLines={false}>
</CodeMirrorWrapper> </CodeMirrorWrapper>
</div>} </div>}
</>; </div>;
}; };
export const AttachmentsTab: React.FunctionComponent<{ export const AttachmentsTab: React.FunctionComponent<{
model: MultiTraceModel | undefined, model: MultiTraceModel | undefined,
selectedAction: ActionTraceEventInContext | undefined, revealedAttachment?: [AfterActionTraceEventAttachment, number],
revealedAttachment?: AfterActionTraceEventAttachment, }> = ({ model, revealedAttachment }) => {
}> = ({ model, selectedAction, revealedAttachment }) => {
const { diffMap, screenshots, attachments } = React.useMemo(() => { const { diffMap, screenshots, attachments } = React.useMemo(() => {
const attachments = new Set<Attachment>(); const attachments = new Set<Attachment>();
const screenshots = new Set<Attachment>(); const screenshots = new Set<Attachment>();
@ -153,8 +154,7 @@ export const AttachmentsTab: React.FunctionComponent<{
return <div className='attachment-item' key={attachmentKey(a, i)}> return <div className='attachment-item' key={attachmentKey(a, i)}>
<ExpandableAttachment <ExpandableAttachment
attachment={a} attachment={a}
highlight={selectedAction?.attachments?.some(selected => isEqualAttachment(a, selected)) ?? false} reveal={(!!revealedAttachment && isEqualAttachment(a, revealedAttachment[0])) ? revealedAttachment : undefined}
reveal={!!revealedAttachment && isEqualAttachment(a, revealedAttachment)}
/> />
</div>; </div>;
})} })}

View file

@ -59,7 +59,7 @@ export const Workbench: React.FunctionComponent<{
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => { }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined); const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined); const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [revealedAttachment, setRevealedAttachment] = React.useState<AfterActionTraceEventAttachment | undefined>(undefined); const [revealedAttachment, setRevealedAttachment] = React.useState<[attachment: AfterActionTraceEventAttachment, renderCounter: number] | undefined>(undefined);
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>(); const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>(); const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
@ -148,7 +148,12 @@ export const Workbench: React.FunctionComponent<{
const revealAttachment = React.useCallback((attachment: AfterActionTraceEventAttachment) => { const revealAttachment = React.useCallback((attachment: AfterActionTraceEventAttachment) => {
selectPropertiesTab('attachments'); selectPropertiesTab('attachments');
setRevealedAttachment(attachment); setRevealedAttachment(currentValue => {
if (!currentValue)
return [attachment, 0];
const revealCounter = currentValue[1];
return [attachment, revealCounter + 1];
});
}, [selectPropertiesTab]); }, [selectPropertiesTab]);
React.useEffect(() => { React.useEffect(() => {
@ -238,7 +243,7 @@ export const Workbench: React.FunctionComponent<{
id: 'attachments', id: 'attachments',
title: 'Attachments', title: 'Attachments',
count: attachments.length, count: attachments.length,
render: () => <AttachmentsTab model={model} selectedAction={selectedAction} revealedAttachment={revealedAttachment} /> render: () => <AttachmentsTab model={model} revealedAttachment={revealedAttachment} />
}; };
const tabs: TabbedPaneTabModel[] = [ const tabs: TabbedPaneTabModel[] = [

View file

@ -14,6 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import type { EffectCallback } from 'react';
import React from 'react'; import React from 'react';
// Recalculates the value when dependencies change. // Recalculates the value when dependencies change.
@ -224,3 +225,26 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) {
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
/**
* Manages flash animation state.
* Calling `trigger` will turn `flash` to true for a second, and then back to false.
* If `trigger` is called while a flash is ongoing, the ongoing flash will be cancelled and after 50ms a new flash is started.
* @returns [flash, trigger]
*/
export function useFlash(): [boolean, EffectCallback] {
const [flash, setFlash] = React.useState(false);
const trigger = React.useCallback<React.EffectCallback>(() => {
const timeouts: any[] = [];
setFlash(currentlyFlashing => {
timeouts.push(setTimeout(() => setFlash(false), 1000));
if (!currentlyFlashing)
return true;
timeouts.push(setTimeout(() => setFlash(true), 50));
return false;
});
return () => timeouts.forEach(clearTimeout);
}, [setFlash]);
return [flash, trigger];
}

View file

@ -42,24 +42,6 @@ test('should match snapshot with name', async ({ runInlineTest }, testInfo) => {
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
}); });
test('should match snapshot with path', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'test.yml': `
- heading "hello world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
import path from 'path';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ path: path.resolve(__dirname, 'test.yml') });
});
`
});
expect(result.exitCode).toBe(0);
});
test('should generate multiple missing', async ({ runInlineTest }, testInfo) => { test('should generate multiple missing', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `

View file

@ -229,6 +229,7 @@ export function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
// END: Reserved CI // END: Reserved CI
PW_TEST_HTML_REPORT_OPEN: undefined, PW_TEST_HTML_REPORT_OPEN: undefined,
PLAYWRIGHT_HTML_OPEN: undefined, PLAYWRIGHT_HTML_OPEN: undefined,
PW_TEST_DEBUG_REPORTERS: undefined,
PW_TEST_REPORTER: undefined, PW_TEST_REPORTER: undefined,
PW_TEST_REPORTER_WS_ENDPOINT: undefined, PW_TEST_REPORTER_WS_ENDPOINT: undefined,
PW_TEST_SOURCE_TRANSFORM: undefined, PW_TEST_SOURCE_TRANSFORM: undefined,

View file

@ -959,10 +959,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
await page.getByRole('link', { name: 'passing' }).click(); await page.getByRole('link', { name: 'passing' }).click();
const attachment = page.getByTestId('attachments').getByText('foo-2', { exact: true }); const attachment = page.getByText('foo-2', { exact: true });
await expect(attachment).not.toBeInViewport(); await expect(attachment).not.toBeInViewport();
await page.getByLabel('attach "foo-2"').click(); await page.getByLabel(`attach "foo-2"`).getByTitle('reveal attachment').click();
await page.getByTitle('see "foo-2"').click();
await expect(attachment).toBeInViewport(); await expect(attachment).toBeInViewport();
await page.reload(); await page.reload();
@ -989,10 +988,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
await page.getByRole('link', { name: 'passing' }).click(); await page.getByRole('link', { name: 'passing' }).click();
const attachment = page.getByTestId('attachments').getByText('attachment', { exact: true }); const attachment = page.getByText('attachment', { exact: true });
await expect(attachment).not.toBeInViewport(); await expect(attachment).not.toBeInViewport();
await page.getByLabel('step').click(); await page.getByLabel('step').getByTitle('reveal attachment').click();
await page.getByTitle('see "attachment"').click();
await expect(attachment).toBeInViewport(); await expect(attachment).toBeInViewport();
}); });

View file

@ -258,6 +258,51 @@ for (const useIntermediateMergeReport of [false, true] as const) {
expect(text).toContain('1) a.test.ts:3:15 passes outer 1.0 inner 1.1 ──'); expect(text).toContain('1) a.test.ts:3:15 passes outer 1.0 inner 1.1 ──');
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
}); });
test('print stdio', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', async ({}) => {
await new Promise(resolve => process.stdout.write('line1', () => resolve()));
await new Promise(resolve => process.stdout.write('line2\\n', () => resolve()));
await new Promise(resolve => process.stderr.write(Buffer.from(''), () => resolve()));
});
test('passes 2', async ({}) => {
await new Promise(resolve => process.stdout.write('partial', () => resolve()));
});
test('passes 3', async ({}) => {
await new Promise(resolve => process.stdout.write('full\\n', () => resolve()));
});
test('passes 4', async ({}) => {
});
`,
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PLAYWRIGHT_FORCE_TTY: '80' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(4);
const expected = [
'#0 : 1 a.test.ts:3:15 passes',
'line1line2',
`#0 : ${POSITIVE_STATUS_MARK} 1 a.test.ts:3:15 passes`,
'',
'#3 : 2 a.test.ts:9:15 passes 2',
`partial#3 : ${POSITIVE_STATUS_MARK} 2 a.test.ts:9:15 passes 2`,
'',
'#5 : 3 a.test.ts:13:15 passes 3',
'full',
`#5 : ${POSITIVE_STATUS_MARK} 3 a.test.ts:13:15 passes 3`,
'#7 : 4 a.test.ts:17:15 passes 4',
`#7 : ${POSITIVE_STATUS_MARK} 4 a.test.ts:17:15 passes 4`,
];
const lines = result.output.split('\n');
const firstIndex = lines.indexOf(expected[0]);
expect(firstIndex, 'first line should be there').not.toBe(-1);
for (let i = 0; i < expected.length; ++i)
expect(lines[firstIndex + i]).toContain(expected[i]);
});
}); });
} }

View file

@ -399,7 +399,7 @@ test('step timeout option', async ({ runInlineTest }) => {
}, { reporter: '', workers: 1 }); }, { reporter: '', workers: 1 });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.output).toContain('Error: Step timeout 100ms exceeded.'); expect(result.output).toContain('Error: Step timeout of 100ms exceeded.');
}); });
test('step timeout longer than test timeout', async ({ runInlineTest }) => { test('step timeout longer than test timeout', async ({ runInlineTest }) => {
@ -422,6 +422,27 @@ test('step timeout longer than test timeout', async ({ runInlineTest }) => {
expect(result.output).toContain('Test timeout of 900ms exceeded.'); expect(result.output).toContain('Test timeout of 900ms exceeded.');
}); });
test('step timeout includes interrupted action errors', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('step with timeout', async ({ page }) => {
await test.step('my step', async () => {
await page.waitForTimeout(100_000);
}, { timeout: 1000 });
});
`
}, { reporter: '', workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
// Should include 2 errors, one for the step timeout and one for the aborted action.
expect.soft(result.output).toContain('TimeoutError: Step timeout of 1000ms exceeded.');
expect.soft(result.output).toContain(`> 4 | await test.step('my step', async () => {`);
expect.soft(result.output).toContain('Error: page.waitForTimeout: Test ended.');
expect.soft(result.output.split('Error: page.waitForTimeout: Test ended.').length).toBe(2);
expect.soft(result.output).toContain('> 5 | await page.waitForTimeout(100_000);');
});
test('step timeout is errors.TimeoutError', async ({ runInlineTest }) => { test('step timeout is errors.TimeoutError', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.test.ts': ` 'a.test.ts': `

View file

@ -470,3 +470,32 @@ test('attachments tab shows all but top-level .push attachments', async ({ runUI
- button /bar-attach/ - button /bar-attach/
`); `);
}); });
test('skipped steps should have an indicator', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test with steps', async ({}) => {
await test.step('outer', async () => {
await test.step.skip('skipped1', () => {});
});
await test.step.skip('skipped2', () => {});
});
`,
});
await page.getByRole('treeitem', { name: 'test with steps' }).dblclick();
const actionsTree = page.getByTestId('actions-tree');
await actionsTree.getByRole('treeitem', { name: 'outer' }).click();
await page.keyboard.press('ArrowRight');
await expect(actionsTree).toMatchAriaSnapshot(`
- tree:
- treeitem /outer/ [expanded]:
- group:
- treeitem /skipped1/
- treeitem /skipped2/
`);
const skippedMarker = actionsTree.getByRole('treeitem', { name: 'skipped1' }).locator('.action-skipped');
await expect(skippedMarker).toBeVisible();
await expect(skippedMarker).toHaveAccessibleName('skipped');
});

View file

@ -454,6 +454,50 @@ test('should generate baseline for input values', async ({ runInlineTest }, test
expect(result2.exitCode).toBe(0); expect(result2.exitCode).toBe(0);
}); });
test('should update when options are specified', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'.git/marker': '',
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<input value="hello world">\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`\`, { timeout: 2500 });
await expect(page.locator('body')).toMatchAriaSnapshot('',
{
timeout: 2500
});
});
`
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(trimPatch(data)).toBe(`diff --git a/a.spec.ts b/a.spec.ts
--- a/a.spec.ts
+++ b/a.spec.ts
@@ -2,8 +2,12 @@
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<input value="hello world">\`);
- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`, { timeout: 2500 });
- await expect(page.locator('body')).toMatchAriaSnapshot('',
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - textbox: hello world
+ \`, { timeout: 2500 });
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - textbox: hello world
+ \`,
{
timeout: 2500
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should not update snapshots when locator did not match', async ({ runInlineTest }, testInfo) => { test('should not update snapshots when locator did not match', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'.git/marker': '', '.git/marker': '',
@ -617,4 +661,45 @@ test.describe('update-source-method', () => {
a.spec.ts a.spec.ts
`); `);
}); });
test('should overwrite source when specified in the config', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'.git/marker': '',
'playwright.config.ts': `
export default { updateSourceMethod: 'overwrite' };
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`
- heading "world"
\`);
});
`
}, { 'update-snapshots': 'all' });
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
expect(fs.existsSync(patchPath)).toBeFalsy();
const data = fs.readFileSync(testInfo.outputPath('a.spec.ts'), 'utf-8');
expect(data).toBe(`
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`
- heading "hello" [level=1]
\`);
});
`);
expect(stripAnsi(result.output).replace(/\\/g, '/')).toContain(`New baselines created for:
a.spec.ts
`);
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
}); });

View file

@ -4,7 +4,7 @@ set -x
trap "cd $(pwd -P)" EXIT trap "cd $(pwd -P)" EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
NODE_VERSION="22.13.0" # autogenerated via ./update-playwright-driver-version.mjs NODE_VERSION="22.13.1" # autogenerated via ./update-playwright-driver-version.mjs
cd "$(dirname "$0")" cd "$(dirname "$0")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version") PACKAGE_VERSION=$(node -p "require('../../package.json').version")