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
- `name` <[string]>
Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not specified.
### option: LocatorAssertions.toMatchAriaSnapshot#2.path
* since: v1.50
- `path` <[string]>
Path to the YAML snapshot file.
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.timeout = %%-js-assertions-timeout-%%
* since: v1.50

View file

@ -6,6 +6,74 @@ toc_max_heading_level: 2
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
<LiteYouTube

View file

@ -1822,7 +1822,7 @@ Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout
* since: v1.50
- `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
* 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"`.
- `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.
- `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"]>
- `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.

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"`. |
| `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. |
| `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

60
package-lock.json generated
View file

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

View file

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

View file

@ -60,11 +60,6 @@
color: var(--color-scale-orange-6);
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) {
@ -98,11 +93,6 @@
color: var(--color-scale-orange-2);
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 {

View file

@ -21,7 +21,7 @@ import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard';
import './links.css';
import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils';
import { clsx, useFlash } from '@web/uiUtils';
export function navigate(href: string | URL) {
window.history.pushState({}, '', href);
@ -73,7 +73,8 @@ export const AttachmentLink: React.FunctionComponent<{
linkName?: string,
openInNewTab?: boolean,
}> = ({ 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>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{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 ? () => {
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)));
@ -118,12 +119,12 @@ const kMissingContentType = 'x-playwright/missing';
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 isAnchored = useIsAnchored(id);
React.useEffect(() => {
if (isAnchored)
onReveal();
return onReveal();
}, [isAnchored, onReveal, searchParams]);
}

View file

@ -176,6 +176,7 @@ const StepTreeItem: React.FC<{
}> = ({ test, step, result, depth }) => {
return <TreeItem title={<span aria-label={step.title}>
<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'))}
<span>{step.title}</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 ? () => {
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 attachments = step.attachments.map(attachmentIndex => (
<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);
return snippet.concat(steps);
} : undefined} depth={depth}/>;
};

View file

@ -25,11 +25,14 @@
cursor: pointer;
}
.tree-item-title.selected {
text-decoration: underline var(--color-underlinenav-icon);
text-decoration-thickness: 1.5px;
}
.tree-item-body {
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,
expandByDefault?: boolean,
depth: number,
selected?: boolean,
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);
return <div className={'tree-item'} style={style}>
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
return <div className={clsx('tree-item', flash && 'yellow-flash')} style={style}>
<span className='tree-item-title' style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "playwright",
"version": "1.50.0-next",
"version": "1.50.0",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
@ -56,7 +56,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.50.0-next"
"playwright-core": "1.50.0"
},
"optionalDependencies": {
"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 });
return await zones.run('stepZone', step, async () => {
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)
throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`);
throw new errors.TimeoutError(`Step timeout of ${options.timeout}ms exceeded.`);
step.complete({});
return result.result;
} catch (error) {

View file

@ -59,9 +59,7 @@ export async function toMatchAriaSnapshot(
if (isString(expectedParam)) {
expected = expectedParam;
} else {
if (expectedParam?.path) {
expectedPath = expectedParam.path;
} else if (expectedParam?.name) {
if (expectedParam?.name) {
expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name));
} else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
@ -136,7 +134,7 @@ export async function toMatchAriaSnapshot(
}
return { pass: true, message: () => '', name: 'toMatchAriaSnapshot' };
} 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 };
}
}

View file

@ -282,7 +282,7 @@ async function mergeReports(reportDir: string | undefined, opts: { [key: string]
function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides {
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))
updateSnapshots = options.updateSnapshots;
else
@ -303,7 +303,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid
tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined,
ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined,
updateSnapshots,
updateSourceMethod: options.updateSourceMethod || 'patch',
updateSourceMethod: options.updateSourceMethod,
workers: options.workers,
};

View file

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

View file

@ -68,24 +68,27 @@ export async function applySuggestedRebaselines(config: FullConfigInternal, repo
traverse(fileNode, {
CallExpression: path => {
const node = path.node;
if (node.arguments.length !== 1)
if (node.arguments.length < 1)
return;
if (!t.isMemberExpression(node.callee))
return;
const argument = node.arguments[0];
if (!t.isStringLiteral(argument) && !t.isTemplateLiteral(argument))
return;
const matcher = node.callee.property;
const prop = 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) {
// 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;
if (matcher.loc!.start.column + 1 !== replacement.location.column)
if (prop.loc.start.column + 1 !== replacement.location.column)
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);
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,
// for example when a single test runs multiple times because of projects or retries.
// Do not apply multiple replacements for the same assertion.

View file

@ -322,7 +322,7 @@ export class TestInfoImpl implements TestInfo {
location: data.location,
};
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;
}
@ -421,7 +421,7 @@ export class TestInfoImpl implements TestInfo {
} else {
// trace viewer has no means of representing attachments outside of a step, so we create an artificial action
const callId = `attach@${++this._lastStepId}`;
this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, `attach "${attachment.name}"`, undefined, []);
this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []);
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({
type: 'before',
callId,
parentId,
startTime: monotonicTime(),
class: 'Test',
method: 'step',
method: category,
apiName,
params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])),
stack,

View file

@ -75,16 +75,20 @@ export class WorkerMain extends ProcessRunner {
process.on('unhandledRejection', reason => this.unhandledError(reason));
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._currentTest?._tracing.appendStdioToTrace('stdout', chunk);
if (typeof cb === 'function')
process.nextTick(cb);
return true;
};
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._currentTest?._tracing.appendStdioToTrace('stderr', chunk);
if (typeof cb === 'function')
process.nextTick(cb);
return true;
};
}

View file

@ -8697,16 +8697,11 @@ interface LocatorAssertions {
*/
toMatchAriaSnapshot(options?: {
/**
* 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.
*/
name?: string;
/**
* Path to the YAML snapshot file.
*/
path?: string;
/**
* 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:
* '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.
* '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`.
*/
gracefulShutdown?: {
signal: "SIGINT"|"SIGTERM";

View file

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

View file

@ -15,7 +15,7 @@
*/
import type { ActionTraceEvent, AfterActionTraceEventAttachment } from '@trace/trace';
import { msToString } from '@web/uiUtils';
import { clsx, msToString } from '@web/uiUtils';
import * as React from 'react';
import './actionList.css';
import * as modelUtil from './modelUtil';
@ -25,6 +25,7 @@ import { TreeView } from '@web/components/treeView';
import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil';
import type { Boundaries } from './geometry';
import { ToolbarButton } from '@web/components/toolbarButton';
import { testStatusIcon } from './testUtils';
export interface ActionListProps {
actions: ActionTraceEventInContext[],
@ -119,6 +120,7 @@ export const renderAction = (
const parameterString = actionParameterDisplayString(action, sdkLanguage || 'javascript');
const isSkipped = action.class === 'Test' && action.method === 'test.step.skip';
let time: string = '';
if (action.endTime)
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.class === 'APIRequestContext' && action.params.url && <div className='action-url' title={action.params.url}>{excludeOrigin(action.params.url)}</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])} />}
{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?.()}>
{!!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>}

View file

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

View file

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

View file

@ -14,6 +14,7 @@
limitations under the License.
*/
import type { EffectCallback } from 'react';
import React from 'react';
// Recalculates the value when dependencies change.
@ -224,3 +225,26 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) {
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');
/**
* 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);
});
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) => {
const result = await runInlineTest({
'playwright.config.ts': `

View file

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

View file

@ -959,10 +959,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport();
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 page.getByLabel('attach "foo-2"').click();
await page.getByTitle('see "foo-2"').click();
await page.getByLabel(`attach "foo-2"`).getByTitle('reveal attachment').click();
await expect(attachment).toBeInViewport();
await page.reload();
@ -989,10 +988,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport();
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 page.getByLabel('step').click();
await page.getByTitle('see "attachment"').click();
await page.getByLabel('step').getByTitle('reveal attachment').click();
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(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 });
expect(result.exitCode).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 }) => {
@ -422,6 +422,27 @@ test('step timeout longer than test timeout', async ({ runInlineTest }) => {
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 }) => {
const result = await runInlineTest({
'a.test.ts': `

View file

@ -398,7 +398,7 @@ test('should show custom fixture titles in actions tree', async ({ runUITest })
const { page } = await runUITest({
'a.test.ts': `
import { test as base, expect } from '@playwright/test';
const test = base.extend({
fixture1: [async ({}, use) => {
await use();
@ -457,7 +457,7 @@ test('attachments tab shows all but top-level .push attachments', async ({ runUI
- tree:
- treeitem /step/:
- group:
- treeitem /attach \\"foo-attach\\"/
- treeitem /attach \\"foo-attach\\"/
- treeitem /attach \\"bar-push\\"/
- treeitem /attach \\"bar-attach\\"/
`);
@ -470,3 +470,32 @@ test('attachments tab shows all but top-level .push attachments', async ({ runUI
- 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);
});
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) => {
const result = await runInlineTest({
'.git/marker': '',
@ -617,4 +661,45 @@ test.describe('update-source-method', () => {
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
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")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version")